Naucz się używać transakcji Postgres dla wieloetapowych przepływów: jak grupować aktualizacje, zapobiegać częściowym zapisom, obsługiwać retry i zachować spójność danych.

Większość realnych funkcji to nie pojedyncza zmiana w bazie. To krótki łańcuch: wstaw row, zaktualizuj saldo, oznacz status, zapisz wpis audytowy, może dodaj zadanie do kolejki. Częściowy zapis zdarza się, gdy tylko niektóre z tych kroków trafią do bazy.
Objawia się to, gdy coś przerwie łańcuch: błąd serwera, timeout między aplikacją a Postgresem, awaria po kroku 2 lub retry, który ponownie wykona krok 1. Każde polecenie samo w sobie jest poprawne. Przepływ psuje się, gdy zatrzyma się w połowie.
Zwykle łatwo to zauważyć:
Konkretny przykład: aktualizacja planu zmienia plan klienta, dodaje rekord płatności i zwiększa dostępne kredyty. Jeśli aplikacja padnie po zapisaniu płatności, ale przed dodaniem kredytów, dział wsparcia widzi w jednej tabeli „zapłacono”, a w innej „brak kredytów”. Jeśli klient ponowi żądanie, możesz nawet zapisać płatność dwa razy.
Cel jest prosty: traktuj przepływ jak pojedynczy przełącznik. Albo wszystkie kroki się powiodą, albo żaden, żeby nigdy nie przechowywać pracy w połowie zrobionej.
Transakcja to sposób bazy danych, by powiedzieć: traktuj te kroki jako jedną jednostkę pracy. Albo wszystkie zmiany zajdą, albo żadna. To ma znaczenie zawsze, gdy Twój przepływ wymaga więcej niż jednej aktualizacji, np. stworzenie wiersza, zmiana salda i zapis audytu.
Pomyśl o przenoszeniu pieniędzy między dwoma kontami. Musisz odpowiednio odjąć z konta A i dodać do konta B. Jeśli aplikacja padnie po pierwszym kroku, nie chcesz, żeby system „zapamiętał” tylko odjęcie.
Kiedy commitujesz, mówisz Postgresowi: zachowaj wszystko, co zrobiłem w tej transakcji. Wszystkie zmiany stają się trwałe i widoczne dla innych sesji.
Kiedy rollbackujesz, mówisz Postgresowi: zapomnij wszystko, co zrobiłem w tej transakcji. Postgres cofa zmiany tak, jakby transakcja nigdy nie miała miejsca.
W ramach transakcji Postgres gwarantuje, że nie ujawnisz innym sesjom półgotowych wyników przed commitem. Jeśli coś się nie powiedzie i wycofasz transakcję, baza posprząta zapisy z tej transakcji.
Transakcja nie naprawi złego projektu przepływu. Jeśli odjąłeś złą kwotę, użyłeś złego identyfikatora użytkownika lub pominąłeś potrzebną kontrolę, Postgres wiernie zacommituje błędny wynik. Transakcje też nie zapobiegają automatycznie wszystkim konfliktom biznesowym (jak overselling), chyba że połączysz je z odpowiednimi ograniczeniami, blokadami lub poziomem izolacji.
Za każdym razem, gdy aktualizujesz więcej niż jedną tabelę (lub więcej niż jeden wiersz) by zakończyć jedną realną akcję, masz kandydata do transakcji. Zasada jest ta sama: albo wszystko jest zrobione, albo nic.
Klasyczny przypadek to przepływ zamówienia. Tworzysz wiersz zamówienia, rezerwujesz zapas, pobierasz płatność, potem oznaczasz zamówienie jako opłacone. Jeśli płatność się powiedzie, ale aktualizacja statusu nie, masz pieniądze pobrane, a zamówienie wygląda na nieopłacone. Jeśli wiersz zamówienia jest utworzony, ale zapas nie został zarezerwowany, możesz sprzedać produkt, którego realnie nie masz.
Podobnie psuje się onboarding użytkownika. Tworzenie użytkownika, wstawienie profilu, przypisanie ról i zapis, że trzeba wysłać e‑mail powitalny to jedna logiczna akcja. Bez grupowania możesz mieć użytkownika, który się może zalogować, ale nie ma uprawnień, albo profil bez powiązanego użytkownika.
Działania back-office często potrzebują ścisłego zachowania „papierowy ślad + zmiana stanu”. Zatwierdzenie wniosku, zapisanie wpisu audytowego i aktualizacja salda powinny się udać razem. Jeśli saldo się zmieniło, a log audytu zaginął, tracisz dowód, kto i dlaczego wprowadził zmianę.
Prace w tle też na tym zyskują, zwłaszcza gdy przetwarzasz element roboczy wieloetapowo: zarezerwuj element, żeby dwóch workerów go nie przetworzyło, zastosuj zmianę biznesową, zapisz wynik do raportowania i retry, a potem oznacz element jako wykonany (lub niepowodzenie z powodem). Jeśli te kroki się rozjadą, retry i współbieżność zrobią bałagan.
Wieloetapowe funkcje psują się, gdy traktujesz je jak stertę niezależnych aktualizacji. Zanim otworzysz klienta bazy, napisz przepływ jako krótką historię z jednym wyraźnym punktem końcowym: co dokładnie liczy się jako „zrobione” dla użytkownika?
Zacznij od wypisania kroków prostym językiem, potem zdefiniuj jedną warunkującą się definicję sukcesu. Na przykład: „Zamówienie jest utworzone, zapas zarezerwowany, a użytkownik widzi numer potwierdzenia zamówienia.” Wszystko krótsze niż to to nie jest sukces, nawet jeśli niektóre tabele zostały zaktualizowane.
Następnie oddziel twardą linią pracę bazodanową od pracy zewnętrznej. Kroki bazodanowe to te, które możesz chronić transakcjami. Wywołania zewnętrzne jak płatności kartą, wysyłka maili czy API stron trzecich zawodzą wolno i nieprzewidywalnie i zwykle nie możesz ich cofnąć.
Proste podejście planistyczne: podziel kroki na (1) muszą być wszystko-albo-nic, (2) mogą się zdarzyć po commicie.
W transakcji trzymaj tylko kroki, które muszą być razem spójne:
Przenieś skutki uboczne na zewnątrz. Na przykład: commituj zamówienie, a potem wyślij maila potwierdzającego na podstawie rekordu outbox.
Dla każdego kroku ustal, co powinno się stać, gdy następny krok zawiedzie. „Rollback” może znaczyć wycofanie transakcji lub wykonanie działania kompensującego.
Przykład: jeśli płatność powiedzie się, ale rezerwacja zasobów nie, ustal z góry, czy od razu zwracasz środki, czy oznaczasz zamówienie jako „płatność pobrana, oczekuje na zapas” i obsługujesz to asynchronicznie.
Transakcja mówi Postgresowi: traktuj te kroki jako jedną jednostkę. Albo wszystkie zajdą, albo żaden. To najprostszy sposób, by zapobiec częściowym zapisom.
Użyj jednego połączenia z bazą (jednej sesji) od początku do końca. Jeśli rozdzielisz kroki na różne połączenia, Postgres nie będzie w stanie zagwarantować wyniku wszystko‑albo‑nic.
Sekwencja jest prosta: BEGIN, wykonaj potrzebne odczyty i zapisy, COMMIT jeśli wszystko się uda, w przeciwnym razie ROLLBACK i zwróć czytelny błąd.
Oto minimalny przykład w SQL:
BEGIN;
-- reads that inform your decision
SELECT balance FROM accounts WHERE id = 42 FOR UPDATE;
-- writes that must stay together
UPDATE accounts SET balance = balance - 50 WHERE id = 42;
INSERT INTO ledger(account_id, amount, note) VALUES (42, -50, 'Purchase');
COMMIT;
-- on error (in code), run:
-- ROLLBACK;
Transakcje utrzymują blokady podczas działania. Im dłużej są otwarte, tym bardziej blokujesz inne prace i rośnie prawdopodobieństwo timeoutów lub deadlocków. Wykonuj w transakcji to, co niezbędne, a przenoś wolne zadania (wysyłka maili, wywołania providerów płatności, generowanie PDF) poza nią.
Gdy coś zawiedzie, loguj wystarczający kontekst, by odtworzyć problem bez wycieku danych wrażliwych: nazwa przepływu, order_id lub user_id, kluczowe parametry (kwota, waluta) oraz kod błędu Postgresa. Unikaj logowania pełnych payloadów, danych kart czy szczegółów osobowych.
Współbieżność to po prostu dwa zdarzenia zachodzące jednocześnie. Wyobraź sobie dwóch klientów próbujących kupić ostatni bilet. Oba ekrany pokazują „1 pozostało”, obie osoby klikają Zapłać i twoja aplikacja musi zdecydować, kto dostanie bilet.
Bez ochrony oba żądania mogą odczytać tę samą starą wartość i obie zapisać aktualizację. Tak powstaje ujemny zapas, zduplikowane rezerwacje czy płatność bez zamówienia.
Blokady wierszy to najprostsza ochrona. Zablokujesz konkretny wiersz, który chcesz zmienić, wykonasz swoje sprawdzenia, a potem go zaktualizujesz. Inne transakcje dotykające tego samego wiersza muszą poczekać aż ty commitujesz lub rollbackujesz, co zapobiega podwójnym aktualizacjom.
Typowy wzorzec: rozpocznij transakcję, wybierz wiersz inventory z FOR UPDATE, sprawdź, czy jest zapas, zmniejsz go, a potem wstaw zamówienie. To „trzyma drzwi” podczas krytycznych kroków.
Poziomy izolacji kontrolują, ile nieoczekiwanych nakładań ze współbieżnych transakcji tolerujesz. Zwykle to kompromis między bezpieczeństwem a wydajnością:
Trzymaj blokady krótkie. Jeśli transakcja siedzi otwarta podczas wywołania zewnętrznego API lub czekania na akcję użytkownika, stworzysz długie oczekiwania i time‑outy. Wolisz jasną ścieżkę awaryjną: ustaw timeout blokady, obsłuż błąd i zwróć „proszę spróbować ponownie”, zamiast pozwalać, by żądania wisiały.
Jeśli musisz wykonać pracę poza bazą (np. obciążyć kartę), rozdziel przepływ: zarezerwuj szybko, commituj, potem zrób wolny krok i zakończ krótką transakcją.
Retry to normalna rzecz w aplikacjach opartych o Postgresa. Żądanie może się nie powieść nawet, gdy kod jest poprawny: deadlocki, time‑outy zapytań, krótkie przerwy sieciowe lub błąd serializacji przy wyższych poziomach izolacji. Jeśli po prostu uruchomisz handler ponownie, ryzykujesz stworzenie drugiego zamówienia, podwójne obciążenie karty lub wstawienie zduplikowanych wierszy "zdarzenie".
Rozwiązaniem jest idempotencja: operacja powinna być bezpieczna do uruchomienia dwa razy z tymi samymi danymi. Baza powinna być w stanie rozpoznać „to to samo żądanie” i zareagować spójnie.
Praktyczny wzorzec: dołącz klucz idempotencyjny (często generowany po stronie klienta request_id) do każdego wieloetapowego przepływu i zapisz go na głównym rekordzie, potem dodaj na niego ograniczenie unikatowości.
Na przykład: w checkout wygeneruj request_id, gdy użytkownik kliknie Zapłać, potem wstaw zamówienie z tym request_id. Jeśli nastąpi retry, drugie żądanie trafi w ograniczenie unikatowości i zwrócisz istniejące zamówienie zamiast tworzyć nowe.
Co zwykle się liczy:
Trzymaj pętlę retry poza transakcją. Każda próba powinna zaczynać świeżą transakcję i odtwarzać jednostkę pracy od początku. Retry wewnątrz nieudanej transakcji nie pomaga, bo Postgres oznacza ją jako aborted.
Mały przykład: aplikacja próbuje utworzyć zamówienie i zarezerwować zapas, ale wystąpił timeout tuż po COMMIT. Klient ponawia żądanie. Z kluczem idempotencyjnym drugie żądanie zwróci już utworzone zamówienie i nie zdubluje rezerwacji.
Transakcje trzymają wieloetapowy przepływ razem, ale nie robią danych poprawnymi za Ciebie. Silny sposób, by uniknąć skutków częściowych zapisów, to sprawić, że „błędne” stany będą trudne lub niemożliwe do uzyskania w bazie, nawet jeśli błąd w aplikacji się zdarzy.
Zacznij od podstawowych zabezpieczeń. Klucze obce upewniają, że referencje są prawdziwe (pozycja zamówienia nie może wskazywać na brakujące zamówienie). NOT NULL zatrzymuje półwypełnione wiersze. CHECK constraints łapią wartości, które nie mają sensu (np. quantity > 0, total_cents >= 0). Te reguły działają przy każdym zapisie, bez względu na to, która usługa lub skrypt modyfikuje bazę.
Dla dłuższych przepływów modeluj zmiany stanu wprost. Zamiast wielu booleani użyj jednego pola status (pending, paid, shipped, canceled) i pozwól tylko na legalne przejścia. Możesz to wymusić ograniczeniami lub triggerami, żeby baza odrzucała nielegalne przeskoki, np. shipped -> pending.
Unikalność to kolejna forma poprawności. Dodaj ograniczenia unikatowości tam, gdzie duplikaty złamią przepływ: order_number, invoice_number czy idempotency_key użyty dla retry. Wtedy, jeśli aplikacja spróbuje ponownie to samo żądanie, Postgres zablokuje drugi insert i możesz bezpiecznie zwrócić "już przetworzone" zamiast tworzyć drugie zamówienie.
Gdy potrzebujesz śledzenia, zapisuj je jawnie. Tabela audytu (lub historia) z informacją kto co zmienił i kiedy zmienia „tajemnicze aktualizacje” w fakty, które możesz zapytać podczas incydentów.
Większość częściowych zapisów nie bierze się z „złego SQL”. Wynika z decyzji w projekcie przepływu, które pozwalają zapisać tylko połowę historii.
accounts potem orders, a inne najpierw orders potem accounts, rośnie ryzyko deadlocków przy obciążeniu.Konkretny przykład: w checkout rezerwujesz zapas, tworzysz zamówienie, a potem obciążasz kartę. Jeśli obciążysz kartę wewnątrz tej samej transakcji, możesz trzymać blokadę inventory czekając na sieć. Jeśli obciążenie się powiedzie, a później transakcja wycofa się, obciążyłeś klienta bez zamówienia.
Bezpieczniejszy wzorzec: trzymaj transakcję skupioną na stanie bazy (rezerwuj zapas, twórz zamówienie, zapisz próbę płatności jako pending), commituj, potem wykonaj zewnętrzne API, a wynik zapisz w nowej krótkiej transakcji. Wiele zespołów realizuje to przez status pending i zadanie w tle.
Gdy przepływ ma wiele kroków (insert, update, charge, send), cel jest prosty: albo wszystko jest zapisane, albo nic.
Trzymaj wszystkie wymagane zapisy bazodanowe w jednej transakcji. Jeśli któryś krok zawiedzie, rollback i przywróć dane do stanu sprzed.
Uczyń warunek sukcesu explicite. Na przykład: „Zamówienie jest utworzone, zapas zarezerwowany, a status płatności zapisany.” Wszystko inne to ścieżka błędu, która powinna przerwać transakcję.
BEGIN ... COMMIT.ROLLBACK, a wywołujący dostaje czytelny komunikat o niepowodzeniu.Zakładaj, że to samo żądanie może być powtórzone. Baza powinna pomóc wymusić reguły wykonania tylko raz.
Wykonuj minimalną pracę w transakcji i unikaj czekania na wywołania sieciowe podczas trzymania blokad.
Jeśli nie widzisz, gdzie się psuje, będziesz zgadywać.
Checkout ma kilka kroków, które powinny przesuwać się razem: utworzyć zamówienie, zarezerwować zapas, zapisać próbę płatności, a potem oznaczyć status zamówienia.
Wyobraź sobie, że użytkownik klika Kup dla 1 przedmiotu.
W jednej transakcji wykonaj tylko zmiany w bazie:
orders ze statusem pending_payment.inventory.available lub utwórz wiersz reservations).payment_intents z kluczem idempotencyjnym dostarczonym przez klienta (unikalnym).outbox typu "order_created".Jeśli któreś polecenie zawiedzie (brak zapasu, błąd ograniczenia, awaria), Postgres wycofa całą transakcję. Nie skończysz z zamówieniem bez rezerwacji ani z rezerwacją bez zamówienia.
Dostawca płatności jest poza twoją bazą, więc traktuj go jako krok oddzielny.
Jeśli wywołanie providera nie powiedzie się przed commitem, abortuj transakcję i nic nie jest zapisane. Jeśli wywołanie zawiedzie po commicie, uruchom nową transakcję, która oznaczy próbę płatności jako nieudaną, zwolni rezerwację i ustawi status zamówienia na canceled.
Niech klient wysyła idempotency_key dla próby checkoutu. Wymuś to unikalnym indeksem na payment_intents(idempotency_key) (albo na orders, jeśli wolisz). Przy retry kod pobiera istniejące wiersze i kontynuuje zamiast wstawiać nowe zamówienie.
Nie wysyłaj maili wewnątrz transakcji. Zapisz rekord outbox w tej samej transakcji, a potem worker w tle wyśle maila po commicie. Dzięki temu nigdy nie wyślesz maila dla zamówienia, które zostało wycofane.
Wybierz jeden przepływ, który dotyka więcej niż jednej tabeli: signup + kolejka maila powitalnego, checkout + zapas, faktura + wpis w ledgerze czy utworzenie projektu + domyślne ustawienia.
Najpierw napisz kroki, potem reguły, które zawsze muszą być prawdziwe (twoje invariants). Przykład: „Zamówienie jest albo w pełni opłacone i zarezerwowane, albo nieopłacone i niezarezerwowane. Nigdy połowicznie.” Zamień te reguły w jednostkę wszystko‑albo‑nic.
Prosty plan:
Potem testuj celowo złe przypadki. Symuluj awarię po kroku 2, timeout tuż przed commitem i podwójne wysłanie z UI. Celem są nudne wyniki: brak osieroconych wierszy, brak podwójnych obciążeń, nic nie wiszące w stanie pending na zawsze.
Jeśli prototypujesz szybko, pomaga szkicować przepływ w narzędziu planistycznym zanim wygenerujesz handlery i schemat. Na przykład, Koder.ai (koder.ai) ma Planning Mode i wspiera snapshoty oraz rollback, co bywa przydatne podczas iteracji nad granicami transakcji i ograniczeniami.
Zrób to dla jednego przepływu w tym tygodniu. Drugi pójdzie znacznie szybciej.