Kafka: topic, partition và append-only log

4 câu hỏi~25 phútTừ khóa: topic, partition, offset, append-only log, retention, log compaction, key hashing, ordering, replay

Mình hiểu là Kafka gửi message vào các consumer group, mỗi group giao cho một consumer. Như vậy RabbitMQ cũng làm được: bind exchange vào nhiều queue, mỗi queue giao cho một consumer. Chỉ khác là Kafka giữ lại message thay vì xóa, đúng không nhỉ?

Đúng hướng rồi. Về mô hình "nhiều bên cùng nhận một event", RabbitMQ và Kafka nhìn khá giống nhau. Nhưng chuyện "giữ lại message" không phải một chi tiết nhỏ: nó thay đổi cả cách nhìn về dữ liệu. RabbitMQ là queue (việc cần làm), Kafka là log (chuyện đã xảy ra). Bài này học phần lõi đó: topic, partition, append-only log.

1. Bài toán: shop-app phát event đơn hàng

Trong shop-app, order-service tạo đơn rồi phát event. Ba service khác cần event này:

order-service ──(OrderCreated, OrderPaid, OrderCancelled)──▶ ???
                                                   ├──▶ inventory-service    (trừ/hoàn kho)
                                                   ├──▶ notification-service (gửi email)
                                                   └──▶ analytics-service    (thống kê, có thể chạy lại)

Ở bài 8.2 ta làm bằng RabbitMQ: fanout/topic exchange → 3 queue. Chạy tốt. Nhưng có yêu cầu mới:

2. Vấn đề với queue

Queue:  [m1][m2][m3]  → consumer ACK m1 → m1 bị xóa khỏi queue

3. Giải pháp: Kafka là partitioned, append-only log

Kafka lưu event vào log: một dãy record chỉ được ghi thêm vào cuối (append-only), không sửa, không xóa từng record khi consumer đọc xong.

Khái niệmÝ nghĩa
TopicTên luồng event, ví dụ orders. Giống "tên bảng" về mặt logic.
PartitionTopic chia thành nhiều log nhỏ P0, P1, P2... Đây là đơn vị lưu trữ, song song và thứ tự.
OffsetSố thứ tự của record trong một partition: 0, 1, 2... tăng dần.
Recordkey + value + headers + timestamp. Ví dụ key = orderId, value = JSON event.
RetentionKafka giữ record theo thời gian/dung lượng (retention.ms, retention.bytes), không theo "đã đọc chưa".
Log compactioncleanup.policy=compact: chỉ giữ record mới nhất của mỗi key. Hợp cho dạng "trạng thái hiện tại".

Consumer không "lấy đi" record. Mỗi consumer group tự nhớ mình đọc tới offset nào. Vì record còn đó, ta có thể replay: lùi offset về chỗ cũ và đọc lại.

4. Code từng bước

shop-app/
├── order-service/          <-- producer
├── inventory-service/      <-- consumer (bài 8.8)
├── notification-service/
└── analytics-service/

Bước 1: Dependency (Spring Boot quản lý version):

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

Bước 2: application.yml của order-service:

spring:
  kafka:
    bootstrap-servers: ${KAFKA_BOOTSTRAP:localhost:9092}
    producer:
      key-serializer: org.apache.kafka.common.serialization.StringSerializer
      value-serializer: org.springframework.kafka.support.serializer.JsonSerializer

Bước 3: Tạo topic có chủ đích. Số partition là quyết định thiết kế, không để mặc định:

@Configuration
public class KafkaTopicConfig {

    @Bean
    public NewTopic ordersTopic() {
        return TopicBuilder.name("orders")
                .partitions(6)
                .replicas(3)                       // bài 8.7
                .config(TopicConfig.RETENTION_MS_CONFIG,
                        String.valueOf(Duration.ofDays(7).toMillis()))
                .build();
    }
}

Spring Boot có sẵn KafkaAdmin, khi app khởi động sẽ tạo topic nếu chưa có. Production thường tạo topic bằng pipeline/IaC thay vì để app tự tạo.

Bước 4: Publish event với key = orderId

public record OrderEvent(String orderId, String type, BigDecimal amount, Instant occurredAt) {}

@Service
@RequiredArgsConstructor
@Slf4j
public class OrderEventPublisher {

    private final KafkaTemplate<String, OrderEvent> kafkaTemplate;

