Hình dung fanout như gửi một email tới một danh sách phân phối: ai trong danh sách cũng nhận một bản riêng vào hộp thư riêng của mình. Đối lập với nó là một hộp thư dùng chung mà vài người cùng xử lý — ở đó họ chia nhau thư, mỗi người lấy một phần. Hai chuyện nghe giống nhau nhưng ngược hẳn, và máy chủ mail tính công theo số bản nó phải nhân ra, không theo một email bạn bấm gửi. Phần trước kết bằng một lời hứa: chi phí định tuyến không nằm ở kiểu exchange hay số binding, mà ở số hàng đợi mà thông điệp rơi vào. Fanout là chỗ chứng minh điều đó rõ nhất, vì nó chép cho tất cả.
Fanout bỏ qua routing key, thật sự bỏ qua
gửi 4 thông điệp với 4 khoá: "", "moi", "bat-ky-chuoi-nao", "!!!"
-> email=4 thong-ke=4 kiem-toan=4
Bốn khoá khác nhau, ba hàng đợi, cái nào cũng nhận đủ bốn. Fanout không đọc routing key. Vẫn nên để chuỗi rỗng khi gửi — không phải vì broker cần, mà vì người đọc mã sau này cần biết bạn cố ý.
Chia việc không phải là phát tán
Đây là chỗ tôi thấy nhiều người mới lẫn nhất, nên đo cả hai cạnh nhau với cùng 100 thông điệp.
100 thông điệp, 2 consumer chung 1 hàng đợi -> A=50, B=50, TỔNG=100
100 thông điệp, 2 hàng đợi riêng qua fanout -> A=100, B=100, TỔNG=200
Hệ quả thực tế: nếu dịch vụ gửi thư và dịch vụ thống kê cùng đọc một hàng đợi, chúng sẽ giành thông điệp của nhau — mỗi bên nhận khoảng một nửa, và không bên nào biết mình đang thiếu. Mỗi bên phải có hàng đợi riêng bind vào cùng exchange.
Bind hai lần vẫn chỉ một bản
Bind cùng một hàng đợi vào fanout hai lần với hai khoá khác nhau, rồi gửi một thông điệp:
2 binding, gửi 1 thông điệp -> bind-doi=1 bản
số binding thực sự : 2
Hai binding tồn tại thật, nhưng thông điệp chỉ vào hàng đợi một lần. RabbitMQ chép theo đích, không theo binding — nên bind trùng không sinh ra bản sao. Yên tâm khi dựng topology bằng mã chạy lại nhiều lần.
Cái giá của phát tán
Cùng cách đo như phần trước — 20 000 thông điệp, có publisher confirms, hâm nóng, trung vị 3 lượt — chỉ đổi kiểu exchange sang fanout và tăng dần số hàng đợi gắn vào:
| Số hàng đợi | Thông lượng gửi | Số bản ghi mỗi giây |
|---|---|---|
| 1 | 150 189 msg/s | 150 189 |
| 10 | 28 513 msg/s | 285 132 |
| 100 | 2 200 msg/s | 219 991 |
Cột giữa tụt 68 lần. Cột phải gần như đứng yên.
Đó là toàn bộ câu chuyện: broker không tính công theo số thông điệp bạn gửi, mà theo số bản nó phải ghi. Gửi một thông điệp vào fanout có 100 hàng đợi là yêu cầu broker làm 100 việc. So với bảng của phần 8 — 500 binding mà thông lượng phẳng lì — khác biệt không nằm ở kiểu exchange, mà ở chỗ direct chỉ rơi vào một đích còn fanout rơi vào tất cả.
Cách đọc bảng này khi thiết kế: đừng hỏi "exchange này nhanh không", hãy hỏi "mỗi thông điệp sẽ được ghi mấy lần". Con số đó mới là thứ nhân với lưu lượng của bạn.
Không hàng đợi nào thì thông điệp đi đâu
mandatory=true, fanout không có hàng đợi nào -> trả về: 312 NO_ROUTE
Giống hệt direct: không có đích thì thông điệp bốc hơi, và chỉ mandatory mới cho bạn biết. Fanout dễ rơi vào cảnh này hơn vì nó thường dùng cho sự kiện mà bên nghe đến rồi đi — hôm nay có ba hàng đợi, mai ai đó dọn dẹp còn không cái nào, và bên gửi vẫn báo cáo thành công.
Khi nào fanout
Khi mọi bên nghe đều cần mọi sự kiện, và bạn không muốn bên gửi biết có bao nhiêu bên nghe. Thêm một dịch vụ mới chỉ cần thêm hàng đợi và một queueBind — không đụng vào producer. Đó là giá trị thật của fanout, chứ không phải tốc độ.
Đừng dùng fanout rồi lọc ở consumer. Nếu mỗi bên chỉ quan tâm một phần sự kiện, bạn đang trả tiền ghi cho tất cả rồi vứt đi phần lớn — bảng ở trên cho biết cái giá. Đó là việc của topic exchange, bài sau.
Muốn biết ngay exchange nào đang tốn nhất, đếm số đích mỗi exchange đang chép sang:
# đếm xem mỗi exchange đang chép sang bao nhiêu đích
docker exec -u rabbitmq rmq rabbitmqctl -q list_bindings source_name destination_name \
| awk '$1!="" {n[$1]++} END {for (x in n) print n[x], x}' | sort -rn
Dòng đầu tiên là exchange đắt nhất của bạn. Nhân số đó với lưu lượng gửi vào nó, và bạn có số bản ghi mỗi giây mà broker đang gánh — con số đáng theo dõi hơn hẳn tổng số thông điệp.
Mẫu số chung
"Chia việc" và "phát tán" là hai thao tác ngược nhau, và không một cái nút nào biến cái này thành cái kia: thêm consumer vào một hàng đợi thì đống việc được chia (bạn nhanh hơn); thêm hàng đợi thì thông điệp được chép (thêm bên nghe độc lập). Lẫn hai cái là hai dịch vụ âm thầm giành thông điệp của nhau, mỗi bên thấy một nửa mà chẳng bên nào biết. Đây là competing-consumers so với publish- subscribe, cùng một ngã ba ở khắp nơi: một consumer group của Kafka (phân vùng chia cho các thành viên) so với nhiều group (mỗi group nhận tất cả), SQS so với SNS, một bộ cân bằng tải so với một lần quảng bá. Quyết trước xem bạn cần "nhanh hơn" hay "nhiều bên nghe hơn" — vì câu trả lời là hình dạng topology, không phải một cái cờ.
Điều thứ hai: một hệ tính công theo cái nó thật sự làm, không theo cái bạn nộp vào — một lần gửi vào fanout 100 hàng đợi là 100 lần ghi, và thông lượng bám theo số bản chép, không theo số gửi. Nên câu hỏi thiết kế không bao giờ là "exchange này nhanh không" mà là "mỗi thông điệp bị ghi mấy lần" — cái hệ số nhân bản đó mới là thứ nhân với lưu lượng của bạn. Cùng sự khuếch đại ấy nấp trong write-amplification của một kho LSM, trong chữ N của một truy vấn N+1, trong một webhook bắn một sự kiện ra trăm người đăng ký: một hành động logic, một hệ số nhân giấu mặt. Hãy canh cái hệ số nhân, đừng chỉ đếm số hành động.