Muốn biết một chiếc xe ăn bao nhiêu xăng lúc chạy đều, bạn không đo bằng cách: nổ máy lúc nguội, lăn bánh mười mét, tắt máy — rồi lặp lại. Làm thế bạn đo cái đề nổ và động cơ nguội, không đo lúc xe bon bon. Nghe hiển nhiên, vậy mà đây đúng là cách rất nhiều người đo độ trễ Kafka, và họ ra kết luận "Kafka chậm" sai gấp một nghìn lần. Phần này đo xem một broker thật sự tốn bao nhiêu, và mổ xẻ cái bẫy đó.

Dựng

Từ Kafka 3.3, KRaft đã ổn định và không cần ZooKeeper nữa. Một broker là một container:

docker run -d --name kf \
  -e KAFKA_NODE_ID=1 \
  -e KAFKA_PROCESS_ROLES=broker,controller \
  -e KAFKA_LISTENERS=PLAINTEXT://:9092,CONTROLLER://:9093 \
  -e KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://kf:9092 \
  -e KAFKA_CONTROLLER_QUORUM_VOTERS=1@kf:9093 \
  -e KAFKA_CONTROLLER_LISTENER_NAMES=CONTROLLER \
  -e KAFKA_LISTENER_SECURITY_PROTOCOL_MAP=CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT \
  -e KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR=1 \
  apache/kafka:3.9.0

PROCESS_ROLES=broker,controller nghĩa là node này vừa lưu dữ liệu vừa tham gia bầu chọn. Với một node thì bắt buộc phải thế; cụm thật thì tách ra, và phần 21 sẽ đo.

Ba biến cuối là ba thứ hay bị quên. ADVERTISED_LISTENERS là địa chỉ mà broker nói với client rằng hãy kết nối vào đây — nếu nó sai, client kết nối được lần đầu rồi thất bại ở lần thứ hai, và thông báo lỗi không chỉ ra nguyên nhân. OFFSETS_TOPIC_REPLICATION_FACTOR=1 phải đặt vì mặc định là 3, và một node thì không tạo nổi ba bản sao.

Khởi động và bộ nhớ

Dựng lại từ đầu ba lần:

Lần Thời gian Bộ nhớ
1 2,2 s 288,4 MB
2 3,0 s 290,6 MB
3 2,0 s 294,5 MB

Hai giây và 290 MB. Nhẹ hơn nhiều so với tiếng tăm của nó — phần lớn ấn tượng "Kafka nặng" đến từ thời còn phải chạy kèm một cụm ZooKeeper riêng.

Log khởi động cho thấy ba trạng thái:

The broker has caught up. Transitioning from STARTING to RECOVERY
The broker has been unfenced. Transitioning from RECOVERY to RUNNING
Kafka Server started

Chỉ ở RUNNING broker mới nhận yêu cầu. Trước đó nó bị "rào" lại — client kết nối vào sẽ bị từ chối. Đó là lý do kịch bản khởi động phải đợi bằng một lệnh thật (kafka-topics.sh --list) chứ không phải đợi cổng 9092 mở.

Cái bẫy: công cụ dòng lệnh

Tôi đo độ trễ gửi tin bằng cách gọi kafka-console-producer.sh năm lần:

tin 1: 1,03 s
tin 2: 0,90 s
tin 3: 0,90 s
tin 4: 0,89 s
tin 5: 0,88 s

Gần một giây cho một tin 6 byte. Con số đó vô lý, nên tôi đo tiếp: chạy một lệnh CLI không làm gì cả.

kafka-topics.sh --list   ->  0,86 giây

Đó là thời gian khởi động JVM của chính công cụ. Kafka không liên quan.

Đo lại bằng công cụ đo hiệu năng — nó khởi động một JVM rồi gửi 5.000 tin trong đó:

0,89 ms độ trễ trung bình     p50 0 ms     p95 1 ms     p99 20 ms

0,89 mili giây, không phải 0,88 giây. Chênh nhau khoảng một nghìn lần.

Đây là cái bẫy tôi thấy trong rất nhiều bài viết và câu hỏi trên mạng: người ta đo Kafka bằng cách gọi lặp lại công cụ dòng lệnh, rồi kết luận Kafka chậm. Cái họ đo là JVM khởi động.

Quy tắc: mọi phép đo độ trễ phải nằm trong một tiến trình duy nhất. Công cụ dòng lệnh dùng để kiểm tra, không dùng để đo.

Cùng lý do đó áp cho ứng dụng thật: tạo một KafkaProducer rồi dùng lại, đừng tạo mới cho mỗi tin. Producer giữ kết nối, giữ siêu dữ liệu topic, và giữ bộ đệm gộp lô — tạo mới là vứt hết.

Bộ nhớ theo tải

Bộ nhớ
Lúc rỗng 316,5 MB
Sau khi ghi 500.000 tin 577,4 MB

Và trên đĩa: 102 MB cho 500.000 tin × 200 byte = 100 MB dữ liệu thật. Phần dôi ra là phần đầu mỗi bản ghi và tệp chỉ mục.

