Проєктування системи сповіщень без дублікатів на SQS, SES та Redis
Як дизайн SQS із двома чергами, резервний варіант SES-to-SendGrid та атомарний замок Redis забезпечують доставку бізнес-документів до одержувачів рівно один раз, навіть під високим навантаженням.
Джерела: How I Built a Notification Engine Handling 5M+ Events
Двоє працівників отримують однакову подію в один і той самий момент. Кожен перевіряє журнал електронних листів, не знаходить запису та надсилає повідомлення. Постачальник отримує однаковий замовлення двічі, а команда дізнається про це зі заявки на підтримку, а не завдяки моніторингу. У цій статті розглядається механізм сповіщень для платформи B2B ланцюга постачання, яка змогла обробляти понад п’ять мільйонів подій на місяць, а також три проблеми в роботі, які вплинули на її розвиток: втрата електронних листів, блокування листів під час створення PDF та дублікати.
Що має забезпечувати цей механізм
У SaaS-рішенні для ланцюга постачання продуктів бізнес-операції, такі як створення чи редагування замовлення або виконання операцій із запасами, мають генерувати документи для відповідних осіб: замовлення на купівлю, замовлення на продаж, накази про доставку, акти приймання товару та рахунки-фактури. Тож одна операція може розділитися на кілька кроків: створення PDF, його збереження та надсилання електронною поштою кільком одержувачам. Цей же механізм використовується для надсилання SMS- та WhatsApp-повідомлень у кількох інших сценаріях використання.
На кону стоять конкретні наслідки. Повідомлення, яке так і не надійшло, — це не просто естетичний баг; відсутнє замовлення на купівлю може затримати справжні запаси під час їх доставки клієнту.
Дві черги: одна приймає рішення, інша доставляє
Дизайн ґрунтується на двох чергах Amazon SQS із чітко розділеними завданнями.
Основна черга — це місце, де приймаються рішення. Один споживач читає кожну надійшлу подію та визначає, що з неї випливає: який документ потрібно створити, який канал використати та хто має його отримати.
Підчерга лише доставляє повідомлення. Працівники з доставки не застосовують жодних бізнес-правил; вони просто переміщують повідомлення до постачальників.
Одне повідомлення на одного отримувача
Найважливішим рішенням у цій схемі є те, що кожен отримувач отримує власне повідомлення у підчерзі. Замовлення, яке має надійти до десяти осіб, перетворюється на десять окремих повідомлень. Якщо доставка до одної адреси зазнає невдачі, решта дев’ять все одно відправляються, а повторна спроба стосується лише того отримувача, у кого виникла проблема, а не всіх.
Це розділення також ізолює зміни. Заміна постачальника чи каналу впливає лише на процес доставки; код, який вирішує, що надсилати, ніколи не повинен знати, як саме транспортується повідомлення.
Чому не одна черга на кожен канал?
Окремі черги для електронної пошти, SMS та WhatsApp забезпечили б кожному каналу незалежну пропускну здатність та механізми обробки несправностей. При такому обсязі це було непотрібно, а додавання черги там, де це необхідно, є дешевшим, ніж керувати чотирма з самого початку.
Перша проблема: електронні листи, які так і не надійшли
Постачальники електронної пошти іноді виходять з ладу — іноді явно з помилкою, а іноді тихо, надсилаючи відповідь без необхідних полів. Amazon SES є основним постачальником у цьому випадку, і відповідь без ID повідомлення розглядається як несправність, так само як виняток. У будь-якому разі той самий електронний лист надсилається через SendGrid замість цього:
// simplified
try {
const res = await ses.send(email);
if (!res.MessageId) throw new Error("No MessageId from SES");
} catch (err) {
await sendgrid.send(email); // fallback
}
Зверніть увагу на пряме кидання винятку, коли відсутній MessageId: це спрямовує ситуацію з неоднозначним станом до того ж шляху відновлення, що й у разі серйозної помилки. Варто зрозуміти цей компроміс. Якщо SES прийняла електронний лист, але відповідь була втрачена, механізм відновлення може доставити його ще раз, тому цей рівень надає пріоритет „доставлено принаймні один раз“ перед „доставлено не більше одного разу“. Для ділових документів це зазвичай правильний підхід, але це ще одна причина, чому важливий крок усунення дублікатів, описаний нижче.
Коли обидва постачальники зазнають невдачі, повідомлення потрапляє до черги для листів із проблемами та намагається бути доставленим знову через певний інтервал. Крім того, окремий механізм повторної доставки обробляє електронні листи, які були відхилені, тож існує шлях для тих повідомлень, які прослизнули.
Друга проблема: PDF-файли, які не функціонують під навантаженням
Документи у форматі електронних листів підкоряються суворому правилу: нічого не надсилається, поки не буде створений PDF. Приймачі потребують самого замовлення на покупку, а не посилання, яке повертає помилку. Користувач запускає функцію генерації PDF типу Lambda, зберігає файл у S3 тільки після цього надсилає електронний лист із посиланням на нього.
Це правило є правильним, але воно створило приховану залежність. Коли система сповільнювалася та генерація PDF зазнавала невдач, електронний лист не відхилявся видимо; він просто так і не надходив, оскільки чекав на файл, який так і не створювався. Рішенням було надати процесу генерації PDF власні спроби повторного запуску, щоб тимчасова невдача у форматуванні більше не призводила до безслідного скасування подальшого сповіщення.
Третя проблема: один і той самий електронний лист, двічі
Дублікати виникали з трьох різних джерел, і кожне з них вимагало різної реакції:
- звичайні помилки логіки, які були виправлені як такі;
Чому перевірка журналу недостатня
З самого початку система перевіряла журнал електронних листів перед надсиланням: якщо у записі було зазначено, що повідомлення вже надіслано, повторна спроба пропускалася. Це добре працює для послідовних повторних спроб. Однак це не може запобігти конкуренції, оскільки читання журналу та запис у нього — це дві окремі операції. Два працівники можуть одночасно побачити статус „не надіслано“, перш ніж хтось із них здійснить запис, і обидва продовжать роботу. Стандартні черги SQS забезпечують доставку принаймні один раз, тому поява одного й того самого повідомлення у двох споживачів є очікуваною поведінкою, а не рідкісною помилкою.
Атомарний замок у Redis
Рішенням було встановити атомарний замок у Redis перед перевіркою журналу. Ключ поєднує ідентифікатор замовлення, електронну адресу отримувача та тип документа, і він закінчує свою дію приблизно через п’ять хвилин:
// simplified
const key = `notif:${orderId}:${email}:${docType}`;
const locked = await redis.set(key, "1", "NX", "EX", 300);
if (!locked) return; // someone else already has it
SET з опцією NX записує ключ лише у разі, якщо він ще не існує, а EX 300 у тій самій команді встановлює термін дії у 300 секунд. Оскільки перевірка наявності та запис відбуваються як єдина операція на сервері Redis, саме один працівник отримує істинний результат; усі інші працівники бачать null та завершують роботу раніше. Це закриває прогалину, яку залишила перевірка журналу.
Є кілька особливостей цього підходу, які варто пам’ятати:
- Важлива є гранулярність ключа. Включення типу документа означає, що замовлення на покупку та рахунок-фактура для одного й того самого замовлення та отримувача не будуть сприйматися як дублікати.
Та сама ідея використовується у HTTP-API як ключі ідемпотентності; стаття на ключах ідемпотентності у Node.js POST-кінцевих точках розглядає цей аспект.
Яким стало системне рішення
Після цих змін двигун обробляє понад п’ять мільйонів подій на місяць через тригери замовлень, запасів та документів, зі швидкістю доставки 98,9% та часом відгуку p95 менше 1,2 секунди.
Що робити інакше наступного разу
- Виявляйте проблеми раніше, ніж це зроблять клієнти. Занадто багато проблем спочатку з’являються у вигляді скарг, а лише потім — у журналах подій. Попередження про проблеми з доставкою та про глибину черги непереданих повідомлень мають бути присутніми у першій версії, а не у наступних.
- Не дозволяйте подіям зникати. Деякі події зникали з причин, які так і не були повністю пояснені. Транзакційна черга вихідних даних, яка записує подію разом із бізнес-операцією та передає її пізніше, значно зменшує кількість втрачених подій. Стаття на тему таблиць вихідних та вхідних даних пояснює цей механізм.
- Зберігайте єдину підчергу, доки це можливо. Спільна черга доставки досі функціонує; її потрібно розділяти лише тоді, коли один канал починає обмежувати роботу інших.
Чек-лист для створення власної системи
- Зберігайте рішення щодо маршрутизації в одному місці, а процес доставки — в іншому.
- Надсилайте по одному повідомленню на кожного отримувача, щоб одна несправність не заблокувала решту.
- Не довіряйте жодному постачальнику та вважайте відсутність ідентифікатора повідомлення ознакою несправності.
- Надайте невдалим повідомленням пункт призначення: чергу для недоставлених листів із можливістю повторних спроб.
- Ніколи не надсилайте електронний лист із документом до того, як він буде створений, та повторіть крок його створення.
- Зробіть процес усунення дублікатів атомарним, оскільки спочатку перевірка, потім запис залишає можливості для помилок.
- Повідомляйте про несправності, щоб клієнти не виконували функцію вашої системи моніторингу.
Підсумок
Жоден із цих захистів не був спроєктований з самого початку; кожен з них додавався після того, як щось зламувалося в умовах роботи, і саме так насправді розвиваються більшість систем доставки. Урок полягає не стільки у окремих рішеннях, скільки у порядку мислення: припускайте можливість доставки принаймні один раз, припускайте, що постачальники та залежності можуть безшумно зламатися, і робіть кожен крок або ідемпотентним, або захищеним чимось атомарним.