Alternate exchange hứng thông điệp chưa vào được hàng đợi nào. Dead letter exchange hứng thông điệp đã vào hàng đợi rồi mới hỏng. 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ốibasicReject 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.

Cùng một topology, hai kết cục ngược nhau: vòng lặ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.

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.

Thử ba mươi giây

# 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.