Прямой ответ
Система уведомлений разделяет три сущности: событие в бизнес-логике, уведомление как намерение отправить сообщение и доставка как факт передачи в канал. Смешение трёх сущностей в одной операции делает сбои неразборчивыми.
Бизнес-событие ──▶ Уведомление (запись) ──▶ Очередь ──▶ Канал
│
Состояние доставки ◀──┘
Запись уведомления создаётся в той же транзакции, что и бизнес-изменение:
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. Ведите учёт отправленных уведомлений. История показывает, что и когда было отправлено, что требуется при разборе обращений.
Рекомендации:
- Не отправляйте уведомление синхронно в запросе пользователя. Задержка канала влияет на время ответа, поэтому отправка выполняется асинхронно.
- Разделяйте транзакционные и маркетинговые сообщения. Требования к своевременности различаются, поэтому обработка выполняется разными потоками.
- Учитывайте предпочтения пользователя до отправки. Отправка вопреки установленному предпочтению нарушает ожидание пользователя.
- Мониторьте возраст неотправленных уведомлений. Рост числа записей в промежуточном состоянии указывает на сбой отправки.
- Проверяйте идемпотентность в тестах. Повторная обработка записи не должна приводить к повторной отправке.
Практический сценарий: пользователь получил уведомление о несуществующем заказе
Контекст: пользователь получил письмо о подтверждении заказа, которого не существовало в базе.
- Диагностика. В журнале отправки письмо зафиксировано, в таблице заказов запись отсутствовала. Транзакция создания заказа откатилась после отправки письма.
- Причина. Отправка письма выполнялась внутри транзакции создания заказа, до её фиксации. Ошибка на последнем шаге привела к откату, а письмо уже было передано провайдеру.
- Действия. (а) Перенесли создание записи уведомления в транзакцию, а отправку — в отдельный процесс, читающий записи после фиксации; (б) добавили условное обновление состояния, чтобы параллельные обработчики не отправляли одно уведомление дважды; (в) добавили проверку существования связанной сущности перед отправкой как дополнительную защиту; (г) внедрили метрику возраста неотправленных уведомлений; (д) добавили тест на откат транзакции с проверкой отсутствия отправки.
- Результат. Уведомление отправляется только после фиксации бизнес-изменения, а откат транзакции не приводит к отправке.
Вывод: отправка уведомления внутри бизнес-транзакции приводит к сообщениям о несобытиях при откате. Разделение записи и отправки с чтением записей после фиксации устраняет расхождение, а условное обновление состояния исключает дубли при параллельной обработке.
FAQ
Можно ли отправлять уведомление синхронно в запросе? Технически да, но задержка канала влияет на время ответа, а сбой канала приводит к ошибке запроса. Отправка выполняется асинхронно.
Что делать при сбое канала отправки? Повторять с ограничением числа попыток, после исчерпания помещать запись в очередь недоставленных сообщений для разбора.
Нужно ли хранить состав уведомления? Да. Содержание фиксируется на момент создания, поэтому изменение шаблона не меняет уже созданные уведомления и позволяет разобрать обращение.
Как избежать дублей при параллельной обработке? Условным обновлением состояния: запись переходит в состояние отправки только из состояния ожидания, поэтому второй обработчик не захватывает её.
Источники: PostgreSQL explicit locking, RFC 5322, RabbitMQ reliability — ссылки в шапке.