マイクロサービスアーキテクチャ(MSA)において、サービスを跨ぐデータの整合性を保つことはエンジニアが直面する最も困難な課題の一つです。従来のRDBで利用されていた強力なACID特性(2PCなど)は、分散環境では可用性とパフォーマンスを著しく低下させます。
本記事では、イベント駆動型のSagaパターンとTransactional Outboxパターンを組み合わせ、分散ロックを使わずに「結果整合性(Eventual Consistency)」を担保し、メッセージの欠損を防ぐ高信頼なアーキテクチャの実装方法を解説します。
TL;DR — 分散環境では2PCの代わりに、ローカルDB更新とイベント発行を同一トランザクションで処理するOutboxパターンを用い、一連の処理を補償トランザクション(Saga)で管理することで、データ欠損のない堅牢なシステムを構築できます。
1. SagaパターンとOutboxパターンの基本概念
💡 イメージで理解する: Sagaは「各駅停車の旅」です。各駅(サービス)でスタンプを押し、もし途中でトラブルがあれば、逆方向の電車に乗ってスタンプを無効化(補償)しながら戻ります。Outboxは、駅を出る際に必ず「次の駅への連絡帳」を書き残すことで、連絡漏れを防ぐ仕組みです。
Sagaパターンとは、複数のサービスにまたがるビジネスプロセスを、一連のローカルトランザクションとして定義する設計パターンです。各ステップが完了すると、次のステップをトリガーするイベントを発行します。もしあるステップで失敗した場合は、それまでに行われた変更を取り消す「補償トランザクション(Compensating Transaction)」を実行します。最新の分散システム設計では、このSagaの状態管理が不可欠です。
一方、Transactional Outboxパターンは、DBの更新とメッセージ発行のアトミック性を保証します。ビジネスデータと同じDB内の「Outboxテーブル」にイベント情報を書き込み、別プロセス(メッセージリレー)がそれを読み取ってメッセージブローカー(Kafka, RabbitMQなど)に送信します。これにより、DB更新には成功したがメッセージ送信に失敗した、という「不整合の状態」を物理的に回避します。
2. なぜ2PCではなくこの組み合わせが必要なのか
2フェーズコミット(2PC)は、参加するすべてのノードが応答するまでリソースをロックします。マイクロサービス環境では、ネットワーク遅延や一部サービスのダウンがシステム全体の停止(ブロッキング)を招くため、可用性が極端に低下します。CAP定理における「可用性(Availability)」を優先する場合、2PCは不適切です。
また、単純にDB更新後にHTTPリクエストやメッセージ送信を行うコードを書くと、ネットワーク障害やプロセスダウンによって「DBは更新されたが、後続サービスに通知がいかない」という現象が発生します。これは二重書き込み(Dual Write)問題と呼ばれ、これを解決するためにOutboxパターンによる「At-least-once(最低1回)」の配信保証が必要になります。
3. アーキテクチャの実装ステップ
注文処理(Order Service)から在庫引き当て(Inventory Service)を行う例で解説します。
ステップ 1. Outboxテーブルの設計
注文サービス側のDBに、ビジネスデータ(Orders)と対になるOutboxテーブルを作成します。
CREATE TABLE orders (
id UUID PRIMARY KEY,
status VARCHAR(20),
amount DECIMAL
);
CREATE TABLE outbox (
id UUID PRIMARY KEY,
aggregate_type VARCHAR(50),
aggregate_id UUID,
payload JSONB,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
ステップ 2. ローカルトランザクションの実装
注文データ挿入とイベント保存を一つのトランザクションで行います。これにより、どちらか一方が失敗することはありません。
BEGIN;
-- 注文データの作成
INSERT INTO orders (id, status, amount) VALUES ('uuid-123', 'PENDING', 5000);
-- Outboxへのイベント書き込み
INSERT INTO outbox (id, aggregate_type, aggregate_id, payload)
VALUES ('event-456', 'Order', 'uuid-123', '{"type": "OrderCreated", "amount": 5000}');
COMMIT;
ステップ 3. メッセージリレーと補償ロジック
別プロセス(CDC: Change Data Captureツールやポーリング処理)がOutboxテーブルを監視し、Kafka等へメッセージを飛ばします。受信側の在庫サービスは、在庫が足りない場合に「OrderRejected」イベントを発行します。注文サービスはこれを受けて、ステータスを「CANCELLED」に更新する補償トランザクションを実行します。
-- 補償トランザクションの例
UPDATE orders SET status = 'CANCELLED' WHERE id = 'uuid-123';
4. コレオグラフィ型 vs オーケストレーション型
Sagaパターンの実装には、大きく分けて2つのアプローチがあります。システムの複雑度に応じて選択が必要です。
| 比較基準 | コレオグラフィ (Choreography) | オーケストレーション (Orchestration) |
|---|---|---|
| 制御方式 | イベントの連鎖(分散型) | 中央集権(司令塔が存在) |
| 結合度 | 非常に低い | 中程度(司令塔が依存) |
| 複雑性 | ステップ増加で把握困難 | 状態管理が明確 |
| 適した規模 | シンプルなフロー | 複雑で長いビジネスプロセス |
サービス数が3つ以下の単純なフローであれば「コレオグラフィ型」が軽量ですが、4つ以上の複雑な依存関係がある場合は、TemporalやAWS Step Functionsのような「オーケストレーター」を導入するべきです。
5. 実装時の注意点とアンチパターン
⚠️ よくあるミス: 受信側の「べき等性(Idempotency)」を考慮せずに実装すると、同じイベントが2回届いた際にデータの二重計上や不整合が発生します。
Outboxパターンは「At-least-once(最低1回)」を保証するため、ネットワーク再試行などにより同じメッセージが重複して届く可能性があります。これを防ぐため、各サービスは処理済みのイベントIDを保存し、同じIDが来た場合は処理をスキップする仕組みが必要です。
エラー別の対処法
-- 受信側でのべき等性チェック
IF NOT EXISTS (SELECT 1 FROM processed_events WHERE event_id = 'event-456') THEN
-- ビジネスロジック実行
UPDATE inventory SET stock = stock - 1 WHERE item_id = 'item-999';
-- イベントIDを記録
INSERT INTO processed_events (event_id) VALUES ('event-456');
END IF;
6. 実戦での運用・監視チップス
Sagaパターンの導入はデバッグを困難にします。分散トレーシング(OpenTelemetry, Jaeger)を導入し、リクエストの相関ID(Correlation ID)をすべてのメッセージヘッダーに含めることが必須です。これにより、どの注文がどのイベントを生成し、どこで補償トランザクションが走ったかを可視化できます。
また、Outboxテーブルが肥大化しないよう、送信済みのレコードは定期的にアーカイブまたは削除するクリーンアップ戦略を設計初期から組み込むことが、数年後のパフォーマンス低下を防ぐ鍵となります。
📌 まとめ
- 分散トランザクションは強整合性ではなく、Sagaによる「結果整合性」を目指す。
- DB更新とメッセージ送信の不整合は、Transactional Outboxパターンで解決する。
- 受信側は必ず「べき等」な設計にし、重複メッセージによる副作用を防ぐ。
よくある質問
Q. Sagaパターンの補償トランザクション自体が失敗したらどうする?
A. リトライを繰り返すか、最終的には手動介入が必要なデッドレターキュー(DLQ)に送ります。
Q. Outboxの監視にポーリングを使うのは非効率では?
A. はい。大規模環境ではDBのバイナリログを読み取るDebeziumなどのCDCツールの活用を推奨します。
Q. ユーザーに完了をすぐ伝えたい場合は?
A. 受付完了のみを即座に返し、最終結果はWebSocketやプッシュ通知、または画面リロードで確認させます。
Post a Comment