目次を表示する

システム設計とCS概念

メッセージキュー(Kafka)── 耐久性とスループットの両立

メッセージキュー(Kafka)── 耐久性とスループットの両立

扱うCS概念:追記専用ログ(Append-Only Log)、ページキャッシュ、ゼロコピー、パーティション、コンシューマーグループ


Kafka内部構造 — 追記ログ・ゼロコピー・パーティション

この章で何ができるようになるか: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
KafkaRabbitMQ
スループット極めて高い(秒間数百万)高い(秒間数万〜数十万)
メッセージ保持長期保持・再生可能消費後削除
複数コンシューマー✅ 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 のレートリミッターをどう設計するかを、アルゴリズムの選択から分散実装まで見ていく。