Hình dung topology định tuyến của bạn như một quầy lễ tân sàng lọc và chỉ đường cho khách (exchange kiểu topic với các mẫu binding). Bạn dựng luật rất công phu ở cái quầy đó. Nhưng mỗi phòng trong toà nhà lại có một cửa sau dán đúng số phòng, luôn mở, và bạn không được phép khoá — ai biết số phòng và có chân trong toà nhà đều đi thẳng vào, chẳng cần qua quầy lễ tân. Đó chính là default exchange. Mọi ví dụ RabbitMQ đầu tiên đều gửi thế này:

ch.basicPublish("", "hang-doi-cua-toi", null, body);

Chuỗi rỗng ở tham số đầu trông như "không có exchange". Nó không phải vậy, và bài này đo xem nó thật sự là gì — kể cả một cánh cửa sau mà phần 10 không đóng được.

Chuỗi rỗng là một exchange thật

Phần 5 đã thấy nó trong danh sách tám exchange có sẵn: một direct exchange, tên rỗng. Và mỗi hàng đợi vừa tạo ra đều tự động có một binding tới nó:

source_name  destination_name  routing_key
             hq                hq            ← binding ngầm, broker tự tạo

Routing key trùng đúng tên hàng đợi. Đó là toàn bộ phép màu: gửi vào exchange rỗng với routing key là tên hàng đợi thì thông điệp tới đúng hàng đợi đó, vì có một binding direct hoàn toàn bình thường đứng sẵn.

Binding ngầm không gỡ được

Phần 8 đã đo rằng không thêm binding vào nó được. Gỡ cũng không:

ch.queueBind(...)   -> 403 ACCESS_REFUSED - operation not permitted on the default exchange
ch.queueUnbind(...) -> 403 ACCESS_REFUSED - operation not permitted on the default exchange

Nghĩa là chừng nào hàng đợi còn tồn tại, nó luôn đến được bằng tên. Không có cách nào tắt.

Cửa sau

Đây là hệ quả mà tôi nghĩ nhiều người chưa từng nghĩ tới. Dựng một hàng đợi chỉ nghe sự kiện Việt Nam:

ch.exchangeDeclare("su-kien", "topic", true);
ch.queueDeclare("chi-nghe-vn", true, false, false, null);
ch.queueBind("chi-nghe-vn", "su-kien", "don.vn.#");

Luật định tuyến hoạt động đúng như thiết kế:

gửi qua 'su-kien' khoá 'don.us.gap'          -> hàng đợi có 0   (đúng: mẫu chặn lại)
gửi qua default exchange, khoá = tên hàng đợi -> hàng đợi có 1   <- LỌT VÀO
Mọi luật định tuyến bạn dựng bằng topic hay headers chỉ áp dụng cho đường đi qua exchange đó. Ai biết tên hàng đợi và có quyền gửi đều tiêm thẳng vào được, bỏ qua toàn bộ mẫu khớp. Binding không phải là hàng rào — nó là bảng chỉ đường.

Trong nội bộ một ứng dụng thì đây là chuyện nhỏ. Khi hàng đợi nhận dữ liệu từ nhiều bên, từ dịch vụ của đội khác, hoặc từ đối tác, thì đó là lỗ hổng xác thực đầu vào.

Chặn nó bằng phân quyền

Cách duy nhất để đóng cửa sau là không cho quyền ghi vào default exchange. Tạo một user với quyền write chỉ khớp exchange được phép:

rabbitmqctl set_permissions -p / han-che "^$" "^su-kien$" "^.*$"
#                                configure   write        read
guest   qua 'su-kien'        -> gửi được
guest   qua default exchange -> gửi được
han-che qua 'su-kien'        -> gửi được
han-che qua default exchange -> 403 ACCESS_REFUSED - write access to exchange
                                'amq.default' in vhost '/' refused for user 'han-che'

Chú ý cái tên trong thông báo: amq.default. Trong giao thức nó là chuỗi rỗng, nhưng trong hệ thống phân quyền nó mang tên đó. Muốn cho phép một user dùng default exchange thì regex write phải khớp amq.default, chứ không phải khớp chuỗi rỗng — chi tiết này không hiện ra ở đâu cho tới lúc bạn nhận 403.

Gửi tới tên hàng đợi không tồn tại

mandatory=false -> broker báo: (không gì cả)
mandatory=true  -> broker báo: 312 NO_ROUTE key=hang-doi-khong-he-co

