Goトランザクショナルアウトボックス:信頼性の高いイベント駆動アーキテクチャの5つのコアパターン

后端开发

問題提起:イベント駆動のペインポイント

あるEC注文システムがイベント駆動アーキテクチャにリファクタリング後、「注文は作成されたが在庫が扣除されていない」というデータ不整合が頻発しました。調査の結果:メッセージ送信とデータベース操作が同一トランザクションにないことによるメッセージ損失、ネットワークジッターによる重複消費、パーティションキーの不適切な選択によるイベント順序の乱れ、アウトボックスポーリング間隔の長さによる下流遅延——これら4つの問題が重なり、「結果整合性」が「時々整合性」になっていました。トランザクショナルアウトボックス(Transactional Outbox)は、まさにこの種の問題を解決するコアパターンであり、ビジネス操作とイベントパブリッシュの原子性を保証します。


コア概念クイックリファレンス

概念 説明 重要度
トランザクショナルアウトボックス イベントをビジネスと同一トランザクションのOutboxテーブルに書き込み、原子性を保証 ⭐⭐⭐⭐⭐
イベント駆動 イベント通知によりサービス間の疎結合を実現、同期呼び出しに代わる手法 ⭐⭐⭐⭐⭐
メッセージ信頼性 メッセージの損失なし、重複なし、順序通り配信を保証 ⭐⭐⭐⭐⭐
冪等消費 コンシューマーが同一メッセージを複数回処理しても結果が同一 ⭐⭐⭐⭐⭐
CDC 変更データキャプチャ、データベースBinlogを監視しリアルタイムイベントパブリッシュを実現 ⭐⭐⭐⭐⭐
Debezium オープンソースCDCプラットフォーム、MySQL/PostgreSQL等の変更キャプチャをサポート ⭐⭐⭐⭐
メッセージリトライ メッセージ消費失敗後のリトライ機構、バックオフ戦略を含む ⭐⭐⭐⭐
イベントソーシング イベントシーケンスを状態のソースとし、状態再構築と監査をサポート ⭐⭐⭐

問題分析:トランザクショナルアウトボックスの5つの課題

1. ビジネス操作とメッセージ送信の原子性:従来のアプローチは先にDBに書き込み次にメッセージを送信しますが、この2つの操作では原子性を保証できません。DB書き込み成功後にメッセージ送信が失敗すると、下流サービスはイベントを受信できず、メッセージを先に送信してDB書き込みが失敗すると、ゴーストイベントが発生します。

2. メッセージ順序の保証:同一集約ルートのイベントは順序通りに消費される必要がありますが、Kafkaパーティションキーの不適切な選択やポーリングリレーの並列送信により順序が乱れ、下流が古い状態に基づいてビジネスロジックを実行する可能性があります。

3. 冪等消費の実装:ネットワーク再送、リレーの重複送信、コンシューマーの再起動はすべて重複消費を引き起こします。冪等性の保障がなければ、同一注文で在庫が2回扣除される可能性があります。

4. アウトボックスポーリング遅延:ポーリング方式はOutboxテーブルの定期スキャンに依存します。間隔が長すぎると遅延が増加し、短すぎるとデータベースリソースを浪費します。高同時アクセスシナリオではポーリングがパフォーマンスボトルネックになります。

5. CDC設定の複雑さ:DebeziumはKafka Connectのデプロイ、Connectorの設定、Schema変更の管理が必要で、運用コストが高いです。本番環境ではBinlogフォーマット、GTID、高可用性も考慮する必要があります。


パターン1:Outboxテーブル設計とトランザクション書き込み

Outboxテーブルはビジネステーブルと同じデータベーストランザクション内に書き込まれ、ビジネス操作とイベントレコードの原子性を保証します。イベントステータスは初期状態でPENDING、リレーにより非同期送信されます。

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テーブル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;

パターン2:ポーリングリレーセンダー

ポーリングリレーは定期的にOutboxテーブルのPENDINGステータスのイベントをスキャンし、Kafkaにパブリッシュ後、ステータスをSENTに更新します。重要ポイント:SELECT ... FOR UPDATE SKIP LOCKEDを使用して複数インスタンスでの重複送信を防止します。

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

パターン3:CDC変更データキャプチャ(Debezium)

CDCはデータベースBinlogを監視してOutboxテーブルの変更をリアルタイムにキャプチャします。ポーリング不要で遅延が低いのが特徴です。DebeziumはKafka Connectを通じて動作する本番グレードのCDCソリューションです。

Debezium MySQL Connector設定:

