Hình dung một cái cửa quay ở lối vào ga tàu, loại chỉ cho một người qua mỗi lần. Có một người bị kẹt áo vào khe, cứ đẩy mãi không lọt — và cả trăm người phía sau, ai cũng bình thường, đứng chôn chân không nhích được một bước. Một thông điệp "độc" trong RabbitMQ làm đúng chuyện đó với hàng đợi của bạn: nó không chỉ tự hỏng, nó khoá luôn mọi thứ lành xếp sau. Bài này đo cái kẹt đó, và một cơ chế cứu hộ mà hoá ra có điểm mù ngay chỗ bạn cần nó nhất.

Phần 4 đo được rằng consumer chết thì thông điệp quay lại hàng đợi — tính năng cứu dữ liệu. Bài này đo mặt kia của chính tính năng đó: một thông điệp luôn lỗi sẽ quay lại mãi mãi.

Cờ redelivered không đếm gì cả

Cho một thông điệp lỗi và consumer basicNack(requeue=true), chạy 3 giây:

1 thông điệp, 3 giây -> 17 502 vòng (5 834 vòng/giây)
số lần cờ redelivered: 17 501 / 17 502

Cờ bật đúng — từ lần giao thứ hai trở đi. Nhưng nó chỉ có hai giá trị, nên sau 17 501 vòng nó vẫn nói đúng một câu: "cái này đã từng được giao trước đó". Không có cách nào từ cờ đó biết đây là lần thứ hai hay lần thứ mười bảy nghìn.

Vòng lặp ăn hết công suất

5 834 vòng mỗi giây cho một thông điệp. Đó không phải một vòng lặp chậm chạp ở đâu đó — đó là consumer của bạn chạy hết công suất để làm đi làm lại một việc luôn thất bại, và mỗi vòng là một lượt đi về qua broker.

Và nó chặn mọi việc khác

Đây mới là phần tệ. Xếp một thông điệp hỏng đứng đầu, năm thông điệp tốt xếp sau, prefetch=1:

5 thông điệp tốt: xử lý xong 0/5 trong 5 giây (BỊ CHẶN)
thông điệp độc đã quay 30 679 vòng trong lúc đó
Không một thông điệp tốt nào được xử lý. basicNack(requeue=true) trả thông điệp về đầu hàng đợi, nên nó lập tức được giao lại trước cả những cái đang xếp sau. Một bản ghi hỏng đủ để làm chết cả đường ống, và biểu đồ giám sát chỉ hiện "hàng đợi không rút" chứ không nói vì sao.

x-delivery-limit: hai giới hạn ít ai nói

RabbitMQ có sẵn bộ đếm số lần giao, đặt bằng x-delivery-limit. Nhưng nó có hai điều kiện.

Thứ nhất, chỉ quorum queue. Classic queue từ chối thẳng:

classic -> 406 PRECONDITION_FAILED - invalid arg 'x-delivery-limit' for queue 'gh-classic'
           of queue type rabbit_classic_queue
quorum  -> khai báo được

Thứ hai — và đây là chỗ tôi phải đo hai lần mới tin — nó không đếm basicNack. Cùng một hàng đợi quorum với x-delivery-limit=3, hai cách làm thông điệp quay lại:

Consumer làm gì x-delivery-count Số lần giao Kết cục
basicNack(requeue=true) không có header 3 171 vẫn nằm trong hàng đợi
Chết (đóng connection, không ack) 1 → 2 → 3 4 vào dead letter exchange

Với consumer chết, cơ chế chạy hoàn hảo: header x-delivery-count xuất hiện, tăng dần, chạm 3 thì thông điệp bị đẩy sang DLX. Với basicNack thì header không bao giờ xuất hiện và bộ đếm không bao giờ chạy.

Nghĩa là x-delivery-limit bảo vệ bạn khỏi consumer sập, không bảo vệ bạn khỏi mã bắt ngoại lệ rồi requeue — mà cái thứ hai mới là thứ bạn tự viết.

Vậy chặn thế nào

