6. Outbox pattern
Transactional Outbox: надежная доставка сообщений без Dual Write
«Каждый раз, когда вы пытаетесь обновить два независимых конечных автомата одновременно без единого координирующего центра, вы неизбежно убеждаетесь, что законы физики и сеть вступили против вас в сговор».
— Брайан Керниган
Проклятие двойной записи (Dual Write)
Классический сценарий микросервиса электронной коммерции: пользователь нажимает кнопку «Оформить заказ». Нам требуется выполнить две элементарные операции:
- Сохранить сущность заказа в реляционную базу данных (PostgreSQL);
- Опубликовать событие
OrderCreatedEventв брокер сообщений (Kafka или RabbitMQ), чтобы Сервис Склада начал комплектацию товара.
Как выглядит наивная попытка реализовать это на Go?
// АНТИПАТТЕРН: Наивная попытка связать СУБД и брокер
func (s *Service) CreateOrder(ctx context.Context, req OrderRequest) error {
// 1. Начинаем ACID-транзакцию в БД
tx, _ := s.db.BeginTx(ctx, nil)
// 2. Сохраняем бизнес-сущность заказа в таблицу
_ = saveOrderToDB(tx, req)
// 3. Отправляем событие в брокер сообщений
err := s.kafka.Publish("orders", req.ToEvent())
if err != nil {
tx.Rollback() // Откатываем БД, если брокер недоступен
return err
}
// 4. Коммитим транзакцию в БД
return tx.Commit()
}
Пытаться объединить PostgreSQL и Kafka в двухфазный коммит (2PC / XA Transactions) в современных облачных средах бессмысленно: 2PC превращает систему в хрупкую конструкцию, где сбой любого узла блокирует ресурсы базы, а пропускная способность падает до единиц запросов в секунду.
Инженерное решение этой задачи — паттерн Transactional Outbox (Исходящие сообщения).
Анатомия паттерна Outbox
Философия паттерна гениально проста: мы используем локальные ACID-свойства нашей проверенной реляционной базы данных (PostgreSQL/MySQL), чтобы гарантировать 100% атомарность.
Вместо того чтобы совершать ненадежный сетевой вызов к брокеру посреди транзакции, мы создаем в той же базе данных служебную таблицу — outbox (ящик исходящей почты).
Реальная бытовая аналогия:
Руководитель подписывает контракт и сразу кладет его в лоток «Исходящие»
на своем столе. Если выключится свет, и контракт, и конверт останутся
на столе в сохранности. Позже курьер (Relay) заберет конверт и отнесет на почту.
В рамках одной локальной транзакции выполняются строго два действия:
- Записываются изменения бизнес-сущности (таблица
orders); - Записывается тело события (JSON payload) в таблицу
outbox.
Если транзакция коммитится успешно — в базе данных одновременно появляются и заказ, и готовое к отправке событие. Если транзакция откатывается — исчезают оба. Атомарность гарантирована ядром СУБД!
После этого в игру вступает отдельный асинхронный процесс — Relay (Релей / Диспетчер), который считывает строки из таблицы outbox и гарантированно перекладывает их в брокер сообщений.
flowchart TD
subgraph App ["Go Backend (API Сервис)"]
A["Бизнес-логика"] -->|"1. Begin TX<br>2. INSERT INTO orders<br>3. INSERT INTO outbox<br>4. Commit TX"| B[/"PostgreSQL (ACID)"/]
end
subgraph Relay ["Outbox Relay (Диспетчер)"]
C["Message Dispatcher<br>(Polling или Debezium CDC)"] -->|"5. Читает новые строки outbox"| B
C -->|"6. Publish в топик"| D{"Брокер сообщений<br>(Kafka / RabbitMQ / NATS)"}
C -->|"7. Удаляет или помечает<br>строки как отправленные"| B
end
style B fill:#1e293b,stroke:#10b981,stroke-width:2px,color:#f8fafc
style D fill:#0f172a,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
style C fill:#131d33,stroke:#f59e0b,stroke-width:2px,color:#f8fafc
Идиоматичный Go: Реализация записи (Command Side)
На стороне API-сервиса код становится предельно надежным и избавляется от любых внешних сетевых зависимостей в момент обработки клиентского запроса:
package domain
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
)
// OutboxMessage представляет контракт структуры в исходящей таблице
type OutboxMessage struct {
ID string
Topic string
Payload []byte
CreatedAt time.Time
}
type Order struct {
ID string
Total int64
}
type OrderService struct {
db *sql.DB
}
func (s *OrderService) CreateOrder(ctx context.Context, order Order) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
// 1. Сохраняем заказ в бизнес-таблицу orders
_, err = tx.ExecContext(ctx, "INSERT INTO orders (id, total) VALUES ($1, $2)", order.ID, order.Total)
if err != nil {
return fmt.Errorf("insert order: %w", err)
}
// 2. Формируем событие доменной модели
event := map[string]any{
"event": "OrderCreated",
"order_id": order.ID,
"total": order.Total,
}
payload, err := json.Marshal(event)
if err != nil {
return fmt.Errorf("marshal outbox payload: %w", err)
}
// 3. Сохраняем событие в таблицу outbox в ТОЙ ЖЕ САМОЙ транзакции
_, err = tx.ExecContext(ctx, `
INSERT INTO outbox (id, topic, payload, created_at)
VALUES (gen_random_uuid(), 'orders_topic', $1, NOW())
`, payload)
if err != nil {
return fmt.Errorf("insert outbox: %w", err)
}
// Атомарный коммит! Если успешно — событие 100% будет доставлено
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit tx: %w", err)
}
return nil
}
Механизмы Relay: Доставка из базы в брокер
Записать строку в outbox — половина дела. Как надежно и с минимальной задержкой извлечь эти данные и доставить их в брокер сообщений? В индустрии существует два основных подхода.
Подход 1: Polling Publisher (Опрос базы данных)
Периодический фоновый воркер на Go раз в $N$ миллисекунд опрашивает таблицу outbox обычным SQL-запросом.
Чтобы организовать параллельное чтение без взаимных блокировок, в PostgreSQL используется специализированная конструкция FOR UPDATE SKIP LOCKED:
-- Эталонный запрос конкурентного Polling Relay в PostgreSQL:
BEGIN;
WITH cte AS (
SELECT id FROM outbox
WHERE status = 'pending'
ORDER BY created_at ASC
LIMIT 100
FOR UPDATE SKIP LOCKED -- Пропускает строки, уже занятые другим воркером!
)
UPDATE outbox SET status = 'processing'
WHERE id IN (SELECT id FROM cte)
RETURNING id, topic, payload;
-- 1. Полученные строки отправляются в брокер Kafka/RabbitMQ в коде Go
-- 2. При подтверждении успешной отправки от брокера:
DELETE FROM outbox WHERE id IN (...);
COMMIT;
Минусы Polling: Постоянная нагрузка на CPU базы данных (даже когда новых событий нет) и неизбежный лаг доставки, равный интервалу таймера опроса.
Подход 2: Transaction Log Tailing (CDC) — Индустриальный стандарт
В высоконагруженных системах (HighLoad) периодический опрос базы данных неприменим из-за накладных расходов. Вместо этого применяется технология Change Data Capture (CDC — Захват изменений данных).
Преимущества CDC:
- Нулевая нагрузка на диски и индексы СУБД от повторяющихся
SELECT-ов; - Минимальная задержка: событие попадает в брокер за считанные миллисекунды после коммита транзакции;
- Чистый код сервиса: Go-приложению больше не нужно содержать воркеры отправки — сервис только пишет в таблицу
outbox.
Очистка Outbox-таблицы (Housekeeping)
Таблица outbox имеет неприятное свойство непрерывно расти. Если ее не очищать, размер таблицы и ее индексов быстро превысит сотни гигабайт, замедляя основные бизнес-транзакции INSERT INTO orders и вызывая разрастание мертвых строк (Table Bloat в PostgreSQL).
Стратегии очистки:
- Жесткое удаление (Hard Delete): При использовании Polling Relay успешно отправленные строки удаляются сразу в транзакции отправки:
DELETE FROM outbox WHERE id = X. -
Фоновый сборщик мусора (Background Cron): Поскольку Debezium CDC только читает WAL и не трогает строки на диске, запускается регулярный фоновый воркер, выполняющий удаление устаревших данных пачками:
SQL DELETE FROM outbox WHERE created_at < NOW() - INTERVAL '1 day'; -
Партиционирование (Partitioning): В сверхнагруженных базах таблицу
outboxсекционируют по дням (например,outbox_2024_05_01). По прошествии суток администратор выполняет:SQL DROP TABLE outbox_2024_05_01;Удаление файла партиции на уровне файловой системы происходит за доли миллисекунды (Zero I/O operation) и не порождает мертвых строк MVCC и мусора в WAL.
Итог
- Dual Write (попытка прямой записи в СУБД и брокер) — антипаттерн, приводящий к гарантированной рассинхронизации данных при сетевых сбоях.
- Transactional Outbox обеспечивает 100% атомарность, сохраняя бизнес-сущность и событие в рамках одной локальной ACID-транзакции.
- Relay-процесс асинхронно считывает события из таблицы
outboxи гарантированно передает их в брокер сообщений. - Реализации Relay: для простых систем подходит Polling с блокировкой
FOR UPDATE SKIP LOCKED; для HighLoad систем стандартом является CDC (Debezium) на базе журнала WAL базы данных. - Гарантии доставки: Outbox обеспечивает гарантию At-least-once. Консьюмеры обязаны быть готовы к приему дубликатов.
Мы научились гарантированно и безопасно публиковать события наружу. Но как защитить принимающую сторону микросервиса от дубликатов, сбоев сети и повторных ретраев брокера?
Об этом мы поговорим в следующей лекции: 7. Inbox pattern.