Warunki wyścigu w aplikacjach CRUD mogą powodować zdublowane zamówienia i błędne sumy. Poznaj typowe punkty kolizji i praktyczne poprawki: ograniczenia, blokady i zabezpieczenia w UI.

Warunek wyścigu występuje, gdy dwa (lub więcej) żądań aktualizuje te same dane niemal w tym samym czasie, a ostateczny rezultat zależy od kolejności i timingu. Każde żądanie samo w sobie wygląda poprawnie. Razem dają niepoprawny wynik.
Prosty przykład: dwie osoby klikają Zapisz na tym samym rekordzie klienta w ciągu sekundy. Jedna zmienia e‑mail, druga numer telefonu. Jeśli oba żądania wysyłają cały rekord, drugi zapis może nadpisać pierwszy i jedna zmiana znika bez błędu.
Spotkasz to częściej w szybkich aplikacjach, bo użytkownicy wywołują więcej akcji na minutę. Szczyty występują też przy dużym natężeniu: wyprzedaże, koniec miesiąca, duże kampanie e‑mailowe lub każdy moment, gdy backlog żądań trafia w te same wiersze.
Użytkownicy rzadko zgłaszają „warunek wyścigu”. Zgłaszają objawy: zdublowane zamówienia lub komentarze, brakujące aktualizacje ("zapisane, ale wróciło do poprzedniego"), dziwne sumy (stan magazynu idzie poniżej zera, liczniki cofną się) lub statusy, które niespodziewanie się odwracają (zatwierdzone, potem znów oczekujące).
Powtórzenia pogarszają sprawę. Ludzie dwukrotnie klikają, odświeżają po wolnej odpowiedzi, wysyłają z dwóch kart albo mają niestabilne sieci, które ponawiają żądania. Jeśli serwer traktuje każde żądanie jak świeży zapis, możesz dostać dwa create, dwie płatności lub dwa przejścia statusu, które miały być wykonane raz.
Większość aplikacji CRUD wydaje się prosta: czytaj wiersz, zmień pole, zapisz. Problem w tym, że aplikacja nie kontroluje timingu. Baza danych, sieć, retry, praca w tle i zachowanie użytkownika nachodzą na siebie.
Częstym wyzwalaczem są dwie osoby edytujące ten sam rekord. Obie ładują te same „bieżące” wartości, obie dokonują poprawnych zmian i ostatni zapis cicho nadpisuje pierwszy. Nikt nie zrobił nic złego, ale jedna aktualizacja przepadła.
Dzieje się to też u jednej osoby. Podwójne kliknięcie Przycisnij Zapisz, szybkie przejście przód‑tył albo wolne połączenie, które powoduje ponowne naciśnięcie Wyślij, może wysłać ten sam zapis dwukrotnie. Jeśli endpoint nie jest idempotentny, dostaniesz duplikaty, podwójne obciążenia lub przeskok statusu o dwa kroki.
Nowoczesne użycie dodaje więcej nakładania się. Kilka kart lub urządzeń zalogowanych na to samo konto może generować sprzeczne aktualizacje. Zadania w tle (mailingi, billing, synchronizacja, sprzątanie) mogą dotykać tych samych wierszy co żądania webowe. Automatyczne retry po stronie klienta, load balancera czy runnera zadań mogą powtórzyć już zakończone żądanie.
Jeżeli szybko dostarczasz funkcje, ten sam rekord często jest aktualizowany z większej liczby miejsc niż ktokolwiek pamięta. Jeśli używasz narzędzia chatowego jak Koder.ai, aplikacja może rosnąć jeszcze szybciej — warto traktować współbieżność jako normalne zachowanie, a nie przypadek brzegowy.
Warunki wyścigu rzadko wychodzą w demo "stworzyć rekord". Pojawiają się tam, gdzie dwa żądania dotykają tej samej prawdy w niemal tym samym momencie. Znajomość punktów zapalnych pomaga projektować bezpieczne zapisy od pierwszego dnia.
Wszystko, co wygląda jak "po prostu dodaj 1", może się zepsuć pod obciążeniem: polubienia, liczby wyświetleń, sumy, numery faktur, numery biletów. Ryzykowny wzorzec to odczytać wartość, dodać, a potem zapisać z powrotem. Dwa żądania mogą odczytać tę samą wartość początkową i nadpisać się nawzajem.
Workflowy jak Draft -> Submitted -> Approved -> Paid wyglądają prostolinijnie, ale kolizje są powszechne. Problem zaczyna się, gdy dwie akcje są możliwe jednocześnie (zatwierdź i edytuj, anuluj i zapłać). Bez zabezpieczeń możesz dostać rekord, który pomija kroki, cofa się albo pokazuje różne stany w różnych tabelach.
Traktuj zmiany statusu jak kontrakt: pozwalaj tylko na następny poprawny krok i odrzucaj wszystko inne.
Dostępne miejsca, stany magazynowe, terminy wizyt i pola "pojemność pozostała" tworzą klasyczny problem oversell. Dwóch kupujących zobaczy dostępność i obaj sfinalizują zakup. Jeśli baza danych nie jest ostatecznym sędzią, w końcu sprzedasz więcej niż masz.
Niektóre reguły są absolutne: jeden e‑mail na konto, jedna aktywna subskrypcja na użytkownika, jeden otwarty koszyk na użytkownika. Często zawodzą, gdy najpierw sprawdzasz ("czy istnieje?") a potem wstawiasz. Pod obciążeniem oba żądania mogą przejść sprawdzenie.
Jeśli szybko generujesz przepływy CRUD (na przykład poprzez chat z Koder.ai), zanotuj te punkty i zabezpiecz je ograniczeniami i bezpiecznymi zapisami, a nie tylko sprawdzeniami w UI.
Wiele warunków wyścigu zaczyna się od czegoś trywialnego: ta sama akcja jest wysyłana dwa razy. Użytkownicy dwukrotnie klikają. Sieć jest wolna, więc klikają ponownie. Telefon zarejestruje dwa tapnięcia. Czasem to nieintencjonalne: strona odświeża się po POST i przeglądarka proponuje ponowne wysłanie.
Gdy to następuje, backend może równolegle wykonać dwa create lub update. Jeśli oba się powiodą, masz duplikaty, błędne sumy lub zmianę statusu wykonaną dwukrotnie (np. approve i jeszcze jedno approve). Wygląda to losowo, bo zależy od timingu.
Najbezpieczniejsze podejście to obrona w głębi. Napraw UI, ale zakładaj, że UI zawiedzie.
Praktyczne zmiany, które możesz zastosować do większości ścieżek zapisu:
Przykład: użytkownik klika "Opłać fakturę" dwukrotnie na mobilu. UI powinno zablokować drugie tapnięcie. Serwer też powinien odrzucić drugie żądanie po wykryciu tego samego idempotency key i zwrócić oryginalny rezultat sukcesu zamiast obciążać dwukrotnie.
Pola statusu wydają się proste, dopóki dwie rzeczy nie próbują ich zmienić jednocześnie. Użytkownik klika Zatwierdź, podczas gdy zadanie automatyczne oznacza ten sam rekord jako Wygasłe, albo dwóch członków zespołu pracuje nad tym samym itemem w różnych kartach. Obie aktualizacje mogą się powieść, ale ostateczny status zależy od timingu, a nie twoich reguł.
Traktuj status jak małą maszynę stanów. Zachowaj krótką tabelę dozwolonych ruchów (np.: Draft -> Submitted -> Approved, oraz Submitted -> Rejected). Potem każdy zapis sprawdza: "Czy ten ruch jest dozwolony z bieżącego statusu?" Jeśli nie, odrzuć zamiast cicho nadpisać.
Optymistyczne blokowanie pomaga wykrywać przestarzałe aktualizacje bez blokowania innych użytkowników. Dodaj numer wersji (lub updated_at) i wymagaj, żeby pasował podczas zapisu. Jeśli ktoś inny zmienił wiersz po twoim odczycie, twój update dotknie 0 wierszy i możesz pokazać jasny komunikat typu: "Ten element się zmienił, odśwież i spróbuj ponownie."
Prosty wzorzec dla aktualizacji statusu:
Również trzymaj zmiany statusu w jednym miejscu. Jeśli aktualizacje są rozproszone po ekranach, zadaniach w tle i webhookach, przegapisz regułę. Umieść je za jedną funkcją lub endpointem, który za każdym razem egzekwuje te same sprawdzenia przejść.
Najczęstszy błąd z licznikiem wygląda niegroźnie: aplikacja odczytuje wartość, dodaje 1, a potem zapisuje z powrotem. Pod obciążeniem dwa żądania mogą odczytać tę samą liczbę i oba zapisać tę samą nową liczbę, więc jedno inkrementowanie ginie. To łatwo przeoczyć, bo "zwykle działa" w testach.
Jeśli wartość jest tylko inkrementowana lub dekrementowana, pozwól bazie danych zrobić to jednym zapytaniem. Wtedy baza bezpiecznie zastosuje zmiany nawet gdy wiele żądań uderza jednocześnie.
UPDATE posts
SET like_count = like_count + 1
WHERE id = $1;
Ta sama idea dotyczy zapasów, liczników wyświetleń, liczników retry i wszystkiego, co da się wyrazić jako "new = old + delta".
Sumy często się psują, gdy przechowujesz liczbę pochodną (order_total, account_balance, project_hours) i aktualizujesz ją z wielu miejsc. Jeśli możesz obliczać sumę z wierszy źródłowych (pozycje zamówienia, wpisy księgowe), unikasz całej klasy błędów driftu.
Gdy musisz przechowywać sumę dla wydajności, traktuj to jak krytyczny zapis. Trzymaj aktualizacje wierszy źródłowych i przechowywanej sumy w tej samej transakcji. Zapewnij, że tylko jeden writer może aktualizować tę samą sumę w danym momencie (blokady, guarded updates lub pojedyncza ścieżka właściciela). Dodaj ograniczenia, które zapobiegają niemożliwym wartościom (np. stan magazynu >= 0). Potem okresowo przeprowadzaj rekonsyliację, która przelicza i flaguje niespójności.
Konkretne: dwaj użytkownicy dodają produkty do tego samego koszyka jednocześnie. Jeśli każde żądanie odczytuje cart_total, dodaje cenę i zapisuje z powrotem, jedna pozycja może zniknąć. Jeśli zaktualizujesz pozycje koszyka i total razem w jednej transakcji, total pozostanie poprawny nawet przy silnych, równoległych kliknięciach.
Jeśli chcesz mniej warunków wyścigu, zacznij od bazy danych. Kod aplikacji może retry‑ować, timeoutować lub wykonać się dwa razy. Ograniczenie bazy to ostatnia brama, która pozostaje poprawna nawet gdy dwa żądania trafiają w tym samym czasie.
Unikalne ograniczenia zatrzymują duplikaty, które "nigdy nie powinny się zdarzyć" ale się zdarzają: adresy e‑mail, numery zamówień, ID faktur lub reguła "jedna aktywna subskrypcja na użytkownika". Gdy dwa zapisy lądują razem, baza zaakceptuje jeden wiersz i odrzuci drugi.
Klucze obce zapobiegają zerwanym referencjom. Bez nich jedno żądanie może usunąć rekord‑rodzica, podczas gdy inne tworzy dziecko wskazujące na nic, pozostawiając sieroty trudne do sprzątnięcia później.
Check constraints utrzymują wartości w bezpiecznym zakresie i egzekwują proste reguły stanu. Na przykład quantity >= 0, rating między 1 a 5, albo status ograniczony do dozwolonego zbioru.
Traktuj błędy ograniczeń jako spodziewane wyniki, nie "błędy serwera". Łap naruszenia unique, foreign key i check, zwracaj czytelny komunikat typu "Ten e‑mail jest już używany" i loguj szczegóły do debugowania bez wycieku wewnętrznych danych.
Przykład: dwie osoby klikają "Utwórz zamówienie" podczas opóźnienia. Z unique constraint na (user_id, cart_id) nie dostaniesz dwóch zamówień. Dostaniesz jedno zamówienie i jedno czyste, wytłumaczalne odrzucenie.
Niektóre zapisy to nie pojedyncze polecenie. Odczytujesz wiersz, sprawdzasz regułę, aktualizujesz status i może dodajesz wpis audytu. Jeśli dwa żądania zrobią to równocześnie, oba mogą przejść sprawdzenie i oba zapisać. To klasyczny wzorzec awarii.
Opakuj wieloetapowy zapis w jednej transakcji, żeby wszystkie kroki powiodły się razem albo nie powiodły wcale. Co ważniejsze, transakcja daje miejsce do kontrolowania, kto może zmieniać te same dane w tym samym czasie.
Gdy tylko jeden aktor może edytować rekord w danym momencie, użyj blokady na poziomie wiersza. Na przykład: zablokuj wiersz zamówienia, potwierdź, że wciąż jest w stanie "pending", potem przestaw na "approved" i zapisz wpis audytu. Drugie żądanie poczeka, potem ponownie sprawdzi stan i zatrzyma się.
Wybierz w zależności od częstotliwości kolizji:
Trzymaj czas trwania blokady krótko. Rób jak najmniej pracy podczas jej trzymania: żadnych zewnętrznych wywołań API, żadnych wolnych operacji plikowych, żadnych dużych pętli. Jeśli budujesz flowy w narzędziu takim jak Koder.ai, trzymaj transakcję tylko na kroki bazodanowe, a resztę rób po commit.
Wybierz jedną ścieżkę, która może kosztować pieniądze lub zaufanie przy kolizji. Częsty przykład: utwórz zamówienie, zarezerwuj towar, potem ustaw status zamówienia na potwierdzone.
Spisz dokładne kroki, które dziś wykonuje twój kod, w kolejności. Bądź konkretny co jest czytane, co zapisywane i co oznacza "sukces". Kolizje chowają się w przerwie między odczytem a późniejszym zapisem.
Ścieżka utwardzania, która działa w większości stacków:
Dodaj jeden test, który potwierdzi poprawkę. Uruchom dwa równoległe żądania przeciwko temu samemu produktowi i ilości. Asserty, że dokładnie jedno zamówienie zostaje potwierdzone, a drugie niepowoduje się w kontrolowany sposób (brak ujemnego stanu, brak zduplikowanych wierszy rezerwacji).
Nawet jeśli generujesz aplikacje szybko (również z platform jak Koder.ai), ta checklista jest warta zastosowania na kilku ścieżkach zapisu, które mają największe znaczenie.
Jedną z największych przyczyn jest zaufanie do UI. Wyłączone przyciski i sprawdzenia po stronie klienta pomagają, ale użytkownicy mogą dwukrotnie kliknąć, odświeżyć, otworzyć dwie karty lub odtworzyć żądanie z niestabilnego połączenia. Jeśli serwer nie jest idempotentny, duplikaty przedostaną się.
Inny cichy błąd: łapiesz błąd bazy (np. naruszenie unique), ale kontynuujesz workflow mimo to. To często kończy się sytuacją "tworzenie nie powiodło się, ale i tak wysłaliśmy e‑mail" albo "płatność nie powiodła się, ale oznaczyliśmy zamówienie jako opłacone". Gdy wystąpią skutki uboczne, trudno je cofnąć.
Długie transakcje też są pułapką. Jeśli trzymasz transakcję otwartą podczas wysyłania maili, płatności czy wywołań zewnętrznych, trzymasz blokady dłużej niż trzeba. To zwiększa oczekiwanie, time‑outy i szansę, że żądania będą się blokować wzajemnie.
Mieszanie zadań w tle i akcji użytkownika bez jednej źródłowej prawdy tworzy stan split‑brain. Job robi retry i aktualizuje wiersz, podczas gdy użytkownik go edytuje i teraz obie strony myślą, że były ostatnim zapisem.
Kilka "poprawek", które w praktyce nie rozwiązują problemu:
Jeśli budujesz z narzędziem chat‑to‑app jak Koder.ai, te same reguły obowiązują: proś o zabezpieczenia po stronie serwera i jasne granice transakcyjne, nie tylko ładniejsze zabezpieczenia w UI.
Warunki wyścigu często wychodzą tylko przy prawdziwym ruchu. Przejście pre‑release może złapać najczęstsze punkty kolizji bez przepisywania wszystkiego.
Zacznij od bazy danych. Jeśli coś musi być unikalne (e‑maile, numery faktur, jedna aktywna subskrypcja na użytkownika), zrób z tego realny unique constraint, nie regułę sprawdzaną tylko przez aplikację. Potem upewnij się, że kod spodziewa się, iż constraint czasami zawiedzie i zwraca jasną, bezpieczną odpowiedź.
Następnie spójrz na stany. Każda zmiana statusu (Draft -> Submitted -> Approved) powinna być walidowana względem jawnego zestawu dozwolonych przejść. Jeśli dwa żądania próbują przesunąć ten sam rekord, drugie powinno zostać odrzucone albo stać się no‑op, a nie generować stanu pośredniego.
Praktyczna lista kontrolna przed wydaniem:
Jeśli budujesz flowy w Koder.ai, traktuj to jako kryteria akceptacji: wygenerowana aplikacja powinna bezpiecznie się nie powieść przy powtórkach i współbieżności, a nie tylko przechodzić scenariusz "happy path".
Dwóch pracowników otwiera ten sam wniosek zakupowy. Obaj klikają Zatwierdź w ciągu kilku sekund. Oba żądania trafiają na serwer.
Co może pójść nie tak jest nieporządne: wniosek zostaje "zatwierdzony" dwukrotnie, wychodzą podwójne powiadomienia, a wszelkie sumy powiązane z zatwierdzeniami (zużyty budżet, dzienna liczba zatwierdzeń) rosną o 2. Obie aktualizacje są osobno poprawne, ale kolidują.
Oto plan naprawczy, który dobrze działa z bazą PostgreSQL.
Dodaj regułę, która gwarantuje, że tylko jeden rekord zatwierdzenia może istnieć dla żądania. Na przykład przechowuj zatwierdzenia w osobnej tabeli i narzuć unique constraint na request_id. Teraz drugi insert zawiedzie nawet gdy kod aplikacji ma błąd.
Przy zatwierdzaniu wykonaj cały przejście w jednej transakcji:
Jeśli drugi pracownik przyjdzie później, zobaczy albo 0 zaktualizowanych wierszy, albo błąd unique constraint. W obu wypadkach wygra tylko jedna zmiana.
Po naprawie pierwszy pracownik widzi Approved i normalne potwierdzenie. Drugi widzi przyjazny komunikat typu: "To żądanie zostało już zatwierdzone przez kogoś innego. Odśwież, aby zobaczyć najnowszy status." Koniec z powiadomieniami podwójnymi i cichymi błędami.
Jeśli generujesz CRUD w platformie takiej jak Koder.ai (Go backend z PostgreSQL), możesz zaimplementować te sprawdzenia w akcji approve raz i wielokrotnie je wykorzystywać dla innych operacji "tylko jeden zwycięzca".
Warunki wyścigu najłatwiej naprawić, gdy traktujesz je jako powtarzalną rutynę, a nie jednorazowe polowanie na błąd. Skoncentruj się na kilku ścieżkach zapisu, które mają największe znaczenie, i spraw, by były nudno poprawne, zanim dopracujesz resztę.
Zacznij od nazwania swoich głównych punktów kolizji. W wielu aplikacjach CRUD to często ta sama trójka: liczniki (polubienia, zapasy, salda), zmiany statusów (Draft -> Submitted -> Approved) i podwójne wysyłania (dwukliknięcia, retry, wolne sieci).
Rutyna, która się sprawdza:
Jeśli budujesz na Koder.ai, Planning Mode to praktyczne miejsce do zmapowania każdego flowu zapisu jako kroków i reguł zanim wygenerujesz zmiany w Go i PostgreSQL. Snapshots i rollback też się przydają, gdy wdrażasz nowe ograniczenia lub zachowanie blokujące i chcesz szybki powrót w razie nieprzewidzianego przypadku brzegowego.
Z czasem stanie się to nawykiem: każda nowa funkcja zapisu dostaje constraint, plan transakcyjny i test współbieżności. Wtedy warunki wyścigu w aplikacjach CRUD przestają być niespodzianką.