Zapobieganie duplikatom w aplikacjach CRUD wymaga warstw: unikalne ograniczenia w bazie, klucze idempotencji i stany UI zapobiegające podwójnym wysyłkom.

Duplikat rekordu to sytuacja, w której twoja aplikacja zapisuje tę samą rzecz dwukrotnie. To mogą być dwa zamówienia dla tego samego checkoutu, dwa zgłoszenia serwisowe o tych samych szczegółach albo dwa konta utworzone w tym samym przepływie rejestracji. W aplikacji CRUD duplikaty zwykle wyglądają jak zwykłe wiersze, ale są błędne, gdy spojrzysz na dane jako całość.
Większość duplikatów zaczyna się od normalnego zachowania. Ktoś klika Utwórz dwa razy, bo strona wydaje się wolna. Na urządzeniach mobilnych łatwo jest nie zauważyć podwójnego tapnięcia. Nawet ostrożni użytkownicy spróbują ponownie, jeśli przycisk nadal wygląda aktywnie i nie ma wyraźnej informacji, że coś się dzieje.
Potem dochodzi do bałaganu: sieć i serwery. Żądanie może przekroczyć timeout i zostać automatycznie ponowione. Biblioteka kliencka może powtórzyć POST, jeśli uzna, że pierwsza próba się nie powiodła. Pierwsze żądanie mogło się powieść, ale odpowiedź została utracona, więc użytkownik próbuje ponownie i tworzy drugi egzemplarz.
Nie rozwiążesz tego tylko na jednym poziomie, ponieważ każdy widzi tylko część historii. UI może zmniejszyć przypadkowe podwójne wysyłki, ale nie zatrzyma ponowień przez złą sieć. Serwer może wykryć powtórzenia, ale potrzebuje niezawodnego sposobu, by rozpoznać „to jest ta sama próba tworzenia”. Baza danych może wymusić reguły, ale tylko jeśli zdefiniujesz, co oznacza „ta sama rzecz”.
Cel jest prosty: sprawić, by tworzenie było bezpieczne nawet jeśli to samo żądanie zdarzy się dwa razy. Druga próba powinna stać się operacją no-op, czystą odpowiedzią „już utworzone” lub kontrolowanym konfliktem, a nie drugim wierszem.
Wiele zespołów traktuje duplikaty jako problem bazy danych. W praktyce jednak duplikaty rodzą się zwykle wcześniej, gdy ta sama akcja tworzenia jest wywoływana więcej niż raz.
Użytkownik klika Utwórz i nic się nie dzieje, więc klika ponownie. Albo naciska Enter, a potem klika przycisk. Na mobile może dojść do dwóch szybkich tapnięć, nakładających się zdarzeń touch i click albo gestu rejestrowanego dwukrotnie.
Nawet jeśli użytkownik wysłał raz, sieć może powtórzyć żądanie. Timeout może wywołać retry. Aplikacja offline może zbuforować „Zapisz” i wysłać go ponownie po przywróceniu połączenia. Niektóre biblioteki HTTP automatycznie powtarzają przy pewnych błędach i nie zauważysz tego, aż zobaczysz zduplikowane wiersze.
Serwery świadomie powtarzają pracę. Kolejki zadań retry’ują nieudane joby. Dostawcy webhooków często dostarczają to samo zdarzenie więcej niż raz, szczególnie jeśli twój endpoint jest wolny lub zwraca status inny niż 2xx. Jeśli logika tworzenia jest wywoływana przez takie zdarzenia, załóż, że duplikaty będą występować.
Współbieżność tworzy najbardziej podstępne duplikaty. Dwie karty wysyłają ten sam formularz w ciągu milisekund. Jeśli serwer robi „czy istnieje?” a potem wstawia, oba żądania mogą przejść sprawdzenie zanim którekolwiek z nich wstawi wiersz.
Traktuj klienta, sieć i serwer jako oddzielne źródła powtórzeń. Będziesz potrzebować obron na wszystkich trzech poziomach.
Jeśli chcesz jedno niezawodne miejsce do zatrzymywania duplikatów, umieść regułę w bazie danych. Poprawki w UI i kontrole po stronie serwera pomagają, ale mogą zawieść przy retry, opóźnieniach lub dwóch użytkownikach działających jednocześnie. Unikalne ograniczenie w bazie jest ostatecznym autorytetem.
Zacznij od wybrania reguły unikalności odpowiadającej temu, jak ludzie myślą o rekordzie. Typowe przykłady:
Uważaj na pola, które wyglądają na unikalne, ale nie są, np. pełne imię i nazwisko.
Gdy masz regułę, egzekwuj ją przez unikalne ograniczenie (lub unikalny indeks). Sprawi to, że baza odrzuci drugi insert, który złamałby regułę, nawet jeśli dwa żądania nadejdą w tym samym czasie.
Gdy ograniczenie zadziała, zdecyduj, jakie doświadczenie ma mieć użytkownik. Jeśli tworzenie duplikatu jest zawsze błędem, zablokuj je z jasnym komunikatem („Ten e‑mail jest już w użyciu”). Jeśli retry są powszechne i rekord już istnieje, często lepiej potraktować retry jako sukces i zwrócić istniejący rekord („Twoje zamówienie już zostało utworzone”).
Jeśli twoje tworzenie to w praktyce „utwórz albo użyj istniejącego”, upsert może być najczystszym rozwiązaniem. Przykład: „utwórz klienta po emailu” może wstawić nowy wiersz lub zwrócić istniejący. Używaj tego tylko wtedy, gdy pasuje do znaczenia biznesowego. Jeśli do tego samego klucza mogą dojść nieco różne ładunki, zdecyduj, które pola mogą się aktualizować, a które muszą pozostać niezmienione.
Ograniczenia unikalności nie zastąpią kluczy idempotencji ani dobrych stanów UI, ale dają twardy stop, na którym wszystko inne może się oprzeć.
Klucz idempotencji to unikalny token reprezentujący jedną intencję użytkownika, np. „utwórz to zamówienie raz”. Jeśli to samo żądanie zostanie wysłane ponownie (podwójne kliknięcie, retry sieciowe, wznowienie aplikacji mobilnej), serwer traktuje je jako powtórkę, nie jako nowe tworzenie.
To jedno z najpraktyczniejszych narzędzi do zabezpieczania endpointów tworzących, gdy klient nie może stwierdzić, czy pierwsza próba się powiodła.
Najbardziej zyskają endpointy, gdzie duplikat jest kosztowny lub mylący: zamówienia, faktury, płatności, zaproszenia, subskrypcje i formularze wywołujące e‑maile lub webhooki.
Przy retry serwer powinien zwrócić oryginalny wynik z pierwszej udanej próby, włącznie z tym samym ID rekordu i kodem statusu. Aby to zrobić, przechowaj mały rekord idempotencji indeksowany przez (użytkownik lub konto) + endpoint + klucz idempotencji. Zapisz efekt (ID rekordu, ciało odpowiedzi) oraz stan „w toku”, aby dwie niemal równoczesne próby nie utworzyły dwóch wierszy.
Przechowuj rekordy idempotencji wystarczająco długo, by obejmowały rzeczywiste retry. Typowy punkt wyjścia to 24 godziny. Dla płatności wiele zespołów trzyma 48–72 godziny. TTL utrzymuje przestrzeń do przechowywania w ryzach i odpowiada temu, jak długo retry są prawdopodobne.
Jeśli generujesz API za pomocą narzędzia takiego jak Koder.ai, wciąż warto zrobić idempotencję explicite: akceptuj klucz przesyłany przez klienta (nagłówek lub pole) i egzekwuj „ten sam klucz, ten sam wynik” po stronie serwera.
Idempotencja sprawia, że żądanie tworzące jest bezpieczne do powtórzenia. Jeśli klient próbuje ponownie z powodu timeoutu (lub użytkownik klika dwa razy), serwer zwraca ten sam wynik zamiast tworzyć drugi wiersz.
Idempotency-Key), ale można też wysyłać go w ciele JSON.Kluczowy szczegół to to, że „sprawdź + zapisz” musi być bezpieczne przy współbieżności. W praktyce zapisujesz rekord idempotencji z unikalnym ograniczeniem na (scope, key) i traktujesz konflikty jako sygnał do ponownego użycia.
if idempotency_record exists for (user_id, key):
return saved_response
else:
begin transaction
create the row
save response under (user_id, key)
commit
return response
Przykład: klient naciska „Utwórz fakturę”, aplikacja wysyła klucz abc123, a serwer tworzy fakturę inv_1007. Jeśli telefon straci sygnał i spróbuje ponownie, serwer odpowie tą samą odpowiedzią inv_1007, a nie inv_1008.
Podczas testów nie ograniczaj się do „podwójne kliknięcie”. Symuluj sytuację, w której żądanie timeoutuje po stronie klienta, ale kończy się po stronie serwera, a potem spróbuj ponownie z tym samym kluczem.
Obrony po stronie serwera są ważne, ale wiele duplikatów zaczyna się od człowieka wykonującego normalną czynność dwa razy. Dobry UI sprawia, że bezpieczna ścieżka jest oczywista.
Wyłącz przycisk wysyłania zaraz po pierwszym kliknięciu. Zrób to przy pierwszym naciśnięciu, a nie dopiero po walidacji czy rozpoczęciu żądania. Jeśli formularz można wysłać przez różne kontrolki (przycisk i Enter), zablokuj cały stan formularza, nie tylko jeden przycisk.
Pokaż wyraźny stan postępu, który odpowiada na jedno pytanie: czy to działa? Prosty label „Zapisuję...” lub spinner wystarczy. Utrzymaj stabilny layout, by przycisk nie skakał i nie skłaniał do drugiego kliknięcia.
Mały zestaw zasad zapobiega większości podwójnych wysyłek: ustaw flagę isSubmitting na początku handlera submit, ignoruj nowe próby dopóki jest true (dla kliknięć i Enter), i nie czyść jej dopóki nie otrzymasz rzeczywistej odpowiedzi.
Wolne odpowiedzi to miejsce, gdzie wiele aplikacji zawodzi. Jeśli ponownie włączasz przycisk na stałym timerze (np. po 2 sekundach), użytkownicy mogą wysłać ponownie podczas gdy pierwsze żądanie wciąż jest w locie. Włącz ponownie dopiero po zakończeniu próby.
Po sukcesie utrudnij ponowne wysłanie. Przejdź do innego widoku (np. strony z nowym rekordem lub listy) albo pokaż wyraźny stan sukcesu z widocznym utworzonym rekordem. Unikaj zostawiania tego samego wypełnionego formularza na ekranie z aktywnym przyciskiem.
Uparte błędy duplikatów wynikają z codziennych „dziwnych, ale powszechnych” zachowań: dwie karty, odświeżenie strony lub telefon, który traci sygnał.
Po pierwsze, określ właściwie zakres unikalności. „Unikalne” rzadko znaczy „unikalne w całej bazie”. Może to oznaczać jedno na użytkownika, jedno na workspace lub jedno na tenant. Jeśli synchronizujesz z systemem zewnętrznym, może być potrzebna unikalność per external source plus jego external ID. Bezpiecznym podejściem jest zapisanie dokładnego zdania, które masz na myśli (np. „Jeden numer faktury na tenant na rok”), a potem jego egzekwowanie.
Zachowanie w wielu kartach to klasyczna pułapka. Stany ładowania w UI pomogą w jednej karcie, ale nic nie zrobią między kartami. To właśnie tam obrona po stronie serwera musi nadal działać.
Przycisk Wstecz i odświeżenie mogą wywołać przypadkowe ponowne wysyłki. Po udanym tworzeniu użytkownicy często odświeżają, by „sprawdzić”, albo naciskają Wstecz i znowu wysyłają formularz, który nadal wygląda edytowalnie. Preferuj widok utworzonego rekordu zamiast pozostawiania formularza i upewnij się, że serwer obsługuje bezpieczne powtórzenia.
Mobile dodaje przerwania: przechodzenie do tła, niestabilne sieci i automatyczne retry. Żądanie może się powieść, ale aplikacja nigdy nie otrzyma odpowiedzi, więc spróbuje ponownie po wznowieniu.
Najczęstszy tryb awarii to traktowanie UI jako jedyrego zabezpieczenia. Wyłączenie przycisku i spinner pomagają, ale nie pokrywają odświeżeń, niestabilnych sieci mobilnych, otwartych drugich kart ani błędów klienta. Serwer i baza danych nadal muszą umieć powiedzieć „to tworzenie już zaszło”.
Inną pułapką jest wybór niewłaściwego pola do unikalności. Jeśli ustawisz unique na czymś, co nie jest naprawdę unikalne (np. nazwisko, zaokrąglony timestamp, tytuł dowolnego pola), zablokujesz prawidłowe rekordy. Zamiast tego użyj prawdziwego identyfikatora (np. ID dostawcy zewnętrznego) lub reguły zakresowej (unikalne per użytkownik, per dzień, per rekord rodzica).
Klucze idempotencji też łatwo wdrożyć źle. Jeśli klient generuje nowy klucz przy każdym retry, otrzymasz nowe tworzenie za każdym razem. Utrzymuj ten sam klucz dla całego zamiaru użytkownika od pierwszego kliknięcia po wszystkie retry.
Zwróć też uwagę, co zwracasz przy retry. Jeśli pierwsze żądanie utworzyło rekord, retry powinno zwrócić ten sam wynik (albo przynajmniej ten sam ID rekordu), a nie niejasny błąd, który skłoni użytkownika do ponownej próby.
Jeśli unikalne ograniczenie zablokuje duplikat, nie ukrywaj tego pod „Coś poszło nie tak”. Powiedz wprost: „Ten numer faktury już istnieje. Zachowaliśmy oryginał i nie utworzyliśmy drugiego rekordu.”
Przed wypuszczeniem wykonaj szybką rundę skupioną na ścieżkach tworzenia. Najlepsze efekty osiąga się przez nakładanie zabezpieczeń, tak żeby pominięte kliknięcie, retry czy wolna sieć nie mogły stworzyć dwóch wierszy.
Potwierdź trzy rzeczy:
Praktyczny test: otwórz formularz, kliknij submit dwa razy szybko, potem odśwież w trakcie wysyłania i spróbuj ponownie. Jeśli możesz utworzyć dwa rekordy, prawdziwi użytkownicy też to zrobią.
Wyobraź sobie małą aplikację fakturową. Użytkownik wypełnia nową fakturę i naciska Utwórz. Sieć jest wolna, ekran nie zmienia się od razu i naciska ponownie.
Tylko z ochroną w UI możesz wyłączyć przycisk i pokazać spinner. To pomaga, ale nie wystarcza. Podwójne tapnięcie może się jednak nadal zdarzyć na niektórych urządzeniach, retry może wystąpić po timeoutcie albo użytkownik może wysłać formularz z dwóch kart.
Tylko z unikalnym ograniczeniem w bazie możesz zatrzymać dokładne duplikaty, ale doświadczenie może być nieprzyjemne. Pierwsze żądanie się powiedzie, drugie trafi na ograniczenie i użytkownik zobaczy błąd, mimo że faktura została utworzona.
Czysty wynik to idempotencja plus unikalne ograniczenie:
Prosta wiadomość w UI po drugim kliknięciu: „Faktura utworzona — zignorowaliśmy podwójną wysyłkę i zachowaliśmy Twoje pierwsze żądanie.”
Gdy masz już podstawy, kolejne korzyści pochodzą z widoczności, sprzątania i spójności.
Dodaj lekkie logowanie wokół ścieżek tworzenia, żeby odróżnić prawdziwe akcje użytkownika od retry. Loguj klucz idempotencji, pola unikalne i wynik (utworzono vs zwrócono istniejące vs odrzucono). Nie potrzebujesz ciężkiego narzędzia, by zacząć.
Jeśli duplikaty już istnieją, oczyść je według jasnej reguły i z audytem. Na przykład zachowaj najstarszy rekord jako „zwycięzcę”, podczep powiązane wiersze (płatności, pozycje) i oznacz pozostałe jako scalone zamiast usuwać. To ułatwia wsparcie i raportowanie.
Zapisz swoje reguły unikalności i idempotencji w jednym miejscu: co jest unikalne i w jakim zakresie, jak długo żyją klucze idempotencji, jak wyglądają błędy i co UI powinien robić przy retry. To zapobiegnie sytuacji, w której nowe endpointy omijają zabezpieczenia.
Jeśli szybko budujesz ekrany CRUD w Koder.ai (koder.ai), warto włączyć te zachowania do domyślnego szablonu: unikalne ograniczenia w schemacie, idempotentne endpointy tworzące w API i wyraźne stany ładowania w UI. W ten sposób szybkość nie będzie kosztem bałaganu w danych.
Duplikat to sytuacja, w której ten sam realny obiekt zostaje zapisany dwukrotnie — na przykład dwie zamówienia z jednego checkoutu albo dwa zgłoszenia dotyczące tej samej sprawy. Zwykle pojawia się, gdy ta sama operacja „utwórz” zostanie wykonana więcej niż raz z powodu podwójnego kliknięcia, ponowień lub równoległych żądań.
Bo drugie tworzenie może zajść bez wiedzy użytkownika — np. podwójne tapnięcie na mobile, naciśnięcie Enter, a potem kliknięcie przycisku. Nawet gdy użytkownik wysyła raz, klient, sieć lub serwer mogą powtórzyć żądanie po timeoutcie, więc nie można zakładać, że „POST oznacza jeden raz”.
Nie, nie wystarczy to samo. Wyłączenie przycisku i pokazanie „Zapisuję…” zmniejsza przypadkowe podwójne wysyłki, ale nie zatrzyma ponowień spowodowanych niestabilną siecią, odświeżeniem strony, wieloma kartami czy ponownym wysłaniem webhooka. Potrzebujesz też obrony po stronie serwera i bazy danych.
Ograniczenie unikalności w bazie danych to ostateczna linia obrony — zatrzyma wstawienie drugiego wiersza, nawet jeśli dwa żądania nadejdą równocześnie. Najlepiej ustawiać je na regule odpowiadającej rzeczywistości (często zakresowej, np. per tenant lub per workspace) i egzekwować bezpośrednio w bazie.
Oba rozwiązania się uzupełniają. Ograniczenia blokują duplikaty według pól (np. numer faktury), a klucze idempotencji sprawiają, że konkretna próba tworzenia jest bezpieczna do powtórzenia (ten sam klucz zwraca ten sam wynik). Użycie obu daje bezpieczeństwo i lepsze doświadczenie użytkownika.
Wygeneruj jeden klucz dla pojedynczego zamiaru użytkownika (np. naciśnięcie „Utwórz”), używaj go podczas wszystkich prób tego konkretnego działania i dołączaj do żądania. Klucz powinien być stabilny przez timeouty i wznowienia aplikacji, ale nie powinien być ponownie używany dla innych tworzeń.
Przechowuj rekord idempotencji z zakresem (np. użytkownik lub konto), endpointem i kluczem idempotencji oraz odpowiedź, którą zwrócono przy pierwszym udanym żądaniu. Jeśli ten sam klucz pojawi się ponownie, zwróć zapisaną odpowiedź z tym samym ID utworzonego rekordu zamiast tworzyć nowy wiersz.
Zastosuj bezpieczną wobec współbieżności strategię „sprawdź + zapisz”, najczęściej poprzez unikalne ograniczenie na rekord idempotencji (dla zakresu i klucza). Dzięki temu dwa niemal równoczesne żądania nie będą mogły oba uznać się za „pierwsze” i jedno z nich będzie musiało ponownie użyć zapisanego wyniku.
Przechowuj je na tyle długo, by pokryć realistyczne ponowne próby; typowo to około 24 godziny, a dla płatności 48–72 godziny. Dodaj TTL, by nie rosnęła bez końca ilość przechowywanych rekordów i żeby okres odpowiadał temu, jak długo klient może realistycznie próbować ponownie.
Jeśli oczywiście mamy do czynienia z tym samym zamiarze, potraktuj duplikat jako udaną powtórkę i zwróć oryginalny rekord (to samo ID), zamiast niejasnego błędu. Gdy chodzi o coś, co musi być unikalne (np. email), zwróć jasny komunikat konfliktu wyjaśniający, co już istnieje i co się stało.