Cách dễ nhất để không bao giờ lẫn Kafka với RabbitMQ là nghĩ về một thư viện và một bếp nhà hàng. Thư viện giữ cuốn sách trên kệ: bao nhiêu người đọc cũng được, và bạn quay lại sau sáu tháng nó vẫn nằm đó. Bếp nhà hàng thì khác — mỗi phiếu gọi món đi tới đúng một đầu bếp, nấu xong là phiếu bị xé bỏ, không ai đọc lại được. Kafka là thư viện, RabbitMQ là tập phiếu gọi món. Toàn bộ bài này, kể cả bảng thông lượng, xoay quanh đúng khác biệt đó.

Phần 1 của loạt bài này hứa sẽ so Kafka với RabbitMQ và không dựng nổi RabbitMQ trong môi trường đo. Lần này dựng được, và bài đo cho ra hai kết quả — một về tốc độ, một quan trọng hơn nhiều.

Điều kiện đo

200.000 tin × 300 byte, một hàng đợi RabbitMQ so với một partition Kafka — để phép so không bị lệch vì Kafka chia song song.

RabbitMQ đo bốn cấu hình: hàng đợi cổ điển và hàng đợi quorum, mỗi loại có và không có tin bền cộng xác nhận.

Thông lượng

Ghi Đọc
Kafka acks=1 207.900 740.741
Kafka acks=all 261.438 740.741
RabbitMQ classic, không bền 145.901 103.825
RabbitMQ classic, bền + confirm 91.059 114.749
RabbitMQ quorum, không bền 96.452 145.716
RabbitMQ quorum, bền + confirm 88.692 154.758

So công bằng — cả hai đều bền, đều xác nhận:

ghi:   261.438  so với   88.692   ->  Kafka nhanh 2,9 lần
đọc:   740.741  so với  154.758   ->  Kafka nhanh 4,8 lần

Một chi tiết đáng chú ý trong bảng: Kafka acks=all nhanh hơn acks=1. Với replication-factor=1 thì hai chế độ này giống hệt nhau và chênh lệch là nhiễu — phần 4 đã đo và giải thích chính cái bẫy này.

Và ở phía RabbitMQ, hàng đợi quorum đọc nhanh hơn classic (154.758 so với 114.749) dù ghi chậm hơn. Đó là kết quả tôi không chờ đợi, và nó nhắc rằng "quorum thì chậm hơn" là một khái quát không đúng ở mọi chiều.

Nhưng thông lượng không phải điểm khác nhau quan trọng nhất

RabbitMQ: đọc xong là xoá.

ghi 1000 tin  ->  hàng đợi có 1000
đọc hết       ->  hàng đợi có 0
đọc lại?      ->  KHÔNG. Chúng không còn tồn tại.

Kafka: đọc xong vẫn còn.

nhóm c1 đọc hết 400.000 tin, offset đầu vẫn 0
nhóm c2 dựng mới đọc lại toàn bộ ở 1.219.512 tin/giây

Đây là khác biệt kiến trúc, không phải khác biệt hiệu năng, và nó quyết định gần hết mọi lựa chọn khác.

Với RabbitMQ, mỗi tin có đúng một chủ. Xử lý xong thì hết chuyện. Muốn hai hệ thống cùng nhận thì phải cấu hình exchange rẽ nhánh trước khi tin được gửi.

Với Kafka, tin nằm đó cho tới khi hết hạn lưu trữ. Một hệ thống mới muốn đọc dữ liệu của sáu tháng trước chỉ cần một group.id mới — không cần ai đổi gì ở phía ghi. Phần 25 đo được việc đó chạy ở hơn một triệu tin mỗi giây.

Ba thứ RabbitMQ làm được mà Kafka không

Định tuyến theo mẫu khoá. Gửi bốn tin với khoá don.vn.hcm, don.vn.hn, don.jp.tokyo, don.us.ny:

hàng đợi mẫu don.vn.*  nhận  2 tin
hàng đợi mẫu don.#     nhận  4 tin

Broker quyết định tin nào đi đâu. Kafka không có khái niệm này — mọi tin vào topic đều tới mọi consumer của topic đó, và việc lọc là chuyện của consumer.

Hạn dùng theo hàng đợi. 100 tin với x-message-ttl=2000, sau 3,5 giây còn 0. Kafka có retention.ms nhưng nó áp cho cả topic và xoá theo segment (phần 17) — không có cách nào đặt hạn cho từng tin.

Ưu tiên tin, hàng đợi trì hoãn, xác nhận từng tin một. Kafka không có cái nào. basicNack cho phép trả lại đúng một tin trong khi những tin khác vẫn chạy; Kafka chỉ có offset tuyến tính, nên một tin hỏng chặn cả partition (phần 24).

Bảng chọn

Chọn Kafka khi:

  • Nhiều hệ thống cần cùng một dòng dữ liệu, và danh sách đó sẽ còn thay đổi.
  • Cần đọc lại lịch sử — dựng lại chỉ mục, tính lại báo cáo, sửa lỗi rồi xử lý lại.
  • Lưu lượng rất cao và ổn định.
  • Cần thứ tự theo khoá, và chấp nhận nó chỉ đúng trong phạm vi partition (phần 12).

Chọn RabbitMQ khi:

  • Mỗi tin có đúng một người xử lý, và xong là hết.
  • Cần định tuyến phức tạp ở phía broker.
  • Cần trả lại từng tin, ưu tiên, hoặc trì hoãn.
  • Đội nhỏ, tải vừa, và bạn muốn thứ đơn giản hơn — RabbitMQ dựng xong trong 3,8 giây với một lệnh.

