Nếu alternate exchange là cái máng hứng những kiện chưa vào được kho, thì dead letter exchange là khu hàng trả về bên trong kho: nơi nhận những kiện đã vào rồi mới hỏng — bị người nhận từ chối, để quá hạn, hoặc bị đẩy ra vì kệ đã đầy. Và mỗi kiện rơi vào đó đều mang theo một xấp tem ghi rõ vì sao và đã bị trả bao nhiêu lần — đó chính là header x-death. Hai cơ chế tên na ná nhau, giải quyết hai giai đoạn khác nhau, và bài này đo cả ba con đường dẫn tới cái thứ hai.
Ba nguyên nhân, một điểm đến
ch.queueDeclare("viec", true, false, false,
Map.of("x-dead-letter-exchange", "dlx"));
Consumer từ chối — basicReject hoặc basicNack với requeue=false:
reason=rejected count=1 queue=viec exchange= routing-keys=[viec]
Hết hạn — thông điệp hoặc hàng đợi có TTL:
reason=expired count=1 queue=viec2 exchange= routing-keys=[viec2]
Tràn hàng đợi — vượt x-max-length:
gửi 5 vào hàng đợi max 2 -> còn lại 2, nghĩa địa 3
reason=maxlen count=1 queue=viec3 exchange= routing-keys=[viec3]
Chú ý nguyên nhân thứ ba: hàng đợi đầy thì thông điệp cũ nhất bị đẩy ra, không phải cái mới nhất bị từ chối. Nếu bạn đặt x-max-length để chống phình mà không có DLX, bạn đang lặng lẽ vứt dữ liệu cũ.
Đọc x-death
Mỗi lần chết đi, broker thêm một mục vào header x-death. Sáu trường đáng đọc:
| Trường | Nội dung |
|---|---|
reason |
rejected / expired / maxlen / delivery_limit |
count |
Số lần chết vì cùng cặp hàng đợi + nguyên nhân đó |
queue |
Hàng đợi đã đẩy nó ra |
exchange |
Exchange nó đến ban đầu (rỗng nếu là default exchange) |
routing-keys |
Khoá gốc |
time |
Thời điểm |
count là thứ quý nhất — nó là bộ đếm số lần thử lại, có sẵn, không phải tự dựng.
Routing key khi chuyển sang DLX
mặc định -> routing key sang DLX = "khoa-goc"
có x-dead-letter-routing-key -> routing key sang DLX = "khoa-moi"
Mặc định thông điệp giữ nguyên routing key gốc. Điều này quan trọng khi DLX là topic exchange: nếu hàng đợi nghĩa địa bind mẫu # thì không sao, nhưng bind mẫu hẹp thì thông điệp chết sẽ chết lần thứ hai — lần này không ai hứng. Đây là lý do nghĩa địa gần như luôn nên là fanout, giống AE.
Hai kiểu vòng lặp, hai kết cục ngược nhau
Cấu hình DLX trỏ ngược về chính hàng đợi đó là cách dựng "thử lại" ngây thơ nhất. Tôi đo hai biến thể.
Vòng lặp bằng TTL — hàng đợi có x-message-ttl và DLX trỏ về chính nó:
sau 2,5 giây -> còn 0 thông điệp
Thông điệp biến mất. RabbitMQ phát hiện thông điệp quay lại một hàng đợi mà nó đã chết ở đó, với mọi lần chết đều vì hết hạn — và xoá luôn. Đây là hành vi có chủ ý để chặn vòng lặp vô hạn, nhưng nó im lặng: không lỗi, không log, không vào nghĩa địa nào.
Vòng lặp bằng reject — cùng topology, chỉ khác là consumer từ chối thay vì để hết hạn:
lần 1: (chưa có x-death)
lần 2: R/rejected=1
lần 3: R/rejected=2
lần 4: R/rejected=3
lần 5: R/rejected=4
còn lại trong hàng đợi: 1
Thông điệp không bao giờ biến mất. count tăng đều, và nó sẽ quay mãi tới khi có người can thiệp.
expired làm bạn mất dữ liệu âm thầm, vòng lặp rejected làm consumer của bạn quay vô hạn và ăn hết công suất. Cơ chế chống vòng lặp của RabbitMQ chỉ áp cho trường hợp thứ nhất.
Bài học thiết kế: đừng để DLX trỏ về chính hàng đợi nguồn. Thử lại đúng cách cần một hàng đợi chờ riêng, và cần bạn tự đọc x-death.count để dừng lại — chi tiết ở bài 21.
DLX không phải nơi để tự động xử lý lại
Ba việc nên làm với hàng đợi nghĩa địa:
Đặt cảnh báo ở ngưỡng lớn hơn 0. Giống hàng đợi hứng của AE, nó đúng ra phải luôn rỗng.
Đọc x-death trước khi làm gì khác. reason nói cho bạn biết đây là lỗi mã nguồn (rejected), lỗi năng lực xử lý (expired, maxlen), hay lỗi cấu hình — ba nguyên nhân cần ba cách chữa khác nhau.
Đừng cắm consumer tự động đẩy ngược về hàng đợi gốc. Đó chính là vòng lặp rejected ở trên, chỉ khác là bạn tự viết nó bằng tay.
Muốn biết ngay mình có đang mắc một trong hai cái bẫy đó không, hỏi broker xem hàng đợi nào có DLX và nó trỏ đi đâu:
# hang doi nao dang co DLX, va tro di dau
docker exec -u rabbitmq rmq rabbitmqctl -q list_queues name arguments \
| grep dead-letter
Nếu có dòng nào mà x-dead-letter-exchange trỏ tới một exchange bind ngược về chính hàng đợi đó, bạn vừa tìm thấy một trong hai cái bẫy ở trên — và cái nào thì tuỳ consumer của bạn từ chối hay để hết hạn.
Mẫu số chung
Điều quý nhất mà DLX cho bạn không phải cái hàng đợi, mà là lịch sử hỏng đi kèm chính thông điệp: x-death mang theo vì sao chết và chết mấy lần, tức một bộ đếm thử-lại có sẵn mà bạn khỏi phải tự dựng. Gắn ngữ cảnh vào chính cái thứ bị hỏng là một phản xạ tốt ở khắp nơi — header số-lần-giao của một message queue, Retry-After của HTTP, chuỗi cause của một exception, dòng metadata trên một bản ghi dead-letter: ai nhặt được nó lên cũng chẩn đoán được ngay mà không phải đi khai quật lại lịch sử. Và vì ba nguyên nhân rất khác nhau cùng đổ về một chỗ, nước đi đầu tiên luôn là đọc reason trước đã: rejected là lỗi trong mã, expired/maxlen là lỗi năng lực xử lý, còn lại là lỗi cấu hình — ba căn bệnh, ba cách chữa; gộp chúng làm một mà xử như nhau là bốc nhầm thuốc.
Điều thứ hai: cùng một cấu trúc có thể hỏng theo hai hướng ngược nhau tuỳ một tiểu tiết, và một cái lưới an toàn dựng sẵn thường chỉ đỡ được vài trường hợp. Cơ chế chống-vòng-lặp của RabbitMQ âm thầm xoá thông điệp trong vòng lặp expired (bạn mất dữ liệu mà không một tín hiệu nào) nhưng không đỡ gì cho vòng lặp rejected (consumer quay vô hạn) — cùng một topology, một cái mất dữ liệu, một cái cháy CPU. Đừng tin một lưới an toàn khi chưa biết mép lưới của nó nằm ở đâu. Và như cái bể hứng của AE: đừng bao giờ tự-động-xử-lại một khu cách ly — thứ rơi vào đó chính vì không ai biết phải làm gì với nó, tự động hoá phần ấy chỉ chôn con bọ sâu thêm một tầng và biến nó thành cái vòng lặp bạn vừa thấy.
Bài sau: ba cách bố trí exchange và hàng đợi cho một hệ thống thật, kèm số đo cái giá của topology lớn.