イベント駆動のサービスは、いずれ次のようなコードを書くことになります。注文を保存し、それから OrderPlaced を発行する。テストでは動きます。本番では 2 行の間でプロセスがクラッシュし、イベントのない注文が残ります。あるいは発行は成功したのにデータベースのトランザクションがロールバックされ、存在しない注文をイベントが知らせてしまいます。
これが二重書き込み(デュアルライト)の問題で、トランザクショナルアウトボックスパターンはその標準的な解決策です。この記事では、問題、パターン、リレーを作る 2 つの方法、そして実際に機能するかどうかを左右する細部である順序、重複、後片付けを説明します。
二重書き込みの問題
データベースとメッセージブローカーは、共通のトランザクションを持たない 2 つのシステムです。両方に書き込むコードには、一方の書き込みが成功してもう一方が成功していない時間帯が必ずあります。
| 操作の順序 | 起こりうる問題 |
|---|---|
| コミットしてから発行 | コミット後にクラッシュすると、イベントは永久に失われる |
| 発行してからコミット | コミットが失敗し、コンシューマーは存在しなかった注文に基づいて動く |
| トランザクション内で発行 | ブローカーの呼び出しは成功しても、トランザクションはロールバックされうる |
再試行はこの時間帯をなくすのではなく、ずらすだけです。コミット後のフックから発行するといった順序の工夫も、時間帯を狭めるだけです。コミットとフックの間でクラッシュすれば、やはりイベントは失われます。データベースとブローカーをまたぐ分散トランザクションはめったに使えず、使えても遅いものです。
パターン
イベントを業務データと同じトランザクションでデータベースに書き込み、後でそこから発行します。
- サービスは注文の行とアウトボックスの行(イベント ID、集約 ID、種類、ペイロード)を 1 つのローカルトランザクションで挿入します。両方コミットされるか、どちらもされないかです。
- 別の リレー が新しいアウトボックスの行を読み取り、ブローカーに発行します。
- ブローカーが受領を確認したら、リレーは行を発行済みにするか削除します。
これで保証は単純になります。注文が存在するなら、そのイベントは少なくとも 1 回発行されます。Chris Richardson による このパターンの説明 も同じ構成です。
CREATE TABLE outbox (
id uuid PRIMARY KEY,
aggregate_id bigint NOT NULL,
type text NOT NULL,
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
リレーを作る 2 つの方法
ポーリングによる発行。 ループで未発行の行をバッチ単位で選び、発行して削除します。Postgres では SELECT ... FOR UPDATE SKIP LOCKED を使うと、複数のリレーインスタンスが互いをブロックせずに作業を分担できます。詳しくは ロック句のドキュメント を参照してください。作るのも理解するのも簡単です。代償はポーリングの遅延と負荷、そして順序に関する微妙な点です。ID の小さい行が ID の大きい行より後にコミットされることがあるため、ID 順に読むポーラーは、まだ見えていない行を飛ばしてしまうことがあります。
ログの追跡(変更データキャプチャ)。 CDC ツールがデータベースのトランザクションログを読み、コミットされたアウトボックスへの挿入をメッセージに変換します。Debezium の outbox event router はアウトボックステーブルに対してまさにこれを行い、各行を列の内容に基づいてトピックに振り分けます。イベントはコミット順に、低遅延で、ポーリングのクエリなしに出てきます。代償は、CDC のパイプラインを動かし運用することです。
| ポーリング | CDC | |
|---|---|---|
| 遅延 | ポーリング間隔 | コミット時刻に近い |
| 順序 | 注意が必要 | コミット順 |
| 構成要素 | ワーカー 1 つ | コネクターとログの設定 |
| 向いているケース | 中程度の量、小さなチーム | 大量、多数のアウトボックス |
少なくとも 1 回とは、重複があるということ
リレーは行を発行した後、発行済みにする前にクラッシュすることがあります。再起動すると、その行をもう一度発行します。これは避けられないので、コンシューマーは冪等でなければなりません。
- すべてのイベントは一意の ID(アウトボックスの行の ID)を持ちます。
- コンシューマーは処理済みの ID を、理想的には処理の結果と同じトランザクションで(インボックステーブルに)記録し、見たことのある ID は飛ばします。
- あるいは、注文 ID をキーにした upsert のように、処理の結果をもともと冪等にします。
順序
コンシューマーが気にするのは、たいてい集約ごとの順序です。同じ注文なら OrderPlaced は OrderCancelled より先でなければなりません。1 件の注文のイベントがすべて同じパーティションに入るよう、集約 ID をメッセージキーにし、コミット順に発行します(CDC はこれを行います。ポーラーなら、コミット時に割り当てた連番で並べるか、集約ごとのイベントを直列に処理します)。すべての注文にまたがるグローバルな順序はめったに必要なく、提供するにはコストがかかります。
後片付け
一度も空にされないアウトボックステーブルは際限なく大きくなり、ポーラーを遅くします。発行後に行を削除するか、短い監査期間だけ保持して古いパーティションを削除します。CDC なら、コネクターはテーブルではなくログから挿入を読むので、行は挿入した同じトランザクションの中ですぐに削除できます。
障害を順に確認する
クラッシュしうる各時点に対して設計を確認します。
- コミット前のクラッシュ。 注文もイベントも存在しません。クライアントはエラーを受け取り、再試行できます。
- コミット後、リレーが動く前のクラッシュ。 2 つの行は存在します。リレーは起動したときにイベントを発行します。
- 発行後、行を削除する前のリレーのクラッシュ。 再起動時にイベントがもう一度発行され、コンシューマーは ID で重複を捨てます。
- ブローカーの停止。 行はアウトボックスにたまります。サービスは注文を受け付け続け、ブローカーが戻るとリレーが追いつきます。未発行の最も古い行の経過時間にアラートを設定します。
どの場合でもデータベースが信頼できる唯一の情報源であり続けます。これこそがこのパターンの存在理由である性質です。
アーキテクチャ図
ダイアグラムの内容
- Order service
- Postgres: one transaction
- orders
- outbox
- Outbox relay
- order-events
- Shipping service
- Email service
- Processed events
- both rows
- new rows
- publish
- OrderPlaced
- seen it?
図の読み方は次のとおりです。
- 左: 注文サービスが 1 つの Postgres トランザクションの中で、orders と outbox のテーブルに 2 つの行を書き込みます。フレームがトランザクションの境界を示しています。
- 中央: アウトボックスリレーが、ポーリングまたは CDC で新しい行を読み取り、order-events トピックに発行します。
- 右: 配送サービスとメールサービスがイベントを消費します。メールサービスは処理する前に処理済みイベント ID のテーブルを確認し、これによって重複を吸収します。
- SQL ブロック はトランザクションを示しているので、ここで言う「アトミック」が何を意味するかを誰も推測する必要がありません。
図に描くべきこと
- トランザクションの境界。 2 つのテーブルをその中に描きます。それがパターンのすべてです。
- リレーとその種類。 ポーリングか CDC かで、遅延と順序が変わります。
- 配信のセマンティクス。 リレーには「少なくとも 1 回」、各コンシューマーには「冪等」と書きます。
- メッセージキー。 イベントのキーが集約 ID であることを書き添えます。
コンシューマー側は イベント駆動アーキテクチャの記事 で詳しく扱っています。複数サービスにまたがるワークフローの各ステップのどこでアウトボックスが必要になるかは Saga パターンの図 で、CDC が読む先行書き込みログについては データベースレプリケーションの記事 で説明しています。
Archboard で作る
- Google または GitHub でサインインし、図を作成します。
- C4 モデルパックから、注文サービス用とアウトボックスリレー用に コンテナ を 1 つずつ追加します。
- 基本パックから、データベースとトランザクションの名前を付けた フレーム を追加し、その中に orders と outbox のテーブルを表す ER 図パックの エンティティ ノードを 2 つ置きます。
- トピック用にデータパックから Kafka を、コンシューマー用に コンテナ ノードを、処理済みイベント ID 用に データベース を追加します。
- トランザクションの SQL を書いた基本パックの Markdown ブロックを追加します。
この例で使うダイアグラムノードは 10 個で、Free プランの範囲に収まります。
よくある質問
トランザクショナルアウトボックスはどんな問題を解決しますか? サービスがデータベースへの書き込みとメッセージの発行を 2 つの別々のステップで行うときに、イベントが失われたり、存在しないイベントが生まれたりする問題です。アウトボックスによって、イベントは同じデータベーストランザクションの一部になります。
アウトボックスパターンは exactly once ですか? いいえ。保証されるのは少なくとも 1 回の配信です。コンシューマーは重複に対処する必要があり、通常は処理済みのイベント ID を記録して対処します。
ポーリングと CDC、どちらのリレーを使うべきですか? ポーリングのほうが単純で、中程度の量なら十分です。CDC は遅延が小さくコミット順の配信ができるので、量が多い場合や多くのサービスがアウトボックスを使う場合には導入する価値があります。
イベントソーシングを使う場合もアウトボックスは必要ですか? コンシューマーが購読する情報源がイベントストアそのものであれば不要です。サービスが状態をテーブルに保存し、かつイベントも発行しなければならない場合は常に必要です。
アウトボックステーブルはどれくらい大きくなりますか? 放っておけば、いくらでも大きくなります。発行済みの行はすぐに削除するか、テーブルを時間でパーティション分割して古いパーティションを削除します。