Kafka cơ bản: Topic, Partition và Offset

1. Tổng quan

Kafka là hệ thống message broker phân tán, dùng để truyền dữ liệu giữa các service với throughput cao. Trước khi viết producer/consumer đầu tiên, bạn cần nắm ba khái niệm cốt lõi: topic, partition, offset. Bài này giải thích từng khái niệm, kèm ví dụ Spring Boot cụ thể. Đọc xong, bạn sẽ hiểu dữ liệu trong Kafka được tổ chức và đọc như thế nào, tránh những lỗi phổ biến khi mới dùng.

2. Topic là gì

Topic giống như một “bảng” hoặc “kênh” chứa message. Producer gửi message vào topic, consumer đọc message từ topic. Một topic có thể có nhiều producer và nhiều consumer cùng lúc.

Ví dụ, trong hệ thống bán hàng, bạn có thể có các topic:

orders.created
orders.cancelled
payments.processed

Mỗi topic chứa message liên quan đến một loại sự kiện. Kafka không áp đặt schema cho message — bạn tự quyết định format (JSON, Avro, Protobuf…).

Tạo topic bằng CLI để hình dung rõ hơn:

kafka-topics.sh --create \
  --topic orders.created \
  --bootstrap-server localhost:9092 \
  --partitions 3 \
  --replication-factor 1

Lệnh này tạo topic orders.created với 3 partition. Đây chính là khái niệm tiếp theo.

3. Partition — đơn vị song song hóa

3.1. Vì sao cần partition

Một topic được chia thành nhiều partition. Mỗi partition là một log file độc lập, message được append vào cuối, không bao giờ sửa hay xóa giữa đường. Partition cho phép Kafka:

  • Phân tán dữ liệu trên nhiều broker.
  • Cho phép nhiều consumer đọc song song (mỗi consumer một 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 quyết định partition nào

Khi gửi message, Kafka dùng key để tính partition (qua hash), đảm bảo message cùng key luôn vào cùng một partition — giữ đúng thứ tự cho các message liên quan.

// Giả sử gửi event theo orderId làm key
ProducerRecord<String, String> record =
        new ProducerRecord<>("orders.created", "order-123", payloadJson);
kafkaTemplate.send(record);

Dòng này tạo một ProducerRecord với key "order-123". Mọi event của order này sẽ luôn rơi vào cùng partition, nên consumer đọc được chúng theo đúng thứ tự gửi.

Lưu ý: Nếu không truyền key (key = null), Kafka phân phối message theo round-robin (hoặc sticky partitioner từ Kafka 2.4+), không đảm bảo thứ tự giữa các message.

3.3. Thứ tự chỉ đảm bảo trong một partition

Đây là điểm dễ gây nhầm lẫn nhất: Kafka chỉ đảm bảo thứ tự trong một partition, không đảm bảo thứ tự toàn topic. Nếu bạn cần thứ tự tuyệt đối cho một nhóm message (ví dụ tất cả event của một orderId), hãy dùng cùng key cho nhóm đó.

4. Offset — vị trí đọc trong partition

Mỗi message trong một partition có một số thứ tự tăng dần gọi là offset, bắt đầu từ 0.

Partition 0:
offset:  0    1    2    3    4
message: m0   m1   m2   m3   m4

Offset là con số duy nhất trong phạm vi một partition, không phải toàn topic. Consumer dùng offset để biết “mình đã đọc tới đâu”.

4.1. Consumer group và offset commit

Kafka theo dõi offset đã đọc thông qua consumer group. Mỗi consumer group có offset riêng cho mỗi partition, lưu trong topic nội bộ __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: định danh consumer group. Nhiều instance cùng group-id sẽ chia nhau các partition của topic.
  • auto-offset-reset=earliest: nếu chưa có offset lưu sẵn (consumer group mới), đọc từ đầu topic.
  • enable-auto-commit=false: tắt auto-commit để tự kiểm soát khi nào commit offset (tránh mất message khi xử lý lỗi giữa đường).

4.2. Nhiều consumer trong cùng group

Kafka chia partition cho các consumer trong cùng group theo nguyên tắc: một partition chỉ được đọc bởi một consumer trong group tại một thời điểm.

Topic orders.created có 3 partitions
Consumer group "order-service" có 2 instances:

Consumer A -> partition 0, partition 1
Consumer B -> partition 2

Nếu bạn chạy 4 consumer cho topic 3 partition, consumer thứ 4 sẽ rảnh tay, không nhận partition nào. Vì vậy số consumer tối đa hữu ích = số partition.

5. Thiết lập Kafka trong Spring Boot

5.1. Dependency

<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>

5.2. Producer gửi message

@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) gửi message vào topic orders.created, dùng orderId làm key. Spring Boot tự tạo bean KafkaTemplate dựa trên cấu hình spring.kafka.bootstrap-servers.