    public void publish(OrderEvent event) {
        kafkaTemplate.send("orders", event.orderId(), event)   // topic, key, value
            .whenComplete((result, ex) -> {
                if (ex != null) {
                    log.error("Send failed orderId={}", event.orderId(), ex);
                    return;
                }
                RecordMetadata m = result.getRecordMetadata();
                log.info("orderId={} -> partition={} offset={}",
                        event.orderId(), m.partition(), m.offset());
            });
    }
}

Bước 5: Test

curl -X POST localhost:8081/orders -H "Content-Type: application/json" \
     -d '{"productId":"P1","quantity":2}'

# log:
# orderId=101 -> partition=4 offset=0
# (gọi pay cho đơn 101)
# orderId=101 -> partition=4 offset=1     <-- cùng partition

Xem trực tiếp trong Kafka:

kafka-topics.sh --bootstrap-server localhost:9092 --describe --topic orders
kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic orders \
    --from-beginning --property print.key=true --property print.partition=true

5. Cách hoạt động

Topic: orders (6 partitions)

           hash(key) % 6
Producer ─────────────────┐
                          ▼
P0: [0][1][2][3]                    ← append vào cuối
P1: [0][1]
P2: [0][1][2][3][4][5]
P3: [0]
P4: [0 order101 CREATED][1 order101 PAID][2 ...]
P5: [0][1][2]

inventory-group    đọc P4 tới offset 2
analytics-group    đọc P4 tới offset 0   ← chậm hơn, không sao
                   (mỗi group nhớ offset riêng; record không bị xóa)

Retention 7 ngày: record cũ hơn 7 ngày bị xóa theo từng segment file, bất kể đã có ai đọc hay chưa.

6. So sánh: queue và log

RabbitMQ queueKafka log
Sau khi consumer ACK/commitMessage bị xóaRecord vẫn còn tới hết retention
Ai nhớ tiến độ đọcBroker (message còn/không trong queue)Consumer group (offset)
ReplayKhông tự nhiênLùi offset là đọc lại
Subscriber mớiChỉ nhận message mớiĐọc được từ đầu retention
Thứ tựTheo queue, mất khi nhiều consumerĐảm bảo trong một partition

7. Khi nào nên dùng / không nên dùng

Nói rõ hơn về Kafka partition giúp mình:

  • Partition do Kafka tự chia hay do mình config?
  • Một message mới dựa vào đâu để biết nó thuộc partition nào?

Hai câu này là nền của mọi thứ phía sau.

Số partition: do mình config

Message vào partition nào: do producer chọn

1. Producer chỉ định partition rõ ràng      → vào đúng partition đó (hiếm dùng)
2. Có key                                    → murmur2(key) % số_partition
3. Không key, không partition                → partitioner mặc định phân phối
                                               (sticky: dồn một batch vào một partition rồi đổi)
key "101" → murmur2 → ... % 6 = 4 → P4
key "101" → luôn P4 (khi số partition không đổi)
key "102" → P1

Chú ý: đừng học thuộc "không key = round-robin". Client mới dùng sticky partitioning để batch hiệu quả hơn.

Bẫy khi tăng partition

6 partition:  hash("101") % 6 = 4  → P4
tăng lên 8:   hash("101") % 8 = 2  → P2   ← event mới của đơn 101 sang partition khác!

Event cũ của đơn 101 ở P4, event mới ở P2: trong lúc chuyển giao, thứ tự của đơn 101 có thể bị đảo. Vì vậy nên chọn số partition dư ra ngay từ đầu cho tải dự kiến.

Như vậy nếu không config partition thì message được xử lý tuần tự, còn config nhiều partition thì không tuần tự nhỉ? Ví dụ 10 message vào partition 1, sau đó 1 message vào partition 2, thì message thứ 11 có khả năng được xử lý trước?

Đúng ý chính: message 11 hoàn toàn có thể xử lý xong trước. Chỉ chỉnh một điểm: không có chuyện "không có partition". Topic luôn có ít nhất 1 partition.

1 partition:
P0: M1 → M2 → ... → M10 → M11      → consumer đọc đúng thứ tự, nhưng chỉ 1 consumer/group

2 partition:
P0: M1 → M2 → ... → M10   → Consumer A (đang làm M1)
P1: M11                   → Consumer B (xong M11 trước) ✅ hợp lệ

