- Pradžia
- Atvejų analizės
- Idempotentinis „webhook“ priėmimas dideliu mastu
Idempotentinis „webhook“ priėmimas dideliu mastu
Patikimas didelio kiekio „webhook“ priėmimas: priimti kartojimus ir dublikatus, taisyti spragas ir nuolat įrodyti, kad išoriniai įvykiai nedingo.
Paskelbta 2026-06-30Peržiūrėta 2026-07-02
Trumpai
- Problema
- Trečiųjų šalių „webhook“ įvykiai kartojosi, dubliavosi ir atkeliaudavo ne iš eilės; tylus praradimas pasimatytų tik vėliau, sugedus ataskaitoms.
- Apribojimas
- Sistema turėjo toleruoti tiekėjų elgesį, didelį rašymo kiekį, dalinius gedimus ir vėluojančią sutikrą be rankinio detektyvo darbo.
- Sukurta sistema
- Patvari Postgres priėmimo eilė su external-ID upsertais, SKIP LOCKED darbininkais, sutikros praėjimais, tombstone spragų aptikimu ir anomalijų patikromis.
- Rezultatas
- Duomenų praradimo rizika virto nuolatinėmis patikromis, kurios aptinka spragas, jas taiso ir rodo operacinę būklę.
- Kur tai pritaikoma
- Aktualu, kai verslas priklauso nuo įvykių iš Stripe, CRM, prekyviečių ar SaaS API ir negali sau leisti tylaus išsiskyrimo.
- Technologijos
- Go · Postgres (SKIP LOCKED, matviews) · HMAC · advisory locks
Kodėl tai svarbu
Jei jūsų komanda planuoja sistemą su tais pačiais gedimo scenarijais — būtent nuo tokio mąstymo prasideda pirmas pokalbis.


