PostgreSQL advisory locks voor concurrente, veilige workflows
Leer PostgreSQL advisory locks gebruiken om dubbele verwerking te stoppen bij goedkeuringen, facturatie en schedulers met praktische patronen, SQL-voorbeelden en eenvoudige checks.

Het echte probleem: twee processen doen hetzelfde werk
Dubbele verwerking ontstaat wanneer hetzelfde item twee keer wordt afgehandeld omdat twee verschillende actoren beide denken dat zij verantwoordelijk zijn. In echte apps zie je dit als een klant die twee keer wordt belast, een goedkeuring die twee keer wordt toegepast, of een "factuur klaar"-mail die twee keer wordt verstuurd. Alles lijkt goed in tests, maar kan breken onder echt verkeer.
Het gebeurt meestal wanneer de timing krap wordt en meer dan één ding tegelijk kan handelen:
Twee workers pakken op hetzelfde moment dezelfde taak op. Een retry wordt gestart omdat een netwerkoproep traag was, terwijl de eerste poging nog loopt. Een gebruiker dubbelklikt op Goedkeuren omdat de UI een seconde vastliep. Twee schedulers overlappen na een deploy of door klokdrift. Zelfs één tik kan twee verzoeken worden als een mobiele app na een timeout opnieuw verzendt.
Het pijnlijke is dat elke actor op zichzelf gezien "redelijk" handelt. De bug zit in de kloof tussen hen: geen van beiden weet dat de ander hetzelfde record al verwerkt.
Het doel is simpel: voor een gegeven item (een order, een goedkeuringsverzoek, een factuur) mag telkens maar één actor tegelijk het kritieke werk uitvoeren. Iedereen else zou kort moeten wachten of terugtrekken en het later opnieuw proberen.
PostgreSQL advisory locks kunnen helpen. Ze geven je een lichte manier om te zeggen "ik werk aan item X" met de database die je al vertrouwt voor consistentie.
Stel wel verwachtingen. Een lock is geen volledig queuesysteem. Het plant geen taken voor je, garandeert geen volgorde en slaat geen berichten op. Het is een veiligheidshek rond het deel van de workflow dat nooit twee keer mag draaien.
Wat PostgreSQL advisory locks wél en niet zijn
PostgreSQL advisory locks zijn een manier om te zorgen dat slechts één worker een stuk werk tegelijk uitvoert. Je kiest een lock-key (zoals "invoice 123"), vraagt de database om die te locken, voert het werk uit en geeft hem daarna vrij.
Het woord "advisory" is belangrijk. Postgres weet niet wat je sleutel betekent en zal niets automatisch beschermen. Het houdt alleen bij: deze sleutel is gelocked of die is het niet. Je code moet het eens zijn over het sleutelformat en de lock nemen voordat het risicovolle deel draait.
Het helpt ook om advisory locks te vergelijken met rijlocks. Rijlocks (zoals SELECT ... FOR UPDATE) beschermen daadwerkelijke tabelrijen. Ze zijn geweldig wanneer het werk netjes op één rij past. Advisory locks beschermen een sleutel die jij kiest, wat handig is als de workflow veel tabellen raakt, externe services aanroept of begint voordat een rij bestaat.
Advisory locks zijn nuttig wanneer je nodig hebt:
- Eén-op-één acties per entiteit (één goedkeuring per verzoek, één afschrijving per factuur)
- Coördinatie over meerdere app-servers zonder een aparte lock-service toe te voegen
- Bescherming rond een workflowstap die groter is dan een enkele rijaaanpassing
Ze vervangen niet andere veiligheidsmiddelen. Ze maken operaties niet idempotent, handhaven geen businessregels en stoppen duplicaten niet als een codepad vergeet de lock te nemen.
Ze worden vaak "lightweight" genoemd omdat je ze kunt gebruiken zonder schemawijzigingen of extra infrastructuur. In veel gevallen los je dubbele verwerking op door één lock-oproep rond een kritieke sectie te zetten en de rest van het ontwerp te laten zoals het is.
Locktypes die je daadwerkelijk gebruikt
Als mensen het over "PostgreSQL advisory locks" hebben, bedoelen ze meestal een kleine set functies. De keuze bepaalt wat er gebeurt bij fouten, timeouts en retries.
Sessie- versus transactie-locks
Een sessie-lock (pg_advisory_lock) duurt zo lang als de databaseverbinding leeft. Dat kan handig zijn voor langlopende workers, maar het betekent ook dat een lock kan blijven hangen als je app crasht op een manier die een gepoolde verbinding achterlaat.
Een transactie-lock (pg_advisory_xact_lock) is gebonden aan de huidige transactie. Als je commit of rollbackt, geeft PostgreSQL de lock automatisch vrij. Voor de meeste request-response-workflows (goedkeuringen, betalingen, admin-acties) is dit de veiligere standaard omdat je hem minder snel vergeet vrij te geven.
Blokkerend versus try-lock
Blokkerende aanroepen wachten tot de lock beschikbaar is. Simpel, maar het kan een webrequest laten hangen als een andere sessie de lock houdt.
Try-locks geven meteen terug:
pg_try_advisory_lock(session-level)pg_try_advisory_xact_lock(transaction-level)
Try-lock is vaak beter voor UI-acties. Als de lock bezet is, kun je een duidelijk bericht teruggeven zoals "Al in behandeling" en de gebruiker vragen het opnieuw te proberen.
Gedeeld versus exclusief
Exclusieve locks zijn "één tegelijk". Gedeelde locks laten meerdere houders toe maar blokkeren een exclusieve lock. De meeste double-processingproblemen gebruiken exclusieve locks. Gedeelde locks zijn nuttig wanneer veel readers kunnen doorgaan, maar een zeldzame writer alleen moet draaien.
Hoe locks worden vrijgegeven
Vrijgave hangt af van het type:
- Sessie-locks: vrij bij disconnect, of expliciet met
pg_advisory_unlock - Transactie-locks: automatisch vrij wanneer de transactie eindigt
De juiste lock-key kiezen
Een advisory lock werkt alleen als elke worker op exact dezelfde sleutel probeert te locken voor precies hetzelfde werk. Als het ene codepad lockt op "invoice 123" en een ander op "customer 45", kun je alsnog duplicaten krijgen.
Begin met het benoemen van het "ding" dat je wilt beschermen. Maak het concreet: één factuur, één goedkeuringsverzoek, één geplande taakuitvoering, of de maandelijkse billing-cyclus van een klant. Die keuze bepaalt hoeveel gelijktijdigheid je toestaat.
Kies een scope die bij het risico past
De meeste teams kiezen één van de volgende:
- Per record: het veiligst voor goedkeuringen en facturen (lock op invoice_id of request_id)
- Per klant/account: handig wanneer acties per klant moeten worden geserialiseerd (facturatie, kredietwijzigingen)
- Per workflowstap: wanneer verschillende stappen parallel mogen lopen, maar elke stap één-op-één moet zijn
Zie scope als een productbeslissing, niet als een database-detail. "Per record" voorkomt dat dubbelklikken twee keer in rekening brengt. "Per klant" voorkomt dat twee achtergrondjobs overlappende uitschrijvingen genereren.
Kies een stabiele sleutelstrategie
Je hebt meestal twee opties: twee 32-bit integers (vaak gebruikt als namespace + id), of één 64-bit integer (bigint), soms gemaakt door het hashen van een string-ID.
Twee-int keys zijn makkelijk te standaardiseren: kies een vaste namespace-nummer per workflow (bijvoorbeeld: goedkeuringen vs facturatie) en gebruik het record-ID als tweede waarde.
Hashing is handig wanneer je identifier een UUID is, maar accepteer een klein risico op collisions en wees consistent overal.
Wat je ook kiest, schrijf het format vast en centraliseer het. "Bijna dezelfde sleutel" op twee plekken is een veel voorkomende manier om duplicaten weer binnen te halen.
Stap voor stap: een veilig patroon voor één-op-één verwerking
Een goed advisory-lock workflow is simpel: lock, verifieer, voer uit, registreer, commit. De lock is op zichzelf geen businessregel. Het is een vangrail die de regel betrouwbaar maakt wanneer twee workers tegelijk hetzelfde record raken.
Een praktisch patroon:
- Open een transactie wanneer het resultaat atomair moet zijn.
- Verkrijg de lock voor de specifieke eenheid van werk. Geef de voorkeur aan een transactie-gescopeerde lock (
pg_advisory_xact_lock) zodat deze automatisch vrijkomt. - Controleer de staat opnieuw in de database. Ga er niet van uit dat je de eerste bent. Bevestig dat het record nog steeds in aanmerking komt.
- Doe het werk en schrijf een duurzaam "klaar"-marker in de database (status-update, grootboekregel, audit-row).
- Commit en laat de lock los. Als je een sessie-lock gebruikte, unlock dan voordat je de verbinding teruggeeft aan de pool.
Voorbeeld: twee app-servers ontvangen "Approve invoice #123" in dezelfde seconde. Beide starten, maar slechts één krijgt de lock voor 123. De winnaar controleert dat invoice #123 nog pending is, zet hem op approved, schrijft de audit/betalingsregel en commit. De tweede server kan ofwel snel falen (try-lock) of wacht totdat de eerste klaar is, controleert dan opnieuw en stopt zonder duplicaat te maken. Zo vermijd je dubbele verwerking en blijft de UI responsief.
Waar advisory locks passen: goedkeuringen, facturatie, schedulers
Advisory locks passen het beste wanneer de regel eenvoudig is: voor een specifiek ding mag maar één proces het "winnende" werk tegelijk doen. Je houdt je bestaande database en appcode, maar voegt een klein hek toe dat racecondities veel moeilijker maakt.
Goedkeuringen
Goedkeuringen zijn klassieke concurrency-valkuilen. Twee beoordelaars (of dezelfde persoon die dubbelklikt) kunnen binnen milliseconden op Goedkeuren drukken. Met een lock op het request-ID voert slechts één transactie de statuswijziging uit. Iedereen else ziet snel het resultaat en kan bijvoorbeeld "al goedgekeurd" tonen.
Dit komt vaak voor in klantportalen en adminpanels waar veel mensen dezelfde wachtrij in de gaten houden.
Facturatie
Facturatie heeft meestal strengere regels: één betalingspoging per factuur, zelfs bij retries. Een netwerktimeout kan een gebruiker een tweede keer op Pay laten klikken, of een achtergrondretry kan lopen terwijl de eerste poging nog bezig is.
Een lock op het factuur-ID zorgt dat slechts één pad tegelijk met de payment provider praat. De tweede poging kan "betaling bezig" teruggeven of de meest recente betaalstatus lezen. Dat voorkomt duplicaatwerk en verkleint het risico op dubbele afschrijvingen.
Schedulers en achtergrondworkers
In multi-instance setups kunnen schedulers per ongeluk hetzelfde venster parallel draaien. Een lock op jobnaam plus tijdsvenster (bijv. "daily-settlement:2026-01-29") zorgt dat maar één instantie het uitvoert.
Dezelfde aanpak werkt voor workers die items uit een tabel halen: lock op het item-ID zodat slechts één worker het verwerkt.
Veelgebruikte sleutels zijn een goedkeuringsrequest ID, een factuur-ID, een jobnaam plus tijdsvenster, een klant-ID voor "één export tegelijk" of een unieke idempotency-key voor retries.
Een realistisch voorbeeld: dubbele goedkeuring stoppen in een portal
Stel je een goedkeuringsverzoek voor in een portal: een inkooporder wacht en twee managers klikken binnen dezelfde seconde op Goedkeuren. Zonder bescherming kunnen beide verzoeken "pending" lezen en allebei "approved" schrijven, wat dubbele auditregels, dubbele notificaties of downstream werk twee keer kan triggeren.
PostgreSQL advisory locks geven je een eenvoudige manier om deze actie één-op-één per goedkeuring te maken.
De flow
Wanneer de API een approve-actie ontvangt, neemt deze eerst een lock op basis van het approval-id (zodat verschillende goedkeuringen parallel verwerkt kunnen worden).
Een veelgebruikt patroon is: lock op approval_id, lees de huidige status, werk de status bij en schrijf een audit-record, alles in één transactie.
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;
Wat de tweede klik ervaart
Het tweede verzoek kan de lock niet krijgen (dus het geeft snel "Al in behandeling" terug) of het krijgt de lock nadat de eerste klaar is, ziet dan dat de status al approved is en stopt zonder iets te veranderen. Hoe dan ook voorkom je dubbele verwerking en blijft de UI responsief.
Voor debugging, log genoeg om elke poging te traceren: request-id, approval-id en berekende lock-key, actor-id, resultaat (lock_busy, already_approved, approved_ok) en timing.
Omgaan met wachten, timeouts en retries zonder de app te blokkeren
Wachten op een lock klinkt onschuldig totdat het een draaiende knop, een vastgelopen worker of een achterstand wordt die nooit weggaat. Als je de lock niet kunt krijgen, faal dan snel waar een mens wacht en wacht alleen waar wachten veilig is.
Voor gebruikersacties: try-lock en duidelijke respons
Als iemand op Goedkeuren of Betalen klikt, blokkeer dan niet hun verzoek voor seconden. Gebruik try-lock zodat de app meteen kan antwoorden.
Een praktische aanpak: probeer de lock en als dat faalt, geef een helder "bezig, probeer opnieuw"-antwoord (of vernieuw het item). Dat vermindert timeouts en ontmoedigt herhaalde klikken.
Houd het vergrendelde gedeelte kort: controleer status, voer de statuswijziging uit, commit.
Voor achtergrondjobs: blokkeren is oké, maar begrens het
Voor schedulers en workers kan blokkeren prima zijn omdat er geen mens wacht. Maar je hebt nog steeds limieten nodig, anders kan één trage job een hele fleet stilzetten.
Gebruik timeouts zodat een worker kan opgeven en door kan gaan:
SET lock_timeout = '2s';
SET statement_timeout = '30s';
SELECT pg_advisory_lock(123456);
Stel ook een maximum verwachte runtime voor de job in. Als facturatie normaal binnen 10 seconden klaar is, beschouw 2 minuten als een incident. Houd starttijd, job-id en hoelang locks worden vastgehouden bij. Als je jobrunner cancel ondersteunt, annuleer taken die de limiet overschrijden zodat de sessie eindigt en de lock vrijkomt.
Plan retries bewust. Als een lock niet wordt verkregen, beslis wat er daarna gebeurt: herschrijf met backoff (en wat willekeur), sla best-effort werk over voor deze cyclus, of markeer het item als contended als herhaalde mislukkingen aandacht nodig hebben.
Veelgemaakte fouten die vastzittende locks of duplicaten veroorzaken
De meest voorkomende verrassing zijn session-level locks die nooit vrijgegeven worden. Connection pools houden verbindingen open, dus een sessie kan langer leven dan een request. Als je een sessie-lock neemt en vergeet te unlocken, kan de lock vast blijven tot die verbinding gerecycled wordt. Andere workers wachten (of falen) en het kan moeilijk zijn te zien waarom.
Een andere bron van duplicaten is locken maar de staat niet opnieuw controleren. Een lock zorgt alleen dat één worker de kritieke sectie tegelijk uitvoert. Het garandeert niet dat het record nog in aanmerking komt. Controleer altijd opnieuw binnen dezelfde transactie (bijv. bevestig pending voordat je naar approved gaat).
Lock-keys zorgen ook voor verwarring. Als de ene service lockt op order_id en een andere op een anders berekende sleutel voor dezelfde resource, heb je nu twee locks. Beide paden kunnen tegelijk draaien, wat een valse veiligheidsillusie creëert.
Langdurige lock-holds zijn meestal zelf toegebracht. Als je trage netwerkcalls doet terwijl je de lock vasthoudt (payment provider, e-mail/SMS, webhooks), wordt een korte vangrail een bottleneck. Houd de locked sectie gefocust op snelle databasewerkzaamheden: valideer status, schrijf de nieuwe status, registreer wat er daarna moet gebeuren. Trigger side effects pas nadat de transactie committed is.
Tot slot vervangen advisory locks idempotency of databaseconstraints niet. Zie ze als een verkeerslicht, niet als bewijsvoering. Gebruik waar relevant unieke constraints en idempotency-keys voor externe oproepen.
Korte checklist voordat je het uitrolt
Behandel advisory locks als een klein contract: iedereen in het team moet weten wat de lock betekent, wat het beschermt en wat toegestaan is terwijl hij vastgehouden wordt.
Een korte checklist die de meeste problemen vangt:
- Eén duidelijke lock-key per resource, opgeschreven en overal hergebruikt
- Verkrijg de lock voordat iets onherroepelijks gebeurt (betalingen, e-mails, externe API-calls)
- Controleer de staat opnieuw nadat de lock is verkregen en voordat je schrijft
- Houd de locked sectie kort en meetbaar (log lock-wacht- en uitvoeringstijden)
- Bepaal wat "lock busy" betekent voor elk pad (UI-bericht, retry met backoff, overslaan)
Volgende stappen: pas het patroon toe en houd het onderhoudbaar
Kies één plek waar duplicaten het meest pijn doen en begin daar. Goede eerste doelen zijn acties die geld kosten of permanent status veranderen, zoals "factuur innen" of "verzoek goedkeuren." Wikkel alleen die kritieke sectie met een advisory lock en breid uit naar aangrenzende stappen zodra je het gedrag vertrouwt.
Voeg vroege observability toe. Log wanneer een worker een lock niet krijgt en hoe lang vergrendeld werk duurt. Als lock-wachten pieken, betekent dat meestal dat de kritieke sectie te groot is of dat een trage query erin verstopt zit.
Locks werken het beste bovenop data-veiligheid, niet in plaats daarvan. Houd heldere statusvelden (pending, processing, done, failed) en ondersteun ze met constraints waar mogelijk. Als een retry op het slechtste moment gebeurt, kan een unieke constraint of een idempotency-key de tweede verdedigingslinie zijn.
Als je workflows bouwt in AppMaster (appmaster.io), kun je hetzelfde patroon toepassen door de kritieke statuswijziging binnen één transactie te houden en een kleine SQL-stap toe te voegen om een transaction-level advisory lock te nemen voordat de "finalize"-stap draait.
Advisory locks zijn een goede oplossing totdat je echt queue-functies nodig hebt (prioriteiten, vertraagde jobs, dead-letter handling), je zware contentie hebt en slimmere paralleliteit nodig hebt, je moet coördineren over databases zonder gedeelde Postgres, of je strengere isolatieregels nodig hebt. Het doel is saaie betrouwbaarheid: houd het patroon klein, consistent, zichtbaar in logs en ondersteund door constraints.
FAQ
Gebruik een advisory lock wanneer je "slechts één actor tegelijk" nodig hebt voor een specifiek werkitem, zoals het goedkeuren van een verzoek, het innen van een factuur of het uitvoeren van een gepland venster. Het is vooral nuttig wanneer meerdere app-instances hetzelfde item kunnen raken en je geen aparte lock-service wilt toevoegen.
Rijlocks beschermen echte rijen die je selecteert en zijn ideaal wanneer de hele operatie netjes samenvalt met één rijaandeel. Advisory locks beschermen een door jou gekozen sleutel, dus ze werken ook wanneer de workflow meerdere tabellen raakt, externe services aanroept of start voordat de uiteindelijke rij bestaat.
Standaard kies je pg_advisory_xact_lock (transaction-level) voor request/response-acties omdat die automatisch vrijgegeven wordt bij commit of rollback. Gebruik pg_advisory_lock (session-level) alleen als je de lock echt buiten een transactie nodig hebt en je zeker weet dat je altijd weer pg_advisory_unlock aanroept voordat de verbinding terug naar de pool gaat.
Voor UI-gedreven acties heeft try-lock (pg_try_advisory_xact_lock) de voorkeur zodat het verzoek snel kan falen en een duidelijke “al in behandeling” reactie kan teruggeven. Voor achtergrondwerkers is een blokkende lock vaak oké, maar begrens deze met lock_timeout zodat één vastgelopen taak niet alles stilzet.
Lock het kleinste item dat niet twee keer mag draaien, meestal “één factuur” of “één goedkeuringsverzoek”. Als je te ruim lockt (bijv. per klant) verlaag je de doorvoer; als je te nauw lockt kun je nog steeds duplicaten krijgen omdat verschillende paden niet op dezelfde sleutel coördineren.
Kies één stabiel sleutelformaat en gebruik dat overal waar dezelfde kritieke actie uitgevoerd kan worden. Een veelgebruikte aanpak is twee integers: een vaste namespace voor de workflow plus het entity ID, zodat verschillende workflows elkaar niet per ongeluk blokkeren maar wel correct coördineren.
Nee. Een lock voorkomt alleen gelijktijdige uitvoering; het bewijst niet dat de operatie veilig herhaald kan worden. Controleer altijd de status opnieuw binnen dezelfde transactie (bijv. verifieer dat het item nog pending is) en gebruik waar passend unieke constraints of idempotency-keys.
Houd het vergrendelde gedeelte kort en databasegericht: verkrijg de lock, controleer geschiktheid, schrijf de nieuwe status en commit. Doe trage side-effects (betalingen, e-mails, webhooks) na de commit of via een outbox-achtige tabel zodat je de lock niet tijdens netwerkvertragingen vasthoudt.
De meest voorkomende oorzaak is een session-level lock die vastgehouden wordt door een gepoolde verbinding die nooit ontgrendeld werd door een bug in de code. Geef de voorkeur aan transaction-level locks, en als je session locks gebruikt, zorg dat pg_advisory_unlock betrouwbaar wordt aangeroepen voordat de verbinding teruggaat naar de pool.
Log het entity-ID en de berekende lock-key, of de lock is verkregen, hoe lang het duurde om de lock te verkrijgen en hoe lang de transactie draaide. Log ook uitkomsten zoals lock_busy, already_processed of processed_ok zodat je contending situaties kunt onderscheiden van echte duplicaten.


