Bài trước kết thúc bằng một cảnh báo: "đã gửi" không phải "đã xử lý đúng". Khoảng cách giữa hai mốc đó là nơi sinh ra loại bug tốn tiền nhất trong hệ thống bất đồng bộ — message bị mất (khách đặt hàng mà đơn biến mất) hoặc bị xử lý trùng (trừ tiền khách hai lần). Mức độ hệ thống chống lại hai lỗi này gọi là ngữ nghĩa giao nhận (delivery semantics), và có ba mức: at-most-once, at-least-once, exactly-once. Chúng nghe trừu tượng, nhưng khác biệt thực ra rất cụ thể — nằm gọn ở câu hỏi: consumer commit offset trước hay sau khi xử lý xong? Bài này (phần 2 loạt Message Queue) chạy thật trên Kafka để đo số message mất và trùng trong từng chế độ.

Ba ngữ nghĩa, khác nhau ở thời điểm commit

Kafka theo dõi consumer đã đọc tới đâu bằng committed offset. Chính thời điểm commit so với thời điểm xử lý quyết định ngữ nghĩa:

  • At-most-once — commit offset trước khi xử lý. read → commit → xử lý. Nếu crash giữa commit và xử lý xong, message đó coi như đã đọc (offset đã tiến) nhưng chưa hề được xử lý → mất. Không bao giờ trùng, nhưng có thể mất.
  • At-least-once — commit offset sau khi xử lý. read → xử lý → commit. Nếu crash sau khi xử lý nhưng trước commit, offset chưa tiến → lần sau đọc lại message đó → xử lý trùng. Không bao giờ mất, nhưng có thể trùng.
  • Exactly-once — không mất, không trùng. Trong Kafka, đạt được bằng idempotent producer + transaction + consumer đọc read_committed. Mạnh nhất, nhưng có cái giá về hiệu năng và độ phức tạp.
# TRÙNG (at-least-once): đọc không commit rồi đọc lại
kafka-console-consumer.sh --group g-alo --from-beginning \
  --consumer-property enable.auto.commit=false
# MẤT (at-most-once): dời offset tới cuối (commit mà chưa xử lý)
kafka-consumer-groups.sh --group g-amo --reset-offsets --to-latest --execute
# EXACTLY-ONCE phía producer:
kafka-console-producer.sh --producer-property enable.idempotence=true \
  --producer-property acks=all

Ảnh chụp sơ đồ nền tối ba ngữ nghĩa giao nhận khác ở chỗ commit offset, at-most-once commit offset trước khi xử lý read commit xử lý crash giữa chừng thì mất không trùng, at-least-once commit offset sau khi xử lý read xử lý commit crash trước commit thì đọc lại trùng không mất, exactly-once idempotent producer cộng transaction cộng read_committed không mất không trùng nhưng có giá overhead, các lệnh demo thật Kafka CLI trùng đọc không commit rồi đọc lại enable auto commit false, mất dời offset tới cuối reset-offsets to-latest, exactly-once phía producer enable idempotence true acks all

Hình 1: Ba ngữ nghĩa khác nhau duy nhất ở thời điểm commit offset so với xử lý. Commit trước xử lý → nguy cơ mất (at-most-once); commit sau xử lý → nguy cơ trùng (at-least-once); exactly-once cần idempotent producer + transaction + read_committed. Bên dưới là các lệnh Kafka CLI dùng để tái hiện thật.

Đo thật: 1000 message, đếm mất và trùng

Mình produce 1000 message đánh số vào kafka-lab, rồi tái hiện từng chế độ và đếm kết quả:

Ảnh chụp output thật nền tối đo mất và trùng từng chế độ Kafka CLI 1000 message đánh số, at-least-once trùng đọc lại khi chưa commit lần 1 nhận 1000 cộng lần 2 nhận 1000 bằng giao nhận 2000 message duy nhất 1000 trùng bằng 1000 committed offset chưa có, at-most-once mất commit vượt trước xử lý reset offset to-latest committed bằng 1000 LAG bằng 0 consumer đọc tiếp nhận 0 trên 1000 mất bằng 1000 LAG 0 nhưng không message nào được xử lý khoẻ giả, exactly-once không mất không trùng producer idempotent producer acks all ghi topic có đúng 1000 không trùng dù retry EOS đầy đủ cần transaction read_committed, mặc định thực tế at-least-once cộng idempotency ở consumer rẻ và an toàn hơn EOS đầy đủ

Hình 2: Kết quả thật. At-least-once: đọc không commit hai lần → 1000 + 1000 giao nhận cho 1000 message duy nhất → trùng 1000. At-most-once: dời committed offset tới cuối (1000) rồi đọc → nhận 0/1000 → mất 1000, dù LAG hiển thị 0. Exactly-once: idempotent producer ghi → topic có đúng 1000, không trùng.

