Zapis dwóch identycznych rekordów w bazie danych.

0

Cześć,
uczę się springa i w tym celu stworzyłem aplikację Restową do zarządzania ogłoszeniami.
Ostatnio znalazłem błąd polegający na tym, że jak ktoś na froncie uzupełni formularz i chce zapisać ogłoszenie ale kliknie przycisk zapisz dwukrotnie to wysyłają się do back-endu dwa requesty i finalnie w bazie mam zapisane 2 takie same ogłoszenia.
Zanim zablokuje możliwość ponownego kliknięcia na froncie chciałby wprowadzić zabezpieczenie przed taką sytuacją po stronie back-endu.
Mam testy integracyjne kontrolera w którym dodałem kolejny test symulujący taką sytuację.

@DatabaseSetup(value = "/datasets/main/adverb.xml", connection = DBUNIT_CONNECTION)
public void test() throws Exception { 

       ......
       
  Thread thread = new Thread(() -> {
      try {
          MvcResult result = this.mocMvc.perform(
                          post("/adverb/add")
                                  .header("Content-Type", "application/json")
                                  .content(request)
                  )
                  .andDo(print())
                  .andExpect(status().isOk())
                  .andReturn();
      } catch (Exception e) {
          throw new RuntimeException(e);
      }
  });
  Thread thread2 = new Thread(() -> {
      try {
          MvcResult result2 = this.mocMvc.perform(
                          post("/adverb/add")
                                  .header("Content-Type", "application/json")
                                  .content(request)
                  )
                  .andDo(print())
                  .andExpect(status().isOk())
                  .andReturn();
      } catch (Exception e) {
          throw new RuntimeException(e);
      }
  });
  thread.start();
  thread2.start();
    	
  thread.join();
  thread2.join();
  
  List<AdverbEntity> adverbs = adverbRepository.findAll();
}

w serwisie natomiast mam taką metodę.

  public void save(AdverbEntity adverbEntity){

  LOGGER.info("start" );
  List<AdverbEntity> check = adverbRepository.checkIfExist(...);

  if(check.size() == 0){
      adverbRepository.saveAll(adverbEntity);
  }
  LOGGER.info("stop");
  }

Myślałem, że dodanie adnotacji @Transactional nad metodą save spowoduję, że tylko jeden wątek będzie miał dostęp do metody.
Tymczasem po logach widzę, że oba wątki w tym samym czasie są w metodzie i finalnie zapisują się dwa ogłoszenia.

Dodam, że wczesniej nie miałem doczynienia ani z wątkami ani tranzakcjami w springu więc jeśli źle myśle i problemu nie da się rozwiązać używając w ten sposób @Transactional wyprowadźcie mnie z błędu.

0
kogut8761 napisał(a):

Myślałem, że dodanie adnotacji @Transactional nad metodą save spowoduję, że tylko jeden wątek będzie miał dostęp do metody.
Tymczasem po logach widzę, że oba wątki w tym samym czasie są w metodzie

Takie coś gwarantuje javowe synchronized a nie springowe @Transactional, ale w tym wypadku nie ma sensu używać synchronizacji.

Dodanie @Transactional spowodowało, że ciało metody wykonuje się w ramach jednej transakcji, ale już kwestia tego, czy wmiędzyczasie nie utworzy rekordu inna transakcja, określa poziom izolacji transakcji (by default w większości baz jest READ_COMMITTED, dlatego masz sytuację którą opisujesz). Ale nie ma co zmieniać poziomu izolacji transakcji.

Najlepszym rozwiązaniem tutaj byłoby dodanie optimistic locka (możesz wygooglować po prostu hasłowo optimistic lock java), jeśli korzystasz z Hibernate to wystarczy w encji AdverbEntity dodać pole version opatrzone adnotacją @Version.

3

Zanim zablokuje możliwość ponownego kliknięcia na froncie chciałby wprowadzić zabezpieczenie przed taką sytuacją po stronie back-endu.

Ale czym w zasadzie jest taka sytuacja - tzn. czy mogę założyć to samo ogłoszenie np. godzinę później? (30 minut? 15 minut? 21.37 sekund? ...)