{
  "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コンシューマー統合:

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

パターン4:冪等消費と重複排除

冪等消費はイベント駆動アーキテクチャのセーフティネットです。消費記録テーブルによる重複排除で、同一イベントが重複処理されることを防ぎます。

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

消費記録テーブル:

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;

パターン5:本番グレードアウトボックスフレームワーク(モニタリング付き)

本番グレードのアウトボックスには、ヘルスチェック、メトリクス収集、グレースフルシャットダウン、デッドレターキュー、アラート機構が必要です。このフレームワークは上記の全パターンを統合しています。

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

よくある落とし穴

❌ 先にDB書き込み後にメッセージ送信、2つの操作にトランザクション保証なし ✅ Outboxテーブルを使用して同一トランザクションでイベントを書き込み、原子性を保証

❌ ポーリングリレーにロックなし、複数インスタンスで重複送信FOR UPDATE SKIP LOCKEDでロックフリーの排他消費を実現

❌ Kafkaメッセージキーをランダム生成、イベントの順序が乱れる ✅ aggregate_idをパーティションキーとして使用し、同一集約ルートのイベント順序を保証

❌ コンシューマーに冪等性を実装せず、重複消費でビジネスエラー発生 ✅ 消費記録テーブル+冪等Handlerで、同一イベントの1回のみ処理を保証

❌ Outboxテーブルが無制限に増大、クエリパフォーマンスが低下 ✅ 定期的にSENTステータスのイベントをアーカイブ、7日後に履歴テーブルに移行


エラートラブルシューティング

エラー現象 可能な原因 解決策
OutboxテーブルのPENDINGイベント蓄積 リレー未起動またはKafka到達不能 リレーgoroutineの状態とKafka接続を確認
コンシューマーが重複イベントを受信 送信成功したがステータス更新失敗 トランザクションコミットロジックを確認、送信とステータス更新の原子性を保証
イベント消費順序の乱れ パーティションキーがaggregate_idを使用していない 集約ルートIDをKafkaメッセージKeyとして統一
Debezium Connector停止 BinlogフォーマットがROWでない、または権限不足 binlog_format=ROWを確認、REPLICATION権限を付与
冪等テーブルデッドロック 同一イベントの並行消費とFOR UPDATE 一意インデックス+INSERT IGNOREでSELECT FOR UPDATEを代替
ポーリング遅延が高すぎる バッチサイズが小さすぎるか間隔が長すぎる batch_sizeを200+に増加、intervalを200msに短縮
Outboxテーブルのクエリが遅い データ量が多くインデックスがない (status, created_at)複合インデックスを追加、定期的にアーカイブ
Kafkaメッセージ送信タイムアウト Kafkaクラスタの圧力またはネットワークジッター WriteTimeoutを増加、リトライと冪等プロデューサーを有効化
消費記録テーブルの膨張 期限切れレコードの未クリーンアップ 7日前のPROCESSEDレコードを定期的に削除
CDC遅延が数分に達する Debeziumのsnapshot.modeが不適切 schema_onlyを使用してフルスナップショットを回避、Binlog保持期間を確認

高度な最適化

1. マルチテナントOutbox:Outboxテーブルにtenant_idフィールドを追加し、リレーはテナント別にシャード送信し、大規模テナントのイベントが小規模テナントをブロックするのを防止します。

2. イベント圧縮:Payloadフィールドにgzip圧縮を使用。大規模イベントボディ(注文詳細など)は70%の圧縮率を達成でき、Kafkaの帯域幅とストレージコストを削減します。

3. 優先度キュー:Outboxテーブルにpriorityフィールドを追加。高優先度イベント(支払い成功)を優先送信し、低優先度イベント(通知)を後回しにします。

4. デュアルライトデグラデーション:Kafkaが利用不可の場合、Outboxテーブルが永続化バッファとして機能し、リレーは自動的にローカルストレージモードにデグラデーションし、Kafka復旧後に再送します。

5. イベントSchemaレジストリ:Confluent Schema Registryを使用してイベントSchemaバージョンを管理。コンシューマーはバージョンごとにデシリアライズし、Schema変更による消費失敗を防止します。


比較分析

次元 Outboxポーリング CDC(Debezium) トランザクションMQ Sagaイベント
レイテンシ 中(100ms-1s) 低(<100ms) 低(<50ms)
実装複雑さ
運用コスト 高(Kafka Connect)
データベース依存 強(ポーリング圧力) 弱(Binlog監視) なし
メッセージ順序保証 ✅ パーティションキー制御 ✅ Binlog順序付き ✅ トランザクションメッセージ順序付き ⚠️ 追加設計が必要
冪等性サポート ⚠️ 自前実装が必要 ⚠️ 自前実装が必要 ✅ MQ内蔵 ⚠️ 自前実装が必要
適用シナリオ 中小規模、迅速導入 大規模、低遅延要件 RocketMQエコシステム 長トランザクションオーケストレーション

まとめと展望

トランザクショナルアウトボックスはイベント駆動アーキテクチャの信頼性の礎であり、ビジネス操作とイベントパブリッシュの原子性問題を解決します。ポーリング方式は実装がシンプルで迅速な導入に適しており、CDC方式は大規模シナリオでより低いレイテンシを提供します。どちらも冪等消費と組み合わせて結果整合性を保証する必要があります。今後のトレンドには、eBPFベースのデータベース変更監視によるBinlog解析の代替、ServerlessイベントバスによるOutboxリレーの簡素化、AI駆動のメッセージルーティングと異常検出が含まれます。この5つのコアパターンを習得すれば、本番グレードの信頼性の高いイベント駆動アーキテクチャを構築できます。


オンラインツール推奨

ブラウザローカルツールを無料で試す →

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