Kafka のコンシューマーを増やしたのに、なぜ処理速度は頭打ちになるのか?
Amazon MSK や Confluent Cloud を触ったことがある、あるいは非同期処理を Kafka に置き換える提案書を読んだことがあるかもしれない。「詰まっているなら consumer を増やせばいい」と思ってスケールアウトしたのに、ある地点から先はまったく速くならなかった経験はないだろうか。原因は Kafka の分担単位そのものに、最初から上限が埋め込まれていることにある。
- 1 つのパーティションは同じコンシューマーグループの中で同時に 1 つのコンシューマーにしか割り当てられないため、並列度の天井はパーティション数そのもので決まる。
- パーティション数は後から増やせても減らせない仕様で、増やす場合もキー基準の振り分けが変わるという代償を伴う。
- コンシューマーグループのメンバーが増減するとリバランスが走り、既定のオートコミット間隔(5秒)の隙間でメッセージの重複処理が起こりうる。
パーティションが並列処理の天井を決める
Kafka のトピックは 1 つの受け口に見えるが、実体は複数の独立したログに分割されている。この分割単位をパーティション(partition)と呼び、各パーティションは書き込まれた順にメッセージを保持する。Kafka が順序について保証しているのはこのパーティション内だけで、トピック全体をまたいだ順序は保証されない。
コンシューマーグループ(consumer group)では、同じグループ名を名乗るコンシューマーの間でパーティションが分担される。1 つのパーティションは、あるグループの中では同時にちょうど 1 つのコンシューマーにしか割り当てられない。この制約があるからこそ「あるコンシューマーが受け取った順序 = そのパーティションに書き込まれた順序」という保証が壊れずに済む。
ここまで分かると、「コンシューマーを増やせば増やすほど速くなる」という直感がなぜ崩れるかが見えてくる。パーティションが 3 枚しかないトピックに 4 人目のコンシューマーを同じグループで足しても、担当できるパーティションはもう残っていない。4 人目はただ待機するだけになる。
トピック demo(パーティション数 3) ┌────┐ ┌────┐ ┌────┐ │ P0 │ │ P1 │ │ P2 │ └─┬──┘ └─┬──┘ └─┬──┘ ▼ ▼ ▼ consumer-A consumer-B consumer-C consumer-D は担当なし(待機)
パーティション数はなぜ後から減らせないのか
パーティション数は運用中に増やせるが、減らすことはできない。Kafka の公式運用ドキュメントは「Kafka does not currently support reducing the number of partitions for a topic」と明記している。減らせない理由は単純で、パーティションはディスク上に実体を持つログであり、あるパーティションを削除すればそこに書かれたメッセージそのものが失われるからだ。
増やす方は許可されているが、無条件に安全というわけでもない。キーを指定して送るメッセージは既定で hash(key) % パーティション数 に基づいて振り分けられるため、パーティション数を変えると新しく届くメッセージのキーと担当パーティションの対応がずれる。公式ドキュメントも、この振り分けに依存しているコンシューマーがいる場合はパーティション追加がその前提を崩しうると注意している。
この非対称性への現実的な対処は、最初から余裕を持たせておくことだ。Confluent の運用ガイドは、目標スループットを t、1 パーティションあたりの実測スループットを p として max(t/p, t/c) というパーティション数の目安式を示し、将来の増加分を見込んで意図的に多めに切ることを勧めている。あとから減らせない以上、少なすぎるより多すぎる方が取り返しがつく。
Kafka のパーティションと担当の関係は、窓口が複数ある郵便局に近い。来た客(メッセージ)は窓口ごとに整理券(オフセット)を渡され、同じ窓口に並んだ客同士の順番は守られるが、別の窓口の客との先後関係までは保証されない。窓口は途中から増設できるが、すでに配り終えた整理券の対応まで作り直すことはできず、減らすとなればその窓口に並んでいた客の記録ごと消える。局員(コンシューマー)は 1 人で複数窓口を掛け持ちできるが、1 つの窓口を複数人で同時に回すことはできない — だから窓口の数より局員を増やしても、余った局員は突っ立っているだけになる。
リバランスで何が起きているのか
コンシューマーグループのメンバーが増減すると、パーティションの担当を割り当て直す処理が走る。これをリバランス(rebalance)と呼ぶ。ブローカーの 1 台がそのグループの「グループコーディネーター」を務め、メンバーのハートビートを監視し、新しいコンシューマーの参加やハートビート途絶による離脱を検知した時にリバランスを引き起こす。
「1 人だけ増減したのなら、影響もその 1 人分で済むはずだ」と思うかもしれない。しかし従来の Eager(クラシック)方式のリバランスでは、そうはならない。全コンシューマーが一旦すべての担当パーティションを手放し、コーディネーターが全員分の割り当てを一から計算し直してから配り直す。1 人の増減であっても、その間はグループ全体の処理が止まる。
この全停止を避けるために作られたのが Cooperative(協調的)方式のリバランスだ。全員を巻き込む同期バリアを外し、変更が必要な担当分だけを段階的に付け替える。既存の割り当てをできるだけ保ったまま、段階的な再割り当てを可能にする設計だと Confluent のドキュメントは説明している。
オフセットコミットのタイミングが重複を生む
コンシューマーがどこまで読んだかという記録が、オフセット(offset)だ。この記録は __consumer_offsets という Kafka 自身の内部トピックに書き込まれており、コンシューマーが再起動したりリバランスでパーティションの担当が移ったりしたときは、この記録の続きから読み始める。
既定では enable.auto.commit が有効になっており、auto.commit.interval.ms の既定値である 5000 ミリ秒(5 秒)ごとにオフセットが自動でコミットされる。つまりメッセージを処理してから実際にコミットされるまで、最大で 5 秒近いタイムラグが生じる。
このタイムラグの間にリバランスやクラッシュが起きると、直前に処理済みだがまだコミットされていないメッセージが残る。次にそのパーティションを担当することになったコンシューマー(自分自身の再起動後も含む)は、最後にコミットされたオフセットから読み直すため、すでに処理したはずのメッセージを再び受け取る。Kafka が「少なくとも 1 回は届ける」設計になっているのは、この重複を完全にはなくせないことの裏返しでもある。
Kafka を直接運用していなくても、Amazon MSK や Confluent Cloud のマネージドクラスタとして触れる機会は増えている。「コンシューマーの CPU は余っているのに consumer lag が減らない」という相談の多くは、パーティション数がコンシューマー数より少ないという単純な天井に突き当たっている。逆に「デプロイのたびに通知が 2 重に届く」といった不具合は、リバランスの瞬間にコミット前の処理が再実行された結果であることが多い。
Kafka を Docker で 1 台起動し、3 パーティションのトピックにコンシューマーを 1 つだけ接続して --describe の出力を読む。1 つのコンシューマーが複数パーティションを担当できる一方、1 つのパーティションを複数のコンシューマーで分担することはできない、という非対称性を確認する。
docker run -d --name broker -p 9092:9092 apache/kafka:4.1.2 docker exec --workdir /opt/kafka/bin -it broker \ ./kafka-topics.sh --bootstrap-server localhost:9092 \ --create --topic demo --partitions 3 echo -e "a\nb\nc" | docker exec -i --workdir /opt/kafka/bin broker \ ./kafka-console-producer.sh --bootstrap-server localhost:9092 --topic demo docker exec --workdir /opt/kafka/bin -it broker \ ./kafka-console-consumer.sh --bootstrap-server localhost:9092 \ --topic demo --group demo-group --from-beginning --timeout-ms 5000 docker exec --workdir /opt/kafka/bin -it broker \ ./kafka-consumer-groups.sh --bootstrap-server localhost:9092 \ --describe --group demo-group
--describe の出力には PARTITION が 0・1・2 の 3 行が並ぶが、CONSUMER-ID 列はどの行も同じ 1 つの値になっているはずだ。コンシューマーが 1 つしかいない今の状態では、その 1 人が 3 パーティション全部を担当している。ここで 2 つ目のコンシューマーを同じ demo-group で起動すると CONSUMER-ID は 2 種類に分かれるが、3 パーティションを 2 人で分けるだけなので、3 人目を足しても割り当てられる担当は残らない。
- コンシューマーの数を増やせば増やすほど処理は速くなる — 同時に稼働できるコンシューマー数の実質的な上限はパーティション数で決まり、それを超えた分は担当するパーティションがなく待機するだけになる。
- Kafka はトピック全体でメッセージの順序を保証する — 順序が保証されるのは同一パーティション内だけで、パーティションをまたいだ順序は保証されない。
- オフセットをコミットしていれば同じメッセージが 2 回処理されることはない — 既定のオートコミットは 5 秒間隔でしか走らないため、処理済みだが未コミットの間にリバランスや再起動が起きると、次の担当者は直前のコミット地点から再開し同じメッセージを再処理しうる。
- パーティション
- トピックを分割した、書き込み順が保証される単位の独立したログ。
- コンシューマーグループ
- 同じグループ名を名乗り、パーティションを分担して処理するコンシューマーの集合。
- オフセット
- パーティション内でメッセージ 1 件ごとに振られる、読み取り位置を示す連番。
- リバランス
- グループのメンバー増減に応じてパーティションの担当を再割り当てする処理。
- グループコーディネーター
- メンバーシップとハートビートを管理し、リバランスの引き金を引くブローカー上の役割。
- Basic Kafka Operations | Apache Kafka 公式ドキュメント — パーティション数を減らせないこと、追加時のキー分割への影響を明記した一次情報。
- Consumer Configs | Apache Kafka 公式ドキュメント —
enable.auto.commit・auto.commit.interval.msの既定値の一次情報。 - Kafka Consumer for Confluent Platform | Confluent Documentation — Eager 方式と Cooperative 方式のリバランスの違いを解説。
- Choose and Change the Partition Count in Kafka | Confluent Documentation — パーティション数の目安式と、あらかじめ多めに切る運用指針。