Prerequisites:
- module-4/04-content-based-routing
Outbox Pattern: Теория и архитектура
Вы строите микросервисную архитектуру. Сервис Orders обновляет заказ и должен отправить событие в Kafka для обработки downstream-сервисами. Как гарантировать, что событие отправится, если БД-транзакция зафиксирована?
Классическое решение — “обновить БД, затем отправить в Kafka” — не работает надежно. Это dual-write problem, и он приводит к data loss и inconsistency в production.
Outbox Pattern — проверенное архитектурное решение для reliable event publishing в микросервисах. В этом уроке мы разберем теорию паттерна, почему он нужен, и какие гарантии он дает (и НЕ дает).
The Dual-Write Problem
Представьте типичный код микросервиса:
# Опасный код - dual-write antipattern
def approve_order(order_id):
# Шаг 1: Обновляем БД
db.execute("UPDATE orders SET status = 'APPROVED' WHERE id = %s", order_id)
db.commit()
# Шаг 2: Отправляем событие в Kafka
kafka_producer.send('order-events', {
'event': 'OrderApproved',
'orderId': order_id
})
Что может пойти не так?
Классический antipattern distributed систем
Два независимых write operations (БД и message broker) не могут быть атомарными без distributed transactions (2PC). Один может failнуть после успеха другого.
Проблемы:
- Network failure между COMMIT и send() — БД обновлена, Kafka не получил событие
- Kafka недоступен — транзакция зафиксирована, событие потеряно
- Application crash после COMMIT — downstream системы не узнают об изменении
- Retry логика сложна — нужно помнить, какие события отправили, а какие нет
Production истина: Если у вас два independent write operations (БД и message broker), один из них может failить. Распределенные транзакции (2PC) решают проблему, но медленны и сложны в реализации.
Dual-write = два независимых write, один может failить.
Outbox Pattern: Решение через CDC
Outbox Pattern использует фундаментальную идею: один write operation вместо двух.
Ключевая концепция: Вместо UPDATE + SEND делаем UPDATE + INSERT в одной транзакции. CDC-система (Debezium) автоматически публикует события из outbox-таблицы.
Один атомарный write вместо двух независимых
UPDATE + INSERT outbox выполняются в одной транзакции. Если транзакция зафиксирована - событие ГАРАНТИРОВАННО попадет в Kafka (at-least-once).
Поток данных:
- Application: UPDATE business data + INSERT outbox event в одной транзакции
- PostgreSQL: Атомарно фиксирует оба write в WAL
- Debezium: Читает WAL, захватывает изменения outbox-таблицы
- Outbox Event Router SMT: Трансформирует CDC event в domain event
- Kafka: Получает финальное событие в topic
Гарантия: Если транзакция зафиксирована — событие гарантированно попадет в Kafka (at-least-once).
Проверка знанийПочему прямая отправка события в Kafka после COMMIT в базу данных (dual-write) не является надежным решением? Какую гарантию нарушает этот подход?
Outbox Table Schema
Outbox-таблица имеет стандартную структуру, оптимизированную для Event Router SMT.
CREATE TABLE public.outbox (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
aggregatetype VARCHAR(255) NOT NULL,
aggregateid VARCHAR(255) NOT NULL,
type VARCHAR(255) NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW() NOT NULL
);
CREATE INDEX idx_outbox_created ON public.outbox(created_at);
COMMENT ON TABLE public.outbox IS 'Transactional outbox for reliable event publishing via Debezium';
Назначение полей
| Поле | Описание | Пример |
|---|---|---|
| id | Уникальный идентификатор события | 550e8400-e29b-41d4-a716-446655440000 |
| aggregatetype | Тип domain entity (определяет topic) | Order, Customer, Payment |
| aggregateid | ID entity (используется как Kafka key) | order-123 |
| type | Тип события | OrderCreated, OrderApproved, OrderCancelled |
| payload | Event data в JSON-формате | {"orderId": "123", "amount": 299.99} |
| created_at | Timestamp создания события | 2026-02-01 10:30:15 |
Как SMT использует эти поля
Outbox Event Router SMT преобразует outbox record в domain event:
aggregatetype = "Order" → Topic: outbox.event.Order
aggregateid = "order-123" → Kafka key: "order-123"
type = "OrderApproved" → Event header: type = "OrderApproved"
payload = {...} → Kafka message value: {...}
id → Kafka header: id = "550e8400..."
Результат: Kafka получает clean domain event без CDC metadata.
Application Pattern: Transactional Event Emission
Вот как выглядит код с Outbox Pattern:
# Надежный код с Outbox Pattern
def approve_order(order_id):
conn = db.get_connection()
cursor = conn.cursor()
try:
# BEGIN TRANSACTION (implicit)
# Шаг 1: Business logic update
cursor.execute("""
UPDATE orders
SET status = 'APPROVED', updated_at = NOW()
WHERE id = %s
""", (order_id,))
# Шаг 2: Emit event to outbox (same transaction!)
cursor.execute("""
INSERT INTO outbox (id, aggregatetype, aggregateid, type, payload)
VALUES (gen_random_uuid(), %s, %s, %s, %s)
""", (
'Order', # aggregatetype
order_id, # aggregateid
'OrderApproved', # type
json.dumps({ # payload
'orderId': order_id,
'approvedAt': datetime.now().isoformat(),
'amount': 299.99
})
))
# COMMIT - оба write атомарны
conn.commit()
except Exception as e:
conn.rollback()
raise
Ключевое отличие: События публикуются через INSERT, а не через Kafka producer API.
С cleanup’ом outbox-таблицы
Чтобы outbox не рос indefinitely, можно удалять события сразу после INSERT:
# Emit event + cleanup в одной транзакции
cursor.execute("""
WITH inserted AS (
INSERT INTO outbox (id, aggregatetype, aggregateid, type, payload)
VALUES (gen_random_uuid(), %s, %s, %s, %s)
RETURNING id
)
DELETE FROM outbox WHERE id = (SELECT id FROM inserted)
""", ('Order', order_id, 'OrderApproved', json.dumps({...})))
conn.commit()
Важно: Debezium обрабатывает INSERT до DELETE. DELETE-событие игнорируется Outbox Event Router по умолчанию, так что cleanup безопасен.
Guarantees и Limitations
Outbox Pattern — не magic. Важно понимать, что он дает, а что нет.
✅ Что Outbox ГАРАНТИРУЕТ
| Guarantee | Описание |
|---|---|
| Atomicity (single-database) | UPDATE + INSERT outbox атомарны (либо оба зафиксированы, либо оба откачены) |
| At-least-once delivery | Если транзакция зафиксирована → событие попадет в Kafka (может дублироваться при retry) |
| Ordering per aggregate | События одного aggregateid доставляются в том же порядке (Kafka partition key = aggregateid) |
| Durability | События в WAL, пережевут crash приложения или Debezium |
❌ Что Outbox НЕ ГАРАНТИРУЕТ
| Limitation | Описание |
|---|---|
| Exactly-once delivery | События могут дублироваться при Debezium retry. Consumers должны быть idempotent |
| Distributed ACID | Outbox НЕ дает ACID-гарантии между микросервисами (eventual consistency, не immediate) |
| Cross-service rollback | Если downstream-сервис failнулся, upstream-транзакция НЕ откатывается |
| Immediate consistency | Есть lag между COMMIT и появлением события в Kafka (обычно менее 1 секунды, но возможно больше при backpressure) |
Critical callout: Outbox Pattern — это НЕ distributed transactions. Это reliable messaging с eventual consistency. Если вам нужен distributed ACID — используйте Saga pattern или 2PC (но готовьтесь к сложности).
Что такое At-Least-Once Delivery?
At-least-once означает: событие доставится хотя бы один раз, возможно больше.
От application до Kafka с атомарностью
Если приложение crashит между UPDATE и INSERT - транзакция откачена, оба write отменены. Если crashит после COMMIT - событие в WAL, Debezium доставит его в Kafka после restart.
Следствие: Consumers должны обрабатывать duplicate events idempotently.
# Consumer MUST be idempotent
def handle_order_approved(event):
order_id = event['orderId']
event_id = event.headers['id'] # Outbox event ID
# Deduplication check
if already_processed(event_id):
print(f"Event {event_id} already processed, skipping")
return
# Process event
send_approval_email(order_id)
# Mark as processed
mark_processed(event_id)
Проверка знанийOutbox Pattern гарантирует at-least-once delivery. Почему это означает, что consumers обязаны быть идемпотентными? Как реализуется deduplication?
Outbox Cleanup Strategies
Outbox-таблица может расти indefinitely, если не удалять обработанные события.
Strategy 1: DELETE в той же транзакции (рекомендуется для учебных целей)
BEGIN;
UPDATE orders SET status = 'APPROVED' WHERE id = '123';
INSERT INTO outbox (...) VALUES (...);
DELETE FROM outbox WHERE id = '<just-inserted-id>'; -- Cleanup сразу
COMMIT;
Плюсы:
- Простота
- Outbox всегда “пустая”
- Нет дополнительных процессов
Минусы:
- DELETE также генерирует WAL (overhead)
- Debezium игнорирует DELETE по умолчанию (OK для нас)
Strategy 2: External Cleanup Job (production альтернатива)
-- Периодический cleanup (cron job, scheduled task)
DELETE FROM outbox
WHERE created_at < NOW() - INTERVAL '1 hour';
Плюсы:
- Меньше WAL overhead в hot path
- Можно batch удалять события
Минусы:
- Риск lock contention с INSERTs
- Нужен отдельный процесс
Recommendation для курса: Используйте Strategy 1 (DELETE в той же транзакции) для простоты. В production оцените volume и выберите подходящую стратегию.
Когда использовать Outbox Pattern?
✅ Use Cases для Outbox
- Microservices event publishing: Сервис обновляет свои данные и должен уведомить другие сервисы
- CQRS (Command Query Responsibility Segregation): Write model эмитит события для Read model
- Event Sourcing integration: События из БД попадают в event stream
- Audit trail: Гарантированная запись критичных бизнес-событий
❌ Когда Outbox избыточен
- Request-response communication: Если downstream система должна ответить синхронно — используйте REST/gRPC
- Low-value events: Если потеря события некритична — используйте direct Kafka send
- Single database, no downstream systems: Если нет внешних потребителей — Outbox не нужен
Архитектурная диаграмма: Microservices с Outbox
Каждый сервис имеет свою outbox-таблицу
Каждый микросервис владеет своей БД и своей outbox-таблицей. Debezium коннектор per service публикует события в отдельные топики. Downstream сервисы подписываются на нужные топики.
Flow:
- Order Service обновляет orders + INSERT в outbox (атомарно)
- Payment Service обновляет payments + INSERT в outbox (атомарно)
- Debezium connectors захватывают outbox events из каждой БД
- События публикуются в Kafka topics
- Notification Service потребляет события и отправляет уведомления
Eventual consistency: Order Service не знает, успешно ли Notification Service обработал событие. Это trade-off для decoupling.
Что дальше?
Вы поняли теорию Outbox Pattern. Теперь ключевой вопрос: как настроить Debezium Outbox Event Router SMT?
В следующем уроке мы:
- Создадим outbox-таблицу в PostgreSQL
- Настроим Debezium connector с Outbox Event Router SMT
- Напишем application code для event emission
- Реализуем idempotent consumer для обработки событий
Ключевые выводы
- Dual-write problem: UPDATE БД + SEND Kafka = два independent writes, один может failить
- Outbox solution: UPDATE + INSERT outbox в одной транзакции, CDC публикует события
- At-least-once delivery: События гарантированно доставляются, но могут дублироваться
- NOT distributed ACID: Outbox дает eventual consistency, не immediate consistency
- Idempotency required: Consumers должны обрабатывать duplicate events
- Cleanup strategies: DELETE в той же транзакции (простота) или external job (production scale)
- Use case: Microservices event publishing, CQRS, Event Sourcing integration
- Schema: id, aggregatetype, aggregateid, type, payload — стандартная структура для Event Router SMT
Check Your Understanding
Finished the lesson?
Mark it as complete to track your progress