Cuốn sách mượn ở thư viện là một hàng đợi: một người cầm, đọc xong trả, người sau mới tới lượt. Một bộ phim trên dịch vụ xem trực tuyến thì khác hẳn — ai cũng xem chung một bản, mỗi người tự nhớ mình xem tới đâu, và tua lại lúc nào cũng được. Kafka là cái thứ hai, không phải cái thứ nhất, và gần như mọi hiểu nhầm về nó bắt đầu từ chỗ người ta gọi nó là "hàng đợi tin nhắn". Phần này đo đặc tính thật sự định nghĩa Kafka, và hai con số mà bài giới thiệu nào cũng nhắc nhưng ít ai đặt cạnh nhau.

Đọc không xoá tin

Đây là khác biệt gốc, và nó đo được trong ba mươi giây.

Ghi ba tin vào một topic, rồi cho ba nhóm tiêu thụ khác nhau cùng đọc:

nhóm-thanh-toán   đọc được 3 tin
nhóm-kho          đọc được 3 tin
nhóm-báo-cáo      đọc được 3 tin

Ba nhóm, mỗi nhóm nhận đủ ba tin. Không ai làm mất của ai.

Với một hàng đợi truyền thống, tin bị xoá khi có người nhận và xác nhận. Ba người tiêu thụ chia nhau ba tin, mỗi người được một.

Đọc tiếp bằng chính nhóm-thanh-toán:

từ offset đã chốt:                    0 tin
một nhóm hoàn toàn mới, từ đầu:       3 tin

Nhóm cũ không đọc lại vì nó đã chốt vị trí. Nhưng dữ liệu vẫn nằm trên đĩa — một nhóm mới đọc lại được đủ.

Nghĩa là: "đã đọc tới đâu" là trạng thái của từng nhóm, không phải của tin. Kafka không nhớ ai đã nhận cái gì; nó chỉ giữ một nhật ký và mỗi nhóm tự nhớ mình đang ở đâu trong nhật ký đó.

Đó là lý do đúng để gọi Kafka là nhật ký được chia sẻ chứ không phải hàng đợi.

Cái này đổi cách thiết kế hệ thống

Ba hệ quả trực tiếp:

Thêm một hệ thống tiêu thụ mới không ảnh hưởng hệ thống cũ. Đội phân tích muốn đọc luồng đơn hàng? Tạo một nhóm mới. Đội thanh toán không biết và không quan tâm.

Xử lý lại được. Mã có bug và đã xử lý sai 100.000 đơn? Đưa offset về, chạy lại. Với hàng đợi thì dữ liệu đã biến mất.

Người tiêu thụ chậm không làm nghẽn người khác. Mỗi nhóm có offset riêng; nhóm chậm chỉ tụt lại phía sau chứ không giữ tin lại.

Đổi lại, Kafka không làm được vài thứ mà hàng đợi làm tốt: định tuyến phức tạp theo nội dung tin, ưu tiên tin, và xoá một tin cụ thể. Nếu bài toán của bạn là "gửi việc này cho đúng một worker rảnh nhất", hàng đợi truyền thống hợp hơn.

Thông lượng: đo ba lần vì lần đầu luôn sai

300.000 tin, mỗi tin 200 byte, trên một broker duy nhất:

Lần Thông lượng Băng thông Độ trễ TB
1 378.788 tin/giây 72,25 MB/giây 144,95 ms
2 463.679 tin/giây 88,44 MB/giây 63,32 ms
3 493.421 tin/giây 94,11 MB/giây 42,61 ms

Ba lần tăng dần, và độ trễ giảm hơn ba lần. Đó là JVM đang khởi động dần — biên dịch JIT, làm nóng bộ nhớ đệm, cấp phát vùng nhớ.

Lần đo đầu tiên trên Kafka luôn thấp hơn thực tế. Nếu bạn thấy một bài viết công bố đúng một con số thông lượng mà không nói đã chạy bao nhiêu lần, con số đó đáng ngờ.

Nhưng con số đó đến từ gộp lô

Đây là chỗ hai con số cần đặt cạnh nhau.

Cấu hình Thông lượng Độ trễ TB
Gộp lô mặc định 493.421 tin/giây 42,61 ms
batch.size=1, linger.ms=0 — 1,25 ms (p50 1 ms, p99 6 ms)

Cùng một broker, cùng một mạng. Bỏ gộp lô thì mỗi tin nhanh hơn 34 lần, nhưng thông lượng sụp đổ.

Kafka nhanh không phải vì mỗi tin đi nhanh. Nó nhanh vì nó gom nhiều tin lại rồi ghi một lần, và ghi tuần tự vào cuối tệp. Mỗi tin riêng lẻ phải đợi lô của nó đầy hoặc hết linger.ms.

Điều này định ra một câu hỏi bạn phải trả lời trước khi chọn Kafka: bạn cần thông lượng hay cần độ trễ thấp? Kafka mặc định chọn thông lượng. Nó chỉnh về phía độ trễ được, nhưng khi đó nó mất phần lớn lợi thế của mình.

Phần 5 sẽ đo batch.size và linger.ms chi tiết.

Một topic trống chiếm 20 MB

Sau khi ghi đúng ba tin:

00000000000000000000.log             97 byte
00000000000000000000.index     10.485.760 byte
00000000000000000000.timeindex 10.485.756 byte

