Mọi hệ thống lưu trữ đều phải trả lời câu hỏi: đã ghi vào bộ đệm của hệ điều hành thì có coi là an toàn chưa? Kafka trả lời khác với cơ sở dữ liệu, và bài này đo cái giá của việc trả lời theo cách kia.
Kafka mặc định không gọi fsync
log.flush.interval.messages = 9223372036854775807 (Long.MAX_VALUE)
log.flush.interval.ms = null
Hai giá trị này nghĩa là: không bao giờ gọi fsync một cách chủ động. Broker ghi vào bộ đệm trang rồi trả lời producer ngay; việc đẩy xuống đĩa để hệ điều hành tự lo theo lịch của nó.
Đây là lựa chọn khác hẳn PostgreSQL, nơi mỗi lần commit đều buộc fsync vào WAL trước khi báo thành công.
Lý do khác biệt: độ bền của Kafka đến từ bản sao, không đến từ đĩa. Với replication-factor=3 và min.insync.replicas=2, một tin được xác nhận nghĩa là nó đã nằm trong bộ nhớ của ít nhất hai máy khác nhau. Một máy mất điện thì hai máy kia vẫn giữ. PostgreSQL một node không có ai để dựa vào, nên phải dựa vào đĩa.
Bật fsync tốn bao nhiêu
150.000 tin × 300 byte, batch.size=65536 (một lô chứa khoảng 210 tin), đo hai vòng:
flush.messages |
Vòng 1 | Vòng 2 |
|---|---|---|
| mặc định (không fsync) | 540k | 530k |
| 1 | 153k | 185k |
| 10 | 169k | 147k |
| 100 | 172k | 178k |
| 1.000 | 326k | 337k |
| 10.000 | 473k | 485k |
Ba giá trị 1, 10 và 100 cho cùng một kết quả — khoảng 170k. Lý do: một lô đã chứa ~210 tin, nên với bất kỳ ngưỡng nào ≤ 210, Kafka đều gọi fsync một lần cho mỗi lô. Ba con số đó là cùng một phép đo.
Chi phí của "fsync mỗi lô": 540k xuống 170k — còn chưa tới một phần ba.
Từ 1.000 trở lên, một lần fsync gộp nhiều lô nên chi phí phân bổ mỏng đi: 1.000 cho 331k, 10.000 cho 479k, và tiến dần về mức mặc định.
Phép đo trước đó của tôi bịa ra một quy luật không có thật
Lần đo đầu tiên cho kết quả hình chữ U, và tôi đã bắt đầu viết một mục để giải thích nó:
flush.messages=1 446k 473k <- nhanh nhất
flush.messages=10 170k 164k
flush.messages=200 101k 95k <- chậm nhất
flush.messages=5000 475k 402k
flush.messages=1 nhanh gấp bốn lần flush.messages=200. Kết quả lặp lại qua hai lần chạy, và nó ngược trực giác đến mức trông rất giống một phát hiện.
Sự thật: hai topic dùng cho giá trị 1 và 2 không hề có cấu hình đó. Lệnh tạo topic hỏng, tôi nuốt lỗi bằng 2>/dev/null như mọi khi, và hai ô đó thật ra đang đo chính giá trị mặc định — vốn nhanh nhất trong bảng.
Cách phát hiện: chạy kafka-configs.sh --describe cho từng topic trước khi đo.
C=$(kafka-configs.sh --bootstrap-server kf:9092 --describe \
--entity-type topics --entity-name w$F | grep -oE "flush.messages=[0-9]+")
[ "$C" = "flush.messages=$F" ] || echo "!! w$F cấu hình KHÔNG đúng: '$C'"
Hai dòng trả về rỗng. Bỏ chúng đi thì hình chữ U biến mất và còn lại một đường đơn điệu hợp lý.
Đây là lần thứ sáu trong loạt bài này tôi mất thời gian vì 2>/dev/null. Quy tắc đã rõ ràng rồi mà vẫn vấp: đừng nuốt lỗi khi dựng môi trường đo. Và thêm một quy tắc nữa từ ca này: xác minh rằng cấu hình bạn đang đo thật sự đã được áp dụng, đừng tin vào việc lệnh đã chạy.
Dấu hiệu đáng ngờ lẽ ra tôi phải bắt sớm hơn: ô "nhanh nhất" trong bảng có giá trị gần bằng ô "mặc định". Khi một cấu hình bật thêm việc lại nhanh bằng cấu hình không làm gì, khả năng cao là nó không được bật.
Còn flush.ms
log.flush.interval.ms đặt giới hạn theo thời gian thay vì theo số tin. Cùng tính chất: nó áp một trần cho lượng dữ liệu có thể mất khi máy mất điện, và trả bằng thông lượng.
Trong thực tế nó ít dùng hơn flush.messages, vì lượng dữ liệu mất được đo bằng byte chứ không bằng giây — và với lưu lượng thay đổi, cùng một khoảng thời gian ứng với những lượng dữ liệu rất khác nhau.
Cái tôi không đo được
Tôi đo được chi phí của fsync nhưng không đo được lợi ích — tức là chứng minh mất dữ liệu khi không có nó.
Để làm vậy cần cắt điện thật máy chủ. docker kill chỉ giết tiến trình, và bộ đệm trang của hệ điều hành vẫn còn nguyên nên dữ liệu không mất. Trong môi trường của tôi không có cách nào dựng lại một lần mất điện thật.
Nên phần dưới đây là lập luận, không phải phép đo, và tôi ghi rõ như vậy.
Khi nào nên bật
Câu trả lời cho gần như mọi trường hợp: để nguyên mặc định.
Kịch bản mà fsync cứu được là: cả min.insync.replicas broker cùng mất điện đột ngột trong khoảng thời gian giữa lúc ghi và lúc hệ điều hành đẩy dữ liệu xuống đĩa — cỡ vài chục giây với thiết lập mặc định của nhân Linux.
Với min.insync.replicas=2, đó là hai máy mất điện cùng lúc. Nếu hai máy đó nằm chung một tủ rack hoặc chung một nguồn điện, rủi ro là thật. Nhưng cách xử lý đúng không phải trả 3,2 lần thông lượng — mà là rải broker qua nhiều vùng nguồn điện, thứ vốn đã nên làm vì nhiều lý do khác.
Trả 3,2 lần thông lượng cho một rủi ro đã có cách xử lý rẻ hơn là đánh đổi tồi.
Hai trường hợp ngoại lệ hợp lý:
replication-factor=1. Không có bản sao thì không có gì đỡ, vàfsynclà thứ duy nhất còn lại. Nhưng nếu dữ liệu quan trọng tới mức cầnfsync, nó cũng quan trọng tới mức cần bản sao — sửareplication-factortrước.- Yêu cầu tuân thủ ghi rõ "dữ liệu phải nằm trên đĩa bền vững trước khi xác nhận". Đây là lý do hành chính, không phải kỹ thuật, và nó vẫn là lý do hợp lệ.
Ba thứ liên quan nên biết
Ổ đĩa cũng nói dối. fsync trả về không có nghĩa dữ liệu đã nằm trên vật liệu từ tính hay ô nhớ flash — nhiều ổ có bộ đệm ghi riêng và xác nhận sớm. Ổ dành cho máy chủ có tụ điện để xả bộ đệm khi mất điện; ổ tiêu dùng thì không. Bật fsync trên ổ tiêu dùng cho bạn cảm giác an toàn nhiều hơn là sự an toàn.
flush.messages đếm bản ghi, không đếm lô. Nhưng vì việc kiểm tra chỉ xảy ra ở ranh giới lô, mọi giá trị nhỏ hơn số bản ghi trong một lô đều tương đương nhau. Bảng đo ở trên chứng minh điều đó.
Đặt ở mức topic, không ở mức broker. Nếu có một topic thật sự cần fsync, đặt riêng cho nó chứ đừng bắt cả cụm trả giá.
Thử ba mươi giây
Kiểm cụm của bạn có topic nào đang bật fsync không:
for t in $(kafka-topics.sh --bootstrap-server kf:9092 --list); do
kafka-configs.sh --bootstrap-server kf:9092 --describe \
--entity-type topics --entity-name "$t" \
| grep -oE "flush.(messages|ms)=[0-9]+" | sed "s/^/$t /"
done
Mỗi dòng hiện ra là một topic đang trả khoảng ba lần thông lượng. Nếu bạn không nhớ vì sao nó được bật, đó là chỗ nên xem lại — và phần lớn trường hợp, replication-factor=3 cộng min.insync.replicas=2 đã cho bạn nhiều hơn thứ nó đang mua.
Phần sau chuyển sang cụm: KRaft, controller, và vì sao ZooKeeper biến mất.