Разбор последствий: заказ уже зафиксирован в базе данных, но отправка письма об оплате завершилась ошибкой. Почему одна SQL-транзакция не может надёжно сделать эти два результата атомарными?
SQL-транзакция гарантирует атомарность только для ресурсов, которыми управляет конкретная СУБД. Отправка письма выполняется внешним почтовым сервисом, поэтому её нельзя надёжно откатить обычным ROLLBACK после успешного COMMIT базы или зафиксировать вместе с ним.
Обычно эту границу решают паттерном Transactional Outbox: в одной транзакции сохраняют заказ и запись о требуемом событии, а отдельный обработчик надёжно доставляет событие во внешний сервис с повторными попытками и идемпотентностью.
Классическая модель ACID рассчитана на согласованную работу операций внутри одной транзакционной системы: базы данных могут журналировать изменения, блокировать ресурсы и выполнять общий COMMIT или ROLLBACK. Когда операция затрагивает несколько независимых систем, у них нет единого локального журнала и общего участника, способного отменить уже выполненное внешнее действие.
Для координации нескольких ресурсов существует распределённая фиксация, например двухфазный протокол подтверждения. Однако на практике он усложняет архитектуру, увеличивает задержки и зависимость от доступности координатора, поэтому для интеграций часто выбирают асинхронную доставку событий.
Последовательность «изменить заказ, зафиксировать транзакцию, отправить письмо» допускает сбой между двумя шагами. Если сначала зафиксировать заказ, а затем почтовый сервис станет недоступен, письмо не уйдёт; если сначала отправить письмо, а затем COMMIT завершится ошибкой, клиент получит уведомление о заказе, которого нет в базе.
Повтор операции тоже создаёт риск дублей: письмо может быть принято внешним сервисом, но ответ потеряется до отметки об успешной обработке. Поэтому нужны не только повторные попытки, но и идемпотентный идентификатор события или операции.
В паттерне Transactional Outbox заказ и сообщение для отправки сохраняются в одной транзакции. Если транзакция откатывается, не появляется ни заказ, ни задание на письмо; если она зафиксирована, запись в outbox остаётся в базе и может быть обработана позже.
Минимальная схема выглядит так:
Фоновый обработчик читает необработанные события, отправляет их в почтовый сервис и отмечает событие доставленным. Между отправкой и отметкой возможен сбой, поэтому обработчик должен повторить отправку, а получатель — распознавать event_id и не выполнять один и тот же эффект повторно.
Outbox не создаёт мгновенную атомарность между базой и почтовым сервисом: он обеспечивает надёжную доставку с возможными повторами, обычно в модели at-least-once. Для строгой защиты от дублей внешний сервис должен поддерживать идемпотентность либо приложение должно использовать собственный механизм дедупликации.
Платёжный сервис записывает успешную оплату и должен отправить клиенту письмо. Вариант с прямой отправкой письма внутри SQL-транзакции не решает проблему: база не может откатить уже принятое письмо, а длительное ожидание внешнего сервиса удерживает блокировки и увеличивает время транзакции.
Вариант с распределённой транзакцией теоретически позволяет координировать ресурсы, но требует поддержки протокола всеми участниками и усложняет восстановление после отказов. Вариант с outbox добавляет фоновый обработчик и задержку доставки, зато сохраняет короткую транзакцию базы и позволяет контролируемо повторять обработку.
Практический выбор — outbox с уникальным event_id, статусом обработки, ограниченным числом одновременных обработчиков и мониторингом зависших событий. В результате заказ не теряется из-за временной недоступности почтового сервиса, а повторная обработка не приводит к повторной отправке, если получатель поддерживает идемпотентность.
COMMIT в том же потоке приложения?Это уменьшает время удержания блокировок, но не устраняет окно сбоя между фиксацией заказа и отправкой письма. Если процесс завершится после COMMIT и до вызова почтового сервиса, письмо будет потеряно; outbox сохраняет намерение отправить его в самой транзакции.
Уникальность записи защищает от появления двух одинаковых событий в outbox, но не предотвращает повторную доставку одного события. Обработчик может отправить письмо, не успеть отметить событие обработанным и затем повторить отправку. Идемпотентность должна действовать на стороне, выполняющей внешний эффект: например, почтовый сервис или промежуточный шлюз должен хранить использованный ключ операции.
Outbox обычно гарантирует сохранение события и повторные попытки, но из-за сбоев на границе «внешний эффект выполнен — результат записан» предоставляет модель at-least-once. Ровно однократный эффект нельзя получить только повторением SQL-операций; его обеспечивают идемпотентный потребитель, дедупликация или протокол координации с соответствующими компромиссами.