Go Distributed Transaction Outbox: 5 Core Patterns for Reliable Event-Driven Architecture

后端开发

The Problem: Event-Driven Pain Points

An e-commerce order system refactored to event-driven architecture frequently experienced data inconsistency — "order created but inventory not deducted." Investigation revealed: message sending and database operations not in the same transaction causing message loss, network jitter triggering duplicate consumption, improper partition key selection causing event ordering issues, and overly long outbox polling intervals causing downstream delays. These four compounding issues turned "eventual consistency" into "occasional consistency." The Transactional Outbox pattern is the core solution, ensuring atomicity between business operations and event publishing.


Core Concepts at a Glance

Concept Description Importance
Transactional Outbox Write events to an Outbox table within the same business transaction, ensuring atomicity ⭐⭐⭐⭐⭐
Event-Driven Decouple services through event notifications instead of synchronous calls ⭐⭐⭐⭐⭐
Message Reliability Ensure messages are not lost, not duplicated, and delivered in order ⭐⭐⭐⭐⭐
Idempotent Consumption Consumer produces the same result when processing the same message multiple times ⭐⭐⭐⭐⭐
CDC Change Data Capture, monitoring database Binlog for real-time event publishing ⭐⭐⭐⭐⭐
Debezium Open-source CDC platform supporting MySQL/PostgreSQL change capture ⭐⭐⭐⭐
Message Retry Retry mechanism for failed message consumption with backoff strategy ⭐⭐⭐⭐
Event Sourcing Using event sequences as the source of truth, supporting state rebuild and audit ⭐⭐⭐

Problem Analysis: 5 Major Challenges in Transactional Outbox

1. Atomicity of Business Operations and Message Sending: The traditional approach writes to DB first then sends a message — two operations that cannot guarantee atomicity. DB write succeeds but message send fails, downstream services never receive the event; message sent first but DB write fails, creating ghost events.

2. Message Ordering Guarantee: Events for the same aggregate root must be consumed in order, but improper Kafka partition key selection or concurrent relay sending can cause reordering, leading downstream to execute business logic based on stale state.

3. Idempotent Consumption Implementation: Network retransmission, relay duplicate sending, and consumer restarts all cause duplicate consumption. Without idempotency, the same order might deduct inventory twice.

4. Outbox Polling Latency: Polling relies on periodically scanning the Outbox table — intervals too long increase latency, too short waste database resources. Under high concurrency, polling becomes a performance bottleneck.

5. CDC Configuration Complexity: Debezium requires deploying Kafka Connect, configuring Connectors, and managing Schema changes — high operational cost. Production environments must also consider Binlog format, GTID, and high availability.


Pattern 1: Outbox Table Design and Transactional Write

The Outbox table is written within the same database transaction as the business table, ensuring atomicity between business operations and event records. Event status starts as PENDING, sent asynchronously by the relay.

package outbox

import (
    "context"
    "database/sql"
    "encoding/json"
    "time"
)

type OutboxEvent struct {
    ID          int64           `json:"id"`
    AggregateID string          `json:"aggregate_id"`
    EventType   string          `json:"event_type"`
    Payload     json.RawMessage `json:"payload"`
    Status      string          `json:"status"`
    Retries     int             `json:"retries"`
    CreatedAt   time.Time       `json:"created_at"`
}

type OutboxRepository struct {
    db *sql.DB
}

func NewOutboxRepository(db *sql.DB) *OutboxRepository {
    return &OutboxRepository{db: db}
}

func (r *OutboxRepository) SaveWithTx(ctx context.Context, tx *sql.Tx, event *OutboxEvent) error {
    query := `INSERT INTO outbox_events (aggregate_id, event_type, payload, status, created_at)
              VALUES (?, ?, ?, 'PENDING', NOW())`
    result, err := tx.ExecContext(ctx, query,
        event.AggregateID, event.EventType, event.Payload)
    if err != nil {
        return err
    }
    event.ID, _ = result.LastInsertId()
    return nil
}

type OrderService struct {
    db     *sql.DB
    outbox *OutboxRepository
}

