Дайджесты новостей
Наглядная инфографическая схема паттерна Transactional Outbox с локальной ACID-транзакцией и надежной отправкой в брокер сообщений.

Transactional Outbox Pattern: решение проблемы Dual Write при сохранении в БД и публикации событий

Если ты проектируешь микросервисы или распределенные системы, то наверняка писал подобный код: в обработчике HTTP-запроса сервис сначала сохраняет сущность заказа в реляционную базу данных, а затем отправляет уведомление OrderCreated в очередь Apache Kafka или брокер RabbitMQ.

На локальной машине разработчика такая наивная связка работает безупречно. Но в реальной распределенной среде ты неизбежно упираешься в классическую дилемму Dual Write (двойной записи).

Сеть между сервисами ненадежна по своей природе:

  • Сценарий А (сначала база, потом брокер): транзакция в базе данных успешно закоммичена, но при попытке отправить сообщение в Kafka моргает сеть или падает контейнер. Итог катастрофический: заказ в базе есть, но зависимые сервисы склада, биллинга и доставки о нем никогда не узнают. Данные безнадежно рассинхронизированы.
  • Сценарий Б (сначала брокер, потом база): сервис публикует событие в шину, а затем падает при коммите в СУБД из-за нарушения внешнего ключа или конфликта сериализации. Итог: брокер уже оповестил всю систему о несуществующем заказе (phantom event), склад зарезервировал товар, а записи в базе нет.

Распределенные двухфазные транзакции (2PC / XA) между разнородными системами либо не поддерживаются современными брокерами, либо убивают пропускную способность сервиса. Стандартом решения этой проблемы стал Transactional Outbox Pattern.

Локальная ACID-транзакция вместо распределенной ненадежности

Идея паттерна Outbox гениальна в своей простоте: раз мы не можем гарантировать атомарность между базой данных и внешним брокером, давай перенесем сохранение события внутрь той же самой реляционной базы данных!

В схему добавляется служебная таблица outbox:

-- DDL служебной таблицы событий outbox в PostgreSQL
CREATE TABLE outbox (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    aggregate_type VARCHAR(64) NOT NULL,
    aggregate_id VARCHAR(64) NOT NULL,
    event_type VARCHAR(64) NOT NULL,
    payload JSONB NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    processed_at TIMESTAMP WITH TIME ZONE
);

CREATE INDEX idx_outbox_unprocessed ON outbox (created_at) WHERE processed_at IS NULL;

Теперь запись бизнес-сущности и публикация события выполняются в рамках единой локальной транзакции:

BEGIN;

-- 1. Сохранение бизнес-сущности
INSERT INTO orders (id, user_id, amount, status)
VALUES ('ord_12345', 'usr_999', 1500.00, 'PAID');

-- 2. Атомарная фиксация исходящего события в той же транзакции
INSERT INTO outbox (aggregate_type, aggregate_id, event_type, payload)
VALUES (
    'Order',
    'ord_12345',
    'OrderPaid',
    jsonb_build_object('orderId', 'ord_12345', 'amount', 1500.00, 'userId', 'usr_999')
);

COMMIT;

Благодаря гарантиям ACID реляционной СУБД ситуация рассинхронизации исключена физически: либо в базе появятся и заказ, и строка события, либо откатятся обе записи.

Доставка событий: поллинг против чтения WAL через Debezium

Остается доставить накопленные записи из таблицы outbox во внешний брокер. Здесь есть два пути:

  1. Polling Publisher (периодический опрос): фоновый воркер раз в 200–500 миллисекунд делает запрос SELECT ... FROM outbox WHERE processed_at IS NULL ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 100, отправляет пачку в брокер и проставляет отметку обработки. Паттерн SKIP LOCKED позволяет запускать несколько параллельных воркеров без блокировок. Но при интенсивном потоке постоянный поллинг создает паразитную нагрузку на дисковую подсистему.
  2. Transaction Log Tailing (Change Data Capture / CDC): более зрелое решение на базе специализированных платформ вроде Debezium. Коннектор Debezium напрямую считывает журнал предзаписи (Write-Ahead Log / WAL в PostgreSQL) и асинхронно перекладывает события в топики Kafka с задержкой в доли миллисекунд и нулевой нагрузкой на SQL-движок.

В связке с практиками построения распределенных очередей на базе Apache Kafka, о которых мы рассказывали ранее, такой конвейер гарантирует доставку по модели at-least-once (как минимум один раз).

Обязательная идемпотентность консьюмеров

Поскольку при сетевых сбоях воркер может отправить сообщение повторно, консьюмеры на принимающей стороне обязаны проверять уникальный идентификатор event_id (например, в Redis или через уникальный индекс таблицы входящих событий). Без идемпотентности на стороне получателя модель at-least-once неизбежно приведет к дублированию действий.

Внедрение Transactional Outbox требует дисциплины в проектировании схем, но навсегда избавляет микросервисную архитектуру от фантомных записей и потерянных транзакций.