Phần trước đã có broker chạy và một thông điệp đi qua. Hình dung năm khái niệm như một hệ thống đường dây điện thoại có tổng đài: connection là đường dây thuê bao — đắt, lắp lâu; channel là những cuộc gọi ảo chạy chung trên đúng đường dây đó; còn exchange là cô tổng đài không ghi gì cả, chỉ nối cuộc gọi tới đúng máy nhận. Bài này mổ xem thông điệp thật ra đi qua những gì, và hai trong năm khái niệm quyết định hiệu năng của mọi thứ bạn viết sau này.

Năm khái niệm

Khái niệm Là gì
Connection Một kết nối TCP tới broker. Đắt, mở lâu
Channel Một phiên làm việc ảo bên trong connection. Rẻ
Exchange Nơi thông điệp đến trước. Nó không lưu gì cả
Queue Nơi thông điệp thật sự nằm chờ
Binding Luật nối exchange với queue. Không có binding thì không có đường đi

Điều quan trọng nhất: producer không bao giờ gửi vào queue. Nó gửi vào exchange, và exchange dựa vào binding để quyết định chép thông điệp sang những queue nào — không, một, hay nhiều.

Connection đắt hơn channel bao nhiêu

Mở 200 cái mỗi loại, tính giờ:

=== A. MO 200 CAI ===
  200 connection :  383,1 ms tổng |  1,915 ms mỗi cái
  200 channel    :   65,6 ms tổng |  0,328 ms mỗi cái
  => connection đắt gấp 6 lần
  channel_max của connection: 2047

Sáu lần nghe không ghê gớm. Nhưng con số đó chỉ đo việc mở. Hãy xem nó đọng lại thế nào khi gửi thật.

Ba cách gửi 1 000 thông điệp

Cùng một broker, cùng một hàng đợi, chỉ khác chỗ tái sử dụng:

=== B. GUI 1000 THONG DIEP ===
  mỗi thông điệp một connection mới          :    2 658 ms |        376 msg/s
  một connection, mỗi thông điệp một channel :      513 ms |      1 948 msg/s
  một connection, một channel dùng lại       :        2 ms |    424 291 msg/s
Mở connection cho mỗi thông điệp chậm hơn 1 128 lần so với dùng lại một channel. Đây là lỗi hay gặp nhất của người mới: bọc newConnection() vào trong hàm gửi vì trông có vẻ "sạch".

Lắp một đường dây thuê bao mới cho mỗi câu nói — thay vì nhấc máy gọi tiếp trên đường đã có. Một chú thích cho trung thực: con số 424 291 msg/s là tốc độ phía client, khi basicPublish chỉ ghi vào socket rồi trả về ngay. Broker chưa chắc đã xử lý xong lúc đó — nó không hứa gì cả. Muốn biết broker đã nhận thật hay chưa thì cần publisher confirms, và con số sẽ khác hẳn; đó là phần 17.

Điểm cần rút ra ở đây không phải "424 nghìn", mà là hai bậc thang giữa ba cách viết cùng một việc.

Phía broker trả giá gì

Client trả giá thời gian; broker trả giá bộ nhớ. Giữ 200 cái mở rồi hỏi chính broker:

200 connection 1 connection + 200 channel
connection_readers 8,5 MB 0,2 MB
connection_channels 0 MB 0,6 MB
connection_other 4,2 MB 2,4 MB

Mỗi connection kéo theo một tiến trình đọc socket riêng — 8,5 MB cho 200 cái, khoảng 43 KB mỗi cái. Hai trăm channel dùng chung một socket thì tốn 0,6 MB, tức khoảng 3 KB mỗi cái.

Còn RSS của container thì đừng đọc ở mức này: giữa hai phép đo nó nhảy từ 191,7 lên 197,7 MiB ở lần một, rồi giảm từ 189,6 xuống 186,6 MiB ở lần hai. Bộ cấp phát của Erlang không trả bộ nhớ về hệ điều hành ngay, nên con số của docker stats nói về thói quen của allocator chứ không nói về tải.

Binding quyết định thông điệp đi đâu

Khai một exchange don-hang kiểu direct, hai hàng đợi kho và ke-toan, nhưng chỉ bind kho với khoá moi. Rồi gửi hai thông điệp, khoá moi và khoá huy:

  kho     : 1 thông điệp
  ke-toan : 0 thông điệp (chưa bind gì)