97 byte dữ liệu thật, và 20 MB tệp chỉ mục.

Kafka cấp phát trước hai tệp chỉ mục theo log.index.size.max.bytes (mặc định 10 MB) để không phải mở rộng tệp trong lúc đang ghi. Chúng thưa và sẽ được cắt lại khi segment đóng.

Đừng hoảng khi thấy một topic vừa tạo đã chiếm 20 MB mỗi partition. Nhưng cũng đừng quên nhân với số partition: 100 topic × 10 partition là 20 GB trước khi có tin nào. Phần 16 sẽ đo cơ chế này.

Một phép đo tôi không dựng được

Đề bài của phần này là so với hàng đợi truyền thống, và tôi định chạy RabbitMQ cạnh Kafka trên cùng máy.

RabbitMQ không khởi động được trong môi trường Docker của tôi. Nó chết ngay lúc khởi động với:

Error when reading /var/lib/rabbitmq/.erlang.cookie: eacces

Tôi thử bốn cách: ảnh alpine, ảnh debian, gắn tmpfs, và volume có tên, kèm khai RABBITMQ_ERLANG_COOKIE tường minh. Cả bốn đều cùng một lỗi. Đây là vấn đề quyền tệp của môi trường chứ không phải của RabbitMQ.

Nên phép so trực tiếp phải đợi tới phần 39, khi tôi dựng được cả hai trên cùng một nền. Tôi ghi lại chỗ này thay vì mượn số liệu của người khác — con số đo trên máy khác, cấu hình khác thì không so được với con số ở trên.

Khi nào chọn Kafka

Bài toán Kafka Hàng đợi
Nhiều hệ thống cùng đọc một luồng có không
Cần xử lý lại từ quá khứ có không
Thông lượng rất cao có vừa
Độ trễ dưới mili giây cho từng tin không có
Định tuyến theo nội dung tin không có
Ưu tiên tin không có
Xoá một tin cụ thể không có
Giữ lịch sử để phát lại có không

Bốn dòng đầu là lý do người ta chọn Kafka. Bốn dòng giữa là lý do người ta vẫn giữ RabbitMQ bên cạnh.

Rất nhiều hệ thống chạy cả hai, và đó không phải thừa: chúng giải hai bài toán khác nhau.

Mẫu số chung

Cả bài rút gọn lại thành một lựa chọn thiết kế: cái trạng thái "tôi đã xử lý tin này chưa" nằm ở đâu. Hàng đợi để nó trong dữ liệu — xác nhận xong thì tin biến mất, nên bản thân dữ liệu mang dấu vết ai đã lấy. Kafka để nó trong người đọc — mỗi nhóm cầm một con trỏ riêng, còn nhật ký thì bất biến. Dời trạng thái sang phía người đọc là thứ khiến một bản dữ liệu duy nhất phục vụ được vô số người đọc độc lập, và khiến việc tua lại quá khứ gần như miễn phí.

Cùng lằn ranh ấy hiện ra ở mọi nơi:

  • Trong Go, nhận từ một channel là mô hình hàng đợi: mỗi giá trị tới đúng một receiver, đọc là tiêu thụ. Còn duyệt một slice bằng chỉ số là mô hình nhật ký: nhiều vòng lặp, mỗi cái giữ chỉ số riêng, ai cũng thấy đủ mọi phần tử và quay lại đầu lúc nào cũng được.
  • Trong cơ sở dữ liệu, đây chính là sao chép qua WAL/binlog: bản ghi là một nhật ký append-only bất biến, và mỗi replica tự nhớ vị trí của mình (LSN, binlog offset) trong nhật ký đó — đúng như offset của consumer group. Cùng ý tưởng đẻ ra cả event sourcing: giữ dòng sự kiện làm nguồn sự thật, dựng lại nhiều "read model" từ nó, và chạy lại từ đầu khi cần.
  • Ngay cả git cũng vậy: lịch sử commit là bất biến, mỗi bản sao tự giữ HEAD của nó trỏ vào chỗ đang đứng — không ai "tiêu thụ" mất commit của ai.

Nên câu hỏi đúng khi chọn giữa hàng đợi và Kafka không phải "cái nào nhanh hơn" mà là "tôi cần dữ liệu tự tan sau khi có người lấy, hay cần nó ở lại để nhiều người và cả tương lai còn đọc được". Muốn tự thấy khác biệt gốc, dựng một broker rồi đọc bằng hai nhóm:

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

docker exec kf sh -c "printf 'a\nb\nc\n' | \
  /opt/kafka/bin/kafka-console-producer.sh --bootstrap-server kf:9092 --topic thu"

docker exec kf /opt/kafka/bin/kafka-console-consumer.sh --bootstrap-server kf:9092 \
  --topic thu --from-beginning --timeout-ms 3000 --group nhom-a

docker exec kf /opt/kafka/bin/kafka-console-consumer.sh --bootstrap-server kf:9092 \
  --topic thu --from-beginning --timeout-ms 3000 --group nhom-b

Cả hai nhóm phải đọc được đủ ba tin. Nếu bạn đến từ thế giới hàng đợi, đó là điều đầu tiên cần chỉnh lại trong đầu.

Phần sau đo việc dựng broker kỹ hơn: thời gian khởi động, bộ nhớ chiếm, và độ trễ của tin đầu tiên.