要点: イベント駆動設計は、確定した業務変更に対応するイベントを失わないときに初めて信頼できます。
この記事の用語: Outbox:業務変更と同じトランザクションでイベントを保存するテーブル。Relay:Outboxからブローカーへ送るプロセス。
現場の課題
DB更新とブローカーへの送信は別の操作です。途中でプロセスが止まると、データだけ保存されるか、確定していないデータのイベントが送られます。再試行による重複も避けられません。
単純な対策だけでは不十分な理由
ブローカーはサービスを時間的に分離しますが、アプリケーション DBとの原子性は作りません。Outboxで書き込み境界を守り、コンシューマー側では重複、遅延、再実行を前提にします。
処理フロー
Command検証→DB
業務データとOutbox→Relay
再試行付き発行→コンシューマー
冪等な副作用
アーキテクチャ上の判断
配送前にIntentをCommit
業務更新とイベントを一緒に保存し、Relayが確認まで発行を再試行します。
コンシューマーを冪等にする
処理済みイベントIDまたは受信済みテーブルを、副作用と同じトランザクションで保存します。
業務上の集約単位の順序を守る
業務上の集約 IDでパーティションし、Global Orderを仮定せず古いバージョンを拒否します。
さらに深く考える
イベントはCommandではなく事実
OrderCreatedはOrder Domainが所有する事実です。SendEmailToCustomerはプロデューサーを他サービスの実装へ結合します。
互換性をGovernanceする
Additiveなスキーマ変更、意味の安定、Contract Testを使い、Retention未承認の機密Fieldは含めません。
実装手順
- イベントは命令ではなく、すでに起きた事実として過去形で定義します。
- 業務データの変更とOutboxレコードを同じDB トランザクションで保存します。
- イベントは非同期送信し、コンシューマーはイベントIDで重複処理を防ぎます。
コード例: 注文作成とイベント発行の原子的化
BEGIN
INSERT INTO orders (...) VALUES (...)
INSERT INTO outbox(id, aggregate_id, type, payload)
VALUES (:eventId, :orderId, 'OrderCreated.v1', :payload)
COMMIT
relay.publish_unpublished(limit = 500)ブローカー Ack後のみPublishedにします。Leaseにより別Relayが中断バッチを回収できます。
想定しておく障害
- 発行後、状態更新前にRelayが停止し、イベントが再配送されます。
- Poison イベントがパーティションを止めるため、コンテキスト付き隔離と制御された再実行が必要です。
- 非互換コンシューマーが保持イベントを誤解します。
監視すべきこと
| 指標 | 何が分かるか |
|---|---|
| 最古の未発行 Outbox Age | 確定済み処理が下流から見えない時間です。 |
| イベント Type別コンシューマー遅延 | 特定業務Flowの遅延を切り分けます。 |
| Duplicate・再実行率 | 不安定なRelayや非冪等 コンシューマーを検出します。 |
設計の検証方法
- Commit直後にAPIを停止し、Relayが発行することを確認します。
- Ack直後にRelayを停止し、重複配送でも業務効果が一度だけか確認します。
- 次期コンシューマーで保持イベントを事前再実行します。
本番導入の進め方
リスクの低い副作用を一つ選び、同期経路と並行して結果を比較します。再実行ツール、未送信イベントのダッシュボード、担当者を整えてからコンシューマーを増やします。
本番前チェックリスト
- 配送はat-least-onceであり、業務効果のexactly-onceは冪等性で実現します。
- プロデューサー デプロイ前にスキーマ互換性を確認します。
- イベント Ageとコンシューマー遅延を利用者影響として監視します。
まとめ
信頼できるイベント駆動設計では、トランザクション境界、イベント担当者、再試行 Semanticsが明示されています。