Najprościej (i ze 100% gwarancją działania) będzie po stronie bazy danych zrobić unikalny indeks np. na tytuł oraz zawartość - choć imho to trochę anty-użytkownikowe podejście, bo sam gdy np. sprzedaję kostki RAM na Olxie, to wystawiam dwa, trzy itd. identyczne ogłoszenia pod rząd, każde na osobną kostkę (ale tak poza tym mają tę samą zawartość, cenę itd.).

Także IMO najlepiej będzie tylko zablokować przycisk po stronie frontendu, bo frontend to jedyne miejsce, w którym znasz intencję użytkownika.

Ewentualnie możesz założyć limit w stylu maksimum jedno nowe ogłoszenie na użytkownika na minutę, ale wtedy i tak będziesz musiał dorzucić specjalną logikę po stronie frontendu, aby nie wyświetlać tego błędu wprost użytkownikowi, który nacisnął przycisk omyłkowo dwa razy, bo to będzie mylące czy ogłoszenie się w ogóle dodało, czy raczej wykrzaczyło (ew. ktoś sobie pomyśli "no tak, jest limit, ale ja przecież dodaję pierwsze ogłoszenie!").

0
Patryk27 napisał(a):

Zanim zablokuje możliwość ponownego kliknięcia na froncie chciałby wprowadzić zabezpieczenie przed taką sytuacją po stronie back-endu.

Ale czym w zasadzie jest taka sytuacja - tzn. czy mogę założyć to samo ogłoszenie np. godzinę później? (30 minut? 15 minut? 21.37 sekund? ...)

IMO najprościej będzie po stronie bazy danych zrobić unikalny indeks np. na tytuł oraz zawartość i tyle; ew. rzeczywiście zablokować przycisk po stronie frontendu, bo frontend to jedyne miejsce, w którym znasz intencję użytkownika.

Jedno ogłoszenie w na x-minut.
Mógłbym to zrobić dodając triger na bazie danych i to działa, ale uczę się springa i szukam jakiś rozwiązań korzystając z niego.
Na szybko dodałem w encji pole z adnotacją @Version jak radził @Pinek :

@Version
private Long version;

ale efekt jest ten sam.
kolumna version w bazie jak powinna wyglądać?
version int DEFAULT NULL ?

0

Jedno ogłoszenie w na x-minut.

Oki, no to możesz sobie np. zrobić mapę (taką w Javie, nie trzeba jej koniecznie wrzucać do bazy danych) w stylu id użytkownika => data ostatniego ogłoszenia i konsultować się z nią w trakcie dodawania nowego ogłoszenia.

2

Za czasów aplikacji serverside leczący był wzorzec POST & Redirect
Robił to każdy, kro CRUDy robił profesjonalnie

1

Także IMO najlepiej będzie tylko zablokować przycisk po stronie frontendu, bo frontend to jedyne miejsce, w którym znasz intencję użytkownika.

Można zrobić tak, jak napisał powyżej @ZrobieDobrze i skorzystać z mechanizmu Post/Redirect/Get - https://en.m.wikipedia.org/wiki/Post/Redirect/Get

Ale też jest inna opcja, totalnie niezwiązana z użytym językiem (czyli uwagi o synchronized/ @Transactional są nieistotne) - podczas generowania formularza dodawać ukryte pole, którego wartość będzie losowa i unikalna. A potem, podczas obsługi wysyłki formularza (czyli po wciśnięciu Submit) sprawdzasz, czy transakcja z danym ID już została przetworzona. Jeśli tak, to po prostu ignorujesz takie żądanie, ewentualnie dajesz informację userowi, że ponownie przesłał ten sam formularz, plus możesz dać (w przypadku hurtowego dodawania większej ilości ogłoszeń) pytanie, czy na pewno chce dodać to ogłoszenie/cokolwiek innego ponownie do systemu.

0

A nie możesz np. rzucic
ResponseStatus(value = HttpStatus.CONFLICT ,reason = ("Jakis tam powód"))

0
bbzzyyczczeek napisał(a):

A nie możesz np. rzucic
ResponseStatus(value = HttpStatus.CONFLICT ,reason = ("Jakis tam powód"))