Kafka không giữ tin trong bộ nhớ để phục vụ. Nó ghi xuống tệp và để bộ đệm trang của hệ điều hành lo phần đọc lại — đó là lý do bộ nhớ JVM không tăng theo lượng dữ liệu. Phần 19 sẽ đo cơ chế này và giải thích vì sao nó là chìa khoá của hiệu năng Kafka.

Hệ quả thực dụng: đừng cấp heap lớn cho Kafka. 4–6 GB là đủ cho hầu hết trường hợp; phần RAM còn lại để hệ điều hành làm bộ đệm trang thì có ích hơn nhiều.

Dừng broker

docker stop     0,9 giây, tắt sạch
khởi động lại   RECOVERY -> RUNNING, không cần khôi phục

Kafka nhận SIGTERM và tắt đàng hoàng trong thời gian chờ mặc định 10 giây của Docker.

Nhưng đó là broker có 102 MB dữ liệu. Trên broker thật với hàng chục gigabyte chưa flush, việc tắt sạch lâu hơn — và nếu vượt thời gian chờ, Docker gửi SIGKILL và lần khởi động sau phải chạy khôi phục log. Phần 20 sẽ đo con số đó.

Đặt sẵn trong Compose:

services:
  kafka:
    stop_grace_period: 120s

Kiểm broker còn sống

Bốn lệnh đáng thuộc:

# broker có nhận kết nối không
kafka-broker-api-versions.sh --bootstrap-server kf:9092 | head -1

# danh sách topic
kafka-topics.sh --bootstrap-server kf:9092 --list

# chi tiết một topic: partition, leader, ISR
kafka-topics.sh --bootstrap-server kf:9092 --describe --topic ten-topic

# offset đầu và cuối của từng partition
kafka-get-offsets.sh --bootstrap-server kf:9092 --topic ten-topic

Lệnh thứ ba là lệnh dùng nhiều nhất khi có sự cố — nó cho biết partition nào đang thiếu bản sao, và phần 13 sẽ đo ý nghĩa của cột ISR.

Mẫu số chung

Cái bẫy ở đây là một chi phí khởi tạo cố định — 0,86 giây bật JVM — đứng lẫn vào phép đo và nuốt trọn thứ tôi tưởng mình đang đo. Cùng một chi phí đó có hai số phận tuỳ cách bạn đối xử: tái dùng thì nó được chia đều tới mức biến mất, còn trả lại mỗi thao tác thì nó vừa giết hiệu năng vừa làm nhiễu con số. Lời khuyên "dùng lại một producer" và quy tắc "đo trong một tiến trình" thật ra là cùng một điều, nhìn từ hai phía.

Đúng cặp đôi này hiện ra ở mọi tầng:

  • Trong Java, người ta phải có cả một khung như JMH chính vì lý do này: nó chạy vòng khởi động (warm-up) để loại thời gian nạp lớp và JIT biên dịch ra khỏi con số đo. Bấm System.nanoTime() quanh lần gọi đầu tiên là đo trình biên dịch, không đo mã ở trạng thái ổn định.
  • Trong Go, testing.B chạy b.N vòng và báo ns/op cũng để phân bổ chi phí thiết lập; còn về phía tái dùng, một http.Client giữ kết nối keep-alive dùng lại cho nhiều request — tạo client mới mỗi lần là trả lại cái bắt tay TLS mỗi lần, đúng như tạo producer mới cho mỗi tin.
  • Còn ở cơ sở dữ liệu, mở một kết nối mới cho mỗi truy vấn phải trả cái bắt tay TCP cộng xác thực (hàng chục mili giây) — lấn át cả câu truy vấn. Đó là lý do có connection pool, và cũng là lý do đo độ trễ truy vấn bằng cách gọi psql mỗi lần cho ra con số của psql cộng thời gian kết nối, không phải của truy vấn.

Nên trước mỗi phép đo, câu hỏi đúng là "con số này có dính chi phí khởi tạo một lần không", và trước mỗi vòng nóng trong sản phẩm, câu hỏi là "cái đắt đỏ này tôi có đang dựng lại mỗi lần không". Hai câu, một gốc. Muốn tự thấy khoảng cách một nghìn lần:

# thời gian của một lệnh CLI không làm gì
time docker exec kf /opt/kafka/bin/kafka-topics.sh --bootstrap-server kf:9092 --list

# độ trễ gửi tin thật, trong một tiến trình
docker exec kf /opt/kafka/bin/kafka-producer-perf-test.sh \
  --topic thu --num-records 5000 --record-size 200 --throughput 500 \
  --producer-props bootstrap.servers=kf:9092 linger.ms=0 batch.size=1

Nếu hai con số của bạn cũng chênh nhau khoảng một nghìn lần, bạn vừa xác nhận rằng mọi phép đo Kafka gọi qua công cụ dòng lệnh đều vô nghĩa.

Phần sau đo topic, partition và offset: dữ liệu thật sự nằm thế nào trên đĩa.