func (s *OrderService) CreateOrder(ctx context.Context, orderID, userID string, items []string) error {
    tx, err := s.db.BeginTx(ctx, nil)
    if err != nil {
        return err
    }
    defer tx.Rollback()

    _, err = tx.ExecContext(ctx,
        `INSERT INTO orders (id, user_id, items, status, created_at) VALUES (?, ?, ?, 'CREATED', NOW())`,
        orderID, userID, items)
    if err != nil {
        return err
    }

    payload, _ := json.Marshal(map[string]interface{}{
        "order_id": orderID,
        "user_id":  userID,
        "items":    items,
        "action":   "order_created",
    })
    event := &OutboxEvent{
        AggregateID: orderID,
        EventType:   "order.created",
        Payload:     payload,
    }
    if err := s.outbox.SaveWithTx(ctx, tx, event); err != nil {
        return err
    }

    return tx.Commit()
}

Outbox table DDL:

CREATE TABLE outbox_events (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    aggregate_id VARCHAR(128) NOT NULL,
    event_type VARCHAR(128) NOT NULL,
    payload JSON NOT NULL,
    status ENUM('PENDING','SENT','FAILED') DEFAULT 'PENDING',
    retries INT DEFAULT 0,
    created_at DATETIME(3) NOT NULL,
    INDEX idx_status_created (status, created_at),
    INDEX idx_aggregate_id (aggregate_id)
) ENGINE=InnoDB;

Pattern 2: Polling Relay Sender

The polling relay periodically scans Outbox events with PENDING status, publishes to Kafka, then updates status to SENT. Key point: use SELECT ... FOR UPDATE SKIP LOCKED to prevent duplicate sending across multiple instances.

package outbox

import (
    "context"
    "database/sql"
    "fmt"
    "log"
    "time"

    "github.com/segmentio/kafka-go"
)

type PollingRelay struct {
    db        *sql.DB
    writer    *kafka.Writer
    batchSize int
    interval  time.Duration
}

func NewPollingRelay(db *sql.DB, kafkaAddr, topic string, batchSize int, interval time.Duration) *PollingRelay {
    return &PollingRelay{
        db: db,
        writer: &kafka.Writer{
            Addr:         kafka.TCP(kafkaAddr),
            Topic:        topic,
            Balancer:     &kafka.LeastBytes{},
            BatchTimeout: 10 * time.Millisecond,
        },
        batchSize: batchSize,
        interval:  interval,
    }
}

func (r *PollingRelay) Start(ctx context.Context) {
    ticker := time.NewTicker(r.interval)
    defer ticker.Stop()
    for {
        select {
        case <-ctx.Done():
            return
        case <-ticker.C:
            if err := r.pollAndPublish(ctx); err != nil {
                log.Printf("polling relay error: %v", err)
            }
        }
    }
}

func (r *PollingRelay) pollAndPublish(ctx context.Context) error {
    tx, err := r.db.BeginTx(ctx, nil)
    if err != nil {
        return err
    }
    defer tx.Rollback()

    rows, err := tx.QueryContext(ctx,
        `SELECT id, aggregate_id, event_type, payload, retries
         FROM outbox_events
         WHERE status = 'PENDING' AND retries < 5
         ORDER BY created_at ASC
         LIMIT ? FOR UPDATE SKIP LOCKED`, r.batchSize)
    if err != nil {
        return err
    }
    defer rows.Close()

    var events []OutboxEvent
    for rows.Next() {
        var e OutboxEvent
        if err := rows.Scan(&e.ID, &e.AggregateID, &e.EventType, &e.Payload, &e.Retries); err != nil {
            return err
        }
        events = append(events, e)
    }
    if len(events) == 0 {
        return nil
    }

    var messages []kafka.Message
    for _, e := range events {
        messages = append(messages, kafka.Message{
            Key:   []byte(e.AggregateID),
            Value: e.Payload,
            Headers: []kafka.Header{
                {Key: "event_type", Value: []byte(e.EventType)},
                {Key: "event_id", Value: []byte(fmt.Sprintf("%d", e.ID))},
            },
        })
    }
    if err := r.writer.WriteMessages(ctx, messages...); err != nil {
        for _, e := range events {
            tx.ExecContext(ctx, `UPDATE outbox_events SET retries = retries + 1 WHERE id = ?`, e.ID)
        }
        return fmt.Errorf("kafka write failed: %w", err)
    }

    for _, e := range events {
        if _, err := tx.ExecContext(ctx, `UPDATE outbox_events SET status = 'SENT' WHERE id = ?`, e.ID); err != nil {
            return err
        }
    }
    return tx.Commit()
}

