アーキテクチャ
スケーラブルなイベント駆動アーキテクチャの構築
イベント駆動システムはよくスケールする一方で、特定の予測可能な形で失敗します。二重書き込みによるイベントの消失、重複、順序の入れ替わり、スキーマのドリフトです。本ガイドでは、それぞれに対処する仕組みと、同期呼び出しのほうが優れた設計となるケースを扱います。
· 5分で読めます · Starone エンジニアリング
イベント駆動アーキテクチャでは、サービスは何かが起きたことを記録し、その事実を発行します。他のサービスはそれぞれのタイミングで反応します。プロデューサーは誰が購読しているかを知らず、その処理を待つこともありません。この疎結合こそがスケーラビリティの源です。プロデューサーを変更せずにコンシューマーを追加し、スケールさせ、停止させることができ、負荷の急増は同期呼び出しの連鎖を伝って波及するのではなく、ログが吸収します。
その代償として、正しさはもはや単一のトランザクションからは得られません。配信保証、冪等性、順序の明示的な取り扱いから組み立てる必要があります。私たちの見解では、イベント駆動設計はこれらの性質を意図して作り込む場合には価値があり、即時整合性を前提としたままのシステムにブローカーを足すだけの場合には負債になります。
イベント、コマンド、そしてペイロードに何を入れるか
イベントとは、プロデューサーが所有する対象について過去形で述べた事実です。OrderPlacedやPaymentCapturedがその例です。コマンドとは、別のサービスに動作を求める依頼です。両者を混同するとSendWelcomeEmailのようなイベントが生まれ、プロデューサーが特定のコンシューマーに結合し、発行することの利点が失われます。
ペイロードの設計はトレードオフです。識別子だけを運ぶ薄い通知では、すべてのコンシューマーが詳細を取りに呼び戻すことになり、同期的な結合と負荷が再現されます。Event-Carried State Transferは関連データを含めることで、コンシューマーが独自のリードモデルを構築できるようにしますが、メッセージが大きくなり、維持すべき契約の範囲も広がります。私たちは原則として、コンシューマーが動作するのに必要な状態を載せ、プロデューサーの内部テーブル構造は決して載せません。
イベントがいったん発行されると、未知のコンシューマーがそれに依存し、古いイベントはログに残り続けます。したがってスキーマは公開された契約です。Avro、Protobuf、JSON Schemaを用い、ビルドパイプラインで互換性チェックを行うスキーマレジストリがあれば、プロデューサーが読み手を壊す変更をリリースすることを防げます。安全な変更は追加型のもの、つまりデフォルト値を持つ新しい任意フィールドです。フィールドの名前変更や削除、意味の変更には、新しいイベント型と、両方を発行する移行期間が必要です。
最初に二重書き込みの問題を解決する
データベースにコミットしてからブローカーに発行するサービスは、共有トランザクションのない二つの書き込みを行っています。その間でプロセスが落ちれば、状態は変わったのにイベントが送られない、あるいはロールバックされた変更に対してイベントが送られる、という事態になります。これは、イベント駆動システムで気づかれない不整合が生じる最も一般的な原因です。
トランザクショナルアウトボックスパターンはこれを解消します。サービスは、業務上の変更とアウトボックスの行を同一のデータベーストランザクションで書き込みます。別のリレーがアウトボックスの行をブローカーに発行します。その方法は、ポーリングか、Debeziumなどのツールによるデータベースログからの変更データキャプチャです。これにより、変更がコミットされた場合に、かつその場合に限り、イベントが少なくとも一回発行されます。
at-least-once配信を前提にする
ブローカーも、リレーも、コンシューマーもリトライするため、重複は通常のことです。Kafkaのexactly-onceセマンティクスが適用されるのは、Kafkaトランザクション内の読み書きに対してです。外部APIを呼び出したり別のデータベースに書き込んだりするコンシューマーには及びません。頼りになる方法は、コンシューマーを冪等にすることです。
- すべてのイベントに一意の識別子を付与し、処理済みの識別子をコンシューマーの状態変更と同じトランザクションで記録します。
- インクリメントよりも、ステータスの設定やキーによるupsertといった、本質的に冪等な操作を選びます。
- 集約にバージョンまたはシーケンス番号を持たせ、古いイベントを検知して無視できるようにします。
- コンシューマーが呼び出す外部システムにも冪等性キーを引き渡します。
順序はキー単位であり、グローバルではない
Kafkaのようなパーティション化されたログが順序を保証するのは、パーティション内に限られます。同じエンティティのイベントは同じパーティションキー、通常は集約の識別子を共有しなければなりません。そうでなければ、コンシューマーがOrderPlacedより先にOrderCancelledを受け取る可能性があります。グローバルな順序には単一パーティションが必要で、スループットはコンシューマー一つ分が上限になります。
パーティションキーは負荷の分散の仕方も決めます。非常に大きなテナントが一つあるなど、分布の偏ったキーはホットパーティションを生み、他のパーティションが空いているのにそこだけが遅延します。リトライは順序をさらに複雑にします。失敗したメッセージを脇に置き、同じキーの後続メッセージの処理を続ければ、コンシューマー自身が順序を入れ替えたことになります。失敗時にパーティションをブロックするのか、順序の入れ替わりを許容してバージョンチェックに頼るのかを、トピックごとに決めます。
障害処理とバックプレッシャー
コンシューマーには、処理できないメッセージに対する方針が必要であり、それは最初の一件が届く前に決めておきます。
健全性を示す最も重要なシグナルはコンシューマーラグです。各コンシューマーグループがログの最新位置からどれだけ遅れているかを、メッセージ数と時間で計測します。ラグが増え続けるなら、コンシューマーのリソースが不足しているか、処理が停滞しています。コンシューマーはパーティション数を上限としてラグに基づいてスケールさせ、ヘッダーで相関IDを伝播させてイベントをエンドツーエンドでトレースします。
- 一時的な障害には、無限ループではなく、バックオフ付きで回数に上限のあるリトライを適用します。
- 形式が不正なメッセージや恒久的に失敗するメッセージ、いわゆるポイズンメッセージは、診断と再処理に十分なコンテキストを添えてデッドレタートピックに移します。そうしなければ、そのパーティションを無期限にブロックします。
- デッドレタートピックにはアラートと担当者が必要です。誰も監視していないデッドレタートピックは、手間を一つ増やしただけのデータ損失です。
- 下流の依存先が遅い場合は消費を一時停止し、処理待ちをメモリに積み上げるのではなく、ログにバックログを保持させます。
サービスをまたぐワークフロー
複数のサービスにまたがる業務プロセスでは、設計の動機であった独立性を手放さない限り、分散トランザクションは使えません。Sagaパターンはこれを、ローカルトランザクションの連なりに置き換えます。それぞれのトランザクションは、失敗時に自身を取り消す補償アクションを持ちます。サービスが互いのイベントに反応するコレオグラフィは、短いフローに適しています。3〜4ステップを超えるとプロセスの全体像が見えにくくなり、ステートマシンを明示的に保持するオーケストレーターのほうが、理解も、テストも、運用もしやすくなります。
同期のままにすべき場合
カードの検証や購入時点での在庫確認のように、呼び出し元が先に進む前に答えを必要とする場合、イベントは適切な手段ではありません。また、一つのチームと一つのデータベースで成り立つ小さなシステムでも費用対効果が低くなります。そこでは、トランザクションのほうがはるかに少ない仕組みで、より強い保証を提供します。
結果整合性は技術上の判断であると同時に、プロダクト上の判断でもあります。書き込み直後に古いデータが読まれることをユーザーが許容できないなら、その経路を同期のままにするか、処理中の状態を反映するようにインターフェースを設計します。よく作られたシステムの大半は混合型です。ユーザーが待っているエッジでは同期、処理を後回しにできるその背後では非同期とします。
