Разбор последствий: заказ уже зафиксирован в базе данных, но отправка письма об оплате завершилась ошибкой....

Разбор последствий: заказ уже зафиксирован в базе данных, но отправка письма об оплате завершилась ошибкой. Почему одна SQL-транзакция не может надёжно сделать эти два результата атомарными?

Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

SQL-транзакция гарантирует атомарность только для ресурсов, которыми управляет конкретная СУБД. Отправка письма выполняется внешним почтовым сервисом, поэтому её нельзя надёжно откатить обычным ROLLBACK после успешного COMMIT базы или зафиксировать вместе с ним.

Обычно эту границу решают паттерном Transactional Outbox: в одной транзакции сохраняют заказ и запись о требуемом событии, а отдельный обработчик надёжно доставляет событие во внешний сервис с повторными попытками и идемпотентностью.

Исторический контекст

Классическая модель ACID рассчитана на согласованную работу операций внутри одной транзакционной системы: базы данных могут журналировать изменения, блокировать ресурсы и выполнять общий COMMIT или ROLLBACK. Когда операция затрагивает несколько независимых систем, у них нет единого локального журнала и общего участника, способного отменить уже выполненное внешнее действие.

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

Постановка проблемы

Последовательность «изменить заказ, зафиксировать транзакцию, отправить письмо» допускает сбой между двумя шагами. Если сначала зафиксировать заказ, а затем почтовый сервис станет недоступен, письмо не уйдёт; если сначала отправить письмо, а затем COMMIT завершится ошибкой, клиент получит уведомление о заказе, которого нет в базе.

Повтор операции тоже создаёт риск дублей: письмо может быть принято внешним сервисом, но ответ потеряется до отметки об успешной обработке. Поэтому нужны не только повторные попытки, но и идемпотентный идентификатор события или операции.

Подробное решение

В паттерне Transactional Outbox заказ и сообщение для отправки сохраняются в одной транзакции. Если транзакция откатывается, не появляется ни заказ, ни задание на письмо; если она зафиксирована, запись в outbox остаётся в базе и может быть обработана позже.

Минимальная схема выглядит так:

BEGIN; INSERT INTO orders(id, status) VALUES (1001, 'paid'); INSERT INTO outbox_events(id, event_type, aggregate_id, payload) VALUES ('evt-1001', 'PaymentConfirmed', 1001, '{...}'); COMMIT;

Фоновый обработчик читает необработанные события, отправляет их в почтовый сервис и отмечает событие доставленным. Между отправкой и отметкой возможен сбой, поэтому обработчик должен повторить отправку, а получатель — распознавать event_id и не выполнять один и тот же эффект повторно.

Outbox не создаёт мгновенную атомарность между базой и почтовым сервисом: он обеспечивает надёжную доставку с возможными повторами, обычно в модели at-least-once. Для строгой защиты от дублей внешний сервис должен поддерживать идемпотентность либо приложение должно использовать собственный механизм дедупликации.

Ситуация из практики

Платёжный сервис записывает успешную оплату и должен отправить клиенту письмо. Вариант с прямой отправкой письма внутри SQL-транзакции не решает проблему: база не может откатить уже принятое письмо, а длительное ожидание внешнего сервиса удерживает блокировки и увеличивает время транзакции.

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

Практический выбор — outbox с уникальным event_id, статусом обработки, ограниченным числом одновременных обработчиков и мониторингом зависших событий. В результате заказ не теряется из-за временной недоступности почтового сервиса, а повторная обработка не приводит к повторной отправке, если получатель поддерживает идемпотентность.

Что кандидаты часто упускают

  1. Можно ли решить проблему, отправляя письмо после COMMIT в том же потоке приложения?

Это уменьшает время удержания блокировок, но не устраняет окно сбоя между фиксацией заказа и отправкой письма. Если процесс завершится после COMMIT и до вызова почтового сервиса, письмо будет потеряно; outbox сохраняет намерение отправить его в самой транзакции.

  1. Почему одной уникальности события недостаточно для идемпотентности?

Уникальность записи защищает от появления двух одинаковых событий в outbox, но не предотвращает повторную доставку одного события. Обработчик может отправить письмо, не успеть отметить событие обработанным и затем повторить отправку. Идемпотентность должна действовать на стороне, выполняющей внешний эффект: например, почтовый сервис или промежуточный шлюз должен хранить использованный ключ операции.

  1. Чем outbox отличается от гарантии ровно однократной доставки?

Outbox обычно гарантирует сохранение события и повторные попытки, но из-за сбоев на границе «внешний эффект выполнен — результат записан» предоставляет модель at-least-once. Ровно однократный эффект нельзя получить только повторением SQL-операций; его обеспечивают идемпотентный потребитель, дедупликация или протокол координации с соответствующими компромиссами.