Kafka chỉ đảm bảo thứ tự trong một partition, không đảm bảo giữa các partition. Cách giải: dùng key là business id cần giữ thứ tự.

key = orderId
P4: order101 CREATED → PAID → SHIPPED     (đúng thứ tự cho đơn 101)
P1: order102 CREATED → CANCELLED          (song song với đơn 101, không sao)

Thực tế hiếm khi cần thứ tự toàn hệ thống. Thường chỉ cần thứ tự của cùng một entity. Đó là điểm mạnh của Kafka: vừa song song, vừa giữ thứ tự theo key.

Lưu ý nhỏ: phía producer, nếu retry mà không bật idempotence thì thứ tự trong partition cũng có thể đảo. Từ Kafka 3.0, enable.idempotence=true là mặc định nên trường hợp này đã được xử lý (bài 8.9).

Phỏng vấn hỏi về Kafka topic, partition, ordering thì trả lời sao?

Q1. Kafka khác message queue truyền thống ở điểm nào?

🇻🇳 Tiếng Việt:

Kafka là một distributed append-only log. Record được giữ theo retention policy chứ không bị xóa khi consumer đọc xong. Mỗi consumer group tự quản lý offset của mình, nên nhiều group đọc cùng topic độc lập và có thể replay bằng cách lùi offset. Queue truyền thống như RabbitMQ thì xóa message sau khi ACK.

🇬🇧 English:

Kafka is a distributed append-only log. Records are kept according to a retention policy, not deleted when a consumer reads them. Each consumer group tracks its own offsets, so many groups can read the same topic independently and replay by resetting offsets. A traditional queue like RabbitMQ removes a message once it is acknowledged.

Q2. Kafka có đảm bảo thứ tự message không?

🇻🇳 Tiếng Việt:

Có, nhưng chỉ trong một partition. Không có thứ tự toàn topic. Nếu các event của cùng một entity cần giữ thứ tự, ví dụ cùng một đơn hàng, em dùng orderId làm key để chúng luôn vào cùng partition. Cần thứ tự toàn topic thì phải dùng 1 partition và chấp nhận mất khả năng song song.

🇬🇧 English:

Yes, but only within a partition; there is no global order across a topic. If events of the same entity must stay in order, such as one order, I use the orderId as the key so they always land in the same partition. Global ordering requires a single partition, which gives up parallelism.

Q3. Producer chọn partition thế nào? Tăng số partition có rủi ro gì?

🇻🇳 Tiếng Việt:

Nếu chỉ định partition thì dùng partition đó. Nếu có key thì hash key (murmur2) modulo số partition. Không có key thì partitioner mặc định phân phối, client mới dùng sticky partitioning. Tăng số partition làm đổi mapping key → partition, nên event mới của một key có thể sang partition khác và mất thứ tự tạm thời. Vì vậy em chọn số partition dư từ đầu.

🇬🇧 English:

An explicit partition wins. With a key, the producer hashes the key with murmur2 modulo the partition count. Without a key, the default partitioner spreads records, using sticky partitioning in modern clients. Adding partitions changes the key-to-partition mapping, so new events for a key may move and lose ordering temporarily. That is why I size partitions with headroom up front.

Cách nhớ ngắn gọn


Tự kiểm tra

Consumer group đọc xong record thì record có bị xóa không?

Không. Record bị xóa theo retention (thời gian/dung lượng) hoặc compaction, không phụ thuộc việc đã đọc. Group chỉ lưu offset của mình.

Vì sao order-service dùng orderId làm key?

Cùng key → cùng partition → các event CREATED, PAID, SHIPPED của một đơn giữ đúng thứ tự, trong khi các đơn khác xử lý song song ở partition khác.

Topic 3 partition, 10 message không key, consumer có nhận đúng thứ tự gửi không?

Không đảm bảo. Message rải ra nhiều partition, Kafka chỉ đảm bảo thứ tự trong từng partition.

Tăng partition từ 6 lên 8 ảnh hưởng gì đến key?

hash(key) % N đổi theo N, nên một key có thể sang partition khác. Event cũ và mới của key đó nằm hai nơi, thứ tự có thể đảo trong giai đoạn chuyển.

Log compaction khác retention theo thời gian thế nào?

Retention xóa segment cũ theo thời gian/dung lượng. Compaction giữ lại record mới nhất của mỗi key, hợp cho topic dạng "trạng thái hiện tại".