Khi một hệ thống gửi tiền, trừ kho, hay tính cước, câu hỏi "message này có chắc chắn được xử lý đúng một lần không?" không còn là chi tiết kỹ thuật — nó là đúng/sai nghiệp vụ. Kafka cho bạn ba mức đảm bảo giao message, và chọn sai mức nghĩa là hoặc mất dữ liệu, hoặc xử lý trùng (trừ tiền hai lần). Ba mức đó — at-most-once, at-least-once, exactly-once — không phải nút gạt duy nhất mà là kết quả của vài tham số: chủ yếu là acks phía producer và enable.idempotence. Bài này (phần 4/12) mổ xẻ cơ chế rồi đo thật, với vài con số đi ngược trực giác.

Ba mức đảm bảo và cơ chế đằng sau

Mấu chốt nằm ở acks — producer chờ xác nhận tới đâu trước khi coi một message là "đã gửi":

Ảnh chụp đoạn mã nền tối minh hoạ ba mức đảm bảo giao message mất trùng hay đúng một lần, acks quyết định producer chờ xác nhận tới đâu retry cộng idempotence quyết định có trùng hay không. Một at-most-once có thể mất acks bằng 0 producer gửi rồi quên không chờ broker xác nhận producer gửi msg tới broker mạng rớt hoặc broker bận msg bay mất không ai biết nhanh nhất về lý thuyết nhưng chấp nhận mất dữ liệu chỉ hợp log metric bỏ được. Hai at-least-once có thể trùng acks bằng all cộng retry chờ broker ghi xong mới coi là thành công producer gửi msg broker ghi OK ack mất trên đường về producer retry gửi lại msg broker ghi lần nữa message trùng không mất nhưng consumer phải chịu được trùng xử lý idempotent. Ba exactly-once phía producer khử trùng enable.idempotence acks bằng all enable.idempotence true mỗi producer có producer-id PID cộng số thứ tự sequence cho mỗi message broker thấy PID seq đã ghi bỏ qua bản trùng trả ack retry an toàn ghi đúng một lần dù gửi lại mặc định bật từ Kafka 3.x

Hình 1: acks=0 gửi rồi quên — có thể mất (at-most-once). acks=all + retry — không mất nhưng có thể trùng khi ack bị mất (at-least-once). enable.idempotence=true gắn producer-id + số thứ tự để broker khử trùng — ghi đúng một lần (exactly-once phía producer).

  • At-most-once (acks=0): producer gửi rồi quên, không chờ broker xác nhận. Nếu mạng rớt hay broker bận, message bay mất mà không ai biết. Chỉ hợp dữ liệu bỏ được (metric, log sampling).
  • At-least-once (acks=all + retry): producer chờ broker ghi xong mới coi là thành công. Nếu ack mất trên đường về, producer tưởng thất bại và retry — broker ghi lần nữa → message trùng. Không mất dữ liệu, nhưng consumer phải chịu được trùng (xử lý idempotent phía nghiệp vụ).
  • Exactly-once phía producer (enable.idempotence=true): mỗi producer được gán một producer-id (PID) và đánh số thứ tự (sequence) cho từng message. Broker thấy cặp (PID, sequence) đã ghi rồi thì bỏ qua bản trùng và vẫn trả ack. Retry trở nên an toàn: ghi đúng một lần dù gửi lại. Từ Kafka 3.x, đây là mặc định bật.
# at-least-once + khử trùng phía producer (khuyến nghị mặc định)
kafka-producer-perf-test.sh --topic ds --num-records 1000000 --record-size 100 --throughput -1 \
  --producer-props bootstrap.servers=localhost:9092 acks=all enable.idempotence=true

Đo thật: throughput theo acks

Mình chạy cùng một tải (1 triệu bản ghi 100 byte) với bốn cấu hình acks khác nhau:

Ảnh chụp bảng kết quả đo thật throughput theo acks 1 triệu bản ghi output thật apache kafka 3.7.0 KRaft single-node bản ghi 100 byte. kafka-producer-perf-test.sh cùng tải đổi acks, acks bằng 0 at-most-once throughput 927.643 mỗi giây 88,5 MB mỗi giây độ trễ trung bình 3,36 ms p99 13 ms, acks bằng 1 chờ leader throughput 1.193.317 mỗi giây 113,8 MB mỗi giây độ trễ trung bình 5,15 ms p99 16 ms, acks bằng all chờ ISR throughput 1.053.740 mỗi giây 100,5 MB mỗi giây độ trễ trung bình 4,32 ms p99 14 ms, acks bằng all cộng idempotence throughput 1.118.568 mỗi giây 106,7 MB mỗi giây độ trễ trung bình 3,33 ms p99 12 ms. Chạy lại 2 triệu bản ghi cho cùng thứ hạng acks 0 chậm nhất 70 MB mỗi giây acks 1 nhanh nhất 134 MB mỗi giây acks all ở giữa 120 MB mỗi giây. Đọc trung thực hai điều một trên single-node acks all chỉ chờ đúng một replica bằng leader nên độ bền acks all xấp xỉ acks 1 khác biệt độ bền thật chỉ hiện trên cluster nhiều broker chênh lệch throughput ở đây là nhỏ và nhiễu, hai acks 0 chậm hơn acks 1 ngược trực giác gửi rồi quên phải nhanh nhất lặp lại hai lần đều vậy thiếu ack producer khó áp back-pressure và quản lý in-flight kém hiệu quả và idempotence gần như miễn phí 106 MB mỗi giây độ trễ 3,33 ms nên cứ bật

