Transactional outbox для SMS: как не терять уведомления и не создавать дубли
Как связать бизнес-транзакцию и отправку SMS через outbox: схема таблицы, worker, idempotency key, retries, дедупликация, DLQ и сверка статусов.

Содержание
Типичная ошибка интеграции выглядит просто: приложение сохраняет заказ, а затем отдельным HTTP- или SMPP-вызовом отправляет SMS. Если процесс завершится между двумя действиями, заказ останется без уведомления. Если вызов успел пройти, но ответ потерялся, повтор может создать дубль. Transactional outbox устраняет разрыв между бизнес-данными и заданием на отправку.
Почему обычный двойной вызов ненадёжен
Рассмотрим два порядка операций.
Сначала база, потом SMS. Транзакция зафиксирована, но процесс падает до обращения к платформе. Бизнес-событие существует, задания на отправку нет.
Сначала SMS, потом база. Сообщение уже принято платформой, но транзакция откатывается. Пользователь получает уведомление о событии, которого система не сохранила.
Распределённая транзакция с внешней SMS-платформой обычно недоступна и была бы слишком дорогой. Outbox оставляет атомарность внутри одной базы данных, которой управляет приложение.
Базовая схема outbox
В одной транзакции приложение:
- изменяет бизнес-объект;
- добавляет строку в таблицу
notification_outbox; - фиксирует обе записи вместе.
Отдельный worker выбирает новые задания, отправляет их в SMS-платформу и сохраняет результат. Если транзакция откатится, не появится ни бизнес-изменения, ни задания. Если worker остановится, строка останется в базе и будет обработана после восстановления.
Полезные поля таблицы:
event_id— уникальный бизнес-идентификатор;event_typeи версия схемы;- получатель и ссылка на шаблон, а не произвольный готовый текст;
- параметры шаблона в контролируемом формате;
- приоритет, TTL и плановое время отправки;
- статус, число попыток и
next_attempt_at; - идентификатор платформы и последнее описание ошибки;
created_at,sent_at,updated_at.
Не помещайте в outbox секреты и лишние персональные данные. Права чтения следует ограничить, а сроки хранения — связать с назначением журнала и внутренними правилами обработки данных.
Idempotency key начинается с бизнес-события
Уникальный ключ должен означать «одно уведомление для одного события и получателя», например комбинацию order_id + status + recipient + template_version. Случайный UUID, создаваемый при каждой попытке, не защищает от повторной постановки одного события.
Добавьте уникальное ограничение базы на выбранный бизнес-ключ. Тогда повторная обработка события не создаст вторую строку. Если SMS API поддерживает ключ идемпотентности, передавайте тот же стабильный идентификатор, но не считайте эту возможность гарантированной без документации и испытаний конкретного интерфейса.
Идемпотентность не должна запрещать легитимные повторные уведомления. Для напоминаний включайте в ключ номер шага или временной интервал, а для нового OTP — идентификатор новой попытки аутентификации.
Как worker забирает задания
Несколько worker могут читать одну таблицу параллельно. Чтобы они не отправили одну строку одновременно, применяют блокировку выбранных записей или атомарную смену статуса. Конкретный механизм зависит от СУБД; важно, чтобы захват был коротким и восстанавливался после падения процесса.
Не держите транзакцию базы открытой во время сетевого запроса. Практическая последовательность:
- коротко захватить пакет строк и пометить их как
processingс lease-дедлайном; - завершить транзакцию;
- выполнить вызовы платформы;
- отдельной короткой транзакцией сохранить результат;
- вернуть просроченный lease в очередь, если worker исчез.
Размер пакета и параллелизм согласуют с пропускной способностью SMS и лимитами TPS. Бесконтрольное чтение всей таблицы только переносит перегрузку из приложения в SMS-маршрут.
Статусы и повторы
Минимальная модель различает:
pending— готово к обработке;processing— временно закреплено за worker;accepted— платформа приняла запрос и вернула идентификатор;retry— временная ошибка, назначена следующая попытка;failed— постоянная ошибка;expired— истёк TTL;cancelled— бизнес-событие отменено до отправки.
Повторяют только временные ошибки: сетевой тайм-аут, ограничение скорости или кратковременную недоступность. Некорректный номер, запрещённое имя отправителя или ошибка шаблона требуют исправления данных, а не бесконечного retry.
Используйте экспоненциальную задержку с jitter, максимальное число попыток и DLQ либо отдельный статус для ручного разбора. TTL проверяется перед каждой попыткой: просроченный OTP не должен догонять пользователя после восстановления очереди.
Не путайте accepted и delivered
Успешный HTTP-ответ или submit_sm_resp означает, что платформа приняла запрос. Финальный статус доставки приходит позже через webhook или DLR. Поэтому outbox может завершить свою задачу в состоянии accepted, а отдельный журнал доставки продолжает жизненный цикл сообщения.
Для сквозной связи сохраняйте внутренний event_id, идентификатор запроса и message_id платформы. Обработчик статусов также должен быть идемпотентным: один DLR может прийти повторно или в неожиданном порядке. Практика обработки описана в статье про надёжный DLR webhook.
Сверка закрывает редкие разрывы
Даже с outbox остаётся неопределённый результат: платформа могла принять запрос, а worker не получил ответ. Автоматический повтор создаёт риск дубля. Для таких строк нужен отдельный статус unknown и процедура сверки по стабильному идентификатору, журналам платформы или допустимому бизнес-решению.
Периодическая reconciliation-задача ищет:
- слишком долго находящиеся в
processingзаписи; acceptedбез идентификатора платформы;- сообщения без финального статуса дольше ожидаемого окна;
- несколько заданий с одинаковым бизнес-ключом;
- расхождение между числом бизнес-событий и уведомлений.
Наблюдаемость и эксплуатация
Контролируйте глубину и возраст очереди по типам сообщений, скорость обработки, распределение попыток, временные и постоянные ошибки, долю unknown, время до accepted и до финального DLR. Отдельный алерт нужен на отсутствие движения: небольшая, но неподвижная очередь может быть опаснее большой очереди, которая быстро уменьшается.
Проверьте восстановление из резервной копии. Если бизнес-таблицы и outbox восстанавливаются в разные точки времени, система может повторно отправить старые задания или потерять новые. Политика восстановления должна рассматривать их как согласованный набор.
Практический вывод
Transactional outbox не обещает магического exactly-once: внешняя сеть и SMS-платформа остаются отдельной системой. Паттерн гарантирует более важное — бизнес-событие и намерение отправить уведомление фиксируются атомарно, а редкие неопределённые случаи становятся видимыми и управляемыми.
При интеграции с QuickTel заранее согласуйте стабильный идентификатор, классификацию ошибок, лимиты, TTL и способ сверки. Затем испытайте падение процесса на каждом переходе статуса и убедитесь, что восстановление не создаёт ни пропусков, ни массовых дублей.


