Kafka Consumer Groups: Chia việc, Offset và Rebalancing

Simplicity is a prerequisite for reliability. – Edsger W. Dijkstra

6 phút đọc
Nội dung bài viết
Bài viết này cũng có bản English.
banner

Hi các bạn 👋. Mình là Hùng Anh.

Trong bài Message Broker mình đã giới thiệu tổng quan về Kafka. Hôm nay đi sâu vào một mảnh ghép mà theo mình là quan trọng nhất để vận hành Kafka đúng cách: consumer group.

Thử một câu hỏi kiểm tra nhanh: topic orders4 partition, bạn bật 5 consumer cùng một group để "xử lý cho nhanh". Kết quả? Một consumer ngồi chơi hoàn toàn — và nếu không hiểu vì sao, sớm muộn bạn cũng sẽ gặp những chuyện khó chịu hơn: message xử lý trùng, group đứng hình vài giây mỗi lần deploy, hay lag tăng vọt không rõ lý do.

Bắt đầu thôi.

Consumer group là gì?

Một consumer group là một tập consumer cùng chia nhau đọc một topic, nhận diện bằng group.id. Kafka áp một luật duy nhất nhưng quyết định tất cả:

Trong một group, mỗi partition thuộc về đúng một consumer. Một consumer có thể giữ nhiều partition, nhưng một partition không bao giờ bị hai consumer trong cùng group đọc song song.

Luật này cho bạn hai chế độ tiêu thụ chỉ bằng cách đặt group.id:

  • Chia việc (work queue): các instance của cùng một service dùng chung một group.id → mỗi message chỉ một instance xử lý. Thêm instance = chia việc nhỏ hơn = nhanh hơn.
  • Phát cho tất cả (pub/sub): các service khác nhau dùng group.id khác nhau → mỗi service đều nhận đủ toàn bộ message. Service billing và service notification cùng đọc orders, không giẫm chân nhau.

Và hệ quả của luật partition-một-chủ: số consumer hữu ích tối đa = số partition. 4 partition thì con thứ 5 thất nghiệp — đó là câu trả lời cho câu hỏi đầu bài. Muốn scale ngang sâu hơn, phải tăng partition trước, tăng consumer sau.

Offset: cuốn sổ đánh dấu "đọc đến đâu"

Mỗi message trong một partition có một số thứ tự tăng dần gọi là offset. Consumer đọc tới đâu thì commit offset tới đó vào một topic nội bộ của Kafka (__consumer_offsets) — như kẹp bookmark vào sách. Consumer chết đi sống lại, hoặc partition được giao cho consumer khác, thì cứ mở bookmark ra đọc tiếp.

Điểm bẫy nằm ở thời điểm commit:

# mặc định: tự commit mỗi 5 giây
enable.auto.commit=true
auto.commit.interval.ms=5000

Auto-commit tiện nhưng mơ hồ: nó commit theo đồng hồ, không theo tiến độ xử lý thật. Hai tai nạn đối xứng nhau:

  • Commit trước khi xử lý xong → consumer crash giữa chừng → message coi như đã đọc → mất message.
  • Xử lý xong nhưng chưa kịp commit → crash → consumer mới đọc lại từ offset cũ → xử lý trùng.

Thực tế, Kafka mặc định cho bạn ngưỡng an toàn at-least-once (không mất, có thể trùng) nếu bạn commit sau khi xử lý xong:

while (true) {
    ConsumerRecords<String, Order> records = consumer.poll(Duration.ofMillis(200));
    for (ConsumerRecord<String, Order> r : records) {
        process(r.value());          // 1. xử lý xong trước
    }
    consumer.commitSync();           // 2. rồi mới commit
}

Vế còn lại — "có thể trùng" — thì xử ở tầng nghiệp vụ: làm cho việc xử lý idempotent (khoá duy nhất theo order_id, INSERT ... ON DUPLICATE KEY, kiểm tra trạng thái trước khi cập nhật…). Công thức mình luôn dùng: commit-sau-xử-lý + consumer idempotent — đơn giản, đúng cho 95% hệ thống.

Rebalancing: chia lại bài khi có người vào/ra

Khi thành viên của group thay đổi — một consumer mới join, một con crash, hay chỉ là deploy lại service — Kafka phải chia lại partition cho các thành viên còn lại. Quá trình đó gọi là rebalance.

C3 join vào group: Kafka chia lại partition — C2 nhường P3 cho C3

Rebalance là tính năng sống còn (không có nó thì consumer chết là partition mồ côi), nhưng cũng là nguồn đau đầu vận hành số một, vì kiểu rebalance cổ điển (eager) bắt toàn bộ group ngừng đọc trong lúc chia lại — người ta gọi là "stop the world". Group to, deploy rolling 20 instance = 20 lần cả thế giới khựng lại.