Pattern 3: CDC Change Data Capture (Debezium)

CDC captures Outbox table changes in real-time by monitoring database Binlog — no polling needed, lower latency. Debezium is a production-grade CDC solution running through Kafka Connect.

Debezium MySQL Connector configuration:

{
  "name": "outbox-connector",
  "config": {
    "connector.class": "io.debezium.connector.mysql.MySqlConnector",
    "database.hostname": "mysql",
    "database.port": "3306",
    "database.user": "debezium",
    "database.password": "dbz_pass",
    "database.server.id": "184054",
    "database.server.name": "outbox_server",
    "database.include.list": "order_db",
    "table.include.list": "order_db.outbox_events",
    "database.history.kafka.bootstrap.servers": "kafka:9092",
    "database.history.kafka.topic": "schema-changes",
    "transforms": "outbox",
    "transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter",
    "transforms.outbox.route.topic.replacement": "order-events",
    "transforms.outbox.table.field.event.id": "id",
    "transforms.outbox.table.field.event.key": "aggregate_id",
    "transforms.outbox.table.field.event.type": "event_type",
    "transforms.outbox.table.field.event.payload": "payload",
    "transforms.outbox.table.fields.additional.placement": "status:header:eventStatus"
  }
}

Go consumer integration:

package consumer

import (
    "context"
    "log"

    "github.com/segmentio/kafka-go"
)

type OutboxEventHandler struct {
    reader *kafka.Reader
}

func NewOutboxEventHandler(kafkaAddr, topic, groupID string) *OutboxEventHandler {
    return &OutboxEventHandler{
        reader: kafka.NewReader(kafka.ReaderConfig{
            Brokers:  []string{kafkaAddr},
            Topic:    topic,
            GroupID:  groupID,
            MinBytes: 10e3,
            MaxBytes: 10e6,
        }),
    }
}

func (h *OutboxEventHandler) Start(ctx context.Context) {
    for {
        msg, err := h.reader.ReadMessage(ctx)
        if err != nil {
            if ctx.Err() != nil {
                return
            }
            log.Printf("read message error: %v", err)
            continue
        }
        eventType := ""
        for _, hdr := range msg.Headers {
            if hdr.Key == "event_type" {
                eventType = string(hdr.Value)
                break
            }
        }
        log.Printf("received event: type=%s key=%s", eventType, string(msg.Key))
    }
}

Pattern 4: Idempotent Consumption and Deduplication

Idempotent consumption is the safety net of event-driven architecture. Deduplication through a consumption record table ensures the same event is never processed twice.

package consumer

import (
    "context"
    "database/sql"
    "fmt"
)

type IdempotentHandler struct {
    db *sql.DB
}

func NewIdempotentHandler(db *sql.DB) *IdempotentHandler {
    return &IdempotentHandler{db: db}
}

func (h *IdempotentHandler) Handle(ctx context.Context, eventID string, handler func(ctx context.Context) error) error {
    tx, err := h.db.BeginTx(ctx, nil)
    if err != nil {
        return err
    }
    defer tx.Rollback()

    var status string
    err = tx.QueryRowContext(ctx,
        `SELECT status FROM consume_records WHERE event_id = ? FOR UPDATE`, eventID).Scan(&status)
    if err == nil {
        if status == "PROCESSED" {
            return nil
        }
        return fmt.Errorf("event %s in status %s, skip", eventID, status)
    }
    if err != sql.ErrNoRows {
        return err
    }

    _, err = tx.ExecContext(ctx,
        `INSERT INTO consume_records (event_id, status, created_at) VALUES (?, 'PROCESSING', NOW())`, eventID)
    if err != nil {
        return err
    }

    if err := handler(ctx); err != nil {
        tx.ExecContext(ctx, `UPDATE consume_records SET status = 'FAILED' WHERE event_id = ?`, eventID)
        return err
    }

    _, err = tx.ExecContext(ctx, `UPDATE consume_records SET status = 'PROCESSED' WHERE event_id = ?`, eventID)
    if err != nil {
        return err
    }
    return tx.Commit()
}