Apžvalga
Pirmiau aprašytas apribojimas — visa užduotis: trečiosios šalies tiekėjo „webhook“ užklausos kartojasi pasibaigus laiko limitui, dubliuojasi dėl paties tiekėjo infrastruktūros trikčių ir atkeliauja ta tvarka, kokia tiekėjo vidinės eilės tuo metu jas išleidžia, ir nė vieno iš šių dalykų integratorius negali derėtis pakeisti. Virš šio nenuspėjamo įvesties modelio yra antras apribojimas, neturintis nieko bendra su tiekėju: apkrova pakankamai didelė, kad naivus modelis „patikrink, ar matėme šį ID, tada įrašyk“ pats savaime tampa lenktynėmis tarp lygiagrečiai rašančių procesų. Ir bet koks rankinis procesas, skirtas pagauti tai, kas prasprūsta, turi susitraukti iki beveik nulio, nes niekas nesirengia praleisti dienos per savaitę, skaitydamas žurnalus ir ieškodamas „webhook“ užklausos, kuri niekada neatkeliavo. Viskas toliau egzistuoja tam, kad atsakytų į vieną klausimą be žmogaus grandinėje: ar kiekvienas tiekėjo išsiųstas įvykis iš tikrųjų pasiekė tikslą, lygiai vieną kartą, forma, kuria likusi sistemos dalis gali pasitikėti?
Ne daugiau kaip vieną kartą: „upsert“ pagal išorinį ID
Kiekviena gaunama užklausa patikrinama prieš patikint jai bet ką kita. Tiekėjas pasirašo savo turinį HMAC parašu, o priėmimas iš naujo apskaičiuoja tą parašą pagal neapdorotą užklausos turinį, dar prieš iš viso paliesdamas patį turinį. Užklausa, nepraėjusi patikros, atmetama iškart — ji niekada nepasiekia eilės ir niekada negauna progos užteršti dublikatų aptikimo logikos suklastotu išoriniu ID. Toliau juda tik patvirtintas įvykis.
Nuo čia esminis mechanizmas sąmoningai paprastas.
Rašymas į patvarią, Postgres pagrindu veikiančią eilę yra „upsert“, raktu naudojant lauką, kurį pats tiekėjas garantuoja esant stabilų per visus pakartotinius siuntimus — jo išorinį ID.
Tai nėra „patikrink, ar šis ID egzistuoja, tada nuspręsk, ar įrašyti“; tai vienas atominis INSERT ... ON CONFLICT (external_id) DO UPDATE, kuris arba sukuria eilutę, arba suderina ją su jau esančia eilute, per vieną kreipimąsi, o pačios duomenų bazės užrakinimo mechanizmas atlieka darbą, kurį rankomis parašytas „patikrink-tada-veik“ atliktų klaidingai esant lygiagretumui.
INSERT INTO webhook_events (external_id, provider, payload, received_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (external_id)
DO UPDATE SET payload = EXCLUDED.payload, received_at = EXCLUDED.received_at
WHERE webhook_events.processed_at IS NULL;
„Upsert“ raktu naudojant tiekėjo išorinį ID
Pakartotinis to paties įvykio pristatymas — nesvarbu, ar tai teisėtas pakartojimas po lėto atsako, ar paties tiekėjo „bent kartą“ garantijos dvigubas suveikimas — kiekvieną kartą patenka į tą pačią eilutę. ID yra eilutės tapatybė, o ne šalutinis stulpelis, todėl „ar mes tai jau matėme?“ nėra atskiras klausimas, kurį programa turi užduoti; tai ta pati komanda, kuri atlieka rašymą.
Kodėl ne atskira dublikatų / idempotentiškumo rakto lentelė
Akivaizdi alternatyva — pridėtinis idempotentiškumo sluoksnis: atskira matytų ID lentelė, tikrinama prieš įvykstant tikrajam rašymui, kai programos kodas atsakingas už tai, kad abi liktų sinchronizuotos. Tai viliojanti, nes atrodo saugiai atskirta nuo domeno lentelės.
Tai taip pat griežtai blogiau sistemai, kuri turi veikti su dideliu rašymo kiekiu be žmogaus, tikrinančio rezultatus. Atskira lentelė reiškia du rašymus vietoj vieno, o šie du rašymai dabar turi likti suderinti vienas su kitu esant lygiagrečiai prieigai — arba suvynioti į tranzakciją su savo pačios konkurencijos profiliu, arba palikti langą, kuriame dublikatų įrašas jau egzistuoja, o tikrasis rašymas dar nepasiekė tikslo, arba atvirkščiai. Kiekvienas gedimo scenarijus, kurį „be rankinio detektyvo darbo“ turėtų pašalinti, arba turi būti tvarkomas dukart, arba, realiau, nuslysta: dublikatų lentelė sako viena, domeno lentelė — kita, ir dabar atsiranda klaidų klasė, nematoma tol, kol sutikra į ją atsitiktinai neužklumpa. „Upsert“ tiesiai į domeno lentelę sumažina šį paviršių iki nulio — yra tik viena lentelė, vienas rašymas ir lygiai vienas dalykas, kuris gali būti tiesa apie tai, ar įvykis buvo matytas.
Lygiagretus priėmimas su SKIP LOCKED darbininkais
„Upsert“ išsprendžia vieno rašymo teisingumą; jis neišsprendžia pralaidumo.
Didelis kiekis reiškia daug darbininkų, tuo pat metu traukiančių iš tos pačios eilės, o naivus būdas paskirstyti darbą — SELECT ... FOR UPDATE be jokių apribojimų — priverčia kiekvieną darbininką stovėti eilėje už to, kuris pirmas užrakino eilutę, paverčiant tai, kas turėtų būti lygiagretus darbas, į eilę.
SKIP LOCKED pakeičia tai, kas nutinka, kai darbininko užklausa aptinka eilutę, kurią jau užrakino kitas darbininkas.
Vietoj to, kad blokuotų, kol tas užraktas atsileis, ji praleidžia tą eilutę ir paima kitą, kuri iš tikrųjų laisva.
Darbininko užklausa tampa artima SELECT ... FROM webhook_events WHERE processed_at IS NULL ORDER BY received_at FOR UPDATE SKIP LOCKED LIMIT 50, ir bet koks skaičius darbininkų gali vykdyti tą pačią užklausą tuo pačiu metu niekada nesikertant, nes nė vienas iš jų niekada nelaukia kito — kiekvienas tiesiog gauna tai, kas dar neužimta.
Būtent tai paverčia „pridėti dar vieną darbininką“ realia mastelio didinimo strategija, o ne dar vienu procesu, praleidžiančiu didžiąją dalį gyvenimo užblokuotu.
Tai turi konkrečią kainą: ORDER BY received_at yra pageidavimas, kuri eilutė bus paimta kitą, o ne pažadas dėl tvarkos, kuria eilutės baigia apdorojimą, nes kai daugiau nei vienas darbininkas lygiagrečiai traukia paketus, tas darbininkas, kuris pirmas baigia savo paketą, pirmas apdoroja savo eilutes — nepriklausomai nuo to, kuris paketas buvo paimtas anksčiau.
Bet kas žemiau esantis, kas daro prielaidą, kad apdorojimo tvarka atspindi rašymo tvarką, turi pats užtikrinti šią garantiją; šis sluoksnis to nedaro.
Sutikros praėjimai
„Upsert“ ir SKIP LOCKED darbininkai užtikrina, kad įvykis būtų įrašytas ir apdorotas; nė vienas iš jų neįrodo, kad jis liko teisingas. Sutikra — sluoksnis, kuris grįžta ir patikrina: praėjimas, iš naujo perskaitantis tai, kas jau yra patvarioje eilėje, palyginant su tuo, ką likusi sistemos dalis su tuo padarė, ieškant eilučių, kurios buvo įrašytos, bet niekada nepažymėtos kaip apdorotos, arba apdorotos, bet būsenoje, neatitinkančioje to, ką rodo šviežias turinio perskaitymas.
Praėjimas turi du paleidiklius. Jis vykdomas pagal fiksuotą tvarkaraštį, nepriklausomai nuo visko kito, ir vykdomas iš naujo, kai kitas sluoksnis — dažniausiai anomalijos patikra — praneša, kad kažkas atrodo negerai. Tvarkaraštis pagauna lėtą, tylų nuokrypį, kurio niekas kitas laiku nepastebėtų; suveikimu paleisti praėjimai pagauna tokį, kuris pakankamai skubus, kad negalima laukti kito suplanuoto praėjimo. Nė vienas paleidiklis nepriklauso nuo kito, todėl klaida viename nenutraukia kito aprėpties.
Kadangi sutikros praėjimas skaito ir galimai perrašo platų lentelės ruožą, tuo pačiu metu leidžiama veikti tik vienam jo egzemplioriui. Tai užtikrinama Postgres advisory užraktu, paimamu praėjimo pradžioje ir atleidžiamu pabaigoje, o ne eilutės užraktu, susietu su konkrečiu įrašu. Antras paleidiklis, suveikęs tuo metu, kai praėjimas jau vyksta, randa užraktą užimtą ir iš karto baigia darbą, užuot pradėjęs perteklinį, konkuruojantį praėjimą.
Schema: sutikros ciklas — gaunamas „webhook“ įrašomas „upsert“ būdu pagal išorinį ID į patvarią eilę, sutikros praėjimas periodiškai iš naujo patikrina, kas buvo įrašyta, „tombstone“ anti-sąjunga skenuoja tiekėjo išorinio ID sekos spragas, bet kokia spraga suveikia papildymą, o anomalijų patikros nuolat stebi delsą ir pamestus įrašus dėl nuokrypių.
Nė vienas iš šių dalykų nepaverčia jokio pavienio praėjimo lemiamu savaime. Sutikros vykdymas, kuris šį ciklą kažką praleidžia, nėra nesėkmė, nes kitas paleidiklis — suplanuotas ar suveiktas — gauna dar vieną progą pažiūrėti.
Spragų aptikimas su „tombstone“ anti-sąjungomis
Sutikra tikrina tai, kas jau yra eilėje, palyginant su tuo, kas su tuo atsitiko; ji nepagauna įvykio, kuris niekada visai neatkeliavo. Jei paties tiekėjo pristatymo bandymas pametamas dar prieš pasiekiant priėmimą — tinklo trikis, gedimas tiekėjo pusėje, „webhook“ galinis taškas, trumpam sustojęs veikti — nėra jokios eilutės, kurią sutikrinti, nes niekas niekada nebuvo įrašyta. Tai pagauti reikia kito metodo: palyginti išorinius ID, iš tikrųjų esančius eilėje, su seka, kurią tiekėjas numano tie ID turėtų sudaryti, ir pažymėti tai, kas trūksta.
Toks palyginimas yra anti-sąjunga — užklausa, grąžinanti lygiai tuos ID iš vienos pusės, kurie neturi atitinkamos eilutės kitoje.
SELECT expected.external_id
FROM generate_external_id_sequence($1, $2) AS expected(external_id)
LEFT JOIN webhook_events e ON e.external_id = expected.external_id
WHERE e.external_id IS NULL
AND expected.external_id NOT IN (SELECT external_id FROM webhook_tombstones);
Anti-sąjunga: ID, kuriuos seka numano turėtų egzistuoti, bet neegzistuoja
„Tombstones“ lentelė — tai, kas neleidžia amžinai pakartotinai pažymėti to paties klaidingai teigiamo atvejo. Ne kiekviena anti-sąjungos rasta spraga yra realus duomenų praradimas — kai kurie ID tiekėjo sekoje teisėtai niekada nesiunčiami, juodraštis, kuris niekada nesuveikė, testinis įvykis, atfiltruotas anksčiau — ir kai pažymėta spraga jau ištirta ir patvirtinta kaip viena iš tokių, ji įrašoma kaip „tombstone“, kad kitas anti-sąjungos paleidimas ją praleistų, o ne keltų tą pačią neproblemą kiekviename papildymo cikle. Spraga, kuri nėra pažymėta „tombstone“ ir savaime neišsisprendžia po pakartojimo, suveikia papildymą: užklausą atgal į tiekėjo API dėl to konkretaus išorinio ID, kuri arba grąžina trūkstamą įvykį — įrodydama, kad jis iš tikrųjų buvo pamestas kelyje, ir jį pataisydama — arba grįžta tuščia, ir tada ji pažymima kaip „tombstone“, o ciklas juda toliau.
Anomalijų patikros
Visi aukščiau aprašyti sluoksniai reaguoja į konkretų, įvardintą gedimą: dublikatą, spragą sekoje, užstrigusį sutikros praėjimą. Anomalijų patikros egzistuoja gedimams, kurių niekas iš anksto neįvardino. Rinkinį sudaro daugiau nei trisdešimt automatinių patikrų, kiekviena lyginanti metriką su savo paties laukiamu diapazonu ir pranešanti signalą, kai ji iš jo nuklysta. Dauguma jų skaito iš pagal tvarkaraštį atnaujinamų materializuotų vaizdų (matviews), o ne tiesiogiai iš gyvų lentelių, todėl patikrų vykdymas nesukuria papildomo skaitymo krūvio toms pačioms lentelėms, į kurias priėmimas tuo metu rašo.
Dvi šeimos apima didžiąją dalį paviršiaus. Delsos patikros stebi laiką tarp įvykio įrašymo ir pažymėjimo apdorotu, nes eilė, tyliai atsiliekanti, izoliuotai atrodo tvarkinga — kiekviena eilutė galiausiai apdorojama — kol „galiausiai“ tampa nepriimtinu, o vienintelis ankstyvas įspėjimas yra platėjantis atotrūkis tarp gavimo ir apdorojimo. Referencinės patikros stebi pamestus (orphaned) įrašus: eilutes, nurodančias išorinį ID be atitinkamo įrašo ten, kur jis turėtų būti — dažnai pirmas matomas simptomas klaidos, esančios trimis sluoksniais toliau nuo tos vietos, kur pamestas įrašas iš tikrųjų parodomas.
Bet kuriai iš šių patikrų suveikus, tai taip pat yra vienas iš dviejų anksčiau aprašytų sutikros paleidiklių — anomalija pati savaime yra priežastis iš karto perleisti sutikrą iš naujo, o ne laukti tvarkaraščio. Tai tas pats redundancijos principas, veikiantis per kiekvieną šios sistemos sluoksnį: nė viena atskira patikra neturi būti išsami, nes patikros persidengia tuo, ką jos pagautų, o tai, ko viena nepastebi, kita, esanti dviem sluoksniais toliau, greičiausiai vis tiek pažymės. Praktinis poveikis toks, kad „ar praradome duomenis?“ nustojo būti klausimu, reikalaujančiu, kad kažkas eitų ieškoti. Tai skydelis, į kurį galima tiesiog žvilgtelėti, nes sistema nuolat pati sau užduoda šį klausimą.
Kompromisai ir apribojimai
Nė vienas iš šių dalykų nėra nemokamas, o sąžiningumas dėl to, ko šis sprendimas neišsprendžia, yra projekto dalis:
- „Upsert“ pagal išorinį ID apsaugo tik nuo to paties ID pakartotinio pristatymo. Tiekėjas, sukuriantis naują ID tam pačiam, semantiškai, loginiam įvykiui, praslysta kiaurai kaip nauja eilutė; niekas čia to savaime neatpažįsta.
- SKIP LOCKED perka lygiagretumą atsisakydamas griežtos tvarkos. Bet kas žemiau esantis, kas daro prielaidą, jog apdorojimo tvarka atitinka įrašymo tvarką, turi pats užtikrinti šią garantiją — šis sluoksnis to nedaro.
- Sutikros praėjimai skaito, o kartais ir perrašo, platų lentelės ruožą kiekvieno vykdymo metu. Esant pakankamai dideliam kiekiui, tai periodinis krūvis, konkuruojantis su tais pačiais priėmimo rašymais, kuriuos jis turėtų apsaugoti.
- „Tombstone“ anti-sąjunga auga proporcingai skenuojamos ID sekos lango dydžiui, o ne realių spragų skaičiui. Tiekėjui su retomis arba labai didelėmis ID erdvėmis pats palyginimas tampa brangus, jei langas neapribotas.
- Anomalijų patikrų slenksčiai derinami, o ne išvedami. Per griežti — rinkinys išmoko žmones ignoruoti savo paties įspėjimus; per laisvi — jis praleidžia nuokrypį, kurį turėtų pagauti; derinimas yra nuolatinis, o ne vienkartinis nustatymo žingsnis.
- Visa tai apsaugo nuo teisingai pasirašytų ir pristatytų įvykių praradimo bei dubliavimo. Tai neapsaugo nuo tiekėjo, siunčiančio galiojantį, teisingai pasirašytą įvykį, kuris tiesiog klaidingas — sugadinti ar semantiškai neteisingi duomenys atkeliauja lygiai vieną kartą, patvirtinti ir sutikrinti, ir vis tiek klaidingi.
Šaltiniai ir tolesnis skaitymas
Čia aprašyti metodai — standartinė Postgres ir „webhook“ projektavimo praktika, ne naujovė:
Susijusios atvejų analizės
Sistemos, kurias su šia sieja bendras apribojimas — pakartojimai, izoliacija, kaina, atkūrimas.
Patvari užduočių eilė, pakeitusi „no-code“ automatizacijų sieną
Gedimai tapo užklausiamomis eilutėmis, pakartojimai — saugūs pagal konstrukciją, o naujas automatizacijas galima tikrinti su gyvu srautu prieš joms veikiant.
Go · Postgres leases · gocron · bearer + HMAC authKelių nuomininkų SaaS su izoliacija, užtikrinama kiekvienoje užklausoje
Nuomininkų izoliacija tapo numatytuoju keliu naujoms funkcijoms, o ne konvencija, kurią galima pamiršti spaudžiant terminams.
tRPC · Prisma · Postgres · Next.js