możesz rozwinąć? bo nie bardzo wiem co by mi to dało.
Generalnie chciałbym całość ograć na backendnie, napisać test i mieć pewność, że taka sytuacja się nie wydarzy bez względu na to czy request przyjdzie z frontu czy np. ktoś sobie strzeli podwójnie z postmana.
Podoba mi się ta koncepcja o której pisał @Pinek z optimistic lock java ale jeszcze nie udało mi się stworzyć działającego kodu

0
kogut8761 napisał(a):

Podoba mi się ta koncepcja o której pisał @Pinek z optimistic lock java ale jeszcze nie udało mi się stworzyć działającego kodu

Ogólnie chyba się zapędziłem troszkę xd bo w sumie optimistic lock jest do ogarniania dwóch równoległych UPDATEów, a nie 2 równoczesnych CREATów, mea culpa.

0

W servisie przed zapisaniem można sprawdzić czy już nie ma w bazie zapisanego takiego ogłoszenie i nie pozwolić zapisać drugi raz.

0

A nie możesz po prostu skleić dane, które przyszły wyliczyć z tego hash i zapisać to do bazy wraz z hashem jako unikalny?

0

@lukascode: ciekawe rozwiązanie :) ale jeśli nie uda mi się tego ogarnąć całkowicie po stronie javy to już chyba wolał bym trigger na bazie, zawsze to kolumna mniej.

btw. trafiłem na taki artykuł
https://medium.com/@hasanli.vusala.73/how-does-singleton-bean-serve-multiple-requests-at-the-same-time-in-spring-f4c9d797dec9
i zacząłem kombinować z synchronized i wątkami.
Zastanawiam się tylko jak odwzorować taką sytuację w teście:
"In Spring every request is executed in separate thread. For example,when 2 users want to login at the same time, JVM creates 2 threads: one thread for first user, another one for second user."
ten test który wkleiłem na początku postu rozumiem, że utworzy jeden wątek dla użytkownika a w nim 2 kolejne dla obu requestów?
Można stworzyć w teście sytuację opisaną powyżej? czyli np. 2 requesty od jednego usera a 1 od kolejnego?

Pewnie wiele mi to nie pomoże w rozwiązaniu problemu ale przynajmniej sprawdzę jak aplikacja będzie się zachowywała w takich sytuacjach i jak zadziała synchronized

1
kogut8761 napisał(a):

... to już chyba wolał bym trigger na bazie, zawsze to kolumna mniej.

Buuuum błysk dym ... z którego roku kolega przyleciał ? 1990 ? Bo od 1995 już nie oszczędzaliśmy kolumn

kogut8761 napisał(a):

i zacząłem kombinować z synchronized i wątkami.

Powiedział ci m.in. cerrato że problem z tematu wynika z projektu danych a nie z konkretnego sposobu zakodowania i jakimś języku / środowisku
Problem zajdzie niezależnie czy trafi w zupełnie inne wątki, czy w tym samym

cerrato napisał(a):

Ale też jest inna opcja, totalnie niezwiązana z użytym językiem (czyli uwagi o synchronized/ @Transactional są nieistotne) - podczas generowania formularza dodawać ukryte pole, którego wartość będzie losowa i unikalna. A potem, podczas obsługi wysyłki formularza ...

0
bbzzyyczczeek napisał(a):

W servisie przed zapisaniem można sprawdzić czy już nie ma w bazie zapisanego takiego ogłoszenie i nie pozwolić zapisać drugi raz.

Zaproponujesz bezpieczny pod względem współbieżności algorytm ?

2

A nie możesz po prostu skleić dane, które przyszły wyliczyć z tego hash i zapisać to do bazy wraz z hashem jako unikalny?

Przeca to nie spełnia warunku jedno ogłosznie na x-minut i do tego jest (jak wspomniałem wyżej) anty-użytkownikowe :-P

0
Patryk27 napisał(a):

A nie możesz po prostu skleić dane, które przyszły wyliczyć z tego hash i zapisać to do bazy wraz z hashem jako unikalny?

Przeca to nie spełnia warunku jedno ogłosznie na x-minut i do tego jest (jak wspomniałem wyżej) anty-użytkownikowe :-P