Ba điều đáng biết để bớt đau:

  • Cooperative rebalancing: từ Kafka 2.4, đặt partition.assignment.strategy=CooperativeStickyAssignor — chỉ những partition thực sự đổi chủ mới tạm dừng, phần còn lại đọc tiếp bình thường. Gần như luôn nên bật cho consumer mới viết.
  • Static membership: đặt group.instance.id cố định cho mỗi instance — restart nhanh (trong session.timeout.ms) sẽ không kích hoạt rebalance, cực hữu ích cho rolling deploy.
  • Đừng để bị đá oan: Kafka đá consumer khỏi group nếu quá max.poll.interval.ms (mặc định 5 phút) mà không gọi poll(). Batch xử lý quá lâu → bị đá → rebalance → message chia lại cho con khác → lại xử lý trùng. Chỉnh max.poll.records nhỏ lại hoặc tăng max.poll.interval.ms cho khớp thời gian xử lý thật.

Consumer lag: chỉ số sức khoẻ số một

Lag của một partition = offset mới nhất − offset đã commit — tức số message đang xếp hàng chờ. Lag tăng đều nghĩa là consumer xử lý không kịp producer; lag nhảy vọt sau deploy thường là dấu hiệu rebalance có vấn đề.

kafka-consumer-groups.sh --bootstrap-server localhost:9092 \
  --describe --group billing

# TOPIC   PARTITION  CURRENT-OFFSET  LOG-END-OFFSET  LAG
# orders  0          51200           51230           30
# orders  1          48100           52400           4300   ← con này có chuyện

Lag lệch hẳn ở một partition thường chỉ về hai thủ phạm: partition key bị lệch (một key quá nóng dồn hết vào một partition) hoặc một consumer cụ thể đang ốm. Đây là metric đáng đặt cảnh báo nhất trong toàn bộ hệ Kafka của bạn.

Kết luận

Gói lại những điều đáng nhớ:

  1. Partition là đơn vị song song — số consumer hữu ích tối đa bằng số partition; scale partition trước, consumer sau.
  2. Chung group = chia việc, khác group = mỗi bên nhận đủ — một cái group.id quyết định cả mô hình tiêu thụ.
  3. Commit sau xử lý + idempotent — công thức at-least-once thực dụng cho đa số hệ thống.
  4. Rebalance là bạn, "stop the world" là kẻ thù — CooperativeStickyAssignor + static membership + max.poll.interval.ms chỉnh đúng.
  5. Theo dõi lag — chỉ số sớm nhất báo cho bạn biết pipeline đang tụt.

Hẹn gặp lại các bạn ở những bài viết sau. Nếu thấy bài viết hữu ích, đừng quên chia sẻ và để lại bình luận nhé 👇.

Tài liệu tham khảo

Do Hung AnhDo Hung Anh@anhdh

Bài viết liên quan

Transaction Isolation Levels: 4 nấc thang cô lập trong MySQL

Hai transaction cùng đọc–ghi một dòng dữ liệu thì chuyện gì xảy ra? Tuỳ vào isolation level bạn chọn, cùng một câu SELECT có thể trả về những kết quả rất khác nhau. Bài này mình giải thích 3 hiện tượng đọc "bẩn", 4 mức cô lập của SQL, và cách InnoDB hiện thực chúng bằng MVCC — kèm ví dụ chạy được để bạn tự kiểm chứng.

8 phút đọc

Cache với Redis: Cache-Aside và 3 sự cố kinh điển

Thêm một lớp cache Redis trước database nghe thì đơn giản: có thì trả, không có thì xuống DB lấy rồi cất lại. Nhưng đằng sau pattern quen thuộc đó là ba cái bẫy đã đánh sập không ít hệ thống lớn: penetration, breakdown và avalanche. Bài này mình giải thích pattern Cache-Aside cho đúng, vì sao dữ liệu cache và DB có thể lệch nhau, và cách phòng cả ba sự cố.

7 phút đọc

Distributed Lock và Cách Triển Khai với Redis

Trong các hệ thống phân tán, việc đảm bảo tính nhất quán của dữ liệu (data consistency) và ngăn chặn tranh chấp tài nguyên (race condition) là một thách thức lớn, đặc biệt khi nhiều tiến trình hoặc service truy cập đồng thời vào các tài nguyên dùng chung. Distributed lock là giải pháp hiệu quả để xử lý vấn đề này. Bài viết này mình sẽ giúp bạn hiểu rõ distributed lock là gì, tại sao nó cần thiết, các phương pháp để thực hiện và cách triển khai nó với Redis nhé.

19 phút đọc

Đừng bỏ lỡ bài mới nhé!