Consumption record table:

CREATE TABLE consume_records (
    event_id VARCHAR(128) PRIMARY KEY,
    status ENUM('PROCESSING','PROCESSED','FAILED') DEFAULT 'PROCESSING',
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL
) ENGINE=InnoDB;

Pattern 5: Production-Grade Outbox Framework (with Monitoring)

A production-grade outbox needs: health checks, metrics collection, graceful shutdown, dead letter queues, and alerting. This framework integrates all the above patterns.

package outbox

import (
    "context"
    "database/sql"
    "log"
    "sync"
    "time"

    "github.com/prometheus/client_golang/prometheus"
    "github.com/segmentio/kafka-go"
)

type OutboxFramework struct {
    db      *sql.DB
    writer  *kafka.Writer
    relay   *PollingRelay
    handler *IdempotentHandler
    cancel  context.CancelFunc
    wg      sync.WaitGroup

    eventsPublished prometheus.Counter
    eventsFailed    prometheus.Counter
    relayLatency    prometheus.Histogram
}

func NewOutboxFramework(db *sql.DB, kafkaAddr, topic string) *OutboxFramework {
    f := &OutboxFramework{
        db: db,
        writer: kafka.NewWriter(kafka.WriterConfig{
            Brokers:      []string{kafkaAddr},
            Topic:        topic,
            Balancer:     &kafka.LeastBytes{},
            BatchTimeout: 10 * time.Millisecond,
        }),
        relay:   NewPollingRelay(db, kafkaAddr, topic, 100, 500*time.Millisecond),
        handler: NewIdempotentHandler(db),
    }

    f.eventsPublished = prometheus.NewCounter(prometheus.CounterOpts{
        Name: "outbox_events_published_total",
        Help: "Total number of outbox events published",
    })
    f.eventsFailed = prometheus.NewCounter(prometheus.CounterOpts{
        Name: "outbox_events_failed_total",
        Help: "Total number of outbox events failed",
    })
    f.relayLatency = prometheus.NewHistogram(prometheus.HistogramOpts{
        Name:    "outbox_relay_latency_seconds",
        Help:    "Latency from event creation to publish",
        Buckets: prometheus.DefBuckets,
    })
    prometheus.MustRegister(f.eventsPublished, f.eventsFailed, f.relayLatency)
    return f
}

func (f *OutboxFramework) Start() {
    ctx, cancel := context.WithCancel(context.Background())
    f.cancel = cancel

    f.wg.Add(1)
    go func() {
        defer f.wg.Done()
        f.relay.Start(ctx)
    }()

    f.wg.Add(1)
    go func() {
        defer f.wg.Done()
        f.monitorPendingEvents(ctx)
    }()

    log.Println("outbox framework started")
}

func (f *OutboxFramework) monitorPendingEvents(ctx context.Context) {
    ticker := time.NewTicker(10 * time.Second)
    defer ticker.Stop()
    for {
        select {
        case <-ctx.Done():
            return
        case <-ticker.C:
            var pending int
            f.db.QueryRowContext(ctx,
                `SELECT COUNT(*) FROM outbox_events WHERE status = 'PENDING'`).Scan(&pending)
            if pending > 1000 {
                log.Printf("ALERT: %d pending outbox events, possible relay lag", pending)
            }
        }
    }
}

func (f *OutboxFramework) Shutdown() {
    f.cancel()
    f.wg.Wait()
    f.writer.Close()
    log.Println("outbox framework shutdown complete")
}

Pitfall Guide