5.3. Consumer đọc message và commit offset thủ công

@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());

        // Xử lý nghiệp vụ ở đây
        ack.acknowledge(); // commit offset sau khi xử lý thành công
    }
}
  • record.partition() và record.offset() cho biết message này ở partition nào, vị trí nào.
  • ack.acknowledge() chỉ commit offset sau khi xử lý xong — nếu service crash giữa đường, message sẽ được đọc lại ở lần restart tiếp theo (nhờ enable-auto-commit=false kết hợp AckMode.MANUAL).

Cần thêm cấu hình để Acknowledgment hoạt động:

spring.kafka.listener.ack-mode=manual

Output khi chạy thử (gửi 3 message với key khác nhau):

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"}

Mỗi key rơi vào partition khác nhau do hash khác nhau — đúng như lý thuyết ở mục 3.2.

6. Test với JUnit 5

Dùng spring-kafka-test để chạy embedded Kafka trong unit test, không cần broker thật.

<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 khởi một Kafka broker trong-memory chỉ để test. KafkaTestUtils.getSingleRecord lấy message đầu tiên từ topic, và ta assert key, value, offset đúng như kỳ vọng — offset 0 vì đây là message đầu tiên của partition duy nhất.

7. Lỗi hay gặp

  • Nhầm giữa số partition và số consumer: tạo quá nhiều consumer so với partition khiến một số consumer không làm gì cả. Số partition quyết định mức độ song song tối đa.
  • Không set key khi cần giữ thứ tự: nếu bạn cần các event của cùng một entity (order, user…) được xử lý đúng thứ tự, phải dùng key, không được để null.
  • Tăng partition sau khi đã chạy production: tăng số partition của topic đang có dữ liệu sẽ làm thay đổi cách hash key → message cùng key có thể rơi vào partition khác, phá vỡ tính đảm bảo thứ tự cho dữ liệu cũ. Nên xác định số partition hợp lý từ đầu.
  • Auto-commit mặc định (enable-auto-commit=true): offset được commit theo chu kỳ cố định (mặc định 5s), không gắn với việc xử lý xong message hay chưa — dễ mất message khi consumer crash giữa lúc xử lý.

Lưu ý: Nếu ứng dụng của bạn không cần thứ tự nghiêm ngặt và throughput không phải vấn đề, bạn có thể bỏ qua việc tối ưu key/partition — Kafka vẫn hoạt động đúng với cấu hình mặc định. Đừng tối ưu sớm khi chưa có nhu cầu thực tế.

8. Kết luận

Topic là nơi chứa message theo chủ đề, partition là đơn vị chia nhỏ để song song hóa và phân tán, offset là vị trí đọc trong từng partition. Hiểu rõ ba khái niệm này giúp bạn thiết kế đúng: chọn key hợp lý để giữ thứ tự, chọn số partition phù hợp với số consumer, và kiểm soát việc commit offset để tránh mất dữ liệu khi có lỗi. Đây là nền tảng bắt buộc trước khi đi sâu vào các chủ đề nâng cao hơn như rebalancing, exactly-once semantics hay Kafka Streams.

kafkaspring-bootmessagingjava