Триггеры против фоновых задач для надёжной доставки уведомлений
Узнайте, когда триггеры или фоновые воркеры безопаснее для уведомлений — практические рекомендации по ретраям, транзакциям и предотвращению дубликатов.

Почему доставка уведомлений ломается в реальных приложениях
Уведомления кажутся простыми: пользователь что‑то делает, и отправляется email или SMS. Большинство реальных сбоев сводятся к проблемам тайминга и дублирования. Сообщения отправляются до того, как данные действительно сохранены, или отправляются дважды после частичного сбоя.
«Уведомление» может означать многое: подтверждения по email, одноразовые коды в SMS, push‑уведомления, сообщения в приложении, пинги в Slack или Telegram, или webhook в другую систему. Общая проблема всегда одна: нужно согласовать изменение в базе данных с чем‑то вне вашего приложения.
Внешний мир грязный. Провайдеры могут быть медленными, возвращать таймауты или принять запрос, в то время как ваше приложение никогда не получит ответ об успехе. Ваше приложение может упасть или перезапуститься в середине запроса. Даже «успешные» отправки могут выполниться повторно из‑за ретраев инфраструктуры, рестартов воркеров или если пользователь нажал кнопку снова.
Типичные причины поломки доставки уведомлений включают сетевые таймауты, сбои провайдеров или лимиты скорости, перезапуски приложения в неудачный момент, ретраи, которые повторно выполняют одну и ту же логику без уникальной защиты, и дизайны, где запись в базу и внешняя отправка выполняются как одна объединённая операция.
Когда просят «надёжные уведомления», обычно имеют в виду одно из двух:
- доставить ровно один раз, или
- по крайней мере не дублировать (дубликаты часто хуже задержки).
Достичь и того, и другого быстро и совершенно безопасно сложно, поэтому приходиться выбирать компромиссы между скоростью, надёжностью и сложностью.
Поэтому выбор между триггерами и фоновыми воркерами — это не просто архитектурный спор. Речь о том, когда можно отправлять, как обрабатывать ошибки и ретраи, и как не допустить дублирующих email или SMS при сбое.
Триггеры и фоновые воркеры: что это значит
Когда сравнивают триггеры и фоновые воркеры, на самом деле сравнивают, где выполняется логика уведомления и насколько она связана с действием, которое её вызвало.
Триггер — это «сделать сейчас, когда X произошло». Во многих приложениях это означает отправку письма или SMS сразу после действия пользователя, внутри того же веб‑запроса. Триггеры также могут жить на уровне базы данных: триггер БД запускается автоматически при вставке или обновлении строки. Оба типа кажутся мгновенными, но наследуют тайминг и ограничения того, что их вызвало.
Фоновый воркер — это «сделать скоро, но не на переднем плане». Это отдельный процесс, который забирает задания из очереди и пытается их выполнить. Основное приложение фиксирует, что должно произойти, и быстро возвращает ответ, а воркер занимается медленными и ненадёжными частями, такими как вызов email‑ или SMS‑провайдера.
«Задание» — это единица работы, которую обрабатывает воркер. Обычно в ней указано, кого уведомить, какой шаблон, какие данные вставить, текущий статус (queued, processing, sent, failed), сколько попыток сделано и иногда запланированное время.
Типичный поток уведомления выглядит так: вы готовите детали сообщения, ставите задание в очередь, отправляете через провайдера, записываете результат и решаете, ретраить ли, остановить или оповестить кого‑то.
Границы транзакций: когда действительно безопасно отправлять
Граница транзакции — это раздел между «мы попытались сохранить» и «это действительно сохранено». Пока база не закоммитила, изменение всё ещё может быть откатано. Это важно, потому что уведомления тяжело «забрать назад».
Если вы отправите письмо или SMS до коммита, вы можете уведомить человека о том, чего на самом деле не произошло. Клиент может получить «Ваш пароль изменён» или «Ваш заказ подтверждён», а затем запись упадёт из‑за ошибки ограничения или таймаута. Теперь пользователь запутан, и службе поддержки придётся разбираться.
Отправка внутри триггера БД выглядит заманчиво потому, что срабатывает автоматически при изменении данных. Загвоздка в том, что триггеры выполняются внутри той же транзакции. Если транзакция откатится, вы можете уже обратиться к провайдеру email/SMS.
Триггеры БД также сложнее наблюдать, тестировать и безопасно повторять. И если они выполняют медленные внешние вызовы, они держат блокировки дольше ожидаемого и усложняют диагностику проблем с базой.
Более безопасный подход — идея outbox: записать намерение уведомить как данные, закоммитить, а затем отправить.
Вы делаете бизнес‑изменение и в той же транзакции вставляете строку outbox, которая описывает сообщение (кому, что, по какому каналу и уникальный ключ). После коммита фоновый воркер читает ожидающие outbox‑строки, отправляет сообщение и отмечает их как отправленные.
Немедленные отправки всё ещё подходят для низко‑критичных информационных сообщений, где ошибка допустима, например «Мы обрабатываем ваш запрос». Для всего, что должно соответствовать финальному состоянию, ждите после коммита.
Ретраи и обработка ошибок: где что выигрывает
Ретраи обычно решающий фактор.
Триггеры: быстро, но хрупко при ошибках
У большинства дизайнов на базе триггеров нет хорошей истории по ретраям.
Если триггер вызывает провайдера и вызов падает, обычно остаются два плохих выбора:
- провалить транзакцию (и заблокировать исходное обновление), или
- проглотить ошибку (и молча потерять уведомление).
Ни один из вариантов неприемлем, когда важна надёжность.
Попытки организовать циклы или задержки внутри триггера могут только ухудшить ситуацию, удерживая транзакции дольше, увеличивая время блокировок и замедляя базу. И если база или приложение умрут во время отправки, часто невозможно понять, получил ли провайдер запрос.
Фоновые воркеры: созданы для ретраев
Воркер рассматривает отправку как отдельную задачу со своим собственным состоянием. Это естественно позволяет повторять только когда это имеет смысл.
Практическое правило: обычно повторяют временные ошибки (таймауты, временные сетевые сбои, ошибки сервера, лимиты скорости с увеличением интервала). Обычно не повторяют постоянные проблемы (некорректные номера, неправильно сформированные email, жёсткие отказы вроде отписки). Для «неизвестных» ошибок ограничивают число попыток и делают состояние видимым.
Backoff предотвращает ухудшение ситуации при повторных попытках. Начинайте с короткой паузы, затем увеличивайте её (например 10с, 30с, 2м, 10м) и останавливайтесь после фиксированного числа попыток.
Чтобы это выживало при деплоях и рестартах, храните состояние повторов вместе с каждым заданием: счётчик попыток, время следующей попытки, последнее краткое читаемое сообщение об ошибке, время последней попытки и ясный статус вроде pending, sending, sent, failed.
Если приложение перезапустится в середине отправки, воркер может проверять «зависшие» задания (например статус = sending с устаревшей меткой времени) и повторить их безопасно. Здесь идемпотентность критична, чтобы повторная попытка не отправила дважды.
Предотвращение дублирования email и SMS с помощью идемпотентности
Идемпотентность означает, что вы можете выполнить одно и то же действие «отправить уведомление» несколько раз, и пользователь получит его один раз.
Классический случай дублирования: таймаут. Ваше приложение делает вызов к провайдеру email/SMS, запрос таймаутит, и код пытается повторить. Первый запрос мог на самом деле выполниться, поэтому повтор создаёт дубликат.
Практическое решение — дать каждому сообщению стабильный ключ и считать этот ключ единственным источником правды. Хорошие ключи описывают смысл сообщения, а не момент отправки.
Распространённые подходы:
- сгенерированный
notification_id, созданный в момент решения «это сообщение должно существовать», или - бизнес‑ключ вроде
order_id + template + recipient(только если он действительно определяет уникальность).
Затем храните журнал отправок (часто та же таблица outbox) и заставляйте все ретраи проверять его перед отправкой. Держите статусы простыми и видимыми: created (решено), queued (готово), sent (подтверждено), failed (подтверждённый провал), canceled (больше не нужно). Критическое правило — разрешать только одну активную запись на idempotency‑ключ.
Идемпотентность на стороне провайдера помогает, если она поддерживается, но не заменяет ваш собственный реестр. Всё равно нужно обработать ретраи, деплои и рестарты воркеров.
Также относитесь к «неизвестным» исходам как к полноценным случаям. Если запрос таймаутит, не отправляйте сразу снова. Пометьте как ожидание подтверждения и повторяйте безопасно, проверяя статус доставки у провайдера, когда это возможно. Если подтвердить нельзя — откладывайте и сигнализируйте, вместо того чтобы дублировать отправку.
Безопасный дефолт: outbox + фоновой воркер (пошагово)
Если нужна безопасная отправная точка, паттерн outbox плюс воркер — отличный выбор. Он выносит отправку из бизнес‑транзакции, но при этом гарантирует, что намерение уведомить сохранено.
Поток
Рассматривайте «отправить уведомление» как данные, которые вы сохраняете, а не как действие, которое триггерите сразу.
Вы сохраняете бизнес‑изменение (например, обновление статуса заказа). В той же транзакции вставляете запись outbox с получателем, каналом (email/SMS), шаблоном, payload и idempotency‑ключом. Коммитите транзакцию. Только после этого можно что‑то отправлять.
Фоновый воркер регулярно подбирает pending outbox‑строки, отправляет их и фиксирует результат.
Добавьте простой шаг «захвата», чтобы два воркера не взяли одну строку одновременно. Это может быть смена статуса на processing или пометка с блокировкой по времени.
Блокировка дубликатов и обработка ошибок
Дубликаты часто возникают, когда отправка прошла, но приложение упало до того, как отметило «sent». Решение — сделать запись «отправлено» безопасной для повторения.
Используйте правило уникальности (например уникальное ограничение по idempotency‑ключу и каналу). Повторяйте по ясным правилам: ограниченное число попыток, увеличивающиеся задержки и только для retryable ошибок. После последней попытки переводите задачу в dead‑letter состояние (например failed_permanent) для ручного просмотра и повторной обработки.
Мониторинг можно упростить: счётчики pending, processing, sent, retrying и failed_permanent, плюс самая старая pending‑запись. Если самая старая pending постоянно растёт — скорее всего застрял воркер, провайдер упал или есть баг в логике.
Конкретный пример: когда заказ переходит из «Упакован» в «Отправлен», вы обновляете строку заказа и создаёте одну outbox‑запись с idempotency‑ключом order-4815-shipped. Даже если воркер упадёт во время отправки, повторные выполнения не создадут дубликат, потому что запись «sent» защищена уникальным ключом.
Когда лучше использовать фоновые воркеры
Триггеры хорошо реагируют в момент изменения данных. Но если задача — «надежно доставлять уведомления в реальных нестабильных условиях», фоновые воркеры обычно дают больше контроля.
Воркеры лучше подходят, когда нужны отправки по расписанию (напоминания, дайджесты), высокая нагрузка с лимитами скорости и обратным давлением, терпимость к вариативности провайдера (429, медленные ответы, краткие простои), многошаговые рабочие процессы (отправить, ждать доставки, затем сделать follow up) или кросс‑системная согласованность и реконсиляция.
Простой пример: вы списали деньги с клиента, затем отправляете SMS‑чек, затем email‑счёт. Если SMS провайдер упал, вы всё равно хотите, чтобы заказ оставался оплачен и чтобы отправка была безопасно повторена позже. Помещать такую логику в триггер рискует смешать «данные корректны» и «третья сторона сейчас доступна».
Фоновые воркеры также упрощают операционное управление. Можно приостановить очередь при инциденте, посмотреть ошибки и ретраить с задержкой.
Частые ошибки, приводящие к пропущенным или дублированным сообщениям
Самый быстрый путь получить ненадёжные уведомления — «отправлять где удобно, а там пусть ретраи спасут». Независимо от того, используете ли вы триггеры или воркеры, детали вокруг ошибок и состояния решают, получит ли пользователь одно сообщение, два или ни одного.
Распространённая ловушка — отправка из триггера БД с предположением, что это не может провалиться. Триггеры выполняются внутри транзакции, поэтому любой медленный вызов провайдера может остановить запись, вызвать таймаут или держать блокировки дольше, чем вы ожидаете. Ещё хуже — если отправка упадёт и транзакция откатится, вы сможете повторить отправку позже и тем самым отправить её дважды, если провайдер принял первый запрос.
Ошибки, которые повторяются:
- Повтор попыток для всех ошибок одинаково, включая постоянные (плохой email, заблокированный номер).
- Не отделено «queued» от «sent», поэтому нельзя понять, что безопасно ретраить после краша.
- Использование временных меток как ключей для дедупа — ретраи естественным образом обходят уникальность.
- Вызовы провайдеров в пути обработки пользовательского запроса (checkout и отправка форм не должны ждать шлюзов).
- Рассмотрение таймаутов провайдера как «не доставлено», тогда как многие такие исходы фактически «неизвестны».
Простой пример: вы отправляете SMS, провайдер таймаутит, и вы ретраите. Если первый запрос всё же дошёл, пользователь получает два кода. Решение — записать стабильный idempotency‑ключ (например notification_id), пометить сообщение как queued до отправки, а как sent — только после ясного подтверждения успеха.
Быстрая проверка перед релизом уведомлений
Большинство багов с уведомлениями не в инструменте, а в тайминге, ретраях и пропущенных записях.
Убедитесь, что отправка происходит после надёжного коммита записи. Если вы отправляете внутри той же транзакции и она откатывается, пользователи могут получить сообщение о том, чего не было.
Далее: сделайте каждое уведомление уникально идентифицируемым. Дайте каждому сообщению стабильный idempotency‑ключ (например order_id + event_type + channel) и обеспечьте это на уровне хранения, чтобы ретрай не создал второе «новое» уведомление.
Перед релизом проверьте базовые вещи:
- Отправка происходит после коммита, не во время записи.
- Каждое уведомление имеет уникальный idempotency‑ключ, дубликаты отклоняются.
- Ретраи безопасны: система может запустить одно и то же задание снова и отправить не более одного письма.
- Каждая попытка фиксируется (статус, last_error, метки времени).
- Количество попыток ограничено, а застрявшие элементы имеют понятное место для просмотра и повторной обработки.
Протестируйте поведение при рестарте целенаправленно. Убейте воркер во время отправки, перезапустите и проверьте, что ничего не отправилось дважды. Проделайте то же при высокой нагрузке на базу.
Простой сценарий для проверки: пользователь меняет номер телефона, затем вы отправляете SMS‑верификацию. Если SMS‑провайдер таймаутит, ваше приложение повторит попытку. С хорошим idempotency‑ключом и журналом попыток вы либо отправите один раз, либо безопасно повторите позже, но не разошлёте спам.
Пример сценария: обновления заказа без двойной отправки
Магазин отправляет два типа сообщений: (1) email‑подтверждение заказа сразу после оплаты, и (2) SMS‑обновления при статусах «выдано курьеру» и «доставлено».
Вот что идёт не так при слишком ранней отправке (например, внутри триггера БД): шаг оплаты записывает строку в orders, триггер срабатывает и отправляет письмо клиенту, а затем захват оплаты проваливается. В итоге у вас «Спасибо за заказ» для несуществующего заказа.
Теперь обратная проблема: статус доставки меняется на «Выдано курьеру», вы вызываете SMS‑провайдера, и провайдер таймаутит. Вы не знаете, отправлено ли сообщение. Если вы тут же ретраите, рискуете отправить два SMS. Если не ретраите — можете не отправить ни одного.
Более безопасный поток использует outbox‑запись плюс фонового воркера. Приложение коммитит заказ или изменение статуса и в той же транзакции пишет outbox‑запись типа «send template X to user Y, channel SMS, idempotency key Z». Только после коммита воркер доставляет сообщения.
Пример временной линии:
- Оплата прошла, транзакция закоммичена, сохранена outbox‑запись для email‑подтверждения.
- Воркер отправляет письмо и помечает outbox как sent с ID сообщения провайдера.
- Статус доставки меняется, транзакция закоммичена, в outbox добавлена запись для SMS‑обновления.
- Провайдер таймаутит, воркер помечает outbox как retryable и повторяет позже, используя тот же idempotency‑ключ.
При повторной попытке outbox — это единый источник правды. Вы не создаёте вторую «отправку», вы завершаете первую.
Для поддержки это тоже яснее: можно видеть сообщения в состоянии failed с последней ошибкой (таймаут, плохой номер, заблокированный email), сколько было попыток и безопасно ли их повторять без двойной отправки.
Следующие шаги: выберите паттерн и реализуйте его аккуратно
Выберите дефолтный подход и задокументируйте его. Непоследовательное поведение часто возникает из‑за случайного смешения триггеров и воркеров.
Начните с малого: таблица outbox и один цикл воркера. Первая цель — не скорость, а корректность: сохраняйте то, что собираетесь отправить, отправляйте после коммита и помечайте как отправленное только после подтверждения провайдера.
Простой план внедрения:
- Определите события (order_paid, ticket_assigned) и какие каналы они могут использовать.
- Добавьте таблицу outbox с полями event_id, recipient, payload, status, attempts, next_retry_at, sent_at.
- Постройте один воркер, который опрашивает pending‑строки, отправляет и обновляет статус в одном месте.
- Добавьте идемпотентность с уникальным ключом на сообщение и «ничего не делать, если уже отправлено».
- Разделите ошибки на retryable (таймауты, 5xx) и non‑retryable (плохой номер, заблокированный email).
Прежде чем масштабировать объёмы, добавьте базовую видимость. Отслеживайте количество pending, процент ошибок и возраст самой старой pending‑записи. Если самый старый pending растёт — скорее всего, застрял воркер, провайдер упал или логика содержит баг.
Если вы строите в AppMaster (appmaster.io), этот паттерн легко отображается: смоделируйте outbox в Data Designer, выполните бизнес‑изменение и запись outbox в одной транзакции, затем выполняйте логику отправки и ретраев в отдельном фоновом процессе. Именно это разделение поддерживает надёжность доставки уведомлений, даже когда провайдеры или деплои ведут себя непредсказуемо.
Вопросы и ответы
Background workers обычно являются более безопасным выбором, потому что отправка медленная и подвержена сбоям, а воркеры изначально рассчитаны на ретраи и видимость состояния. Триггеры могут быть быстрыми, но они тесно связаны с транзакцией или запросом, который их вызвал, а это усложняет обработку ошибок и предотвращение дубликатов.
Это рискованно, потому что запись в базу данных ещё может откатиться. В результате вы можете уведомить пользователя об изменении пароля, оплате или заказе, которое в итоге не зафиксировалось, и вернуть назад уже отправленное письмо или SMS невозможно.
Триггер базы данных выполняется внутри той же транзакции, что и изменение строки. Если триггер вызывает провайдера email/SMS, а позже транзакция откатывается, вы можете уже отправить реальное сообщение о несостоявшемся изменении, или же замедлить саму запись из‑за медленного внешнего вызова.
Паттерн outbox сохраняет намерение отправить как строку в базе данных в той же транзакции, что и бизнес‑изменение. После коммита фоновой воркер читает pending outbox‑записи, отправляет сообщение и отмечает их как отправленные — это делает тайминги и повторы безопаснее.
Если запрос к провайдеру таймаутит, реальный исход часто неизвестен, а не гарантированно «не доставлено». Хорошая система фиксирует попытку, откладывает и повторяет безопасно с тем же идентификатором сообщения, вместо немедленной повторной отправки и риска дубля.
Используйте идемпотентность: дайте каждому уведомлению стабильный ключ, который описывает смысл сообщения (а не момент отправки). Храните этот ключ в реестре отправок (часто в таблице outbox) и обеспечивайте только одну активную запись на ключ, чтобы повторы завершали то же самое сообщение, а не создавали новое.
Повторяйте временные ошибки: таймауты, 5xx или ограничения по скорости (с увеличивающимся ожиданием). Не повторяйте постоянные ошибки: некорректные адреса, заблокированные номера или жёсткие отказы — пометьте их как failed и предоставьте видимость, чтобы данные исправили вручную.
Фоновый воркер может находить задания, застрявшие в статусе sending дольше допустимого, переводить их обратно в retryable и пытаться снова с backoff. Это безопасно только при наличии записанной информации о задаче (количество попыток, метки времени, последнее сообщение об ошибке) и при обеспеченной идемпотентности для предотвращения двойной отправки.
Это значит, что вы можете ответить на вопрос «безопасно ли повторить?». Храните понятные статусы — pending, processing, sent, failed — а также счётчик попыток и последнее сообщение об ошибке. Это делает поддержку и отладку практичными и позволяет системе восстанавливаться без догадок.
Смоделируйте таблицу outbox в Data Designer, выполните бизнес‑обновление и запись outbox в одной транзакции, затем запустите логику отправки и повторов в отдельном фоновом процессе. Держите один idempotency‑ключ на сообщение и фиксируйте попытки, чтобы деплои, ретраи и рестарты воркеров не приводили к дубликатам.


