Thiết kế partition key trong Kafka: thứ tự message và phân tải

1. Tổng quan

Trong Kafka, partition key quyết định message rơi vào partition nào. Từ đó nó quyết định luôn hai việc: message nào được giữ đúng thứ tự, và tải được chia đều đến đâu giữa các consumer.

Chọn key sai thì hậu quả thường đi theo một trong hai hướng. Hướng thứ nhất là các event của cùng một đơn hàng bị xử lý lộn xộn. Hướng thứ hai là một partition “nóng” gánh gần hết lưu lượng trong khi các partition khác ngồi chơi.

Đọc xong bài này, bạn sẽ hiểu Kafka ánh xạ key sang partition thế nào. Bạn cũng sẽ biết cách chọn key theo yêu cầu thứ tự, cách xử lý hot key, và cấu hình producer/consumer trong Spring Boot 3.x để không vô tình phá vỡ thứ tự.

2. Kafka chọn partition như thế nào

2.1. Key khác null: hash rồi chia lấy dư

Khi message có key, producer tính partition theo công thức sau:

partition = toPositive(murmur2(keyBytes)) % numPartitions

Ta có thể tái hiện đúng logic này bằng các hàm tiện ích có sẵn trong kafka-clients:

import org.apache.kafka.common.utils.Utils;
import java.nio.charset.StandardCharsets;

public final class PartitionCalculator {

    private PartitionCalculator() {}

    public static int partitionFor(String key, int numPartitions) {
        byte[] keyBytes = key.getBytes(StandardCharsets.UTF_8);
        return Utils.toPositive(Utils.murmur2(keyBytes)) % numPartitions;
    }

    public static void main(String[] args) {
        for (String orderId : new String[]{"order-1001", "order-1002", "order-1001"}) {
            System.out.println(orderId + " -> partition " + partitionFor(orderId, 6));
        }
    }
}

Giải thích từng phần:

  • Key được chuyển thành byte bằng StringSerializer, tức là UTF-8. Ví dụ dùng UTF-8 để khớp với hành vi đó.
  • Utils.murmur2 là hàm hash mà default partitioner dùng.
  • toPositive bỏ bit dấu để kết quả không bị âm. Phép % sau đó cho ra số thứ tự partition.

Kết quả chạy có dạng như sau (con số cụ thể tùy key):

order-1001 -> partition 3
order-1002 -> partition 0
order-1001 -> partition 3

Điểm quan trọng: cùng key và cùng số partition thì luôn ra cùng partition. Kafka chỉ đảm bảo thứ tự bên trong một partition. Vì vậy, muốn giữ thứ tự cho nhóm message nào thì các message đó phải dùng chung key.

2.2. Key là null: không có đảm bảo thứ tự

Từ Kafka 3.3, message không có key được phân phối theo cơ chế “uniform sticky”. Producer gom một batch vào một partition, rồi chuyển sang partition khác, có tính đến tốc độ của từng broker. Tải được chia khá đều, nhưng hai message liên tiếp có thể nằm ở hai partition khác nhau. Khi đó không còn thứ tự nào giữa chúng.

Lưu ý: Hash phụ thuộc vào byte của key chứ không phụ thuộc vào giá trị logic. Nếu một service gửi key 1001 bằng LongSerializer và một service khác gửi "1001" bằng StringSerializer, hai message sẽ có thể vào hai partition khác nhau. Hãy thống nhất serializer cho key trên toàn hệ thống.

3. Gửi message có key với Spring Boot

Thêm dependency:

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

Cấu hình producer trong application.yml:

spring:
  kafka:
    bootstrap-servers: localhost:9092
    producer:
      key-serializer: org.apache.kafka.common.serialization.StringSerializer
      value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
      acks: all
      properties:
        enable.idempotence: true
        max.in.flight.requests.per.connection: 5

Ý nghĩa các thiết lập:

  • acks: all và enable.idempotence: true giúp retry không tạo bản ghi trùng.
  • Quan trọng hơn, khi bật idempotence, broker từ chối batch đến sai thứ tự. Nhờ vậy thứ tự trong partition vẫn được giữ, kể cả khi có retry và max.in.flight lên tới 5.

Service gửi event:

import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Service;

public record OrderEvent(String orderId, String customerId, String type) {}

@Service
public class OrderEventPublisher {

    private final KafkaTemplate<String, OrderEvent> kafkaTemplate;

    public OrderEventPublisher(KafkaTemplate<String, OrderEvent> kafkaTemplate) {
        this.kafkaTemplate = kafkaTemplate;
    }

    public void publish(OrderEvent event) {
        kafkaTemplate.send("order-events", event.orderId(), event)
                .whenComplete((result, ex) -> {
                    if (ex == null) {
                        var md = result.getRecordMetadata();
                        System.out.printf("%s %s -> p%d@%d%n",
                                event.orderId(), event.type(), md.partition(), md.offset());
                    }
                });
    }
}

Tham số thứ hai của send(topic, key, value) là key. Ở đây ta dùng orderId. Nếu gửi lần lượt CREATED, PAID, SHIPPED cho cùng một đơn, đầu ra sẽ như sau:

order-1001 CREATED -> p3@120
order-1001 PAID -> p3@121
order-1001 SHIPPED -> p3@122

Cả ba event nằm cùng partition 3, với offset tăng dần. Consumer sẽ đọc chúng đúng thứ tự này.

4. Chọn key: bắt đầu từ yêu cầu thứ tự

Câu hỏi đầu tiên không phải “key nào chia tải đều nhất”. Câu hỏi đúng là: những message nào bắt buộc phải xử lý theo thứ tự? Key nên là định danh của nhóm nhỏ nhất thỏa yêu cầu đó.

Yêu cầu nghiệp vụ Key phù hợp Ghi chú
Event của một đơn hàng phải theo thứ tự orderId Nhiều key, phân tải tốt
Số dư ví của khách phải cộng/trừ đúng thứ tự customerId hoặc walletId Dễ gặp hot key nếu có khách rất lớn
Log truy cập, không cần thứ tự null Phân tải đều nhất
Tồn kho theo sản phẩm sku Sản phẩm “hot” có thể gây lệch tải

Hai nguyên tắc đi kèm:

  1. Key càng mịn thì phân tải càng tốt. orderId có hàng triệu giá trị nên phân bố đều. countryCode chỉ có vài giá trị nên vài partition sẽ gánh gần hết.
  2. Số giá trị key nên lớn hơn nhiều so với số partition. Nếu chỉ có 10 key mà topic có 12 partition, chắc chắn sẽ có partition trống. Thường cũng sẽ có partition nhận nhiều hơn một key nặng.

5. Hot partition và cách xử lý

Giả sử key là customerId, và một khách doanh nghiệp tạo ra 40% lưu lượng. Partition chứa khách này sẽ bị lag. Thêm consumer cũng không giúp gì, vì một partition chỉ được một consumer trong group đọc tại một thời điểm.

5.1. Salting: chia key nóng thành nhiều bucket

Ý tưởng là thêm hậu tố vào key để rải key nóng ra nhiều partition:

import java.util.Set;

public class OrderKeyStrategy {

    private final Set<String> hotCustomers;
    private final int buckets;

    public OrderKeyStrategy(Set<String> hotCustomers, int buckets) {
        this.hotCustomers = hotCustomers;
        this.buckets = buckets;
    }

    public String keyFor(OrderEvent event) {
        if (!hotCustomers.contains(event.customerId())) {
            return event.customerId();
        }
        int bucket = Math.floorMod(event.orderId().hashCode(), buckets);
        return event.customerId() + "#" + bucket;
    }
}

Với khách bình thường, key giữ nguyên là customerId. Với khách nóng, bucket được tính từ orderId, nên key có dạng cust-big#0 … cust-big#7.

Hệ quả cần nắm rõ:

  • Mọi event của cùng một đơn vẫn có cùng key, nên vẫn giữ thứ tự.
  • Thứ tự giữa các đơn khác nhau của cùng khách không còn được đảm bảo.

Salting chỉ dùng được khi nghiệp vụ chấp nhận đánh đổi đó. Nếu bạn cần thứ tự tuyệt đối theo khách (ví dụ số dư ví), salting sẽ gây sai dữ liệu.

Lưu ý: Không dùng giá trị ngẫu nhiên hoặc timestamp làm bucket. Làm vậy thì các event của cùng một đơn sẽ rơi vào nhiều partition, và thứ tự bị phá vỡ hoàn toàn.

5.2. Kiểm thử chiến lược key

Test dưới đây kiểm tra hai hành vi chính: cùng đơn thì cùng partition, và khách nóng được rải ra nhiều partition.

import org.junit.jupiter.api.Test;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

import static org.assertj.core.api.Assertions.assertThat;

class OrderKeyStrategyTest {

    private static final int PARTITIONS = 12;
    private final OrderKeyStrategy strategy = new OrderKeyStrategy(Set.of("cust-big"), 8);

    @Test
    void sameOrder_alwaysGoesToSamePartition() {
        var created = new OrderEvent("order-1", "cust-big", "CREATED");
        var paid = new OrderEvent("order-1", "cust-big", "PAID");

        int p1 = PartitionCalculator.partitionFor(strategy.keyFor(created), PARTITIONS);
        int p2 = PartitionCalculator.partitionFor(strategy.keyFor(paid), PARTITIONS);

        assertThat(p1).isEqualTo(p2);
    }

    @Test
    void hotCustomer_isSpreadAcrossPartitions() {
        Set<Integer> partitions = IntStream.range(0, 1_000)
                .mapToObj(i -> new OrderEvent("order-" + i, "cust-big", "CREATED"))
                .map(e -> PartitionCalculator.partitionFor(strategy.keyFor(e), PARTITIONS))
                .collect(Collectors.toSet());

        assertThat(partitions).hasSizeGreaterThan(1);
    }

    @Test
    void normalCustomer_keepsCustomerIdAsKey() {
        var event = new OrderEvent("order-9", "cust-small", "CREATED");
        assertThat(strategy.keyFor(event)).isEqualTo("cust-small");
    }
}

