Если ты проектируешь микросервисы или распределенные системы, то наверняка писал подобный код: в обработчике 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 во внешний брокер. Здесь есть два пути:
- Polling Publisher (периодический опрос): фоновый воркер раз в 200–500 миллисекунд делает запрос
SELECT ... FROM outbox WHERE processed_at IS NULL ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 100, отправляет пачку в брокер и проставляет отметку обработки. ПаттернSKIP LOCKEDпозволяет запускать несколько параллельных воркеров без блокировок. Но при интенсивном потоке постоянный поллинг создает паразитную нагрузку на дисковую подсистему. - 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 требует дисциплины в проектировании схем, но навсегда избавляет микросервисную архитектуру от фантомных записей и потерянных транзакций.
