Kafka基礎: Topic、Partition、Offset
1. 概要
Kafkaは分散型のメッセージブローカーシステムで、高いスループットでサービス間のデータ転送に使用されます。最初のproducer/consumerを書く前に、3つの中核的な概念を理解する必要があります:topic、partition、offset。この記事では、各概念を説明し、具体的なSpring Bootの例を示します。読み終わったら、Kafkaのデータがどのように組織化され、どのように読み込まれるか、そして初心者が陥りやすい一般的なエラーを避ける方法が理解できるでしょう。
2. Topicとは
Topicは、メッセージを保持する「テーブル」または「チャネル」のようなものです。Producerはメッセージをtopicに送信し、consumerはtopicからメッセージを読み込みます。1つのtopicは、複数のproducerと複数のconsumerを同時に持つことができます。
例えば、eコマースシステムでは、次のようなtopicがあります:
orders.created
orders.cancelled
payments.processed
各topicは、特定の種類のイベントに関連するメッセージを含みます。Kafkaはメッセージのスキーマを強制しません——形式(JSON、Avro、Protobuf…)は自由に選択できます。
CLIでtopicを作成してみます:
kafka-topics.sh --create \
--topic orders.created \
--bootstrap-server localhost:9092 \
--partitions 3 \
--replication-factor 1
このコマンドは、3つのpartitionを持つtopic「orders.created」を作成します。これが次の概念です。
3. Partition——並列化の単位
3.1. partitionが必要な理由
Topicは複数のpartitionに分割されます。各partitionは独立したログファイルで、メッセージは最後に追加され、途中で編集や削除されることはありません。Partitionにより、Kafkaは以下のことができます:
- 複数のbroker間でデータを分散する。
- 複数のconsumerが並行して読み込むことができます(consumerごとに1つのpartition)。
Topic: orders.created (3 partitions)
Partition 0: [msg0][msg1][msg4][msg7]...
Partition 1: [msg2][msg5][msg8]...
Partition 2: [msg3][msg6][msg9]...
3.2. keyがpartitionを決定する
メッセージを送信する際、Kafkaはkeyを使用してpartitionを決定します(ハッシュを通じて)。同じkeyを持つメッセージは常に同じpartitionに入り、関連するメッセージの順序を保証します。
// orderIdをkeyとしてイベントを送信する場合
ProducerRecord<String, String> record =
new ProducerRecord<>("orders.created", "order-123", payloadJson);
kafkaTemplate.send(record);
このコードは、key「order-123」を持つ「ProducerRecord」を作成します。このorderのすべてのイベントは常に同じpartitionに配置されるため、consumerは送信順に読み込むことができます。
注意: keyを指定しない場合(key =
null)、Kafkaはround-robinで分配します(またはKafka 2.4+のsticky partitioner)。メッセージ間の順序は保証されません。
3.3. 順序は1つのpartition内でのみ保証される
これが最も混乱しやすいポイントです:Kafkaは1つのpartition内でのみ順序を保証します。topic全体の順序は保証されません。メッセージのグループに絶対的な順序が必要な場合(例えば、1つの「orderId」のすべてのイベント)、そのグループに同じkeyを使用します。
4. Offset——partition内の読み込み位置
partition内の各メッセージには、offsetと呼ばれる増加する順序番号があり、0から始まります。
Partition 0:
offset: 0 1 2 3 4
message: m0 m1 m2 m3 m4
OffsetはPartition内でのユニークな番号で、topic全体ではありません。Consumerは、「どこまで読み込んだか」を知るためにoffsetを使用します。
4.1. consumer groupとoffset commit
Kafkaはconsumer groupを通じて読み込んだoffsetを追跡します。各consumer groupは、各partitionの独自のoffsetを持ち、内部topic「__consumer_offsets」に保存されます。
# application.properties
spring.kafka.consumer.group-id=order-service
spring.kafka.consumer.auto-offset-reset=earliest
spring.kafka.consumer.enable-auto-commit=false
group-id:consumer groupを識別します。同じgroup-idの複数のインスタンスは、topicのpartitionを分け合います。auto-offset-reset=earliest:保存されたoffsetがない場合(新しいconsumer group)、topicの最初から読み込みます。enable-auto-commit=false:自動コミットを無効にして、offsetをコミットするタイミングを自分で制御します(エラー処理中のメッセージ損失を防ぎます)。
4.2. 同じgroup内の複数のconsumer
Kafkaは同じgroup内のconsumerにpartitionを割り当てます:1つのpartitionは、同時に1つのconsumerによってのみ読み込まれます。
Topic orders.created に3つのpartitions
Consumer group "order-service" に2つのインスタンス:
Consumer A -> partition 0, partition 1
Consumer B -> partition 2
topic 3 partitionに対して4 consumerを実行する場合、4番目のconsumerはアイドル状態になり、partitionを受け取りません。したがって、実質的に有用なconsumerの最大数 = partitionの数です。
5. Spring BootでのKafka設定
5.1. 依存関係
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
5.2. Producerがメッセージを送信する
@Service
public class OrderEventProducer {
private final KafkaTemplate<String, String> kafkaTemplate;
public OrderEventProducer(KafkaTemplate<String, String> kafkaTemplate) {
this.kafkaTemplate = kafkaTemplate;
}
public void publishOrderCreated(String orderId, String payloadJson) {
kafkaTemplate.send("orders.created", orderId, payloadJson);
}
}
「kafkaTemplate.send(topic, key, value)」はメッセージをtopic「orders.created」に送信します。キーとして「orderId」を使用します。Spring Bootは、設定「spring.kafka.bootstrap-servers」に基づいて自動的にbean「KafkaTemplate」を作成します。
5.3. Consumerがメッセージを読み込んで、offsetを手動でコミットする
@Component
public class OrderEventConsumer {
@KafkaListener(topics = "orders.created", groupId = "order-service")
public void onOrderCreated(
ConsumerRecord<String, String> record,
Acknowledgment ack) {
System.out.printf("partition=%d offset=%d key=%s value=%s%n",
record.partition(), record.offset(), record.key(), record.value());
// ここでビジネスロジックを処理
ack.acknowledge(); // 処理完了後にoffsetをコミット
}
}
- 「record.partition()」と「record.offset()」は、このメッセージがどのpartitionのどの位置にあるかを示します。
- 「ack.acknowledge()」は、処理が完了した後にのみoffsetをコミットします。サービスが途中でクラッシュした場合、メッセージは次回の再起動時に再び読み込まれます(「enable-auto-commit=false」と「AckMode.MANUAL」を組み合わせたため)。
「Acknowledgment」が機能するために、次の設定を追加する必要があります:
spring.kafka.listener.ack-mode=manual
実行してみた場合の出力(異なるkeyを持つ3つのメッセージを送信):
partition=1 offset=0 key=order-123 value={"status":"CREATED"}
partition=0 offset=0 key=order-456 value={"status":"CREATED"}
partition=2 offset=0 key=order-789 value={"status":"CREATED"}
各keyは異なるハッシュ値により異なるpartitionに配置されます——セクション3.2の理論通りです。
6. JUnit 5でのテスト
「spring-kafka-test」を使用してユニットテスト内に組み込みKafkaを実行できます。実際のbrokerは必要ありません。
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka-test</artifactId>
<scope>test</scope>
</dependency>
@SpringBootTest
@EmbeddedKafka(partitions = 1, topics = "orders.created")
class OrderEventProducerTest {
@Autowired
private OrderEventProducer producer;
@Autowired
private EmbeddedKafkaBroker embeddedKafka;
@Test
void shouldPublishMessageWithGivenKey() throws Exception {
Consumer<String, String> consumer = createTestConsumer();
embeddedKafka.consumeFromAnEmbeddedTopic(consumer, "orders.created");
producer.publishOrderCreated("order-123", "{\"status\":\"CREATED\"}");
ConsumerRecord<String, String> record =
KafkaTestUtils.getSingleRecord(consumer, "orders.created");
assertThat(record.key()).isEqualTo("order-123");
assertThat(record.value()).contains("CREATED");
assertThat(record.offset()).isEqualTo(0L);
}
private Consumer<String, String> createTestConsumer() {
Map<String, Object> props = KafkaTestUtils.consumerProps(
"test-group", "true", embeddedKafka);
props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
DefaultKafkaConsumerFactory<String, String> factory =
new DefaultKafkaConsumerFactory<>(props);
return factory.createConsumer();
}
}
「@EmbeddedKafka」は、テスト用のメモリ内Kafkaブローカーを起動します。「KafkaTestUtils.getSingleRecord」はtopicから最初のメッセージを取得し、key、value、offsetが期待通りであることをアサートします。offset「0」は、これが唯一のpartitionの最初のメッセージだからです。
7. よくあるエラー
- partitionの数とconsumerの数の混同:partitionより多くのconsumerを作成すると、一部のconsumerがアイドル状態になります。partitionの数が最大並列度を決定します。
- 順序を保つ必要がある場合、keyを設定しない:同じエンティティ(order、user…)のイベントが正しい順序で処理される必要がある場合、keyを使用する必要があります。「null」のままにしてはいけません。
- 本番運用中のpartition数の増加:既存データを持つtopicのpartition数を増やすと、keyのハッシュ方法が変わり、同じkeyのメッセージが異なるpartitionに落ちる可能性があります。これは既存データの順序保証を破壊します。最初から適切なpartition数を決定してください。
- デフォルトの自動コミット(「enable-auto-commit=true」):offsetは固定間隔(デフォルトは5秒)でコミットされ、メッセージの処理が完了したかどうかに関係なく——consumerが処理中にクラッシュするとメッセージが失われやすくなります。
注意: アプリケーションが厳密な順序を必要とせず、スループットが問題でない場合、key/partitionの最適化をスキップできます。Kafkaはデフォルト設定で正しく動作します。実際のニーズがないのに早期最適化をしないでください。
8. 結論
Topicはテーマ別のメッセージコンテナ、partitionは並列化と分散のための単位、offsetは各partition内の読み込み位置です。これら3つの概念を理解することで、正しい設計ができます:順序を保つための適切なkeyの選択、consumerの数に合わせたpartition数の選択、エラー時のデータ損失を避けるためのoffsetコミット制御。これは、rebalancing、exactly-once semantics、Kafka Streamsなどのより高度なトピックに進む前に必須の基礎です。