Москва: проверенные б/у ноутбуки с гарантией и проверкой перед оплатой
+7 906 722-94-45

Главная → Статьи → Архитектура системы уведомлений в сервисе

Архитектура системы уведомлений: каналы, очередь и состояние

Проектирование системы уведомлений: отделение события от доставки, очередь, каналы, состояние отправки и идемпотентность повторных отправок.

Прямой ответ

Система уведомлений разделяет три сущности: событие в бизнес-логике, уведомление как намерение отправить сообщение и доставка как факт передачи в канал. Смешение трёх сущностей в одной операции делает сбои неразборчивыми.

Бизнес-событие ──▶ Уведомление (запись) ──▶ Очередь ──▶ Канал
                                                    │
                              Состояние доставки ◀──┘

Запись уведомления создаётся в той же транзакции, что и бизнес-изменение:

CREATE TABLE notifications (
    id           bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    event_type   text NOT NULL,
    user_id      bigint NOT NULL,
    channel      text NOT NULL,
    payload      jsonb NOT NULL,
    status       text NOT NULL DEFAULT 'queued',
    attempts     int NOT NULL DEFAULT 0,
    created_at   timestamptz NOT NULL DEFAULT now(),
    sent_at      timestamptz
);

Запись в транзакции исключает потерю уведомления при сбое после изменения данных. Отправка выполняется отдельным процессом, который читает записи в состоянии queued.

Слой Ответственность Сбой
Бизнес-событие Создание записи уведомления Откат транзакции
Очередь Доставка записи обработчику Повтор чтения
Отправитель Передача в канал провайдера Повтор с ограничением
Состояние Фиксация результата Уточнение по ответу

Отделение записи от отправки даёт три свойства: повторная отправка не создаёт дубль записи, сбой канала не откатывает бизнес-изменение, а состояние доставки доступно для разбора.

Выбор канала определяется типом события и предпочтениями пользователя; о выборе канала — в статье про каналы уведомлений.

Почему отправка внутри бизнес-транзакции не работает

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

Сбой канала после фиксации транзакции. Данные изменены, сообщение не отправлено, а откатить изменение уже нельзя. Расхождение остаётся незамеченным, поскольку запись об отправке отсутствует.

Откат транзакции после отправки. Сообщение отправлено, но транзакция откатилась, поэтому пользователь получил уведомление о несуществующем событии.

Замедление провайдера. Ожидание ответа канала удерживает транзакцию открытой, что блокирует другие операции и увеличивает задержку запроса.

Повторная отправка при сбое. Без записи уведомления повторное выполнение бизнес-операции отправляет сообщение повторно, поскольку факт предыдущей отправки нигде не зафиксирован.

Практическое следствие: запись уведомления создаётся в транзакции, а отправка выполняется после фиксации. Паттерн исходящей очереди решает ту же задачу для внешних систем; о гарантиях доставки — в статье про надёжность доставки в очередях.

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

UPDATE notifications
SET status = 'sending', attempts = attempts + 1
WHERE id = $1 AND status = 'queued';

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

Практика: проектирование системы уведомлений

1. Создавайте запись уведомления в бизнес-транзакции. Потеря уведомления при сбое исключается, поскольку запись существует до отправки.

2. Отправляйте после фиксации транзакции. Отправка вне транзакции не блокирует бизнес-операцию и не приводит к уведомлению о несуществующем событии.

3. Обновляйте состояние условно. Параллельные обработчики не отправляют одно уведомление дважды, поскольку условие проверяет текущее состояние.

4. Храните состав уведомления в записи. Содержание фиксируется на момент создания, поэтому изменение шаблона не меняет уже созданное уведомление.

5. Различайте тип события и канал. Одно событие может требовать нескольких каналов, а канал определяется предпочтениями пользователя.

6. Ограничивайте число попыток отправки. Бесконечные повторы при постоянном отказе создают нагрузку без результата.

7. Ведите учёт отправленных уведомлений. История показывает, что и когда было отправлено, что требуется при разборе обращений.

Рекомендации:

  1. Не отправляйте уведомление синхронно в запросе пользователя. Задержка канала влияет на время ответа, поэтому отправка выполняется асинхронно.
  2. Разделяйте транзакционные и маркетинговые сообщения. Требования к своевременности различаются, поэтому обработка выполняется разными потоками.
  3. Учитывайте предпочтения пользователя до отправки. Отправка вопреки установленному предпочтению нарушает ожидание пользователя.
  4. Мониторьте возраст неотправленных уведомлений. Рост числа записей в промежуточном состоянии указывает на сбой отправки.
  5. Проверяйте идемпотентность в тестах. Повторная обработка записи не должна приводить к повторной отправке.

Практический сценарий: пользователь получил уведомление о несуществующем заказе

Контекст: пользователь получил письмо о подтверждении заказа, которого не существовало в базе.

  1. Диагностика. В журнале отправки письмо зафиксировано, в таблице заказов запись отсутствовала. Транзакция создания заказа откатилась после отправки письма.
  2. Причина. Отправка письма выполнялась внутри транзакции создания заказа, до её фиксации. Ошибка на последнем шаге привела к откату, а письмо уже было передано провайдеру.
  3. Действия. (а) Перенесли создание записи уведомления в транзакцию, а отправку — в отдельный процесс, читающий записи после фиксации; (б) добавили условное обновление состояния, чтобы параллельные обработчики не отправляли одно уведомление дважды; (в) добавили проверку существования связанной сущности перед отправкой как дополнительную защиту; (г) внедрили метрику возраста неотправленных уведомлений; (д) добавили тест на откат транзакции с проверкой отсутствия отправки.
  4. Результат. Уведомление отправляется только после фиксации бизнес-изменения, а откат транзакции не приводит к отправке.

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

FAQ

Можно ли отправлять уведомление синхронно в запросе? Технически да, но задержка канала влияет на время ответа, а сбой канала приводит к ошибке запроса. Отправка выполняется асинхронно.

Что делать при сбое канала отправки? Повторять с ограничением числа попыток, после исчерпания помещать запись в очередь недоставленных сообщений для разбора.

Нужно ли хранить состав уведомления? Да. Содержание фиксируется на момент создания, поэтому изменение шаблона не меняет уже созданные уведомления и позволяет разобрать обращение.

Как избежать дублей при параллельной обработке? Условным обновлением состояния: запись переходит в состояние отправки только из состояния ожидания, поэтому второй обработчик не захватывает её.

Источники: PostgreSQL explicit locking, RFC 5322, RabbitMQ reliability — ссылки в шапке.