Blokady doradcze PostgreSQL dla przepływów pracy odpornych na współbieżność
Poznaj blokady doradcze PostgreSQL, by zapobiegać podwójnemu przetwarzaniu w zatwierdzeniach, rozliczeniach i harmonogramach — praktyczne wzorce, fragmenty SQL i proste kontrole.

Prawdziwy problem: dwa procesy robią to samo
Podwójne przetwarzanie występuje, gdy ten sam element jest obsługiwany dwukrotnie, ponieważ dwa różne podmioty myślą, że to one są odpowiedzialne. W praktycznych aplikacjach objawia się to jako dwukrotne obciążenie klienta, dwukrotne zastosowanie zatwierdzenia lub wysłanie wiadomości „faktura gotowa” dwa razy. Wszystko może działać w testach, a potem zepsuć się przy prawdziwym ruchu.
Zwykle zdarza się to, gdy czas jest napięty i więcej niż jedna rzecz może zadziałać:
Dwaj workerzy pobierają to samo zadanie w tym samym czasie. Retry uruchamia się, bo wywołanie sieciowe było wolne, ale pierwsza próba wciąż działa. Użytkownik dwukrotnie kliknął Zatwierdź, bo UI zacięło się na sekundę. Dwa harmonogramy nachodzą na siebie po deployu lub z powodu dryfu zegara. Nawet jedno stuknięcie może stać się dwoma żądaniami, jeśli aplikacja mobilna wysyła ponownie po timeoutcie.
Bolesne jest to, że każdy aktor zachowuje się "rozsądnie" samodzielnie. Błąd to luka między nimi: żaden nie wie, że inny już przetwarza ten sam rekord.
Cel jest prosty: dla danej pozycji (zamówienie, prośba o zatwierdzenie, faktura) tylko jeden aktor powinien móc wykonywać krytyczną pracę w danym momencie. Inni powinni albo chwilę poczekać, albo się wycofać i spróbować ponownie.
Blokady doradcze PostgreSQL mogą pomóc. Dają lekki sposób na oznaczenie "pracuję nad elementem X" używając bazy danych, której już ufasz w kwestii spójności.
Ustal oczekiwania: blokada to nie pełny system kolejkowy. Nie zaplanuje zadań za Ciebie, nie zagwarantuje kolejności i nie przechowa wiadomości. To bramka bezpieczeństwa wokół części workflowa, która nigdy nie powinna uruchomić się dwa razy.
Czym (i czym nie) są blokady doradcze PostgreSQL
Blokady doradcze PostgreSQL to sposób, by upewnić się, że tylko jeden worker wykonuje daną operację w danym momencie. Wybierasz klucz blokady (np. "faktura 123"), prosisz bazę o zablokowanie go, wykonujesz pracę, a potem zwalniasz blokadę.
Słowo "doradcze" ma znaczenie. Postgres nie zna znaczenia Twojego klucza i nie będzie niczego chronił automatycznie. Śledzi tylko jedno: czy klucz jest zablokowany, czy nie. Twój kod musi zgadzać się co do formatu klucza i musisz pobrać blokadę przed uruchomieniem ryzykownej części.
Warto też porównać blokady doradcze do blokad wierszy. Blokady wierszy (np. SELECT ... FOR UPDATE) chronią faktyczne wiersze tabel. Są świetne, gdy praca mapuje się dokładnie na jeden wiersz. Blokady doradcze chronią dowolny klucz, który wybierzesz — przydatne, gdy workflow dotyka wielu tabel, wywołuje zewnętrzne serwisy lub zaczyna się zanim wiersz w ogóle powstanie.
Blokady doradcze są przydatne, gdy potrzebujesz:
- Akcji „jedna na raz” dla encji (jedno zatwierdzenie na żądanie, jedno obciążenie na fakturę)
- Koordynacji między wieloma serwerami aplikacji bez dodatowego serwisu blokującego
- Ochrony kroku workflowu, który jest większy niż pojedynczy update wiersza
Nie zastąpią innych narzędzi bezpieczeństwa. Nie czynią operacji idempotentnymi, nie egzekwują reguł biznesowych i nie powstrzymają duplikatów, jeśli jakaś ścieżka kodu zapomni pobrać blokadę.
Nazywa się je "lekki"m, bo można ich używać bez zmian w schemacie czy dodatkowej infrastruktury. W wielu przypadkach naprawisz podwójne przetwarzanie, dodając jedno wywołanie blokady wokół krytycznej sekcji, zachowując resztę projektu bez zmian.
Typy blokad, których faktycznie będziesz używać
Kiedy ludzie mówią „blokady doradcze PostgreSQL”, zwykle mają na myśli kilka funkcji. Wybór odpowiedniej zmienia zachowanie przy błędach, timeoutach i retryach.
Blokady sesyjne vs transakcyjne
Blokada na poziomie sesji (pg_advisory_lock) trwa tak długo, jak połączenie z bazą. To wygodne dla długotrwałych workerów, ale oznacza też, że blokada może zalegać, jeśli aplikacja się rozpadnie w sposób pozostawiający wiszące połączenie w puli.
Blokada na poziomie transakcji (pg_advisory_xact_lock) jest powiązana z bieżącą transakcją. Po commit/rollback PostgreSQL zwalnia ją automatycznie. Dla większości request-response workflowów (zatwierdzenia, płatności, akcje admina) to bezpieczniejszy domyślna opcja, bo trudniej zapomnieć o zwolnieniu.
Blokujące vs try-lock
Wywołania blokujące czekają, aż blokada będzie dostępna. Proste, ale mogą sprawić, że żądanie webowe będzie się wydawać zablokowane, jeśli inna sesja trzyma blokadę.
Try-lock zwraca natychmiast:
pg_try_advisory_lock(poziom sesji)pg_try_advisory_xact_lock(poziom transakcji)
Try-lock często jest lepszy dla akcji UI. Jeśli blokada jest zajęta, możesz zwrócić jasny komunikat typu „Już w trakcie przetwarzania” i poprosić użytkownika o ponowną próbę.
Wspólne vs wyłączne
Blokady wyłączne to "jeden na raz". Blokady współdzielone pozwalają na wielu posiadaczy, ale blokują blokadę wyłączną. Większość problemów z podwójnym przetwarzaniem wymaga blokad wyłącznych. Blokady współdzielone przydają się, gdy wielu czytelników może postępować równolegle, a rzadszy zapis ma przebiegać solo.
Jak zwalniane są blokady
Zależy to od typu:
- Blokady sesji: zwalniane przy rozłączeniu lub jawnie przez
pg_advisory_unlock - Blokady transakcyjne: zwalniane automatycznie po zakończeniu transakcji
Wybór właściwego klucza blokady
Blokada doradcza działa tylko wtedy, gdy każdy worker próbuje zablokować dokładnie taki sam klucz dla tej samej jednostki pracy. Jeśli jedna ścieżka blokuje „invoice 123”, a inna „customer 45”, nadal możesz otrzymać duplikaty.
Zacznij od nazwania „rzeczy”, którą chcesz chronić. Uczyń ją konkretną: jedna faktura, jedno żądanie zatwierdzenia, jedno uruchomienie zadania okresowego lub jeden miesięczny cykl rozliczeniowy klienta. Ten wybór decyduje o dopuszczalnej równoległości.
Wybierz zakres dopasowany do ryzyka
Zwykle zespoły wybierają jedno z poniższych:
- Na rekord: najbezpieczniejsze dla zatwierdzeń i faktur (blokuj po invoice_id lub request_id)
- Na klienta/konto: przydatne, gdy akcje muszą być serializowane per klient (rozliczenia, zmiany kredytu)
- Na krok workflowu: gdy różne kroki mogą działać równolegle, ale każdy krok musi być wykonywany pojedynczo
Traktuj zakres jako decyzję produktową, nie szczegół bazy danych. "Na rekord" zapobiega podwójnemu kliknięciu powodującemu podwójne obciążenie. "Na klienta" zapobiega dwóm background jobom generującym nakładające się zestawienia.
Wybierz stabilną strategię klucza
Masz zwykle dwie opcje: dwie 32-bitowe liczby całkowite (często używane jako namespace + id) albo jedna 64-bitowa liczba całkowita (bigint), czasem tworzona przez hashowanie ciągu.
Dwucyfrowe klucze są łatwe do ustandaryzowania: wybierz stały numer przestrzeni nazw dla workflowu (np. approvals vs billing) i użyj ID rekordu jako drugiej wartości.
Hashowanie jest wygodne, gdy identyfikator to UUID, ale musisz zaakceptować małe ryzyko kolizji i konsekwentnie stosować to wszędzie.
Cokolwiek wybierzesz, zapisz format i scentralizuj implementację. "Prawie ten sam klucz" w dwóch miejscach to częsta droga do ponownego wprowadzenia duplikatów.
Krok po kroku: bezpieczny wzorzec przetwarzania jedno-na-raz
Dobry workflow z blokadą doradczą jest prosty: zablokuj, sprawdź, działaj, zapisz, commit. Sama blokada nie jest regułą biznesową — to zabezpieczenie, które sprawia, że reguła działa niezawodnie, gdy dwóch workerów uderza w ten sam rekord jednocześnie.
Praktyczny wzorzec:
- Otwórz transakcję, gdy wynik musi być atomowy.
- Pozyskaj blokadę dla konkretnej jednostki pracy. Preferuj blokadę zasięgu transakcji (
pg_advisory_xact_lock), aby zwalniała się automatycznie. - Ponownie sprawdź stan w bazie. Nie zakładaj, że jesteś pierwszy. Potwierdź, że rekord wciąż kwalifikuje się do przetwarzania.
- Wykonaj pracę i wpisz trwały znacznik "zrobione" w bazie (aktualizacja statusu, wpis w księdze, wiersz audytu).
- Commit i pozwól blokadzie się zwolnić. Jeśli używałeś blokady sesji, odblokuj przed zwróceniem połączenia do puli.
Przykład: dwa serwery aplikacji otrzymują "Zatwierdź fakturę #123" w tej samej sekundzie. Oba zaczynają, ale tylko jeden dostaje blokadę dla 123. Zwycięzca sprawdza, czy faktura #123 jest nadal pending, oznacza ją approved, zapisuje wpis audytowy/płatność i robi commit. Druga instancja albo szybko się wycofuje (try-lock), albo czeka, a potem po zdobyciu blokady widzi, że status jest już zatwierdzony i wychodzi bez tworzenia duplikatu.
Gdzie pasują blokady doradcze: zatwierdzenia, rozliczenia, harmonogramy
Blokady doradcze najlepiej pasują, gdy reguła jest prosta: dla konkretnej rzeczy tylko jeden proces może wykonać "zwycięską" pracę na raz. Zachowujesz obecną bazę i kod aplikacji, ale dodajesz małą bramkę, która znacząco utrudnia wywołanie warunków wyścigu.
Zatwierdzenia
Zatwierdzenia to klasyczne pułapki współbieżności. Dwóch recenzentów (albo ta sama osoba klikająca dwa razy) może trafić Zatwierdź w ciągu milisekund. Z blokadą opartą na ID żądania tylko jedna transakcja wykona zmianę stanu. Inni szybko poznają wynik i mogą pokazać jasny komunikat jak "już zatwierdzone" lub "już odrzucone".
To częste w portalach klientów i panelach administracyjnych, gdzie wiele osób obserwuje tę samą kolejkę.
Rozliczenia
Rozliczenia zwykle wymagają surowszej reguły: tylko jedna próba płatności na fakturę, nawet gdy występują retry. Timeout sieciowy może skłonić użytkownika do ponownego kliknięcia Zapłać, albo retry backgroundu może uruchomić się, gdy pierwsza próba wciąż leci.
Blokada na ID faktury gwarantuje, że tylko jedna ścieżka rozmawia z dostawcą płatności naraz. Druga próba może zwrócić "płatność w toku" lub pobrać aktualny status płatności. To zapobiega podwójnemu wysiłkowi i zmniejsza ryzyko podwójnych obciążeń.
Harmonogramy i background workery
W setupach z wieloma instancjami harmonogramy mogą przypadkowo uruchamiać to samo okno równolegle. Blokada oparta na nazwie zadania plus oknie czasowym (np. "daily-settlement:2026-01-29") gwarantuje, że tylko jedna instancja je wykona.
To samo podejście działa dla workerów ciągnących elementy z tabeli: blokuj po ID elementu, by tylko jeden worker mógł go przetworzyć.
Typowe klucze to pojedyncze ID żądania zatwierdzenia, ID faktury, nazwa zadania + okno czasu, ID klienta dla "jednego eksportu na raz" lub unikalny klucz idempotencji dla retryów.
Realistyczny przykład: powstrzymywanie podwójnego zatwierdzenia w portalu
Wyobraź sobie żądanie zatwierdzenia w portalu: zamówienie czeka, a dwóch menedżerów kliknęło Zatwierdź w tej samej sekundzie. Bez ochrony obie prośby mogą odczytać "pending" i obie wpisać "approved", tworząc duplikaty wpisów audytowych, duplikaty powiadomień lub podwójnie uruchomione prace downstream.
Blokady doradcze PostgreSQL dają prosty sposób, by ta akcja była jedno-na-raz dla konkretnego zatwierdzenia.
Przebieg
Kiedy API odbiera akcję zatwierdzenia, najpierw pobiera blokadę opartą o approval id (różne zatwierdzenia nadal mogą być przetwarzane równolegle).
Powszechny wzorzec: zablokuj po approval_id, odczytaj bieżący status, zaktualizuj status, a następnie wpisz rekord audytu — wszystko w jednej transakcji.
BEGIN;
-- One-at-a-time per approval_id
SELECT pg_try_advisory_xact_lock($1) AS got_lock; -- $1 = approval_id
-- If got_lock = false, return "someone else is approving, try again".
SELECT status FROM approvals WHERE id = $1 FOR UPDATE;
-- If status != 'pending', return "already processed".
UPDATE approvals
SET status = 'approved', approved_by = $2, approved_at = now()
WHERE id = $1;
INSERT INTO approval_audit(approval_id, actor_id, action, created_at)
VALUES ($1, $2, 'approved', now());
COMMIT;
Co doświadcza drugie kliknięcie
Drugie żądanie albo nie dostanie blokady (więc szybko zwraca "W trakcie przetwarzania"), albo dostanie blokadę po zakończeniu pierwszego, a potem zobaczy, że status jest już zatwierdzony i wyjdzie bez zmian. W obu przypadkach unikasz podwójnego przetwarzania, a UI pozostaje responsywny.
Dla debugowania loguj wystarczająco dużo danych, by śledzić każdą próbę: request id, approval id i obliczony klucz blokady, actor id, wynik (lock_busy, already_approved, approved_ok) i czas.
Obsługa oczekiwania, timeoutów i retryów bez zawieszania aplikacji
Czekanie na blokadę brzmi niewinnie, dopóki nie zmieni się w obracający się przycisk, wiszącego workera lub backlog, który nigdy się nie rozładuje. Gdy nie możesz zdobyć blokady, odrzucaj szybko tam, gdzie czeka człowiek, i czekaj tylko tam, gdzie oczekiwanie jest bezpieczne.
Dla akcji użytkownika: try-lock i czytelna odpowiedź
Jeśli ktoś klika Zatwierdź lub Zapłać, nie blokuj żądania przez sekundy. Użyj try-lock, aby aplikacja mogła od razu odpowiedzieć.
Praktyczne podejście: spróbuj zablokować, a jeśli się nie uda, zwróć jasną odpowiedź "zajęte, spróbuj ponownie" (lub odśwież stan elementu). To zmniejsza timeoute i zniechęca do wielokrotnych kliknięć.
Utrzymuj sekcję zablokowaną krótką: waliduj stan, zastosuj zmianę stanu, commit.
Dla zadań w tle: blokowanie OK, ale ograniczaj je
Dla schedulerów i workerów blokowanie może być dopuszczalne, bo nikt nie czeka. Jednak nadal potrzebujesz limitów, inaczej jedno wolne zadanie może zablokować cały klaster.
Użyj timeoutów, by worker mógł się poddać i przejść dalej:
SET lock_timeout = '2s';
SET statement_timeout = '30s';
SELECT pg_advisory_lock(123456);
Ustal też maksymalny oczekiwany czas działania zadania. Jeśli billing zwykle kończy się poniżej 10 sekund, traktuj 2 minuty jako incydent. Śledź czas rozpoczęcia, id zadania i jak długo blokady są trzymane. Jeśli runner wspiera anulowanie, anuluj zadania przekraczające limit, aby sesja się zakończyła i blokada zwolniła.
Planuj retryy celowo. Kiedy nie możesz zdobyć blokady, zdecyduj: przełóż z lekkim opóźnieniem i backoffem (z małą losowością), pomiń pracę best-effort w tej rundzie, albo oznacz element jako kontestowany, jeśli wielokrotne niepowodzenia wymagają uwagi.
Częste błędy powodujące zablokowane blokady lub duplikaty
Najczęstsze zaskoczenie to blokady sesji, które nigdy się nie zwalniają. Connection pool trzyma połączenia otwarte, więc sesja może przeżyć dłużej niż żądanie. Jeśli pobierzesz blokadę sesyjną i zapomnisz ją odblokować, może ona być trzymana do czasu recyklingu połączenia. Inni workery będą czekać (lub sypać błędy) i trudno zrozumieć, dlaczego.
Innym źródłem duplikatów jest zablokowanie, ale brak ponownego sprawdzenia stanu. Blokada jedynie zapewnia, że tylko jeden worker wykona krytyczną sekcję na raz. Nie gwarantuje, że rekord nadal kwalifikuje się do przetworzenia. Zawsze sprawdzaj ponownie wewnątrz tej samej transakcji (np. potwierdź pending zanim przejdziesz do approved).
Klucze blokad również potrafią zmylić zespoły. Jeśli jeden serwis blokuje po order_id, a inny po inaczej obliczonym kluczu dla tego samego zasobu, masz dwie niezależne blokady. Obie ścieżki mogą działać równolegle, co daje fałszywe poczucie bezpieczeństwa.
Długie okresy trzymania blokad są zwykle samospowodowane. Jeśli wykonujesz wolne wywołania sieciowe trzymając blokadę (dostawca płatności, email/SMS, webhooks), krótka bariera staje się wąskim gardłem. Trzymaj sekcję zablokowaną krótko i skupioną na szybkiej pracy bazodanowej: sprawdź stan, zapisz nowy stan, zanotuj, co ma się zdarzyć dalej. Efekty uboczne wyzwalaj po commicie.
Na koniec: blokady doradcze nie zastępują idempotencji ani ograniczeń bazy. Traktuj je jako światło drogowe, nie system dowodowy. Gdzie pasuje, używaj unikalnych ograniczeń i kluczy idempotencji dla wywołań zewnętrznych.
Szybka lista kontrolna przed wdrożeniem
Traktuj blokady doradcze jak małą umowę: cały zespół powinien wiedzieć, co blokada oznacza, co chroni i co wolno robić, gdy jest trzymana.
Krótka lista, która łapie większość problemów:
- Jeden jasny klucz blokady na zasób, zapisany i używany wszędzie
- Pobierz blokadę zanim wykonasz cokolwiek nieodwracalnego (płatności, emaile, wywołania zewnętrzne)
- Ponownie sprawdź stan po zdobyciu blokady i przed zapisaniem zmian
- Trzymaj sekcję objętą blokadą krótką i mierzalną (loguj czas oczekiwania i czas wykonania)
- Zdecyduj, co oznacza "lock busy" dla każdej ścieżki (komunikat UI, retry z backoffem, pominięcie)
Następne kroki: zastosuj wzorzec i utrzymuj go
Wybierz jedno miejsce, gdzie duplikaty najbardziej bolą i zacznij tam. Dobre pierwsze cele to akcje kosztowne lub zmieniające stan trwale, jak "obciąż fakturę" lub "zatwierdź żądanie". Opakuj tylko tę krytyczną sekcję blokadą doradczą, a potem rozszerzaj stopniowo, gdy zaufasz zachowaniu.
Dodaj podstawową obserwowalność wcześnie. Loguj, gdy worker nie może zdobyć blokady i ile trwa przetwarzanie zablokowanej pracy. Jeśli oczekiwania na blokadę rosną, zazwyczaj znaczy to, że sekcja krytyczna jest za duża lub ukrywa się w niej wolne zapytanie.
Blokady działają najlepiej na bazie bezpieczeństwa danych, a nie zamiast niego. Trzymaj czytelne pola statusu (pending, processing, done, failed) i wspieraj je ograniczeniami tam, gdzie się da. Jeśli retry nastąpi w najgorszym momencie, unikalne ograniczenie lub klucz idempotencji może być drugą linią obrony.
Jeśli budujesz workflowy w AppMaster (appmaster.io), możesz zastosować ten sam wzorzec, trzymając krytyczną zmianę stanu w jednej transakcji i dodając mały krok SQL, który pobierze transakcyjną blokadę doradczą przed krokiem "finalize".
Blokady doradcze dobrze się sprawdzają, dopóki naprawdę nie potrzebujesz funkcji kolejkowania (priorytety, opóźnione zadania, dead-letter), masz silne kontencje i potrzebujesz inteligentniejszej paralelizacji, musisz koordynować się między bazami bez wspólnego Postgresa, lub potrzebujesz surowszych reguł izolacji. Celem jest nudna niezawodność: utrzymuj wzorzec małym, spójnym, widocznym w logach i wspieranym ograniczeniami.
FAQ
Użyj blokady doradczej, gdy potrzebujesz "tylko jeden aktor naraz" dla konkretnej jednostki pracy, np. zatwierdzenie żądania, obciążenie faktury czy uruchomienie okna zaplanowanego. Jest to szczególnie pomocne, gdy wiele instancji aplikacji może dotknąć tej samej pozycji i nie chcesz dodawać oddzielnego serwisu blokującego.
Blokady wierszy zabezpieczają faktyczne wiersze tabeli i są świetne, gdy cała operacja sprowadza się do jednego update'u wiersza. Blokady doradcze chronią klucz, który sam wybierasz, więc działają nawet jeśli workflow dotyka wielu tabel, wywołuje zewnętrzne serwisy lub zaczyna się zanim końcowy wiersz istnieje.
Domyślnie wybieraj pg_advisory_xact_lock (poziom transakcji) dla akcji request/response, ponieważ zwalnia się automatycznie przy commit/rollback. Używaj pg_advisory_lock (poziom sesji) tylko wtedy, gdy naprawdę potrzebujesz, by blokada przetrwała poza transakcją i masz pewność, że zawsze odblokujesz przed zwróceniem połączenia do puli.
Dla akcji interfejsu użytkownika preferuj try-lock (pg_try_advisory_xact_lock), aby żądanie mogło szybko zakończyć się odpowiedzią "już w trakcie przetwarzania". Dla background workerów blokowanie może być w porządku, ale ogranicz je lock_timeout, aby jedno zablokowane zadanie nie wstrzymało całego systemu.
Zablokuj najmniejszą rzecz, która nie może być wykonana równolegle — zwykle "jedna faktura" lub "jedno żądanie zatwierdzenia". Zbyt szeroki zakres (np. na poziomie klienta) zmniejszy przepustowość; zbyt wąski może nadal pozwolić na duplikaty.
Wybierz jeden stabilny format klucza i stosuj go wszędzie, gdzie może zostać wykonana ta sama krytyczna akcja. Popularne podejście to dwie liczby całkowite: stała przestrzeń nazw dla workflow oraz ID encji, tak by różne workflowy nie blokowały się przypadkowo wzajemnie, a jednocześnie koordynowały poprawne akcje.
Nie. Blokada tylko zapobiega współbieżnemu wykonaniu; nie udowadnia, że operacja jest bezpieczna do powtórzenia. Nadal trzeba ponownie sprawdzić stan wewnątrz transakcji (np. potwierdzić, że element jest nadal pending) i użyć unikalnych ograniczeń lub kluczy idempotencji tam, gdzie to pasuje.
Utrzymaj sekcję objętą blokadą krótką i skupioną na bazie danych: zdobądź blokadę, sprawdź uprawnienia/eligibility, zapisz nowy stan i zrób commit. Wolne efekty uboczne (płatności, emaile, webhooks) wykonuj po commicie lub za pomocą outboxa, żeby nie trzymać blokady podczas opóźnień sieciowych.
Najczęstszą przyczyną jest blokada sesji trzymana przez połączenie z puli, które nigdy nie zostało odblokowane z powodu błędu w kodzie. Preferuj blokady transakcyjne; jeśli musisz używać blokad sesji, upewnij się, że pg_advisory_unlock wykona się niezawodnie przed zwrotem połączenia do puli.
Loguj ID encji i obliczony klucz blokady, informację czy blokadę udało się zdobyć, ile czasu to zajęło oraz jak długo trwała transakcja. Loguj też wynik jak lock_busy, already_processed lub processed_ok, aby odróżnić kontencję od prawdziwych duplikatów.


