バックエンド
Kafkaとイベント駆動アーキテクチャ
Kafkaはパーティション分割され、レプリケートされたログであり、その保証の大半も、意外な振る舞いの大半も、この一つの事実から導かれます。本ガイドでは、順序、コンシューマーグループ、配信セマンティクス、アウトボックスパターン、スキーマ、保持期間、そしてよりシンプルなキューを選ぶべき場合を取り上げます。
· 5分で読めます · Starone エンジニアリング
Kafkaはメッセージキューとして紹介されることが多く、そのためチームはキューの振る舞いを期待し、後で驚くことになります。実際には分散コミットログに近いものです。プロデューサーはパーティションにレコードを追記し、レコードは誰かが読んだかどうかにかかわらず設定された期間そこに残り、各コンシューマーが自身の位置を管理します。
私たちがKafkaを使うのは、このモデルこそがシステムに必要な場合です。同じストリームを複数の独立したコンシューマーが読むこと、履歴をリプレイできること、エンティティ単位で順序を保ちながら大量に処理できることが求められる場合です。そうした要件がないのであれば、採用しないよう提言します。以下のセクションでは、イベント駆動の設計が本番環境で持ちこたえるかどうかを左右する仕組みを説明します。
パーティションが順序を決める
Kafkaが順序を保証するのはパーティション内だけで、それ以外では保証しません。レコードキーがパーティションを決めるため、キーを選ぶことは順序付けの単位を選ぶことです。口座識別子をキーにすれば、ある口座のすべてのイベントは生成された順に消費され、異なる口座のイベントは並列に処理されます。
ここから2つの帰結が導かれます。1つは、非常に大きな1テナントのような偏ったキーがホットパーティションを生み、単一のコンシューマーがそれを単独で処理しなければならなくなることです。もう1つは、後からパーティション数を増やすとキーが対応するパーティションが変わり、既存のキーの順序が変更の前後で崩れることです。私たちは成長を見込んでパーティション数を決め、増加は設定変更ではなく移行作業として扱います。
コンシューマーグループとリバランス
同じグループのコンシューマーはトピックのパーティションを分け合い、各パーティションは一度に1つのメンバーだけが担当します。したがって並列度はパーティション数が上限となり、余分なコンシューマーはアイドルのままです。異なるグループは同じトピックを独立して読み取ります。これによって、新しいサービスはプロデューサーに手を入れることなく既存のストリームを消費できます。
メンバーが参加、離脱、あるいはポーリングを停止すると、グループはリバランスを行い、パーティションが移動します。バッチの処理にmax.poll.interval.msより長くかかったコンシューマーは停止したとみなされて除外され、そのパーティションは再割り当てされ、未コミットの処理は別の場所でもう一度実行されます。そのため、遅いハンドラーは障害のように見えるリバランスループを引き起こします。協調的なインクリメンタルリバランスと静的グループメンバーシップはデプロイ時の混乱を抑え、小さなバッチと上限のある処理時間はそもそもループの発生を防ぎます。
at-least-once配信と冪等なコンシューマー
実用上の標準はat-least-onceです。コンシューマーはレコードを処理してからオフセットをコミットします。この2つの間でクラッシュすると、再起動後にそのレコードはもう一度処理されます。先にコミットすればat-most-onceになりますが、クラッシュ時にレコードを失うため、業務イベントで許容できることはまれです。
Kafkaの冪等プロデューサーは、プロデューサーのリトライによる重複を取り除き、トランザクションは消費、変換、生成のサイクルをKafka内でアトミックにします。どちらもKafkaの外にある副作用には及びません。ハンドラーがデータベースに書き込む、APIを呼び出す、メールを送信するといった処理を行った時点で重複は起こりうるため、コンシューマーは冪等でなければなりません。
- 処理済みのイベント識別子を状態変更と同じデータベーストランザクションで記録し、すでに見た識別子はスキップします。
- インクリメントよりも、ステータスの設定やキーによるupsertのように、本質的に冪等な操作を優先します。
- イベントにエンティティごとのバージョンまたはシーケンスを持たせ、古いイベントやリプレイされたイベントを識別して破棄できるようにします。
- 繰り返し失敗するレコードはエラーのコンテキストとともにデッドレタートピックへ送り、1件のポイズンレコードがパーティションを無期限にブロックしないようにします。
アウトボックスパターン
データベースを更新してからイベントを発行するサービスは、共有トランザクションのない2つの書き込みを行っています。その間でプロセスが停止すると、状態は変わったのにイベントが送られない、あるいはロールバックされた変更についてイベントが送られる、という事態になります。この二重書き込み問題は、イベント駆動システムにおいて気づかれにくい不整合のもっとも一般的な原因です。
アウトボックスパターンはこの問題を解消します。サービスは、業務上の変更と同じローカルトランザクションの中で、イベントをアウトボックステーブルに挿入します。その後、別のリレーがアウトボックスの行をKafkaに発行します。方法はポーリングか、Debeziumのようなツールを使ってデータベースの先行書き込みログ(WAL)から変更データキャプチャを行うかのいずれかです。アウトボックスからの配信はat-least-onceですが、コンシューマーはすでに冪等であるため問題ありません。代償は、追加のテーブル、運用すべきリレー、そしてわずかな発行遅延です。
スキーマの進化
イベントは、プロデューサーが把握していないかもしれないコンシューマーとの公開された契約であり、それを最初に書いたコードより長く生き続けます。私たちはイベントをAvroやProtobufのようなスキーマ言語で定義し、スキーマレジストリに登録し、CIで互換性を強制します。これにより、互換性のない変更は実行時にコンシューマーを壊す代わりにビルドを失敗させます。
運用上のルールは単純です。フィールドはデフォルト値を持つオプショナルとして追加し、フィールドを別の用途に流用したり型を変更したりせず、いずれかのコンシューマーが読んでいる間はフィールドを削除しません。互換性を保てない変更は新しいトピックまたは新しいイベント型として扱い、移行期間中は両方を発行します。
保持期間とコンパクション
保持期間はトピックごとに時間またはサイズで設定し、コンシューマーがどこまで遡ってリプレイできるかを決めます。保持期間のウィンドウを超えて遅れたコンシューマーは、エラーも出ないままデータを失います。そのため、保持期間に対するコンシューマーラグは、私たちが必須と考えるアラートの一つです。
ログコンパクションは別のポリシーです。Kafkaは各キーについて少なくとも最新のレコードを保持し、古いものを破棄します。値がnullのトゥームストーンは削除を示します。コンパクションされたトピックは、設定情報や顧客ごとの最新プロファイルのような現在の状態を表すデータに向いており、新しいコンシューマーが全履歴をリプレイせずに状態を再構築できます。すべての発生事象が意味を持つイベントストリームには向きません。
Kafkaが過剰な場合
Kafkaを導入すると、運用すべきクラスターか費用のかかるマネージドサービスが加わり、さらにチームのすべてのエンジニアが理解しなければならない一連の概念が増えます。複数のコンシューマー、リプレイ、継続的な大量のデータがあるなら、それは正当化されます。バックグラウンドジョブを実行したいだけの単一のサービスでは正当化されません。
- コンシューマーの種類が1つのワークキューであれば、SQSのようなマネージドキューやRabbitMQのようなブローカーが、メッセージ単位の確認応答、遅延、リトライをはるかに少ない仕組みで提供します。
- 1つのサービス内の控えめな処理量であれば、SELECT FOR UPDATE SKIP LOCKEDで読み取るPostgreSQLのテーブルが業務データと同じトランザクションで扱え、新たなインフラを必要としません。
- 即時の応答を必要とする2つのサービス間のリクエストとレスポンスであれば、トピックのペアよりも同期呼び出しのほうが明快です。
