メッセージキュー(Kafka)── 耐久性とスループットの両立
扱うCS概念:追記専用ログ(Append-Only Log)、ページキャッシュ、ゼロコピー、パーティション、コンシューマーグループ

この章で何ができるようになるか:Kafka が「高スループット・耐久性・スケールアウト」を同時に実現できる理由を説明できるようになる。従来の MQ(RabbitMQ 等)との設計上の違いと、ユースケースごとの使い分けを判断できるようになる。
問題設定
「1秒間に数百万件のログを収集して、複数のサービスがそれを処理したい」──この要件を従来のメッセージキュー(RabbitMQ, ActiveMQ)で満たそうとすると壁にぶつかる。
従来 MQ の構造的限界:
- メッセージは消費後に削除 → 複数コンシューマーがそれぞれ処理できない
- 全メッセージをメモリに保持 → スループット上限がメモリ容量に依存
- 受信確認(Ack)の管理がブローカー側に集中 → ブローカーがボトルネック
Kafka はこれらを根本から設計し直すことで、単一クラスターで 1秒間に数百万メッセージを処理できる。
Kafka の基本概念
graph LR
P1[Producer 1] -->|publish| T[(Topic: events<br/>Partition 0,1,2)]
P2[Producer 2] -->|publish| T
T -->|subscribe| CG1[Consumer Group A<br/>Consumer 0,1,2]
T -->|subscribe| CG2[Consumer Group B<br/>Consumer 0,1]
CG1 --> App1[ログ集計サービス]
CG2 --> App2[リアルタイム分析]
Topic:メッセージの分類単位。「注文イベント」「ログイベント」など。
Partition:Topic を分割したもの。各 Partition は独立した追記専用ログ(immutable log)だ。
Consumer Group:同じ Group 内の Consumer はメッセージを分担して処理する。異なる Group は全量をそれぞれ処理できる(従来 MQ との最大の違い)。
Offset:Partition 内のメッセージ位置。Consumer はこの番号を記録することで「どこまで読んだか」を管理する。
なぜ Kafka は速いのか:3つの設計原則
原則1:追記専用ログ(Append-Only Log)
Kafka の Partition は追記専用のシーケンシャルログだ。メッセージの削除・更新をしない。
なぜ速いのか:
ランダムI/O(従来DBの書き込み):
→ ディスクのヘッドをあちこち動かす
→ HDD: 数ms〜数十ms/操作
→ SSD でも IOPS の上限がある
シーケンシャルI/O(Kafka の追記):
→ ファイルの末尾に順番に書くだけ
→ HDD でも 100MB/s 以上
→ SSD では 数GB/s
ディスクへのシーケンシャル書き込みは、ランダムアクセスより 100〜1000倍速い。
Kafka のストレージ構造:
/kafka/data/events-0/ ← Topic "events" の Partition 0
00000000000000000000.log ← Segment ファイル(追記専用)
00000000000000000000.index ← Offset → ファイル位置のインデックス
00000000000001048576.log ← 次のSegment
00000000000001048576.index
古いメッセージは log.retention.hours の設定で自動削除される。
原則2:OS のページキャッシュを最大限に利用
Kafka はメッセージをメモリに保持しない。代わりに**OS のページキャッシュ(ファイルシステムキャッシュ)**を活用する。
従来 MQ の流れ:
Disk → OS Page Cache → JVM Heap(copy1)→ ソケットバッファ(copy2)→ NIC
Kafka の流れ:
Disk → OS Page Cache → ソケットバッファ(copy1)→ NIC
JVM ヒープを経由しないため:
- コピー回数が減る(データコピーのコストが大きい)
- GC(ガベージコレクション)の影響を受けない
- OS が最適なキャッシュ管理をしてくれる(ブローカー再起動後もページキャッシュは残る)
原則3:ゼロコピー転送(Zero-Copy)
Kafka は Linux の sendfile() システムコールを使ってゼロコピー転送を実現する。
通常のデータ転送(4回のコピー):
1. Disk → Kernel Buffer(DMA)
2. Kernel Buffer → User Space Buffer(CPU Copy)
3. User Space Buffer → Socket Buffer(CPU Copy)
4. Socket Buffer → NIC(DMA)
sendfile() を使った場合(2回のコピー、Scatter-Gather DMA 対応時):
1. Disk → Kernel Buffer(DMA)
2. Kernel Buffer → NIC(DMA、Scatter-Gather)
→ User Space をスキップ!
※ Scatter-Gather DMA 非対応の場合は Kernel Buffer → Socket Buffer の CPU コピーが1回加わり計3回
// Kafka の FileChannel.transferTo() の内部(Java)
// これが OS の sendfile() を呼び出す
FileChannel fileChannel = new FileInputStream(logFile).getChannel();
SocketChannel socketChannel = ...;
// ゼロコピー転送:Kernel Space のみでデータが移動
fileChannel.transferTo(position, count, socketChannel);
パーティションとスケールアウト
パーティションが Kafka のスケールアウトの鍵だ。
Topic "orders" のパーティション設定:
Partition 0 → Broker 1(リーダー), Broker 2(フォロワー)
Partition 1 → Broker 2(リーダー), Broker 3(フォロワー)
Partition 2 → Broker 3(リーダー), Broker 1(フォロワー)
- 各 Partition のリーダーが書き込みを受け付け、フォロワーがレプリカを作る
- Consumer Group の各 Consumer は異なる Partition を担当(並列処理)
- Broker を追加すれば Partition を再分配してスケールアウトできる
パーティション数の選び方:
目安:
コンシューマーの並列数 × 1.5 〜 2 倍
理由:
Partition 数 < Consumer 数 → 余分な Consumer が待機状態(無駄)
Partition 数 >> Consumer 数 → 1 Consumer が多数 Partition を担当(不均衡)
実例:
24時間365日動くコンシューマーが10台 → Partition を 20〜30 に設定
メッセージの順序保証
「注文→支払→発送」のような順序依存のイベントをどう扱うか。
Kafka の順序保証は同一 Partition 内のみだ。異なる Partition 間の順序は保証されない。
# ❌ 順序が壊れる可能性がある
producer.send("orders", value=order_created_event)
producer.send("orders", value=payment_event)
# → 異なる Partition に入る可能性 → 処理順序が逆になりうる
# ✅ 同一パーティションに送る(キーを使う)
producer.send("orders", key=str(order_id).encode(), value=order_created_event)
producer.send("orders", key=str(order_id).encode(), value=payment_event)
# → 同じ order_id は同じ Partition へ → 順序保証
key を設定すると hash(key) % partition_count で Partition が決まる。同じキー(同じ注文ID)は必ず同じ Partition に入り、順序が保たれる。
デリバリー保証:At-Least-Once vs Exactly-Once
At-Most-Once(最大1回)
メッセージが届かないことがあるが、重複はない。
# Ack を待たずに次のメッセージへ(最速だが損失リスク)
producer = KafkaProducer(acks=0)
producer.send("events", value=message)
At-Least-Once(少なくとも1回)
メッセージは必ず届くが、重複する可能性がある。最もよく使われる設定。
# リーダーのみ確認(速い・レプリカが追いつく前にリーダーが落ちるとロスあり)
producer = KafkaProducer(acks=1)
# 全レプリカが確認するまで待つ(最も安全・遅い)
producer = KafkaProducer(acks="all", retries=3)
コンシューマー側での重複対策(冪等性の確保):
def process_payment(event: dict):
payment_id = event["payment_id"]
# 冪等チェック:既に処理済みなら無視
if redis.sismember("processed_payments", payment_id):
return # 重複メッセージを無視
# 処理実行
execute_payment(event)
# 処理済みとしてマーク(TTL = 7日)
redis.sadd("processed_payments", payment_id)
redis.expire("processed_payments", 604800)
Exactly-Once(厳密に1回)
Kafka 0.11 以降、Idempotent Producer + Transactional Producer で実現できる。
producer = KafkaProducer(
enable_idempotence=True, # プロデューサーIDで重複検知
transactional_id="payment-producer-1" # トランザクション識別子
)
producer.init_transactions()
try:
producer.begin_transaction()
producer.send("payments", value=payment_event)
producer.send("notifications", value=notify_event)
producer.commit_transaction() # 全て成功 or 全て失敗(アトミック)
except Exception:
producer.abort_transaction()
Kafka vs RabbitMQ の選択基準
graph TD
Q{要件}
Q -->|"メッセージを複数サービスで<br>それぞれ処理したい"| Kafka
Q -->|"高スループット(>10万/秒)"| Kafka
Q -->|"時系列イベントログの保持・再生"| Kafka
Q -->|"ルーティングロジックが複雑<br>(ヘッダー・パターンマッチ等)"| RabbitMQ
Q -->|"処理後に確実にキューから削除<br>(タスクキュー)"| RabbitMQ
Q -->|"プッシュ型の通知<br>(メール送信など)"| RabbitMQ
| Kafka | RabbitMQ | |
|---|---|---|
| スループット | 極めて高い(秒間数百万) | 高い(秒間数万〜数十万) |
| メッセージ保持 | 長期保持・再生可能 | 消費後削除 |
| 複数コンシューマー | ✅ Consumer Group で全量配信 | △ Fan-out exchange で可能だが複雑 |
| 順序保証 | Partition 内で保証 | キュー内で保証 |
| ルーティング | シンプル(Topic/Partition) | 複雑なルーティングに強い |
| 学習コスト | 高い | 低い |
まとめ
Kafka が高スループット・耐久性を両立できる理由:
| 設計選択 | 効果 | CS概念 |
|---|---|---|
| 追記専用ログ | シーケンシャルI/Oで高速 | Immutable Log |
| ページキャッシュ活用 | JVM GC の影響を排除 | OS Buffer Cache |
| ゼロコピー転送 | CPU コピー回数を最小化 | sendfile() syscall |
| パーティション | 水平スケールアウト | シャーディング |
| オフセット管理をコンシューマーへ | ブローカー負荷を軽減 | 責務の分離 |
| レプリカ(ISR) | データロス防止 | WAL ベースレプリケーション |
次章では、API のレートリミッターをどう設計するかを、アルゴリズムの選択から分散実装まで見ていく。