Hình 2: Đo thật trên single-node. acks=0: 88,5 MB/giây; acks=1: 113,8 MB/giây; acks=all: 100,5 MB/giây; acks=all + idempotence: 106,7 MB/giây. Chạy lại với 2 triệu bản ghi cho cùng thứ hạng (acks=0 chậm nhất, acks=1 nhanh nhất).

Hai điều cần đọc trung thực từ bảng này:

1. Trên single-node, acks=all gần như acks=1 — về cả độ bền lẫn tốc độ. acks=all nghĩa là "chờ mọi replica trong ISR xác nhận". Nhưng lab chỉ có một broker, replication-factor = 1, nên ISR chỉ có đúng một replica (chính là leader). Vì vậy acks=all ở đây không bền hơn acks=1 chút nào, và throughput cũng sát nhau. Khác biệt độ bền thật của acks=all chỉ xuất hiện trên cluster nhiều broker, nơi nó chờ dữ liệu được sao chép sang máy khác — mình sẽ nói kỹ ở bài kafka-09. Đừng để con số single-node đánh lừa rằng "acks=all gần như miễn phí"; trên cluster thật nó có cái giá đồng bộ qua mạng.

2. acks=0 lại CHẬM hơn acks=1 — ngược trực giác. "Gửi rồi quên" đáng lẽ phải nhanh nhất, nhưng đo thật (lặp lại hai lần, 1 triệu rồi 2 triệu bản ghi) đều cho acks=0 thấp hơn acks=1. Lý do: khi không có ack, producer mất tín hiệu để áp back-pressure và quản lý số request đang bay (in-flight) hiệu quả — nó gửi dồn dập, gây tranh chấp buffer, và nghịch lý là tổng throughput giảm. Đây là ví dụ điển hình vì sao phải đo thay vì suy luận: trực giác "ít chờ hơn = nhanh hơn" sai ở đây.

Và một điểm thực dụng: idempotence gần như miễn phí — acks=all + enable.idempotence=true cho 106,7 MB/giây với độ trễ trung bình 3,33 ms, ngang (thậm chí nhỉnh hơn) acks=all thuần. Vì nó rẻ và chặn trùng khi retry, hãy cứ bật — và Kafka 3.x đã bật sẵn.

Exactly-once end-to-end: còn hơn thế

Cần phân biệt rõ: enable.idempotence cho exactly-once khi ghi vào Kafka (producer → broker không tạo bản trùng). Nhưng "exactly-once" đầy đủ của một pipeline (đọc → xử lý → ghi) cần thêm transaction (transactional.id, isolation.level=read_committed) để gom việc đọc-xử-lý-ghi thành một đơn vị nguyên tử. Đó là cơ chế đứng sau Kafka Streams exactly-once. Với phần lớn ứng dụng, kết hợp at-least-once + consumer idempotent (consumer tự chịu được trùng, ví dụ dùng khoá nghiệp vụ để khử) là lựa chọn đơn giản và đủ mạnh; exactly-once transaction mạnh hơn nhưng phức tạp và tốn hơn.

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

Không có "exactly-once" miễn phí cho mọi thứ. Idempotent producer chỉ khử trùng trong một phiên producer và trên cùng partition. Nếu producer khởi động lại (PID mới), hoặc bạn ghi cùng dữ liệu từ hai chỗ, Kafka không biết đó là trùng về mặt nghiệp vụ. Khử trùng theo nghĩa nghiệp vụ (cùng đơn hàng gửi hai lần) vẫn là trách nhiệm của ứng dụng, thường bằng một khoá idempotency.

at-least-once là mặc định thực dụng, nhưng dồn gánh nặng sang consumer. Chọn at-least-once nghĩa là chấp nhận "thà trùng còn hơn mất", và consumer phải xử lý được message trùng mà không gây tác dụng phụ hai lần (dùng UPSERT theo khoá, kiểm tra đã xử lý chưa...). Nếu consumer không idempotent, at-least-once âm thầm gây lỗi nghiệp vụ.

acks=0 hiếm khi đáng dùng. Đo thật cho thấy nó không nhanh hơn (còn chậm hơn), lại chấp nhận mất dữ liệu. Gần như luôn có lựa chọn tốt hơn: acks=1 nếu chịu được mất hiếm khi leader chết trước lúc sao chép, hoặc acks=all trên cluster thật nếu cần độ bền. Giữ acks=0 cho đúng loại dữ liệu thực sự bỏ được.

Ba ý mang về

  1. acks + idempotence quyết định mất/trùng/đúng-một-lần. acks=0 có thể mất (at-most-once); acks=all + retry không mất nhưng có thể trùng (at-least-once); enable.idempotence=true khử trùng bằng producer-id + sequence (exactly-once phía producer, mặc định bật từ 3.x).
  2. Đo thật phá vỡ hai trực giác sai. Trên single-node acks=all ≈ acks=1 (chỉ một replica, nên độ bền không hơn — khác biệt thật cần cluster nhiều broker); và acks=0 chậm hơn acks=1 vì mất back-pressure. Idempotence gần như miễn phí (106 MB/giây) — cứ bật.
  3. Exactly-once đầy đủ cần hơn idempotent producer. Idempotence chỉ lo producer→broker; pipeline đọc-xử-lý-ghi exactly-once cần transaction. Thực dụng nhất cho đa số: at-least-once + consumer tự idempotent theo khoá nghiệp vụ.

Nguồn

Phần sau ta mổ xẻ offset: consumer nhớ đã đọc tới đâu bằng cách commit offset — auto-commit tiện nhưng nguy hiểm thế nào, khi nào phải commit thủ công, và cách tua lại (seek) để đọc lại từ một điểm.