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などのより高度なトピックに進む前に必須の基礎です。

kafkaspring-bootmessagingjava