Проектирование уведомлений: сравниваем варианты и выбираем
Синхронная отправка, очередь, transactional outbox и CDC решают разные задачи. Сравниваем задержку, надёжность, стоимость и сложность, чтобы выбрать архитектуру уведомлений без потери сообщений и повторных отправок.

Уведомление, отправленное прямо из HTTP-запроса, может увеличить время ответа на сотни миллисекунд и всё равно потеряться при сбое почтового провайдера. Для небольшого сервиса этого иногда достаточно, но при оплате заказа, изменении статуса доставки или срабатывании алерта нужна другая схема: бизнес-событие должно сохраниться, а попытку доставки можно повторить. Ниже сравниваются четыре подхода — от прямого вызова канала до CDC, — с критериями выбора для проекта на PostgreSQL 16 и RabbitMQ 3.13.
Сначала разделите событие и доставку
Уведомление — не одно действие, а цепочка из нескольких независимых этапов:
- сервис фиксирует бизнес-факт: заказ оплачен, документ готов, лимит превышен;
- система решает, кому и по какому каналу отправлять сообщение;
- шаблонизатор формирует текст, тему и параметры;
- провайдер принимает запрос на отправку;
- сервис получает результат: принято, временная ошибка, постоянная ошибка или жалоба.
Если объединить все этапы в одном запросе пользователя, сбой SMTP, push-провайдера или SMS-шлюза станет ошибкой основного сценария. Если полностью разделить их, появятся очередь, повторные попытки, идемпотентность и наблюдаемость. Цена надёжности — не только инфраструктура, но и дополнительная логика.
Сноска. Идемпотентность — свойство операции, при котором повтор одного и того же события не создаёт второй побочный эффект. Для уведомлений это обычно означает: повторная обработка не отправляет дубликат, если предыдущая попытка уже успешно принята провайдером.
Надёжность уведомлений начинается не с выбора брокера, а с ответа на вопрос: что именно вы обязуетесь не потерять — событие, попытку отправки или факт доставки.
Четыре архитектурных варианта
1. Синхронный вызов из бизнес-запроса
Приложение меняет данные и сразу вызывает email-, SMS- или push-провайдера. Ответ провайдера возвращается пользователю в рамках того же HTTP-запроса.
Подход подходит для внутренней панели, прототипа или операции, где отправка — часть обязательного результата и допустима небольшая задержка. Его главное преимущество — простая трассировка: один запрос, один лог, один результат.
Цена — связность. Время ответа зависит от внешнего сервиса, таймаут провайдера может занять значительную часть лимита запроса, а падение приложения между записью в базе и отправкой создаёт неочевидное состояние. Повторить неудачную попытку без риска дубля тоже трудно.
2. Фоновая задача после фиксации события
Основной запрос записывает бизнес-данные, а отдельный обработчик позже формирует уведомление. Передача задачи может идти через очередь или внутренний механизм фоновых заданий.
Пользователь получает быстрый ответ, а временный сбой провайдера не ломает оплату или изменение статуса. Однако если задача хранится только в памяти процесса, при перезапуске её можно потерять. Если она публикуется в брокер отдельной операцией после записи в базу, возникает окно рассинхронизации: транзакция базы зафиксировалась, а публикация не состоялась.
3. Transactional outbox
Сервис в одной транзакции записывает бизнес-изменение и строку в таблицу outbox. В этой строке лежат тип события, идентификатор сущности, полезная нагрузка, статус обработки и время следующей попытки. Отдельный воркер читает outbox, отправляет сообщение и фиксирует результат.
Это практичный вариант для большинства систем, где основная база уже использует PostgreSQL 16. Транзакция защищает от потери события: если заказ сохранён, запись outbox тоже должна сохраниться. При этом схема не обещает ровно одну отправку. Воркер может упасть после принятия запроса провайдером, но до отметки об успехе, поэтому провайдеру или вашему обработчику нужен ключ идемпотентности, а при его отсутствии — собственная защита от дублей.
4. CDC и публикация изменений из журнала базы
CDC, или Change Data Capture, считывает изменения из журнала транзакций базы и передаёт их в потоковую систему. Сервисы подписываются на события независимо от приложения, а уведомления становятся одним из потребителей этого потока.
Подход полезен, когда события нужны нескольким командам: аналитике, аудиту, поиску и коммуникациям. Он уменьшает число прямых интеграций, но добавляет операционный контур: коннектор, мониторинг позиции чтения, восстановление после разрыва и правила эволюции схемы. CDC также публикует факт изменения данных, а не всегда готовое бизнес-событие. Для события «платёж подтверждён» может потребоваться дополнительная интерпретация.
Сравнение по цене и риску
| Подход | Задержка | Риск потери | Сложность | Когда выбирать |
|---|---|---|---|---|
| Синхронный вызов | Минимальная, но зависит от провайдера | Высокий при сбое между операциями | Низкая | Прототип, админская функция, некритичный канал |
| Фоновая задача | От миллисекунд до минут | Средний, зависит от хранилища задач | Средняя | Неблокирующие уведомления при уже готовом job-механизме |
| Transactional outbox | Обычно секунды, зависит от воркера | Низкий для потери события | Средняя | Заказы, платежи, статусы и другие важные события |
| CDC | От секунд и выше | Низкий при корректной эксплуатации потока | Высокая | Несколько потребителей и большой поток изменений |
Здесь не указаны фиксированные числа задержки: они зависят от размера очереди, частоты опроса outbox, лимитов провайдера и времени обработки. Их нужно измерять на вашей схеме, а не переносить из чужого бенчмарка.
Как выбрать вариант
- Определите, может ли уведомление блокировать основную операцию. Если пользователь не должен ждать ответа email-провайдера, синхронный вызов исключается. Зафиксируйте целевую задержку: например, «95% уведомлений принимаются провайдером не позднее двух минут».
- Опишите гарантию. Для напоминания о подборке допустима потеря одного сообщения. Для ссылки на восстановление доступа важнее не потерять событие, но при этом нужно контролировать срок жизни ссылки. Для чека понадобится ещё и аудит результата.
- Проверьте границы транзакции. Если событие и изменение данных должны существовать вместе, используйте outbox или другой механизм, который записывает их атомарно. Простая публикация «после commit» не даёт такой гарантии.
- Решите, кто отвечает за повторы. Воркер должен различать временную ошибку сети и постоянную ошибку адреса. Повтор для заблокированного номера только увеличит нагрузку и может ухудшить репутацию отправителя.
- Определите ключ дедупликации. Он должен связывать бизнес-событие и тип уведомления: например, идентификатор заказа, версию состояния и канал. Один только email пользователя не подходит, потому что одному адресату могут законно отправляться разные сообщения.
- Проверьте отмену. Если заказ отменён до отправки, система должна знать, можно ли убрать отложенное уведомление или нужно отправить новое сообщение, исправляющее предыдущее.
Практическая схема для transactional outbox
В простом варианте таблица outbox содержит уникальный идентификатор события, тип, версию схемы, идентификатор получателя, канал, полезную нагрузку, число попыток, время следующей попытки и состояние. Полезную нагрузку лучше хранить в версионированном формате: изменение шаблона не должно ломать старые записи.
Воркер забирает ограниченную пачку необработанных записей, временно резервирует их, вызывает провайдера и записывает результат. Резервирование должно иметь срок: если процесс умер, запись возвращается в обработку. Не оставляйте строки навсегда в состоянии «в работе».
Отдельно храните технический результат провайдера и пользовательский результат. «Запрос принят API» не всегда означает, что письмо доставлено. Для email окончательный статус может прийти позже через webhook, а для push-провайдера — через отдельный отчёт. Если продукту нужна именно доставка, измеряйте её отдельным показателем.
Что ломается чаще всего
Дубли после таймаута
Провайдер мог принять сообщение, но ответ не дошёл до воркера. Повторная отправка без идемпотентного ключа создаст дубль. Если внешний API не поддерживает дедупликацию, заведите локальную таблицу попыток и явно примите ограничение: стопроцентно исключить дубль при неопределённом результате нельзя.
Бесконечные повторы
Экспоненциальная задержка между попытками снижает давление на провайдера, но не исправляет постоянную ошибку. Для окончательных отказов нужен отдельный статус и очередь разбора. Максимальное число попыток выбирайте по сроку жизни сообщения, а не по привычному числу.
Перепутанный порядок
Очередь может доставить уведомление о новом статусе раньше сообщения о предыдущем статусе, особенно если работают несколько воркеров. Если порядок важен, добавьте номер версии состояния и не отправляйте устаревшие события. Для всех уведомлений это не нужно: часто достаточно показать пользователю последний актуальный статус.
Отсутствие лимитов
Сбой внешнего сервиса может создать тысячи повторных задач. Нужны ограничения на скорость по провайдеру, каналу, получателю и кампании. Эти лимиты должны быть видны в метриках, иначе команда заметит проблему только по счёту или жалобам.
Когда outbox уже недостаточно
Transactional outbox начинает тянуть систему вниз, если таблица превращается в общий поток для десятков независимых потребителей. Тогда растут конкуренция за чтение, требования к retention и стоимость повторного проигрывания. CDC или отдельный брокер оправданы, когда события должны читать несколько сервисов, требуется длительное хранение потока или команды выпускают потребителей независимо.
Но переход на CDC не решает семантическую проблему автоматически. Сначала зафиксируйте контракт события: обязательные поля, версию, правила совместимости и значение удаления или отмены. Иначе вы просто перенесёте неявную связанность из приложения в инфраструктуру.
Что сделать дальше
Начните с карты событий и таблицы гарантий: какие события нельзя терять, какой допустим дубль, сколько времени сообщение может ждать и что считать доставкой. Для критичных изменений соберите минимальный прототип transactional outbox на PostgreSQL 16, затем нагрузите его сценариями: таймаут провайдера, перезапуск воркера, рост очереди и повторная доставка одного события.
В проде смотрите не только на число отправленных сообщений. Нужны возраст самой старой записи outbox, доля временных и постоянных ошибок, время от фиксации события до принятия провайдером, количество дублей и размер очереди повторов. Когда эти показатели определены, решение о переходе к брокеру или CDC можно принимать по наблюдаемой нагрузке, а не по моде на архитектурные компоненты.
Задачи: математика, физика, химия: Разбор задачи по шагам с проверкой и похожей задачей
Спросить за 10 ₽Источники: Transactional outbox pattern, PostgreSQL 16 Documentation: Transaction Isolation
Частые вопросы
Нужно ли сразу ставить брокер сообщений для уведомлений?
Можно ли гарантировать, что пользователь получит уведомление ровно один раз?
Что делать, если воркер упал после отправки сообщения?
Подойдёт ли эта схема для другой версии PostgreSQL или RabbitMQ?
Почему уведомления отправляются дважды, хотя в базе одна запись?
Нужно ли хранить весь текст уведомления в outbox?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.