Bộ nhớ khi rảnh: Kafka 541,7 MB, RabbitMQ 597,5 MB. Tương đương. Cả hai đều không nặng. Chọn theo bài toán, đừng chọn theo tài nguyên — và đặc biệt đừng chọn theo bảng thông lượng ở đầu bài, vì 88.692 tin/giây đã vượt xa nhu cầu của phần lớn hệ thống.

Hai câu hỏi thay cho một bảng so sánh

Nếu phải rút gọn cả bài xuống hai câu hỏi:

"Tin này có ai khác cần đọc không, kể cả sau này?" Có → Kafka.

"Tôi có cần xử lý từng tin một, với khả năng trả lại và ưu tiên không?" Có → RabbitMQ.

Hai câu trả lời "có" cùng lúc là chuyện thường, và khi ấy dùng cả hai không phải thất bại về kiến trúc: Kafka làm xương sống sự kiện, RabbitMQ làm hàng đợi công việc phía sau. Chúng giải hai bài toán khác nhau.

Ghi chú về phép đo trước đó

Ở phần 1, tôi không dựng nổi RabbitMQ trong môi trường này — mọi lần thử đều chết với Error when reading /var/lib/rabbitmq/.erlang.cookie: eacces, và tôi đã hoãn phép so sánh sang bài này thay vì mượn số của người khác.

Nguyên nhân hoá ra là ở chỗ gắn thư mục và tên máy chủ. Chạy rabbitmq:3.13-management-alpine với --hostname rmq và RABBITMQ_NODENAME=rabbit@rmq, không gắn volume nào, thì nó lên trong 3.831 ms theo chính log của nó.

Bài học lặp lại từ nhiều phần trước: khi một thứ không chạy, nguyên nhân thường ở môi trường chứ không ở công cụ — và hoãn một phép đo lại tốt hơn là đoán kết quả của nó.

Cùng một lằn ranh "đọc-để-tiêu-huỷ vs đọc-để-dịch-con-trỏ", đổi ngăn xếp

Khác biệt lõi giữa Kafka và RabbitMQ — tin ở lại hay tin biến mất sau khi đọc — không phải đặc sản của hai công cụ này. Nó là một trong những lựa chọn mô hình dữ liệu cổ xưa nhất, và ở đâu cũng quyết định đúng ba thứ: fan-out, phát lại, và ai sở hữu một bản ghi.

Trong thế giới Go, đúng khác biệt này nằm ngay trong ngôn ngữ: đọc từ một channel (<-ch) tiêu huỷ giá trị — chỉ một goroutine nhận được, lấy xong là hết, đúng tinh thần RabbitMQ. Còn append vào một slice rồi cho nhiều goroutine range lại nhiều lần thì dữ liệu ở lại cho mọi người đọc, đúng tinh thần Kafka log.

Trong thế giới Java, đó là khác biệt giữa BlockingQueue.poll() (lấy ra là phần tử rời khỏi hàng, một người nhận) với một List bất biến bạn append rồi duyệt lại bao nhiêu lần tuỳ ý; trong reactive là cold observable (phát lại từ đầu cho mỗi subscriber) so với hot observable (chỉ fan-out trực tiếp, ai đến sau mất phần đầu).

Còn ở cơ sở dữ liệu, đây là khác biệt giữa một bảng hàng-đợi-công-việc mà bạn SELECT ... FOR UPDATE SKIP LOCKED rồi DELETE sau khi xử lý (đọc là tiêu huỷ, một chủ mỗi dòng) với một log append-only kiểu event sourcing hay chính WAL — bản ghi ở lại, và bạn luôn dựng lại được trạng thái hiện tại, hay một view mới, bằng cách đọc lại từ đầu.

Mẫu số chung: có hai mô hình chuyển dữ liệu giữa các thành phần — "hàng đợi" nơi đọc là tiêu huỷ (mỗi bản ghi có đúng một chủ, xử lý xong là biến mất) và "nhật ký" nơi đọc chỉ dịch con trỏ của riêng bạn (bản ghi ở lại, người đọc mới và chính bạn trong tương lai vẫn đọc lại được) — và chọn mô hình nào quyết định gần hết mọi thứ còn lại: fan-out có miễn phí không, phát lại có được không, và một tin thuộc về một người hay thuộc về cả dòng sự kiện. Nên câu hỏi đúng không phải "cái nào nhanh hơn" mà là "dữ liệu này sau khi được xử lý xong thì còn ai cần nữa không, kể cả chính tôi sáu tháng sau" — trả lời "có" là bài toán của nhật ký, trả lời "đúng một chủ rồi thôi" là bài toán của hàng đợi.

Nếu bạn đang dùng RabbitMQ và tự hỏi có nên chuyển sang Kafka, một cách tự soi nhanh:

# hàng đợi nào đang tồn đọng
docker exec rmq rabbitmqctl list_queues name messages consumers

# có hàng đợi nào bạn ước có thể đọc lại không?

Nếu câu trả lời cho câu hỏi thứ hai là "không", RabbitMQ đang làm đúng việc của nó và 88.692 tin/giây nhiều khả năng là thừa đủ. Nếu là "có, nhiều lần rồi", đó là dấu hiệu rõ nhất cho thấy bài toán của bạn là bài toán của Kafka.

Phần cuối gom lại toàn bộ bốn mươi phép đo của loạt bài thành một danh sách kiểm.