Skip to content
Learning Platform
Intermediate
30 minutes
outbox microservices patterns transactions

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
    })

Что может пойти не так?

Dual-Write Problem: Почему нельзя просто UPDATE + SEND

Классический antipattern distributed систем

approve_order(123)
UPDATE ordersCOMMIT
Transaction committed
kafka_producer.send(...)Risk point!
SUCCESS
✅ БД обновлена
✅ Событие отправлено
Consistency OK
FAILURE
✅ БД обновлена
❌ Событие НЕ отправлено
💥 Data loss!
Почему dual-write не работает:

Два независимых write operations (БД и message broker) не могут быть атомарными без distributed transactions (2PC). Один может failнуть после успеха другого.

Проблемы:

  1. Network failure между COMMIT и send() — БД обновлена, Kafka не получил событие
  2. Kafka недоступен — транзакция зафиксирована, событие потеряно
  3. Application crash после COMMIT — downstream системы не узнают об изменении
  4. 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-таблицы.

Outbox Pattern: Решение через CDC

Один атомарный write вместо двух независимых

Application
Business Logic
BEGIN TX
UPDATE orders
+
INSERT outbox
COMMIT
COMMIT
PostgreSQL
orders table
outbox table
Atomic write
WALAtomic!
CDC (Debezium)
Debezium
CDC capture
Outbox Event Router SMT
Apache Kafka
order-events topic
Consumers
Ключевое преимущество:

UPDATE + INSERT outbox выполняются в одной транзакции. Если транзакция зафиксирована - событие ГАРАНТИРОВАННО попадет в Kafka (at-least-once).

Поток данных:

  1. Application: UPDATE business data + INSERT outbox event в одной транзакции
  2. PostgreSQL: Атомарно фиксирует оба write в WAL
  3. Debezium: Читает WAL, захватывает изменения outbox-таблицы
  4. Outbox Event Router SMT: Трансформирует CDC event в domain event
  5. Kafka: Получает финальное событие в topic

Гарантия: Если транзакция зафиксирована — событие гарантированно попадет в Kafka (at-least-once).

Проверка знаний
Почему прямая отправка события в Kafka после COMMIT в базу данных (dual-write) не является надежным решением? Какую гарантию нарушает этот подход?
Ответ
Dual-write выполняет два независимых write-операции: COMMIT в БД и send() в Kafka. Между ними может произойти network failure, crash приложения или недоступность Kafka. В результате БД обновлена, но событие потеряно -- нарушается гарантия atomicity. Outbox Pattern решает это, сводя два write к одному: UPDATE + INSERT в outbox выполняются в одной транзакции БД, а CDC асинхронно доставляет событие.

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
aggregateidID entity (используется как Kafka key)order-123
typeТип событияOrderCreated, OrderApproved, OrderCancelled
payloadEvent data в JSON-формате{"orderId": "123", "amount": 299.99}
created_atTimestamp создания события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 ACIDOutbox НЕ дает 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 означает: событие доставится хотя бы один раз, возможно больше.

Outbox Pattern: Транзакционный поток

От application до Kafka с атомарностью

Applicationapprove_order(123)
BEGIN TRANSACTION
UPDATE orders
INSERT outbox
COMMIT (atomic)
PostgreSQL WALAtomic commit ✅
CDC capture
Debezium + Outbox SMT
Publish event
Kafka Topicorder-events
Почему атомарность важна?

Если приложение 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?
Ответ
At-least-once означает, что событие доставится минимум один раз, но может дублироваться -- например, при restart Debezium может повторно прочитать WAL-сегмент. Consumer реализует deduplication по event ID (UUID из поля id outbox-таблицы): сохраняет обработанные ID в таблицу processed_events и проверяет перед обработкой. Если ID уже обработан -- событие пропускается.

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

Microservices Architecture с Outbox Pattern

Каждый сервис имеет свою outbox-таблицу

Order Service
REST API
PostgreSQLorders + outbox
Payment Service
REST API
PostgreSQLpayments + outbox
CDC Infrastructure
DebeziumOrder Service
DebeziumPayment Service
Apache Kafka
outbox.event.Order
outbox.event.Payment
Notification Service (Consumer)
Kafka ConsumerSubscribes to both topics
PostgreSQLnotifications
Database-per-Service Pattern:

Каждый микросервис владеет своей БД и своей outbox-таблицей. Debezium коннектор per service публикует события в отдельные топики. Downstream сервисы подписываются на нужные топики.

Flow:

  1. Order Service обновляет orders + INSERT в outbox (атомарно)
  2. Payment Service обновляет payments + INSERT в outbox (атомарно)
  3. Debezium connectors захватывают outbox events из каждой БД
  4. События публикуются в Kafka topics
  5. 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 для обработки событий

Ключевые выводы

  1. Dual-write problem: UPDATE БД + SEND Kafka = два independent writes, один может failить
  2. Outbox solution: UPDATE + INSERT outbox в одной транзакции, CDC публикует события
  3. At-least-once delivery: События гарантированно доставляются, но могут дублироваться
  4. NOT distributed ACID: Outbox дает eventual consistency, не immediate consistency
  5. Idempotency required: Consumers должны обрабатывать duplicate events
  6. Cleanup strategies: DELETE в той же транзакции (простота) или external job (production scale)
  7. Use case: Microservices event publishing, CQRS, Event Sourcing integration
  8. Schema: id, aggregatetype, aggregateid, type, payload — стандартная структура для Event Router SMT

Check Your Understanding

Score: 0 of 0
Conceptual
Question 1 of 5. Что такое проблема двойной записи (dual-write problem) в контексте микросервисной архитектуры?

Finished the lesson?

Mark it as complete to track your progress