A to nie doczytałem o tym warunku :) Natomiast te rozwiązanie z hashem wydaje mi się spk ponieważ wydaje mi się bez sensu aby istniały
dokładnie takie same ogłoszenia z tym samym tytułem, opisem, zdjęciami itd. A jedno ogłoszenie na x-minut to też proste wystarczy ostatnie
ogłoszenie z bazy pobrać i sprawdzić ile czasu minęło.

1
lukascode napisał(a):

A to nie doczytałem o tym warunku :) Natomiast te rozwiązanie z hashem wydaje mi się spk ponieważ wydaje mi się bez sensu aby istniały
dokładnie takie same ogłoszenia z tym samym tytułem, opisem, zdjęciami itd. A jedno ogłoszenie na x-minut to też proste wystarczy ostatnie
ogłoszenie z bazy pobrać i sprawdzić ile czasu minęło.

Pomysł hasha jest niezręczny, choćby jak kontynuować dalsze życie ogłoszenia po korekcie literówek. Tak / siak / owak
Dla wydajnej pracy tak samo wymaga unikalnego indeksu - jak w moim pomysle na UUID

Dla wielu powiedzieć GUID/UUID i wszystko jasne.
Dla tych co nie maja gotowych skojarzeń: na bardzo wczesnym etapie życia obiektu / DTO wypełniamy GUID, o wiele wcześniej zanim dostanie klucz unikalny z bazy, i sobie taki tożsamościowy - unikalny klucz posiada już do końca świata

Nie widzę żadnego diabelskiego zła, gdyby UUID powstawał na kliencie, ale sympatyzuję z interakcją z backendem "hej, mam zamiar edytować nowy rekord, daj mi UUID", z tym że backend nie powinien tego zbyt szybko zapisywać do docelowej bazy danych, ale np trzymanie w cache jest ok.
W common sense UUID o wiele lepiej wyraża intencje "potrzebuję unikalnej tożsamości" niż CRC/Hash

edit: oprócz tego Hash na nasze współczesne warunki wytpada uznać jako za krótki, jak myślimy Hash, to zakładamy kolizje - a u zdecydowanie nie chcemy fałszywych kolizji

1

Pewnie będziesz korzystał z jpa więc z automatu dorzuć sobie do encji jakieś unikalne uuid + odpowiednio zaimplementuj hash code & equals:

Ponad to możesz albo sobie wyliczać odpowiedni hash (i go walidować) jak już ktoś wspomniał i go trzymać w db albo ustawić constraint unique na odpowiedniej kolumnie w db.

0

@RequiredNickname: tak jak napisał @Patryk27 "Przeca to nie spełnia warunku jedno ogłosznie na x-minut i do tego jest (jak wspomniałem wyżej) anty-użytkownikowe :-P"

0
kogut8761 napisał(a):

Zanim zablokuje możliwość ponownego kliknięcia na froncie chciałby wprowadzić zabezpieczenie przed taką sytuacją po stronie back-endu.

Jaki jest powód takiego zabezpieczenia po stronie backendu? Przecież problem bierze się z powodu błędnego UI, które nie eliminuje błędu użytkownika polegającego na 2 kliku, zamiast pojedynczym kliknięciu. Backend w przypadku metody CRUD nie powinien prowadzić kontroli kto, gdzie kiedy jak, tylko dlatego, że na froncie ktoś zrobił dwuklika.
Możliwe podejścia:

  • Po stronie frontu przy odpaleniu aplikacji generujesz unikalne id ogłoszenia, wysyłasz dla porządku przez PUT (upsert) i niech sobie klika ile razy chce. Zrobi parę update'ów i nic się nie stanie, bo "idempotencja".
  • Po stronie frontu blokujesz guzik do momentu uzyskania 201 z POST

Oczywiście mogą się pojawić uzasadnienia biznesowe dla wprowadzenia takich limitów ogłoszeń. Np. chcesz się zabezpieczyć przed jakimś złośliwcem, który napisze automat wysyłający żądania w celu uwalenia twojego serwisu (DDoS), lub zwyczajnego spamu. Metodę masz podaną przez @RequiredNickname

2 użytkowników online, w tym zalogowanych: 0, gości: 2