Đừng dùng requeue=true cho lỗi xử lý. Đây là quy tắc quan trọng nhất của bài. requeue=true chỉ dành cho "tôi tạm thời không xử lý được, ai đó khác thử đi" — ví dụ đang tắt máy. Với lỗi dữ liệu thì dùng requeue=false và một dead letter exchange: thông điệp ra khỏi đường đi ngay lần đầu, hàng đợi tiếp tục chảy, và bạn có chỗ để điều tra.

Cần thử lại thì phải có độ trễ. requeue=false cộng một hàng đợi chờ, không phải requeue thẳng — đó là bài sau.

Đếm số lần bằng x-death. Phần 14 đo được rằng broker tự tăng count trong header đó mỗi lần thông điệp đi qua DLX. Đó là bộ đếm thật, dùng được, và không phụ thuộc kiểu hàng đợi.

Dùng quorum queue kèm x-delivery-limit như lưới an toàn cuối — nó bắt được trường hợp consumer sập, thứ mà không bộ đếm nào trong mã của bạn bắt được vì mã đó đã chết cùng tiến trình.

Cách nhanh nhất để phân biệt "vòng lặp độc" với "hàng đợi đang bận" là nhìn tỉ lệ giao — mà rabbitmqctl list_queues không có cột nào cho nó, phải hỏi HTTP API:

curl -su guest:guest 'localhost:15672/api/queues/%2F/<ten-hang-doi>' \
  | jq '{messages, deliver_get: .message_stats.deliver_get,
         rate: .message_stats.deliver_get_details.rate}'

Trên hàng đợi đang có vòng lặp độc, số liệu trông thế này:

{ "messages": 1, "deliver_get": 31704, "rate": 6324 }

Một thông điệp, ba mươi mốt nghìn lượt giao. Đó là con số duy nhất tách bạch "vòng lặp thông điệp độc" với "hàng đợi đang bận".

Mẫu số chung

Cái làm một thông điệp hỏng thành thảm hoạ không phải bản thân nó hỏng — mà là nó đứng đầu một hàng xử lý theo thứ tự, nên cả trăm cái lành phía sau chết kẹt theo, y như một người mắc áo ở cửa quay khoá luôn dòng người. Đây là "head-of-line blocking", và nó rình ở mọi chỗ có một hàng nghiêm ngặt: một gói TCP mất chặn mọi byte đến sau nó dù chúng đã tới nơi (đúng lý do HTTP/2 tách luồng, HTTP/3 bỏ hẳn TCP), một truy vấn chậm giữ khư khư kết nối pool, một migration kẹt chặn cả hàng deploy. Cách gỡ luôn là một trong hai: cho cái kẹt ra khỏi hàng (dead-letter, tách nó đi chỗ khác) hoặc bỏ ràng buộc thứ tự (nhiều phân vùng, nhiều luồng độc lập) — chứ đừng để một phần tử hỏng cầm tù cả tập thể lành.

Điều thứ hai, cũng đắt không kém: một cơ chế an toàn chỉ canh một trong hai kiểu hỏng thì nguy hiểm hơn không có gì, vì nó cho bạn cảm giác đã được bảo vệ. x-delivery-limit bắt hoàn hảo khi consumer sập, nhưng mù hoàn toàn với khi mã của bạn nack — mà nack mới là thứ bạn tự viết và tự gây ra nhiều nhất. Cùng cái điểm-mù ấy ở một healthcheck chỉ dò tiến trình chết mà không thấy nó deadlock, một timeout canh mạng chậm nhưng bỏ qua CPU chậm, một try/catch bắt đúng một loại ngoại lệ rồi tưởng đã lo hết. Khi tin vào một tấm lưới, hỏi thẳng: nó KHÔNG bắt được kiểu hỏng nào — vì đúng cái nó bỏ sót là chỗ bạn sẽ ngã, và ngã trong lúc yên tâm rằng mình đã có lưới.

Bài sau: dựng vòng thử lại có độ trễ bằng TTL và DLX, và cái bẫy làm thông điệp hẹn nửa giây phải chờ năm giây.