Go Event-Driven Architecture with Outbox Pattern: Reliable Transactional Messaging

技术架构

Summary

  • Event-Driven Architecture (EDA) is the mainstream microservice decoupling approach in 2026
  • The Outbox pattern solves the dual-write consistency problem by writing business data and messages in the same transaction
  • At-Least-Once delivery + consumer idempotency = production-grade message reliability
  • Go + Kafka + PostgreSQL is the golden stack for EDA in 2026
  • Complete solution from theory to Go implementation, including Outbox Relay and dead letter queue handling

Table of Contents


Why Event-Driven Architecture

Issue Synchronous Calls Event-Driven
Coupling Strong — A waits for B Loose — A publishes event
Availability Downstream failure cascades Consumer catches up after recovery
Extensibility New consumer requires caller changes New consumer subscribes to topic
Peak handling Blocking, avalanche risk Async buffering via message queue

Good for: order creation → inventory/logistics/points/SMS (1 write, N reads); user registration; payment completion; CDC-driven search index updates.

Not for: queries requiring immediate response; financial transfers needing strong consistency.


The Dual-Write Problem

Order service must: (1) write PostgreSQL, (2) send Kafka message.

If step 1 succeeds but step 2 fails → order created but inventory unaware → overselling.

Solution Consistency Complexity Recommendation
Outbox (local message table) Strong Medium ★★★★★
Distributed transaction (2PC/XA) Strong High ★★ (poor performance)
Saga Eventual High ★★★★ (long transactions)
Message first, then DB Inconsistent Low ★ (not recommended)

Outbox Pattern Principles

Write the message as a record in the same database transaction as business data. An independent Relay process reads the Outbox table and publishes to Kafka.

CREATE TABLE outbox_events (
    id            BIGSERIAL PRIMARY KEY,
    aggregate_type VARCHAR(64) NOT NULL,
    aggregate_id   VARCHAR(64) NOT NULL,
    event_type     VARCHAR(128) NOT NULL,
    payload        JSONB NOT NULL,
    status         VARCHAR(20) NOT NULL DEFAULT 'pending',
    created_at     TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    published_at   TIMESTAMPTZ,
    retry_count    INT NOT NULL DEFAULT 0
);

CREATE INDEX idx_outbox_pending ON outbox_events (created_at)
    WHERE status = 'pending';
func (s *OrderService) CreateOrder(ctx context.Context, req CreateOrderRequest) (*Order, error) {
    tx, err := s.db.BeginTx(ctx, nil)
    if err != nil {
        return nil, err
    }
    defer tx.Rollback()

    order := &Order{ID: uuid.New().String(), UserID: req.UserID, Amount: req.Amount, Status: "created"}

    _, err = tx.ExecContext(ctx,
        `INSERT INTO orders (id, user_id, amount, status) VALUES ($1, $2, $3, $4)`,
        order.ID, order.UserID, order.Amount, order.Status,
    )
    if err != nil {
        return nil, err
    }

    eventPayload, _ := json.Marshal(map[string]interface{}{
        "order_id": order.ID, "user_id": order.UserID, "amount": order.Amount, "items": req.Items,
    })

    _, err = tx.ExecContext(ctx,
        `INSERT INTO outbox_events (aggregate_type, aggregate_id, event_type, payload)
         VALUES ($1, $2, $3, $4)`,
        "order", order.ID, "order.created", eventPayload,
    )
    if err != nil {
        return nil, err
    }

    return order, tx.Commit()
}

Go Outbox Relay Implementation

Key technique: FOR UPDATE SKIP LOCKED — multiple Relay instances skip rows locked by other transactions.

func (r *OutboxRelay) processBatch(ctx context.Context) error {
    rows, err := r.db.QueryContext(ctx, `
        SELECT id, aggregate_type, aggregate_id, event_type, payload
        FROM outbox_events
        WHERE status = 'pending'
        ORDER BY created_at
        LIMIT $1
        FOR UPDATE SKIP LOCKED
    `, r.batchSize)
    // ... publish to Kafka, mark published or failed
}

Partition by aggregate_id for ordering. Failed events: retry_count threshold → dead letter.


Kafka Consumer and Idempotency

At-Least-Once delivery requires idempotent consumers:

func (h *InventoryHandler) HandleOrderCreated(ctx context.Context, msg *kafka.Message) error {
    eventID := getHeader(msg, "event_id")

    var processed bool
    h.db.QueryRowContext(ctx,
        `SELECT EXISTS(SELECT 1 FROM processed_events WHERE event_id = $1)`, eventID,
    ).Scan(&processed)
    if processed {
        return nil
    }

    tx, _ := h.db.BeginTx(ctx, nil)
    defer tx.Rollback()

    // business logic + INSERT INTO processed_events
    return tx.Commit()
}

Event Schema Design

Use CloudEvents standard format with backward-compatible schema evolution.

Strategy Description Use Case
Backward compatible New fields optional Most scenarios
Dual-write transition Publish v1 and v2 events Major schema changes
Consumer version routing Different consumer groups per version Multi-team evolution

Production Deployment and Monitoring

Metric Alert Threshold Meaning
outbox_pending_count > 1000 for 5min Relay cannot keep up
outbox_oldest_pending_age > 60s Message delay too high
outbox_failed_count > 0 Publish failures
relay_publish_latency_p99 > 5s Relay performance degradation

Interview Topics and Architecture Selection

Q1: Outbox vs CDC?

Outbox writes events at application layer with business semantics (order.created). CDC captures row-level changes (INSERT/UPDATE). Complex business → Outbox; simple data sync → CDC.

Q2: What if Relay goes down?

Pending events are not lost. Relay resumes on recovery. May cause temporary delay but no message loss.

Q3: Outbox vs Saga?

EDA for 1-write-N-read notifications. Saga for long cross-service transactions with compensation. Can combine both.


Outbox vs CDC (Debezium)

Dimension Outbox CDC
Event semantics Business events Row-level changes
Code intrusion Write outbox in transaction Zero intrusion
Best for Complex domain events Simple data sync

Saga + Outbox Combination

Orchestration-style Saga with compensation: payment fails → refund → cancel order. Each step emits Outbox events for downstream awareness.


Message Ordering

Partition by aggregate_id for per-order ordering. Consumer validates state transitions; out-of-order events trigger retry or DLQ.


CDC Evolution with Debezium

Debezium Outbox Event Router replaces custom Relay — reads outbox table from WAL, routes to Kafka by aggregate type.


Dead Letter Queue and Replay

After 5 retries, mark failed and send to DLQ. Ops replay: UPDATE outbox_events SET status='pending' WHERE id=$1.


Full-Chain Observability

Propagate traceparent in Kafka headers. Dashboard: outbox pending count, consumer lag, business consistency checks.


Hands-On: Local Outbox + Kafka

Docker Compose with PostgreSQL + Kafka. Create order → verify outbox pending → start Relay → confirm consumer processes. Fault injection: kill Relay, stop Kafka — verify business writes remain atomic.


Outbox starters in Spring Boot/Go frameworks, Event Sourcing revival, serverless consumers, event-driven AI automation, CloudEvents standardization.


Summary and Further Reading

Key takeaways:

  1. Dual-write consistency is EDA's #1 challenge; Outbox is the optimal solution
  2. Business data and events must commit in the same DB transaction
  3. Relay uses FOR UPDATE SKIP LOCKED for multi-instance deployment
  4. Consumers must be idempotent: processed_events table + business unique constraints
  5. Event schema follows CloudEvents standard

Related reading:

References:

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

#事件驱动架构#Outbox模式#Go微服务#事务消息#Kafka#2026