Đọc kết quả:

  • At-least-once → trùng 1000: mình cho consumer đọc với enable.auto.commit=false (không bao giờ commit), mô phỏng consumer crash trước khi commit. Lần 1 nhận đủ 1000 message; vì committed offset vẫn trống, consumer "khởi động lại" ở lần 2 đọc lại từ đầu đúng 1000 message nữa. Tổng giao nhận 2000 cho 1000 message duy nhất → 1000 lần xử lý trùng. Đây chính xác là điều xảy ra khi consumer xử lý xong, gửi email/trừ tiền, rồi chết trước khi commit.
  • At-most-once → mất 1000: mình dời committed offset của group tới cuối (--reset-offsets --to-latest), mô phỏng "đã commit mà chưa xử lý". Consumer sau đó đọc tiếp và nhận 0/1000 message — toàn bộ 1000 message không bao giờ được xử lý. Điều đáng sợ nhất: LAG = 0, dashboard báo consumer "khoẻ, đã bắt kịp" — một tín hiệu khoẻ giả trong khi thực tế mất sạch.
  • Exactly-once (phía producer) → đúng 1000: idempotent producer (enable.idempotence=true, acks=all) ghi 1000 message và topic chứa chính xác 1000 — producer tự khử trùng ngay cả khi phải retry (một produce timeout nhưng thực ra đã thành công sẽ không tạo bản sao). Đây là nửa producer của exactly-once.

Vì sao "đúng 1000" của idempotent producer chưa phải exactly-once đầy đủ

Phải nói thẳng để không gây hiểu nhầm: demo exactly-once ở trên chỉ chứng minh nửa phía producer — rằng idempotent producer không tạo bản sao khi retry. Exactly-once end-to-end (đọc–xử lý–ghi đúng một lần) cần thêm hai mảnh: transaction (gom việc ghi kết quả và commit offset vào một giao dịch nguyên tử, để hoặc cả hai cùng xảy ra hoặc không), và consumer phía sau đọc ở chế độ read_committed (chỉ thấy message đã commit giao dịch). Mình không demo được trọn vẹn phần transaction bằng CLI nên nói rõ cơ chế thay vì phô trương một con số không chứng minh đủ. Và ngay cả EOS đầy đủ của Kafka cũng chỉ đúng trong phạm vi Kafka; nếu consumer gọi ra một hệ ngoài (API thanh toán) thì exactly-once phải tự lo bằng idempotency (bài sau).

Đánh đổi cần cân nhắc

At-least-once là mặc định thực tế — vì mất tệ hơn trùng. Hầu hết hệ thống chọn at-least-once: thà xử lý trùng (và khử trùng bằng idempotency) còn hơn mất message. Lý do: trùng thường sửa được (dedup theo key), còn mất thì không — dữ liệu đã biến mất. Nên công thức phổ biến nhất không phải exactly-once đắt đỏ, mà là at-least-once + consumer idempotent (chủ đề bài kế). Đây là lựa chọn kỹ thuật đúng cho đa số, không phải thoả hiệp.

Exactly-once có giá, không phải nút bấm thần kỳ. Transaction thêm độ trễ (chờ commit giao dịch), giảm throughput, và tăng độ phức tạp vận hành. Nhiều team bật EOS vì "nghe an toàn nhất" rồi trả giá hiệu năng cho một đảm bảo họ không thực sự cần. Chỉ dùng EOS khi bản chất nghiệp vụ không chịu được trùng và không thể khử trùng ở tầng ứng dụng.

LAG = 0 không có nghĩa "mọi thứ ổn". Demo at-most-once phơi bày điều này: offset tiến tới cuối, LAG về 0, mọi dashboard xanh — nhưng 1000 message bốc hơi. Giám sát consumer phải nhìn thêm throughput xử lý thật (số message xử lý thành công), không chỉ LAG. Một consumer commit offset nhưng âm thầm bỏ qua message sẽ cho LAG đẹp mà mất dữ liệu.

Ba ý mang về

  1. Khác biệt nằm ở thời điểm commit offset: đo thật commit sau xử lý mà crash trước commit → đọc lại → trùng 1000; commit trước xử lý (offset dời tới cuối) → mất 1000. Chọn ngữ nghĩa thực chất là chọn commit trước hay sau xử lý.
  2. Mất tệ hơn trùng, nên at-least-once + idempotency là mặc định: đo thật at-least-once cho trùng (sửa được bằng dedup), at-most-once cho mất (không cứu được) và còn ngụy trang bằng LAG=0; vì vậy công thức thực tế là at-least-once rồi khử trùng ở consumer.
  3. Exactly-once có thật nhưng có giá và có giới hạn: đo thật idempotent producer ghi đúng 1000 không trùng, nhưng EOS đầy đủ cần thêm transaction + read_committed, chỉ đúng trong phạm vi Kafka, và đánh đổi throughput — đừng bật nếu không thực sự cần.

Nguồn

Phần sau ta mổ xẻ consumer group và partition — cơ chế để mở rộng throughput bằng cách chia tải cho nhiều consumer, đo thật throughput tăng khi thêm consumer và cái gì xảy ra lúc rebalancing.