Phần 6 đã đo cơ chế giao dịch và cái nó để lại trên đĩa. Bài này đo cái giá — và cái giá phụ thuộc hoàn toàn vào một con số bạn tự chọn.

Thông lượng theo cỡ giao dịch, chi phí offset, và phía đọc

Cỡ giao dịch quyết định tất cả

Tin 300 byte, acks=all, cùng một máy, chỉ đổi số tin gói trong mỗi giao dịch:

Thông lượng So với không giao dịch
không giao dịch 602.936 tin/giây 1,00×
giao dịch cỡ 1 96 tin/giây 1/6.280
giao dịch cỡ 10 1.857 1/325
giao dịch cỡ 100 58.138 1/10
giao dịch cỡ 1.000 475.918 1/1,27
giao dịch cỡ 10.000 639.285 1,06×

Một tin một giao dịch cho ra 96 tin mỗi giây. Sáu nghìn hai trăm tám mươi lần chậm hơn.

Ở cỡ 10.000, thông lượng cao hơn cả không giao dịch — chênh lệch nằm trong nhiễu, nhưng nó cho thấy chi phí cố định của giao dịch đã tan biến hoàn toàn khi chia cho đủ số tin.

Con số 96 tin/giây tương đương 10,4 mili giây cho mỗi giao dịch. Đó là chi phí cố định: hai vòng trao đổi với điều phối viên giao dịch (đăng ký partition và commit), cộng với việc ghi bản ghi điều khiển lên mọi partition đã chạm tới.

10 mili giây không lớn. Nhưng nếu bạn trả nó cho từng tin thì không có cách nào đi nhanh hơn 100 tin mỗi giây, bất kể máy mạnh cỡ nào.

Bản ghi điều khiển làm lag không bao giờ về 0

Phần 6 đã nêu hiện tượng này; đây là con số chính xác:

Offset cuối Tin đọc được Chênh Tỉ lệ
không giao dịch 200.000 200.000 0 0%
giao dịch cỡ 100 101.000 100.000 1.000 1%
giao dịch cỡ 1.000 100.100 100.000 100 0,1%

Mỗi giao dịch để lại một bản ghi điều khiển chiếm một offset mà không consumer nào đọc được.

Hệ quả: consumer đã đọc hết mọi thứ nó đọc được vẫn hiện lag = 1.000 trên topic dùng giao dịch cỡ 100. Và con số đó không bao giờ giảm.

Giao dịch càng nhỏ, lag cố định càng lớn theo tỉ lệ. Một tin một giao dịch nghĩa là 50% offset là bản ghi điều khiển.

Đây là lý do thứ hai — sau thông lượng — để không dùng giao dịch nhỏ: nó phá vỡ mọi bảng theo dõi lag của bạn.

Phía đọc gần như miễn phí

read_uncommitted   141,6 MB/giây
read_committed     131,8 MB/giây      chậm 7%

Chỉ 7%. Broker phải lọc bản ghi của giao dịch bị huỷ, nên nó mất sendfile (phần 19), nhưng chi phí thực tế nhỏ.

Chi phí của exactly-once nằm gần hết ở phía ghi. Nếu bạn đang cân nhắc, hãy đo phía producer trước.

Cỡ giao dịch: đánh đổi với độ trễ

Từ bảng đầu tiên, cỡ càng lớn càng nhanh. Nhưng có một giới hạn ngược chiều mà bảng đó không cho thấy.

Consumer đọc read_committed không thấy tin nào cho tới khi cả giao dịch commit. Giao dịch 10.000 tin mất 156 ms để gom xong, nên mọi tin trong đó đều trễ ít nhất 156 ms — kể cả tin đầu tiên, vốn đã sẵn sàng từ mili giây thứ nhất.

Với hệ thống cần phản hồi nhanh, đây là chi phí thật và nó không hiện ra trong phép đo thông lượng.

