Таблиці вихідної та вхідної пошти: проектування Webhooks, які витримують збої
Дізнайтеся, як транзакційна скринька вихідних повідомлень, ідемпотентна скринька вхідних повідомлень, відкладення з урахуванням стану та черги для „мертвих“ листів забезпечують надійну доставку webhook у AWS, Azure та GCP.
Webhooks здаються найпростішим способом інтеграції: одна сторона надсилає запит HTTP POST, а інша його обробляє. Насправді вони містять усі небезпеки розподіленої системи, адже мережа між двома сервісами може втрачати запити, досягати тайм-ауту на півдорозі, доставляти однаковий вантаж двічі або змінювати порядок подій. Якщо ставитися до webhook як до звичайного запиту CRUD, зрештою можна втратити сповіщення, двічі активувати побічні ефекти та отримати дві системи, які не погоджуються щодо того, що сталось.
Цей посібник розглядає дизайн, який витримує такі умови. Ви дізнаєтесь, чому наївний підхід не функціонує, як транзакційна скринька вихідних повідомлень забезпечує надійність вихідних webhook-ів, як ідемпотентна скринька вхідних повідомлень дозволяє безпечно пересилати вхідні webhook-и знову, як боротися з подіями, що надходять у неправильному порядку, як ізолювати дані, які ніколи не можуть бути оброблені успішно, та які керовані послуги в AWS, Azure та Google Cloud підходять для кожної частини цього дизайну.
Чому очевидна реалізація втрачає дані
Розгляньмо SaaS-бекенд, який обробляє значущі зміни стану, наприклад виконання замовлення чи активацію підписки. Партнер викликає ваш API, щоб підтвердити дію, і тепер у вашому сервісі є два завдання:
- Зберегти новий стан, наприклад встановити статус об’єкта на
Active. - Повідомити сервіс, що знаходиться нижче в ланцюзі, що об’єкт готовий, надіславши йому webhook.
Інтуїтивно зрозумілий код записує дані у базу даних, а потім, у наступному рядку, надсилає HTTP-запит. Це є двостороннім записом: дві незалежні системи оновлюються по черзі, без жодних зв’язків між ними.
З цього безпосередньо випливають два способи збою:
- Процес завершується між цими двома кроками. База даних тепер вважає об’єкт активним, але запит так і не був надісланий. Ваші записи є правильними, послуга, яка працює далі, нічого не знає, і ніхто не помічає проблеми, поки клієнт не поскаржиться.
- Запит надісланий, але транзакція зазнала невдачі. Послуга, яка працює далі, отримала інформацію про те, що об’єкт активний, але ваша база даних скасувала операцію та все ще вважає її невдалою.
Жоден з варіантів розташування коду не вирішує проблеми. Якщо розмістити HTTP-запит на початку, виникне друга помилка; якщо — наприкінці, — перша. Основна причина полягає у тому, що здійснення коміту бази даних та мережевого запиту не можуть відбуватися атомарно одночасно, тож будь-яка збій чи помилка між ними призводить до розбіжностей між обома сторонами.
Надійна передача webhook-ів за допомогою транзакційного механізму
Модель outbox усуває необхідність подвійного запису, оскільки HTTP-запит взагалі не виконується з маршруту запиту. Натомість намір надіслати webhook перетворюється на дані, які записуються в тій самій транзакції бази даних, що й зміна бізнес-логіки. Або обидва елементи комітуються, або жоден з них.
Чотири кроки процесу outbox
- Відкриття транзакції. Бізнес-операція запускає звичайну транзакцію бази даних.
entities (наприклад, встановлюючи статус на Active) та додає рядок у outbox_events, який містить саме той вміст, що має отримати наступний сервіс.Таблиця outbox
У таблиці нижче зберігається по одному рядку на кожне очікуюче сповіщення. aggregate_type та aggregate_id визначають, який бізнес-об’єкт стосується подія, event_type позначає, що саме сталось, payload містить тіло повідомлення, яке потрібно доставити, а processed_at залишається порожнім до тих пір, поки система передачі не підтвердить доставку. Запит на рядки, де processed_at дорівнює null, надає системі передачі список завдань. Зверніть увагу, що вбудовані коментарі використовують один дефіс; у PostgreSQL для коментаряв потрібні два (--), тож виправте це перед виконанням запиту.
CREATE TABLE outbox_events (
id UUID PRIMARY KEY,
aggregate_type VARCHAR(50), - e.g., 'Order' or 'User'
aggregate_id UUID, - e.g., Entity ID
event_type VARCHAR(100), - e.g., 'order.activated'
payload JSONB NOT NULL, - The exact webhook payload
created_at TIMESTAMP DEFAULT NOW(),
processed_at TIMESTAMP - Null until successfully sent
);
Що гарантує система відправки та що ні
Якщо сервер зупиниться після виконання операції коміт, нічого не буде втрачено: рядок все ще залишається в таблиці, і реле знайде його під час наступного проходження. Якщо кінцева точка не доступна, реле просто спробує ще раз, бажано з експоненційним затримуванням, щоб не перевантажувати пристрій, який має труднощі. Зміна стану та намір повідомити більше не можуть розходитися.
Компроміс полягає у тому, що доставка відбувається щонайменше один раз. Реле може успішно надіслати запит, а потім зламатися перед тим, як оновити processed_at; у такому разі та сама подія буде надіслана знову під час наступної обробки. Це прийнятно лише за умови, що отримувачі видаляють дублікати, що саме і забезпечує модель скриньки вхідних повідомлень з іншого боку. Включення id рядка зі скриньки вихідних повідомлень у навантаження або заголовок дає отримувачам стабільний ключ для видалення дублікатів. Якщо ви запускаєте кілька інстанцій реле, переконайтеся, що два працівники не можуть одночасно отримати той самий рядок; у PostgreSQL поширеним способом для цього є вибір рядків за допомогою FOR UPDATE SKIP LOCKED.
Безпечне отримання webhook-ів за допомогою ідемпотентної скриньки вхідних повідомлень
Тепер змініть точку зору на webhook-и, які ваш сервіс отримує від партнерів або систем верхнього рівня.
Припустимо, ваш обробник виконує свої дії протягом п’яти секунд через складні обчислення чи очікування на блокування, яке утримує інша служба. HTTP-клієнт відправника може здатися до того, як ви відповісте, зробити висновок, що ви так і не отримали подію, та надіслати її знову. Тепер одна й та сама подія надходить двічі. Якщо ваш обробник щоразу, коли запускається, надсилає електронний лист чи створює запис, клієнт отримує два листи, а ви — дублікат рядка.
Паттерн «скриньки вхідних повідомлень» розділяє процес прийняття webhook та його обробку.
Чотири кроки процесу у скриньці вхідних повідомлень
- Отримати та перевірити. Як тільки надходить запит, перевірте його підпис HMAC, щоб переконатися, що він справді надійшов від партнера та не був підроблений чи змінений.
webhook_inbox, ключувана власним унікальним ідентифікатором події партнера та захищена обмеженням щодо унікальності в базі даних.200 OK, ще до виконання будь-якої бізнес-логіки.Таблиця inbox
Тут кожен рядок фіксує того, хто надіслав подію (partner_name), ідентифікатор надсилача (partner_event_id), дані, які надіслано, чи перевірився підпис, а також status, який може бути PENDING, PROCESSED або QUARANTINED. Важливим є композитний обмеження UNIQUE(partner_name, partner_event_id): саме воно перетворює дублікати на безневинні операції, які не мають наслідків. Як і у таблиці вихідних повідомлень, коментарі з однією крапкою мають бути замінені на --, щоб PostgreSQL прийняв цю інструкцію.
CREATE TABLE webhook_inbox (
id UUID PRIMARY KEY,
partner_name VARCHAR(50), - e.g., 'Stripe' or 'GitHub'
partner_event_id VARCHAR(100), - The unique ID from the sender
payload JSONB NOT NULL,
signature_verified BOOLEAN,
status VARCHAR(20), - 'PENDING', 'PROCESSED', 'QUARANTINED'
received_at TIMESTAMP DEFAULT NOW(),
processed_at TIMESTAMP,
UNIQUE(partner_name, partner_event_id) - Prevents duplicate inserts
);
Чому саме це обмеження виконує основну роботу
Оскільки обробник лише перевіряє, вставляє та повертає дані, він реагує швидко, і надсилач рідко стикається з проблемою тайм-ауту. Якщо надсилач все ж намагається зробити спробу ще раз, навіть десять разів поспіль, обмеження щодо унікальності дозволяє виконати успішну вставку лише один раз. Ваш обробник повинен розглядати помилку порушення унікальності (або результат ON CONFLICT DO NOTHING) як успіх та все одно повертати 200 OK; інакше надсилач буде продовжувати намагатися відправити подію, яка вже існує. Оскільки існує лише один рядок, працездатний процес виконує супутні дії лише один раз.
Є два моменти, які варто правильно налаштувати. По-перше, усунення дублікатів залежить від того, чи надає партнер стабільний ідентифікатор події; більшість постачальників webhook мають такий інструмент, але перевірте це для кожної інтеграції. По-друге, процесор може зупинитися після виконання побічних ефектів, але до моменту позначки про обробку рядка, тому де завгодно можливо виконуйте зміни в бізнес-логіці та оновлення статусу в одній транзакції, а також робіть зовнішні побічні ефекти ідемпотентними. Щоб детальніше дізнатися про усунення дублікатів запитів за допомогою ключів, перегляньте ключі ідемпотентності в Node.js POST-кінцевих точках.
Обробка подій, які надходять не в порядку
Навіть якщо дублікати контролюються, немає гарантій того, що події надходитимуть у тому порядку, в якому вони були створені. Ваш сервіс може отримати entity.completed раніше, ніж entity.started. Обробник, який сліпо застосовує кожну подію, спробує перемістити ентитет безпосередньо з стану draft у стан completed, що може пошкодити його стан або призвести до помилки типу 409 Conflict.
Перевірка кожної транзиції за допомогою машини станів
Рішення полягає у тому, щоб перестати розглядати події як команди для зміни стану та почати розглядати їх як пропоновані транзиції, які потрібно підтвердити. Це іноді називається двигуном узгодження станів, в дусі підходу event sourcing: працівник порівнює надійшлу подію з поточним станом ентитета та вирішує, чи є ця транзиція допустимою.
Наведений нижче скетч ілюструє це рішення. Якщо подія завершення надходить, поки ентитет ще перебуває у стані чернетки, передумова ще не виконалася, тому функція повідомляє про цю подію як відкладену замість того, щоб її застосувати. У примітках наведено два способи роботи з відкладенням: залишити запис у папці вхідних повідомлень та спробувати знову пізніше, або зафіксувати прогнозований стан та чекати на відсутню подію. Подія початку для ентитета у стані чернетки є дійсною транзицією та застосовується. Розглядайте це як псевдокод: return status: 'DEFERRED'; — це недійсний JavaScript, і його слід замінити на return { status: 'DEFERRED' };, а справжня реалізація також мала б обробляти інші комбінації подій та станів.
function processWebhookEvent(event, currentEntityState) {
if (event.type === 'entity.completed' && currentEntityState === 'draft') {
// The 'started' event hasn't arrived yet!
// We cannot transition from 'draft' directly to 'completed'.
// Option A: Leave it in the inbox and retry in 5 minutes.
// Option B: Store a "Projected State" and wait for the missing piece.
return status: 'DEFERRED';
}
if (event.type === 'entity.started' && currentEntityState === 'draft') {
return transitionTo('started');
}
}
Відкладення як самовідновлювальний цикл
Візьмемо замовлення, де подія „відправлено“ надходить раніше за подію „оплачено“. Якщо одразу застосувати подію „відправлено“, замовлення потрапить у стан, який не дозволяється вашою моделлю. За допомогою процесора, який враховує стани, послідовність стає такою:
- Надходить подія „Відправлено“; оцінювач бачить, що оплата відсутня, тож подія відкладається.
- Надходить подія „Оплачено“; вона є дійсною та оновлює стан замовлення.
- Подія „Відправлено“, яка була відкладена, намагається знову бути застосованою; тепер умови для її виконання задоволені, тож вона застосовується.
Задовжені події можуть знаходитися у спеціальній черзі для повторних спроб, наприклад у Amazon SQS або черзі на основі Redis, де фоновий працівник періодично намагається їх виконати. Це забезпечує робочий процес, який відхиляє недійсні переходи, але зрештою досягає правильного стану, не втрачаючи жодної події. Однак необхідно встановити ліміт на час, протягом якого подія може залишатися у стані задовження: якщо передумова ніколи не надходить, подію слід зрештою вважати невдачею, а не продовжувати намагатися її виконати без кінця, що приводить до наступного розділу.
Ізоляція проблемних подій за допомогою повторних спроб та черги для некоректних листів
Деякі події ніколи не будуть успішними, незалежно від того, як часто ви їх намагатиметеся виконати: це може бути некоректний вміст або посилання на ID, якого немає у вашій базі даних. Це називається отруйними таблетками. Наївний працівник буде намагатися їх виконати без кінця, і якщо черга обробляється по порядку, одне некоректне повідомлення може заблокувати всі коректні події, що знаходяться після нього.
Стандартним захистом є політика обмежених спроб із збільшенням інтервалів між ними, після чого повідомлення потрапляє до черги для некоректних повідомлень (DLQ). Типовий графік виглядає так:
- Перша спроба провалюється; чекати одну хвилину.
- Друга спроба провалюється; чекати п’ять хвилин.
- Третя спроба провалюється; чекати п’ятнадцять хвилин.
- Четверта спроба провалюється; перемістити подію до DLQ.
DLQ може бути таблицею у вашій власній базі даних або функцією керованої черги. Важливим є те, що відбувається далі: події з DLQ мають відображатися у внутрішньому адміністративному інтерфейсі та викликати сповіщення високої пріоритетності, оскільки кожна з них символізує дані, які система не змогла обробити. Інженер досліджує ситуацію, виправляє помилку мапування або пошкоджені дані, а потім повторно виконує подію, щоб вона пройшла через звичайний шлях обробки. Необхідно якомога раніше створити цю функцію повторного виконання; без неї відновлення після проблем у DLQ перетворюється на ручну корекцію бази даних під тиском.
Мапування дизайну на AWS, Azure та Google Cloud
Поштові скриньки вихідні та вхідні знаходяться у вашій реляційній базі даних, але супутні компоненти (вхід даних, черги, обробники, DLQ) добре підходять для керованих хмарних сервісів, що значно зменшує обсяг операційних завдань. Структура залишається однаковою у всіх провайдерів; змінюються лише назви продуктів.
AWS
- Вхідні дані: Amazon API Gateway приймає вхідні webhook-запити, причому модуль авторизації Lambda перевіряє підпис HMAC ще до того, як запит надійде на сервер.
- База даних: Amazon Aurora PostgreSQL зберігає бізнес-таблиці разом із таблицями
webhook_inboxтаoutbox_events, що забезпечує дотримання гарантій транзакцій. - Очереді та DLQ: Стандартна очередь SQS забезпечує асинхронну обробку, а налаштована очередь для некоректних повідомлень SQS отримує повідомлення, коли кількість отриманих повідомлень перевищує максимальний ліміт. Стандартні очереді забезпечують принаймнє одноразову доставку повідомлень, але не зберігають їхній порядок, що є ще однією причиною важливості вищезазначених перевірок ідемпотентності та стану.
processed_at.Azure
- Вхід: Azure API Management отримує webhook-повідомлення, перевіряє підписи та передає запити до бекенду.
- База даних: Azure Database for PostgreSQL Flexible Server зберігає стан додатку, а також таблиці вхідної та вихідної скриньок.
- Очереді та DLQ: Azure Service Bus координує передачу повідомлень та має вбудовану систему обробки некоректних повідомлень, яка автоматично відкладає повідомлення після кількості спроб доставки, визначеної у налаштуваннях.
- Робітники: Azure Functions з тригерами Service Bus обробляють дані з скриньки вхідних повідомлень. Релеєй скриньки вихідних повідомлень працює у фоновому циклі в Azure Container Apps або як Kubernetes CronJob у середовищі AKS, перевіряючи PostgreSQL на наявність ненадісланих подій та передаючи їх через HTTP.
Google Cloud
- Вхідні дані: Google Cloud API Gateway обробляє надходження HTTP-вебхуків та здійснює автентифікацію.
- База даних: Cloud SQL для PostgreSQL зберігає реляційні дані, включаючи обидві таблиці.
- Очереді та DLQ: Pub/Sub асинхронно направляє повідомлення. Основна підписка обробляє події, а тема для неприйнятих повідомлень збирає ті, які так і не були підтверджені після належної кількості спроб доставки.
- Робітники: Сервіси Cloud Run, які можуть скоротитися до нуля інстанцій між сплесками навантаження, отримують повідомлення типу Pub/Sub для обробки пошти. Ретранслятор вихідної пошти — це або завдання Cloud Run, або сервіс Cloud Run, який запускається за графіком за допомогою Cloud Scheduler, який перевіряє Cloud SQL та надсилає очікувані події.
Щоб дізнатися більше про шаблони підключення сервісів, такі як OAuth та надійні виклики API, перегляньте шість шаблонів інтеграції для надійного підключення сервісів Node.js.
Ключові висновки
- Надійна система webhook — це конвеєр обробки подій, а не пара HTTP-кінцевих точок.
- Ніколи не оновлюйте базу даних та не викликайте віддалений сервіс як два незалежні кроки; записуйте рядок у вихідну пошту в одній транзакції та дозвольте ретранслятору його доставити.