Test dùng PartitionCalculator ở mục 2.1, vốn tái hiện đúng công thức của producer. Vì vậy ta không cần dựng broker mà vẫn kiểm chứng được nơi message thực sự rơi vào.

6. Phía consumer: đừng tự phá thứ tự

Producer giữ thứ tự đúng chưa đủ. Consumer cũng có thể làm hỏng nó.

import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;

@Component
public class OrderEventListener {

    @KafkaListener(topics = "order-events", groupId = "order-service", concurrency = "6")
    public void onEvent(OrderEvent event) {
        // Xử lý tuần tự trong thread của listener
        System.out.println(Thread.currentThread().getName() + " " + event);
    }
}

concurrency = "6" tạo 6 consumer thread. Mỗi partition được gán cho đúng một thread, nên thứ tự trong partition vẫn được giữ. Nếu topic có 6 partition, mỗi thread nhận một partition. Nếu đặt concurrency lớn hơn số partition, các thread thừa sẽ ngồi không.

Những lỗi hay gặp ở phía consumer:

  • Đẩy message sang thread pool riêng (executor.submit(...)) bên trong listener. Hai event của cùng đơn có thể chạy song song, và PAID có thể xong trước CREATED. Nếu cần song song hóa sâu hơn mức partition, hãy song song theo key, mỗi key một hàng đợi tuần tự. Đừng song song theo từng message.
  • Retry bằng cách gửi sang topic khác (retry topic, non-blocking retry). Message lỗi bị tách khỏi dòng chính, và các message sau của cùng key vẫn được xử lý tiếp. Với luồng cần thứ tự nghiêm ngặt, nên dùng blocking retry qua DefaultErrorHandler kèm BackOff. Cách này chấp nhận partition bị dừng tạm thời.

7. Thay đổi số partition: cái bẫy âm thầm

Vì công thức có phép % numPartitions, việc tăng partition từ 6 lên 12 sẽ làm phần lớn key chuyển sang partition khác:

public class RepartitionDemo {
    public static void main(String[] args) {
        String key = "order-1001";
        System.out.println("6 partitions  -> " + PartitionCalculator.partitionFor(key, 6));
        System.out.println("12 partitions -> " + PartitionCalculator.partitionFor(key, 12));
    }
}
6 partitions  -> 3
12 partitions -> 9

Các event cũ của order-1001 nằm ở partition 3 và có thể chưa được xử lý hết. Event mới thì vào partition 9. Hai consumer khác nhau có thể xử lý chúng cùng lúc, và thứ tự bị đảo.

Cách giảm rủi ro:

  • Ước lượng dư số partition ngay từ đầu. Tính theo throughput mục tiêu chia cho throughput của một consumer, rồi nhân hệ số dự phòng.
  • Nếu buộc phải tăng partition, hãy dừng producer, chờ consumer xử lý hết lag, tăng partition, rồi mới bật lại producer.
  • Hoặc tạo topic mới với số partition lớn hơn, rồi chuyển dần luồng sang topic mới.

Lưu ý: Kafka không hỗ trợ giảm số partition. Chọn quá nhiều partition cũng có chi phí: tốn tài nguyên trên broker, rebalance chậm hơn, và nhiều file mở hơn. Hãy dư vừa đủ, đừng đặt 1000 partition “cho chắc”.

8. Khi nào cần custom partitioner

Bạn hiếm khi cần tự viết Partitioner. Đa số trường hợp, chọn key đúng là đủ. Custom partitioner chỉ hợp lý khi bạn cần ánh xạ tường minh, ví dụ dành riêng vài partition cho khách VIP.

Đổi lại, custom partitioner có những nhược điểm:

  • Mọi producer ghi vào topic đó, kể cả service viết bằng ngôn ngữ khác, phải dùng cùng logic. Nếu không, thứ tự bị phá.
  • Các công cụ như Kafka Streams hay Kafka Connect mặc định dùng murmur2. Chúng có thể không khớp với cách phân partition của bạn.

Vì vậy, hãy ưu tiên giải quyết bằng thiết kế key trước khi nghĩ tới partitioner.

9. Kết luận

Partition key là quyết định thiết kế chứ không chỉ là một tham số của send(). Các điểm chính cần nhớ:

  • Kafka chỉ đảm bảo thứ tự trong một partition. Cùng key và cùng số partition thì cùng partition, theo công thức murmur2 chia lấy dư.
  • Chọn key là định danh của nhóm nhỏ nhất cần giữ thứ tự. Key càng nhiều giá trị thì phân tải càng đều.
  • Xử lý hot key bằng salting, nhưng chỉ khi nghiệp vụ chấp nhận mất thứ tự giữa các bucket.
  • Bật idempotence ở producer. Ở consumer, không đẩy message sang thread pool tùy tiện, và cân nhắc kỹ trước khi dùng non-blocking retry.
  • Tránh tăng partition trên topic đang chạy nếu luồng cần thứ tự. Hãy ước lượng dư ngay từ đầu.

Nắm vững những điểm này, bạn có thể thiết kế topic vừa đúng thứ tự vừa mở rộng được khi lưu lượng tăng.

kafkaspring-bootpartitionmessagingjava