Vùng hợp lý: 500 đến 5.000 tin mỗi giao dịch, hoặc gói theo thời gian — commit mỗi 100 ms bất kể được bao nhiêu tin. Cách thứ hai đặt trần cho độ trễ, và đó thường là thứ bạn muốn kiểm soát.

Không bao giờ nên: một tin một giao dịch.

Khi nào thật sự cần giao dịch

Giao dịch có nghĩa cho đúng một mẫu: đọc từ Kafka, xử lý, ghi lại vào Kafka, và cam kết offset đọc trong cùng giao dịch với việc ghi.

pr.beginTransaction();
for (var r : records) pr.send(bienDoi(r));
pr.sendOffsetsToTransaction(offsets, c.groupMetadata());   // <- mấu chốt
pr.commitTransaction();

Dòng sendOffsetsToTransaction là thứ khiến nó thành exactly-once: offset đọc và dữ liệu ghi cùng thành công hoặc cùng thất bại. Không có dòng đó thì bạn chỉ có giao dịch trên phía ghi, và vẫn có thể xử lý trùng khi consumer chết giữa chừng.

Kafka Streams làm sẵn toàn bộ chuyện này khi bật processing.guarantee=exactly_once_v2, và nó cũng tự chọn cỡ giao dịch hợp lý. Nếu đường ống của bạn là Kafka vào và Kafka ra, dùng Streams thay vì tự viết là lựa chọn đúng.

Khi nào không cần

Kafka → cơ sở dữ liệu. Giao dịch Kafka không bao trùm PostgreSQL. Cách đúng là lưu offset đã xử lý trong cùng bảng và cùng giao dịch với dữ liệu, rồi seek() về đó khi khởi động — phần 8 đã mô tả. Cách này cho exactly-once thật, và không tốn gì của Kafka.

Chỉ ghi, không đọc từ Kafka. Nếu producer của bạn không tiêu thụ từ Kafka thì enable.idempotence=true (mặc định từ 3.0) đã đủ chống trùng do thử lại. Giao dịch không thêm gì.

Consumer chịu được xử lý trùng. Nếu phía tiêu thụ dùng INSERT ... ON CONFLICT DO NOTHING hoặc khoá tự nhiên, "ít nhất một lần" đã tương đương exactly-once về mặt kết quả — và rẻ hơn nhiều.

Đây là trường hợp phổ biến nhất, và nó thường là câu trả lời đúng. Giao dịch Kafka giải quyết một bài toán hẹp; phần lớn hệ thống không có bài toán đó.

Hai tham số dễ quên

transactional.id phải ổn định qua các lần khởi động lạiduy nhất cho mỗi tiến trình producer. Nó là thứ Kafka dùng để nhận ra một producer đã chết và chặn bản cũ ghi tiếp. Sinh ngẫu nhiên mỗi lần khởi động là làm hỏng cơ chế đó; dùng chung một giá trị giữa nhiều tiến trình thì chúng đá nhau ra khỏi cụm.

transaction.timeout.ms mặc định 60.000 và phải nhỏ hơn transaction.max.timeout.ms của broker (900.000). Giao dịch vượt quá thời gian đó bị huỷ tự động — và nếu bạn gói quá nhiều tin, đó là một cách mất dữ liệu rất khó lần ra.

Thử ba mươi giây

Xem topic của bạn có dùng giao dịch không, và tốn bao nhiêu offset:

# offset cuối
kafka-get-offsets.sh --bootstrap-server kf:9092 --topic ten-topic

# số tin thật sự đọc được
kafka-console-consumer.sh --bootstrap-server kf:9092 --topic ten-topic \
  --from-beginning --timeout-ms 30000 \
  --consumer-property isolation.level=read_committed | wc -l

Chênh lệch chia cho số tin là tỉ lệ bản ghi điều khiển. Nếu nó trên 1%, giao dịch của bạn quá nhỏ — và bảng đo ở đầu bài cho thấy thông lượng đang trả giá cho điều đó.

Phần sau đo Kafka Streams: xử lý dòng dữ liệu ngay trong Kafka.