Transactional Outbox パターンとは?データベースとメッセージングの一貫性を保つ方法
1. はじめに
なぜこの技術を学ぶのか?
マイクロサービスアーキテクチャでは、複数のシステム間でのデータの一貫性を保つことが課題になります。特に、データベースへの書き込みと外部のキューやイベントシステムへの送信を同時に行う場合、トランザクションの管理が重要になります。Transactional Outbox はこの問題を解決するための設計パターンです。
知らなかったことで困ったこと
あるプロジェクトで、データベースの更新後に Kafka にイベントを送信する処理を実装した際、DB の書き込みは成功したものの、ネットワーク障害により Kafka への送信が失敗しました。このような場合、DB には変更が適用されているが、他のサービスには伝わっておらず、データの整合性が崩れる問題が発生しました。
Transactional Outbox を学ぶメリット
- DB とメッセージングシステムのトランザクションを統一的に管理できる
- データの一貫性を維持しながら、非同期処理を安全に行える
- 分散システムにおけるデータの矛盾を減らせる
2. Transactional Outbox とは?
送信箱(Outbox)の概念
Transactional Outbox は、通常のメールの「送信箱(Outbox)」のように、一時的に送信すべきメッセージを保管し、それを後で処理する仕組みです。これは、データベースへの書き込みとイベントの送信をアトミックに保証できない という課題を解決するために導入されます。
2つのシステムに書き込む際のトランザクションの課題
- DB とイベントキューの更新を同時に行うと、アトミックな処理を保証できない
- 外部サービスやキューへの通信は失敗する可能性があるため、データの整合性が崩れるリスクがある
例: 注文確定処理での課題
graph TD; U[ユーザー] -->|注文確定| A[アプリケーション]; A -->|DBに注文を保存| B[データベース]; B -->|在庫システムに通知| C[イベントキュー]; C -->|在庫減少処理| D[在庫システム]; B -.->|通知失敗| C;
- 注文情報を DB に保存 → 在庫システムに通知
- イベント送信に失敗すると、在庫システムが注文を認識できず整合性が崩れる
Transactional Outbox の仕組みとトランザクションの課題の解決策
graph TD; subgraph トランザクション A[アプリケーション] -->|DB 書き込み| B[データベース]; A -->|Outboxテーブル更新| O[Outboxテーブル]; end B -->|トランザクションコミット| O; O -->|Outbox Processor による処理| P[Outbox Processor]; P -->|イベント送信| C[イベントキュー]; C -->|イベント処理| D[イベント処理サービス];
ポイントは次の3点です。
- Outbox Table を活用し、DB の書き込みとイベント記録を同じトランザクションで行う
- Outbox Processor が定期的に Outbox テーブルを監視し、未処理のイベントを取得する
- イベントの送信に失敗しても、リトライ可能な仕組みを構築できる
OutBox Transactional pattern の特徴
アプリケーションがデータを更新
- 通常のデータテーブル(例:
orders
)にデータを保存する。 - 同時に Outbox テーブルにもイベントを記録する。
- 通常のデータテーブル(例:
トランザクションのコミット
- アプリケーションのデータと Outbox のデータを同じトランザクションで確定させる。
Outbox Processor がポーリング(または CDC を利用)
- 一定間隔で Outbox テーブルをスキャンし、未処理のイベントを取得する。
メッセージングシステムにイベントを送信
- SQS/Kafka などのメッセージングシステムに送信し、他のシステムと非同期に連携する。
処理済みのイベントを削除
- イベントの送信が成功したら、Outbox テーブルから削除するか、
processed_at
フィールドを更新する。
- イベントの送信が成功したら、Outbox テーブルから削除するか、
3. どのように実装するのか?
データベースの設計(Outbox テーブルのスキーマ)
CREATE TABLE outbox (
id SERIAL PRIMARY KEY,
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
status TEXT CHECK (status IN ('PENDING', 'PROCESSED')) NOT NULL DEFAULT 'PENDING',
created_at TIMESTAMP DEFAULT NOW()
);
イベント通知の仕組み(ポーリング vs. CDC)
Outbox Processor は、未処理のイベントを取得し、メッセージングシステムに送信する仕組みが必要です。 DB の変更をリアルタイムに検知する CDC(Change Data Capture)を利用することで、ポーリングによるDBへの負荷を軽減できます。
- ポーリング: 定期的に
PENDING
のレコードを取得して処理。 - CDC(Change Data Capture): データベースの変更をリアルタイムで検知して処理。
メッセージの送信(SQS, Kafka, SNS との連携)
Outbox Processor は、未処理のイベントを取得し、メッセージングシステムに送信する必要があります。
同一メッセージが複数回送信される可能性があるため、メッセージングシステムのSubscriber側で冪等性を担保する必要があります。
メッセージングサービスの中には、冪等性を担保するための機能を提供しているものもあります。
例えば、Kafka の enable.idempotence=true
や SQS の MessageDeduplicationId
を利用することで、重複メッセージを排除できます。
4. 運用上の課題と解決策
4.1. Outbox テーブルの肥大化 → 定期削除・アーカイブ戦略
Outbox テーブルは、送信されたイベントの記録を保持するため、時間が経つにつれてデータ量が増加し、クエリ性能が低下する可能性があります。 解決策として、定期的にデータを削除するなどの対策が必要です。
- 定期削除ジョブを実装する
PROCESSED
状態のデータを一定期間(例: 7日間)保持した後に削除する。- 例:
DELETE FROM outbox WHERE status = 'PROCESSED' AND created_at < NOW() - INTERVAL '7 days';
- アーカイブテーブルに移動する
PROCESSED
のデータを別のテーブルやS3に保存し、本番テーブルのサイズを抑制。
- パーティションテーブルを活用する
- 月単位のパーティションに分割し、古いパーティションを削除。
4.2. イベントの二重送信 → Idempotency(冪等性)の担保
イベントが送信される際に、ネットワーク障害やプロセスのクラッシュなどにより、同じイベントが複数回処理される可能性があります。 イベントの購読側で冪等性を担保するのが良いですが、メッセージングシステム側で冪等性を担保することもできます。
- イベントに一意な識別子(
event_id
)を付与する- 受信側が
event_id
をチェックし、既に処理済みのイベントであれば無視する。
- 受信側が
- データベースにイベント処理ログを記録
- 処理済みイベントの
event_id
を保存し、重複処理を防ぐ。
- 処理済みイベントの
- Kafka の
enable.idempotence=true
を利用- Kafka ではトランザクショナルプロデューサーを利用し、同じメッセージが重複送信されないようにする。
4.3. ポーリングの負荷 → CDC(Change Data Capture)との比較
ポーリング(定期的にDBをチェックする方式)は、頻繁にクエリを実行するため、データベースの負荷が増加します。 以下の対策を検討し、負荷を軽減することが重要です。
- ポーリング間隔を調整する
- 初期値は 1秒ごとにポーリングし、データ量に応じて適切な間隔を設定。
- CDC(Change Data Capture)の活用
- RDBMS の場合、Debezium などの CDC ツールを使用し、変更をリアルタイムで検知。
- DynamoDB の場合は DynamoDB Streams を利用し、変更をトリガーに処理を実行。
4.4. 送信失敗時のリトライ → DLQ(デッドレターキュー)の活用
ネットワークエラーや一時的な障害によってイベントの送信に失敗した場合、適切なリトライ戦略が必要です。 リトライが成功しない場合は、DLQ(デッドレターキュー)に送信し、手動で処理するなどの対策が必要です。
- 指数バックオフリトライ
- 失敗時に 1秒 → 2秒 → 4秒 と徐々にリトライ間隔を長くする。
- DLQ(デッドレターキュー)の導入
- 一定回数のリトライ後も失敗する場合、SQS の DLQ や Kafka の DLT に送信し、手動処理を行う。
- リトライ可能なエラーと不可逆エラーを区別する
- ネットワーク障害ならリトライ、データ不整合なら DLQ に送るなどの戦略を設計。
4.5. 送信遅延の問題 → ポーリング間隔の最適化
ポーリング方式では、DBの状態を定期的にチェックするため、リアルタイム性が低下する可能性があります。 リアルタイム性が重要な場合は、CDC を活用するなど、適切な方法を選択することが重要です。
- リアルタイム性が必要な場合は CDC を検討
- RDBMS の binlog や DynamoDB Streams を活用。
- ポーリング間隔を短縮するが、負荷を監視する
- DB の負荷が増えない範囲で最適なポーリング間隔を設定。
5. DynamoDB を使った場合の課題
CDC(Change Data Capture)を利用する場合、RDBMS と比較して DynamoDB は変更をリアルタイムで検知しやすいという利点があります。 しかし、DynamoDB を利用する場合、ホットキー問題やシャード数の変動など、特有の課題が発生する可能性があります。
DynamoDB Streams でのイベント通知の違い
DynamoDB では、トランザクション内でイベントを Outbox テーブルに保存する代わりに、DynamoDB Streams を利用して変更を検知し、イベントを処理することが可能です。
- メリット
- DB の書き込みとイベント通知の仕組みが統合される。
- 別途 Outbox テーブルを管理する必要がない。
- デメリット
- Streams のデータ保持期間は最大 24 時間。
- シャード数が動的に変更されるため、負荷が高まると処理が遅延する可能性がある。
ホットキー問題(特定のキーに負荷が集中する場合)
DynamoDB はパーティションキーを元にデータを分散するが、特定のキーに書き込みが集中するとホットキー問題が発生し、スループットが制限される。 パーティションキーの設計に注意することで、ホットキー問題を回避することができます。
例: orderId
ではなく orderId#shard-0
, orderId#shard-1
のようにランダムな識別子を追加する。
シャード数の増減とスケーリングの仕組み
DynamoDB Streams のシャード数は、テーブルのパーティション数に基づいて決定される。
- パーティション数が増えるとシャード数も増加
- 一定期間データの更新が少ない場合、シャード数は減少する
- 大量の変更が発生すると、シャード数が増え処理能力が向上する
対策
- シャード数の変動を考慮し、Lambda の並列実行数を調整
- シャードの分割が適切に行われるように、アクセスパターンを分散
6. まとめ
- Transactional Outbox によって、データベースとメッセージングの一貫性を保つことができる
- 分散システムではデータの整合性を保証する仕組みが重要