Главная / Статьи

Проектирование системы уведомлений без дубликатов с использованием SQS, SES и Redis

Как конфигурация SQS с двумя очередями, резервный вариант передачи данных от SES в SendGrid и атомарный замок Redis обеспечивают доставку бизнес-документов получателям ровно один раз, даже при высокой нагрузке.

Издатель: REACTAPP.TOP7 мин чтения1509 слов

Источники: How I Built a Notification Engine Handling 5M+ Events

Два сотрудника получают одно и то же событие в один и тот же момент. Каждый проверяет журнал электронных писем, не находит записи и отправляет сообщение заново. Поставщик в итоге получает одну и ту же заказ-наряд дважды, а команда узнаёт об этом из заявки на поддержку, а не благодаря мониторингу. В этой статье рассматривается механизм уведомлений для платформы B2B-цепочек поставок, способной обрабатывать более пяти миллионов событий в месяц, а также три проблемы производства, повлиявшие на её развитие: потеря писем, блокировка писем при генерации PDF и дубликаты.

Что должен обеспечивать механизм

В SaaS-решениях для цепочек поставок продуктов питания бизнес-события, такие как создание или редактирование заказа или выполнение операций с запасами, должны генерировать документы для нужных людей: заказы на покупку, заказы на продажу, накладные на доставку, акты приемки товаров и счета-фактуры. Таким образом, одно событие может разветвляться на несколько шагов: генерация PDF, его хранение и отправка по электронной почте нескольким получателям. Этот же механизм используется для отправки SMS- и WhatsApp-сообщений в ряде других случаев использования.

Последствия имеют конкретный характер. Уведомление, которое так и не приходит, — это не просто внешний сбой; отсутствие заказа на покупку может заблокировать реальные запасы в пути к клиенту.

Две очереди: одна принимает решения, другая доставляет

Дизайн основан на двух очередях Amazon SQS с четко разделенными задачами.

Основная очередь — это место, где принимаются решения. Один потребитель читает каждое поступающее событие и определяет, что оно подразумевает: какой документ необходимо сгенерировать, какой канал использовать и кому его отправить.

Подочередь выполняет только функцию доставки. Рабочие, отвечающие за доставку, не применяют никаких бизнес-правил; они просто перемещают сообщения к поставщикам.

Одно сообщение на получателя

Самое важное решение в этой схеме заключается в том, что каждый получатель получает своё собственное сообщение в подочереди. Заказ, предназначенный для десяти человек, превращается в десять отдельных сообщений. Если доставка по одному адресу не удаётся, остальные девять всё равно отправляются, причем повторная попытка направлена только на того получателя, у которого возникла проблема, а не на всех остальных.

Такое разделение также позволяет изолировать изменения. Замена поставщика или канала влияет только на этап доставки; код, отвечающий за выбор отправляемого контента, никогда не должен знать о способе транспортировки сообщения.

Почему не одна очередь на канал?

Отдельные очереди для электронной почты, SMS и WhatsApp обеспечат каждому каналу независимую пропускную способность и механизмы обработки сбоев. При текущем объёме работы это не требуется, а добавление очереди там, где это необходимо, дешевле, чем управление четырьмя очередями с самого начала.

Сбой номер один: электронные письма, которые так и не пришли

Поставщики электронной почты терпят сбои — иногда явно, с ошибкой, а иногда незаметно, при возвращении ответа без ожидаемых полей. Amazon SES является основным поставщиком в данном случае, и ответ без идентификатора сообщения рассматривается как сбой, так же как и исключение. В любом случае то же самое письмо отправляется через 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. Получатели нуждаются в самом заказе на покупку, а не в ссылке, которая возвращает ошибку. Потребитель запускает функцию Lambda для генерации PDF, сохраняет файл в 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 и завершают работу раньше времени. Таким образом устраняется проблема, возникшая из-за проверки журнала.

    Стоит учитывать несколько особенностей этого подхода:

    • Важна степень детализации ключа. Указание типа документа предотвращает ошибочное считывание заказа-наряда и счета-фактуры для одного и того же заказа и получателя как дубликатов.
  • TTL выполняет функцию предохранительного клапана. Если процесс терпит сбой после взятия блокировки, ключ истекает, и можно попробовать снова позже. Время истечения должно значительно превышать обычное время отправки данных, желательно — время видимости в SQS; иначе блокировка медленного процесса может сняться во время продолжения отправки.
  • Блокировка предотвращает одновременные дубликаты; постоянный журнал всё равно фиксирует последующие дубликаты.
  • Аналогичная концепция используется в HTTP-API под названием ключи идемпотентности; статья о ключах идемпотентности в Node.js POST-эндпоинтах рассматривает этот аспект.

    Каков результат работы системы

    После этих изменений движок обрабатывает более пяти миллионов событий в месяц по трiggерам заказов, запасов и документов, достигая 98,9% успешной доставки при задержке p95 менее 1,2 секунды.

    Что делать иначе в следующий раз

    • Обнаруживайте сбои раньше, чем это сделают клиенты. Слишком много проблем сначала проявляется в виде жалоб, а только затем — в логах. Системы оповещения о сбоях доставки и о глубине очереди непрочитанных сообщений должны быть включены с самого начала, а не позже.
    • Не допускайте исчезновения событий. Некоторые события пропадали по причинам, которые так и не были полностью объяснены. Использование транзакционной очереди выхода, которая записывает событие вместе с операцией изменения данных и затем передаёт его, значительно снижает вероятность потери событий. В статье о таблицах очереди выхода и входа объясняется механизм этого.
    • Используйте одну подочередь до тех пор, пока это не станет проблемой. Общая очередь доставки пока справляется со своей задачей; её нужно разделять только тогда, когда один канал начинает мешать остальным.

    Чек-лист для создания собственной системы

    • Храните решения о маршрутизации в одном месте, а процесс доставки — в другом.
    • Отправляйте по одному сообщению на каждого получателя, чтобы одна неудача не могла заблокировать остальные.
    • Не доверяйте одному поставщику и считайте отсутствие идентификатора сообщения неудачей.
    • Укажите для неудачных сообщений пункт назначения: очередь для нераспространенных сообщений с возможностью повторной отправки.
    • Никогда не отправляйте документ в виде электронного письма до того, как он существует, и повторите шаг его создания.
    • Сделайте процесс удаления дубликатов атомарным, поскольку сначала проверка, затем запись создают «окно возможностей» для ошибок.
    • Уведомляйте о неудачах, чтобы клиенты не выполняли функции вашей системы мониторинга.

    Заключение

    Ни одна из этих мер защиты не была спроектирована с самого начала; каждая из них появлялась после того, как что-то ломалось в производственной среде, и именно так на самом деле развиваются большинство систем доставки. Урок заключается не столько в каком-то конкретном решении, сколько в порядке мышления: предполагайте возможность доставки хотя бы один раз, считайте, что поставщики и зависимости могут тихо выходить из строя, и делайте каждый шаг либо идемпотентным, либо защищённым чем-то атомарным.