Giống mọi exchange khác: gõ sai tên hàng đợi thì thông điệp bốc hơi im lặng. Với default exchange, cái này nguy hiểm hơn bình thường vì routing key là tên hàng đợi — sai một ký tự trong tên là mất dữ liệu, và không có mẫu binding nào để đối chiếu lúc gỡ lỗi.

Nó không nhanh hơn đáng kể

Có người dùng default exchange vì nghĩ bỏ qua bước tra exchange sẽ nhanh hơn. Đo 20 000 thông điệp có publisher confirms, hâm nóng trước, trung vị 3 lượt:

Đường gửi Thông lượng
default exchange "" 138 541 msg/s
direct exchange tự khai 125 179 msg/s

Chênh 11% — nằm ngay ở ranh giới nhiễu của bộ đo này, và nhỏ hơn nhiều so với khác biệt giữa các kiểu exchange mà phần 11 đo được. Đây không phải lý do để chọn.

Vì sao mã ví dụ chạy được mà topology thật thì không

Vì default exchange bỏ qua đúng cái phần mà exchange sinh ra để làm. Gửi bằng tên hàng đợi nghĩa là bên gửi phải biết tên hàng đợi của bên nhận — chính là sự ràng buộc mà hệ thống nhắn tin dùng để tránh.

Hệ quả rất cụ thể: thêm một bên nghe thứ hai thì phải sửa mã bên gửi, vì một thông điệp chỉ có một routing key và nó đang là tên của một hàng đợi cụ thể. Với exchange thật, thêm bên nghe chỉ tốn một queueBind và bên gửi không hay biết.

Nên dùng default exchange khi: viết ví dụ, thử nghiệm, hoặc gửi việc vào đúng một hàng đợi công việc mà bạn chắc chắn sẽ không bao giờ có bên nghe thứ hai. Ngoài ra thì hãy khai một exchange, kể cả khi lúc này nó chỉ có một binding.

Muốn thấy tận mắt mọi cửa sau đang mở trong broker của mình, liệt kê các binding ngầm:

# xem toan bo binding ngam cua broker
docker exec -u rabbitmq rmq rabbitmqctl -q list_bindings source_name destination_name routing_key \
  | awk '$1==""'

Mỗi dòng là một hàng đợi mà bất kỳ ai có quyền ghi vào amq.default đều tiêm thẳng vào được, bất kể bạn đã dựng luật định tuyến công phu đến đâu.

Mẫu số chung

Một luật quyết định thứ gì đi đâu không phải một luật về ai được phép gửi. Binding là bảng chỉ đường, không phải hàng rào — nó định hình luồng, còn cho-phép-hay-không là một tầng riêng phải cưỡng chế tường minh (ở đây là phân quyền trên amq.default), tuyệt đối đừng suy ra từ topology. Giả định chết người là "chưa nối dây thì không ai tới được": một URL không có link trỏ tới không phải kiểm soát truy cập (đúng cái bẫy an-ninh-nhờ-giấu-kín và lỗ hổng IDOR — đổi số trên URL là thấy hồ sơ người khác); một bảng CSDL chẳng truy vấn nào đụng tới vẫn cần GRANT, không phải cần sự-vô-hình; một endpoint "nội bộ" vẫn mở toang cho ai biết đường dẫn. Hình dạng của luồng khác quyền được vào; phải cưỡng chế cái thứ hai một cách có chủ đích.

Điều thứ hai, và là lý do mã ví dụ hay đánh lừa: một mặc định tiện lợi "chạy ngay trong mọi tutorial" có thể giấu đúng cái ràng buộc mà công cụ sinh ra để gỡ. Gửi bằng tên hàng đợi bắt bên gửi phải biết bên nhận — nên thêm một consumer thứ hai là phải sửa mã bên gửi, thay vì chỉ một queueBind mà bên gửi không hay biết; chuỗi rỗng đi vòng qua đúng tầng gián tiếp vốn là lý do tồn tại của một exchange. Cùng một vết ở khắp nơi: SELECT * trong demo, latest thay vì pin phiên bản, gọi thẳng lớp cụ thể thay vì giao diện — cái đường tắt làm ví dụ ngắn gọn lại chính là thứ không được mang vào production. Khi một mặc định "vừa vặn đến đáng ngờ", hỏi nó đang bỏ qua bước nào — và bước bị bỏ qua thường là bước bạn cần nhất khi hệ lớn lên.

Bài sau: khi thông điệp không khớp binding nào, có cách hứng nó lại thay vì để bốc hơi.