Nghĩ tới quầy giữ áo khoác ở nhà hát. Nhân viên treo mọi chiếc áo lên giá y như nhau, gắn thẻ y như nhau — công đoạn cất là giống hệt. Tấm vé chỉ khác nhau ở chỗ ghi "giữ qua đêm" hay "bỏ lúc đóng cửa". Nhiều người tin "bật lưu bền thì chậm vì phải ghi đĩa" đúng theo kiểu tin rằng tấm vé "bỏ đi" giúp nhân viên đỡ việc — trong khi chiếc áo đã được treo lên giá rồi, tấm vé chỉ quyết định nó sống sót sau giờ đóng cửa hay không. Bài này nói về ba phần của một thông điệp, và kết bằng một phép đo lật ngược đúng câu khuyên ai cũng nghe.
Phần trước nói về vỏ đựng. Bài này nói về thứ nằm trong đó, và kết thúc bằng một phép đo đi ngược hẳn câu khuyên mà ai cũng nghe: "bật persistent thì chậm".
Một thông điệp có ba phần
| Phần | Là gì | Broker có đọc không |
|---|---|---|
| Body | Mảng byte thuần | Không. Hoàn toàn không |
| Properties | 14 trường có sẵn trong giao thức | Chỉ 4 trường |
| Headers | Bảng khoá–giá trị bạn tự đặt | Không, trừ khi có plugin |
Body là mảng byte — RabbitMQ không biết đó là JSON, Protobuf hay ảnh, và không quan tâm. Đặt contentType là để consumer biết, không phải để broker biết.
Broker chỉ thật sự dùng bốn thuộc tính
Trong 14 trường của BasicProperties, broker chỉ hành động theo deliveryMode, expiration, priority và userId. Mười trường còn lại — messageId, correlationId, contentType, appId, type, replyTo, timestamp… — chỉ được chở đi nguyên vẹn.
Đó không phải điều xấu. correlationId và replyTo là nền của mẫu request–reply; messageId là nền của việc chống xử lý trùng. Chỉ cần nhớ rằng không có gì trong broker tự dùng chúng giúp bạn.
expiration: broker tự dọn
sau 0,3 giây : 5 thông điệp
sau 1,5 giây : 0 thông điệp
Đặt expiration("500") — đơn vị mili giây, kiểu chuỗi — thì thông điệp tự biến mất sau nửa giây. Ghép với dead letter exchange, đây là nền của cơ chế thử lại có độ trễ ở bài 21.
priority: vô tác dụng nếu hàng đợi không khai
Gửi 5 thông điệp priority 0→4 vào hai hàng đợi, một cái khai x-max-priority=10, cái kia không:
khong-uu-tien : [uu-tien-0, uu-tien-1, uu-tien-2, uu-tien-3, uu-tien-4]
co-uu-tien : [uu-tien-4, uu-tien-3, uu-tien-2, uu-tien-1, uu-tien-0]
Hàng đợi thường bỏ qua priority hoàn toàn và giao đúng thứ tự vào. Không có lỗi, không có cảnh báo — bạn chỉ nhận ra khi thấy việc gấp vẫn xếp sau việc thường. Ưu tiên là thuộc tính của hàng đợi, khai lúc tạo, và phần 6 cho thấy khai lại để thêm nó vào sẽ nhận 406.
userId: trường duy nhất broker đem đi đối chiếu
userId="guest" -> gửi được, hàng đợi có 1
userId="ke-mao-danh" -> BỊ CHẶN: 406 PRECONDITION_FAILED - user_id property set to
'ke-mao-danh' but authenticated user was 'guest'
Khai userId khác tài khoản đang đăng nhập là bị chặn ngay. Đây là cách rẻ nhất để consumer tin được thông điệp thật sự đến từ ai — nhưng nhớ rằng nó chỉ đúng khi mỗi dịch vụ có tài khoản riêng, mà phần 2 cho thấy image Docker mặc định cho tất cả dùng chung guest.
Header đi qua broker rồi đổi kiểu
Đây là cái bẫy tôi nghĩ nhiều người Java đã mất một buổi vì nó:
tin-cay giá trị=true kiểu Java=java.lang.Boolean
lan-thu giá trị=3 kiểu Java=java.lang.Integer
nguon giá trị=web kiểu Java=…impl.LongStringHelper$ByteArrayLongString
so sánh nguon.equals("web") -> false
phải viết nguon.toString().equals("web") -> true
Boolean và Integer về nguyên kiểu. Chuỗi thì không. AMQP mã hoá chuỗi dài thành longstr, và client Java trả về một LongString chứ không phải String. In ra thì thấy web, log cũng thấy web, mà equals("web") vẫn false.
.toString() trên giá trị header lấy từ thông điệp trước khi so sánh. Đây là loại lỗi mà mọi câu lệnh in ra đều nói bạn đúng.
deliveryMode=2 nghĩa là gì
Gửi 500 thông điệp persistent và 500 transient vào cùng một hàng đợi durable, rồi khởi động lại broker:
trước khi khởi động lại: 1000 thông điệp
sau khi khởi động lại : 500 thông điệp
Hàng đợi bền giữ được chính nó; deliveryMode=2 giữ được thông điệp. Cần cả hai. Đây là cặp mà phần 6 mới nói một nửa.
…và không nghĩa là "chậm hơn"
Câu khuyên phổ biến là persistent tốn kém vì phải ghi đĩa. Đo 20 000 thông điệp, có publisher confirms (để chờ broker xác nhận thật chứ không chỉ ghi vào socket), hâm nóng trước, ba lượt, đảo thứ tự mỗi lượt:
| Ba lượt (msg/s) | Trung vị | |
|---|---|---|
deliveryMode=1 (transient) |
132 048 · 134 516 · 139 248 | 134 516 |
deliveryMode=2 (persistent) |
146 845 · 176 863 · 192 123 | 176 863 |
Persistent nhanh hơn 31%. Tôi đã nghi đây là lỗi phép đo — chạy lượt đầu chưa hâm nóng thì đúng là transient bị thiệt — nhưng sau khi hâm nóng và đảo thứ tự, kết quả vẫn giữ nguyên. Nên tôi đi hỏi chính broker:
soi-1 (transient) : messages=20000 persistent=0 messages_ram=1
soi-2 (persistent): messages=20000 persistent=20000 messages_ram=1
messages_ram=1 ở cả hai. Hai mươi nghìn thông điệp transient cũng không nằm trong RAM — chúng đã ở trên đĩa rồi. Classic queue của RabbitMQ hiện đại ghi thân thông điệp xuống đĩa bất kể deliveryMode; cờ đó quyết định thứ được khôi phục sau khi khởi động lại, chứ không quyết định có chạm đĩa hay không.
Nghĩa là phần "tiết kiệm" mà transient hứa hẹn phần lớn không tồn tại nữa, còn đường đi của nó thì lại thêm việc sổ sách để đánh dấu "đừng khôi phục cái này".
Và nhớ rằng đây là phép đo trên máy tôi, với thông điệp 23 byte, hàng đợi classic, không cluster. Đĩa chậm hơn hoặc thông điệp lớn hơn sẽ cho hình khác — nhưng hướng của kết luận thì không đổi, vì nó nằm ở chỗ cả hai đều đã ghi đĩa.
Muốn biết hàng đợi của mình đang giữ bao nhiêu thông điệp không thể khôi phục, hỏi thẳng broker:
# hang doi cua ban dang giu bao nhieu thong diep khong the khoi phuc?
curl -su guest:guest "localhost:15672/api/queues/%2F/<ten-hang-doi>" \
| jq '{messages, messages_persistent, messages_ram}'
messages trừ messages_persistent chính là số thông điệp sẽ bốc hơi trong lần bảo trì tới. Nếu hiệu đó khác 0 trên một hàng đợi nghiệp vụ, có ai đó đã quên deliveryMode.
Mẫu số chung
Chuyện đáng nhớ nhất ở đây là một câu tối ưu từng đúng đã âm thầm hết đúng. "Persistent chậm vì ghi đĩa" hợp lý ở thời classic queue còn giữ transient trong RAM — nhưng phiên bản hiện đại ghi thân thông điệp xuống đĩa bất kể cờ, nên phần "tiết kiệm" bốc hơi, và transient giờ chỉ còn lại mặt xấu: thêm sổ sách, mất độ bền. Đây là lời khuyên hiệu năng sống lâu hơn cái lý do sinh ra nó — thứ nhan nhản trong nghề: "SELECT * thì chậm", "nối chuỗi trong vòng lặp là O(n²)" (StringBuilder và JIT đã chữa), "autoboxing giết hiệu năng", "goto luôn xấu". Cái vì sao nằm dưới một mẹo có thể bị một thay đổi ở tầng dưới lật đổ mà mẹo vẫn được truyền miệng nguyên si. Nguyên tắc: một lời khuyên tối ưu là một phép đo đã hết hạn — trước khi áp, hỏi "điều này còn đúng trên phiên bản mình đang chạy không?", rồi đo lại trên chính hệ của mình.
Điều thứ hai, nhỏ mà cắn cả buổi: in ra giống nhau không có nghĩa là bằng nhau. Header nguon in ra web, log ra web, mà equals("web") trả false vì nó là LongString chứ không phải String — cái bạn nhìn thấy không phải cái phép so sánh dùng để đối chiếu. Cùng cái bẫy ở BigDecimal("1.0").equals("1.00") là false (phải compareTo), ở NaN != NaN, ở hai Integer boxed so bằng ==, ở một char[] toString ra đúng chữ đó. Quy tắc: so sánh trên một dạng chuẩn hoá (ép kiểu, chuẩn hoá, compareTo) — và đừng bao giờ tin rằng "nó hiện ra y hệt" nghĩa là "nó bằng nhau", vì đây đúng là loại lỗi mà mọi dòng println đều gật đầu bảo bạn đúng.