Thông điệp khoá huy đi đâu? Không đi đâu cả. Nó biến mất. Không ngoại lệ, không log, không đếm ở chỗ nào — cô tổng đài không có số máy để nối thì cúp máy luôn. Đây là chỗ nuốt thông điệp phổ biến nhất của người mới, và RabbitMQ có hai cơ chế để bắt nó lại — mandatory cùng alternate exchange (phần 13).

Bind thêm ke-toan vào cùng khoá moi rồi gửi một thông điệp:

  kho     : 2
  ke-toan : 1

Một lần gửi, hai bản sao. Exchange không chuyển thông điệp đi — nó chép sang mọi queue có binding khớp. Nếu bạn từng thắc mắc vì sao hai dịch vụ đọc chung một hàng đợi lại giành nhau thông điệp thay vì mỗi bên nhận một bản: vì chúng cần hai queue, không phải hai consumer.

Ba luật rút ra

Một connection cho cả ứng dụng, một channel cho mỗi luồng. Đừng mở connection theo thông điệp, cũng đừng dùng chung một channel cho nhiều luồng — channel không an toàn luồng, và bug kiểu đó biểu hiện thành khung dữ liệu lỗi lúc chạy tải cao chứ không phải một ngoại lệ dễ đọc.

2 047 channel là trần mặc định của một connection. Đủ dùng cho hầu hết ứng dụng, nhưng nếu bạn tạo channel theo request mà không đóng, đây là chỗ nó sẽ nổ.

Exchange không lưu gì. Gửi vào exchange chưa có binding nào thì cũng như gửi vào hư không. Khai binding trước khi gửi, hoặc bật mandatory để biết mình đang mất gì.

Nếu chỉ soi một thứ sau bài này, hỏi broker xem nó đang giữ bao nhiêu đường dây và cuộc gọi ngay lúc này, trong ba mươi giây:

# xem broker đang giữ bao nhiêu connection và channel ngay lúc này
docker exec -u rabbitmq rmq rabbitmqctl -q list_connections name channels
docker exec -u rabbitmq rmq rabbitmqctl -q list_channels connection number consumer_count

Chạy ứng dụng của bạn rồi gõ lại hai lệnh trên. Nếu số connection tăng theo số request, bạn vừa tìm ra 1 128 lần.

Mẫu số chung

Một connection đắt để mở, nên bạn giữ một cái và ghép nhiều phiên logic rẻ chạy chồng lên trên — đúng như luồng (stream) của HTTP/2 trên một kết nối TCP+TLS, channel của gRPC, pool kết nối CSDL, ghép kênh của SSH. Chi phí thiết lập (bắt tay TCP+TLS, một tiến trình phía broker) trả một lần rồi khấu hao; mở một cái cho mỗi thông điệp hay mỗi request là cú nổ kinh điển (ở đây 1128 lần, chỗ khác là cạn socket vì HttpClient tạo mới mỗi lần gọi). Và cái phiên rẻ ấy thường không an toàn luồng, nên quy tắc là dùng chung cái đắt cho cả ứng dụng, mỗi luồng một cái rẻ riêng — một connection cho app, một channel cho một luồng.

Điều thứ hai: trong một hệ tách rời, bên gửi gửi tới một bộ định tuyến, không phải tới người nhận — producer công bố vào exchange kèm một khoá, và các luật (binding) quyết định queue nào nhận một bản sao — không, một, hay nhiều. Đó là publish/subscribe và định-tuyến-theo-nội-dung ở khắp nơi (topic của Kafka, event bus): điểm cốt lõi là bên gửi không biết ai nhận — thêm một consumer bằng cách thêm một binding, không đụng vào producer. Mặt trái là cái bẫy: một thông điệp không khớp luật nào đi vào hư không, im lặng, vì đúng cái tầng gián tiếp tách bên-gửi khỏi bên-nhận cũng tách "đã gửi" khỏi "đã tới" — nên cái-không-định-tuyến-được phải được cho hiện ra. Và một-lần-gửi-thành-N-bản (fan-out, chép) khác hẳn N-consumer-trên-một-queue (chia việc): bản sao đến từ nhiều queue, chia tải đến từ nhiều consumer trên một queue.

Bài sau: mổ từng tham số của basicPublish và basicConsume — vòng đời một thông điệp từ lúc gửi tới lúc được ack.