❌ Write DB first then send message, two operations without transaction guarantee ✅ Use Outbox table to write events in the same transaction, ensuring atomicity

❌ Polling relay without locking, multiple instances send duplicates ✅ Use FOR UPDATE SKIP LOCKED for lock-free mutual exclusion consumption

❌ Random Kafka message keys causing event reordering ✅ Use aggregate_id as partition key to ensure ordering for the same aggregate root

❌ Consumer without idempotency, duplicate consumption causes business errors ✅ Consumption record table + idempotent Handler ensures each event is processed once

❌ Outbox table grows indefinitely, query performance degrades ✅ Periodically archive SENT events, migrate to history table after 7 days


Error Troubleshooting

Error Symptom Possible Cause Solution
PENDING events accumulating Relay not started or Kafka unreachable Check relay goroutine status and Kafka connection
Consumer receives duplicate events Send succeeded but status update failed Check transaction commit logic, ensure send and status update are atomic
Event consumption order wrong Partition key not using aggregate_id Unify using aggregate root ID as Kafka message Key
Debezium Connector stopped Binlog format not ROW or insufficient permissions Confirm binlog_format=ROW, grant REPLICATION privileges
Idempotent table deadlock Concurrent consumption of same event with FOR UPDATE Use unique index + INSERT IGNORE instead of SELECT FOR UPDATE
Polling latency too high Batch size too small or interval too long Increase batch_size to 200+, shorten interval to 200ms
Outbox table query slowdown Large data volume without index Add (status, created_at) composite index, archive periodically
Kafka message send timeout Kafka cluster pressure or network jitter Increase WriteTimeout, enable retry and idempotent producer
Consume records table bloat Expired records not cleaned Periodically delete PROCESSED records older than 7 days
CDC delay of several minutes Debezium snapshot.mode misconfigured Use schema_only to avoid full snapshot, verify Binlog retention

Advanced Optimization

1. Multi-Tenant Outbox: Add a tenant_id field to the Outbox table, relay sends by tenant shard to prevent large tenants from blocking small ones.

2. Event Compression: Use gzip compression for the Payload field — large event bodies (e.g., order details) can achieve 70% compression, reducing Kafka bandwidth and storage costs.

3. Priority Queue: Add a priority field to the Outbox table. High-priority events (payment success) are sent first, low-priority events (notifications) are deferred.

4. Write Degradation: When Kafka is unavailable, the Outbox table serves as a persistent buffer. The relay automatically degrades to local storage mode and replays when Kafka recovers.

5. Event Schema Registry: Use Confluent Schema Registry to manage event schema versions. Consumers deserialize by version, preventing schema changes from causing consumption failures.


Comparison Analysis

Dimension Outbox Polling CDC (Debezium) Transactional MQ Saga Events
Latency Medium (100ms-1s) Low (<100ms) Low (<50ms) Medium
Implementation Complexity Low High Medium High
Operational Cost Low High (Kafka Connect) Medium High
Database Dependency Strong (polling pressure) Weak (Binlog monitoring) None Medium
Message Ordering ✅ Partition key control ✅ Binlog ordered ✅ Transaction message ordered ⚠️ Needs extra design
Idempotency Support ⚠️ Self-implemented ⚠️ Self-implemented ✅ MQ built-in ⚠️ Self-implemented
Use Case Small-medium scale, quick adoption Large scale, low-latency requirements RocketMQ ecosystem Long transaction orchestration

Summary and Outlook

The Transactional Outbox is the cornerstone of event-driven architecture reliability, solving the atomicity problem between business operations and event publishing. The polling approach is simple to implement and suitable for quick adoption; the CDC approach offers lower latency for large-scale scenarios. Both require idempotent consumption to guarantee eventual consistency. Future trends include: eBPF-based database change monitoring replacing Binlog parsing, Serverless event buses simplifying Outbox relay, and AI-driven message routing and anomaly detection. Mastering these 5 core patterns enables building production-grade reliable event-driven architectures.


Try these browser-local tools — no sign-up required →

#事务性发件箱#分布式事务#Outbox模式#消息可靠性#2026#后端开发