Bài trước lo chuyện không mất message. Bài này lo một tình huống trớ trêu ngược lại: một message mà bạn ước gì nó biến mất — một poison message (message độc). Đó là message mà consumer không tài nào xử lý được: payload hỏng định dạng, thiếu trường bắt buộc, tham chiếu tới dữ liệu đã xoá, hay chạm một bug khiến xử lý luôn ném exception. Vấn đề không phải một message hỏng — mà là nó làm gì với phần còn lại. Trong một partition có thứ tự, consumer xử lý tuần tự; nếu nó kẹt ở message độc và cứ retry mãi, mọi message phía sau không bao giờ được xử lý. Một đơn hàng hỏng chặn đứng cả nghìn đơn tốt. Lời giải là Dead Letter Queue (DLQ). Bài này (phần 6 loạt Message Queue) chạy thật để thấy poison message chặn hàng đợi thế nào và DLQ cứu ra sao.
Poison message và cái bẫy retry vô hạn
Khi xử lý một message thất bại, phản xạ đúng là retry — vì nhiều lỗi là tạm thời (DB timeout, mạng chập chờn). Retry giúp message qua được khi sự cố tạm thời hết. Nhưng có một loại lỗi không bao giờ hết dù retry bao nhiêu: lỗi vĩnh viễn do chính message. Payload amount="XXX" sẽ luôn fail khi parse thành số, dù thử một lần hay một triệu lần.
Đây là cái bẫy: consumer at-least-once xử lý tuần tự trong partition, và chỉ commit offset sau khi xử lý xong (bài 2). Nếu message không bao giờ xử lý xong, offset không bao giờ tiến — consumer kẹt mãi tại đó, retry vô hạn, và message sau nó chờ vô vọng.
DLQ phá vòng lặp: cho message một số lần retry có giới hạn; nếu vẫn fail, tách nó ra một topic riêng (dead letter queue) để điều tra sau, rồi commit offset và đi tiếp. Hàng đợi chính tiếp tục chảy.
# KHÔNG DLQ — retry poison mãi → kẹt cả partition
while msg:
try: process(msg)
except: retry(msg) # mãi không qua → offset không tiến
# mọi message SAU poison không bao giờ được xử lý
# CÓ DLQ — sau N lần fail → tách sang topic riêng
while msg:
for i in range(N): # thử N lần
if process(msg): break
else: # vẫn fail sau N lần
produce(msg → DLQ_topic) # tách poison ra
commit(offset) # TIẾP TỤC, không kẹt

Hình 1: Không có DLQ, consumer retry poison vô hạn nên offset không tiến — mọi message sau bị chặn. Có DLQ, consumer thử N lần rồi tách message độc sang topic DLQ riêng và commit offset để đi tiếp. Demo dùng hai topic Kafka: mq06-main (chính) và mq06-dlq (dead letter).
Đo thật: kẹt 8/10 vs xử lý trọn 8 tốt
Mình produce 10 message vào kafka-lab, trong đó 2 message độc (amount không parse được thành số) ở vị trí 3 và 7, rồi xử lý theo hai cách:

Hình 2: Kết quả thật. Không DLQ: consumer xử lý order-1, order-2 rồi kẹt tại order-3 (amount="XXX") — retry 3 lần vẫn lỗi, offset không tiến → chỉ 2/10 được xử lý, 8 message bị chặn vĩnh viễn. Có DLQ: xử lý trọn 8 message tốt, tách 2 message độc (order-3, order-7) sang mq06-dlq, hàng đợi chính không kẹt.
Đọc kết quả:
- Không DLQ → kẹt 8/10: consumer xử lý order-1, order-2 bình thường, rồi gặp order-3 với
amount="XXX". Parse fail, retry — nhưng lỗi là vĩnh viễn nên retry bao nhiêu cũng fail. Offset dừng tại order-3, và order-3 tới order-10 (8 message) không bao giờ được xử lý. Một message độc duy nhất giữ con tin 80% hàng đợi. Trong hệ thật, đây là sự cố nghiêm trọng: xử lý đình trệ, lag tăng vô hạn, trong khi nguyên nhân chỉ là một message hỏng. - Có DLQ → xử lý trọn 8, tách 2: consumer thử mỗi message tối đa 3 lần; message tốt qua ngay, message độc fail 3 lần (6 lần retry tổng cho 2 message) rồi được produce sang
mq06-dlqvà consumer đi tiếp. Kết quả: 8 message tốt xử lý trọn vẹn, 2 message độc nằm an toàn trong DLQ (order-3:XXX,order-7:NULL) chờ con người điều tra. Hàng đợi chính không hề kẹt.
Thông điệp cốt lõi: một message độc không được phép làm sập việc xử lý của mọi message khác. DLQ biến một lỗi chặn toàn bộ thành một lỗi cô lập — bạn mất khả năng xử lý 2 message hỏng (tạm thời, chờ điều tra) thay vì mất khả năng xử lý tất cả.
DLQ không phải thùng rác — nó là hàng chờ điều tra
Điểm dễ hiểu sai: DLQ không phải nơi để quên message. Một message vào DLQ nghĩa là "có gì đó sai cần con người xem". Nếu không ai theo dõi DLQ, nó âm thầm nuốt message và bạn mất dữ liệu trong im lặng — tệ ngang việc không có DLQ. DLQ cần đi kèm: cảnh báo khi có message mới vào (như bài alerting của loạt Observability), công cụ để đọc và chẩn đoán message độc, và cơ chế replay — sau khi sửa bug hoặc dữ liệu, đẩy message từ DLQ về topic chính xử lý lại. DLQ là trạm trung chuyển có giám sát, không phải bãi rác.
Đánh đổi cần cân nhắc
Phân biệt lỗi tạm thời và vĩnh viễn — đây là phần khó nhất. DLQ chỉ đúng cho lỗi vĩnh viễn (message hỏng). Với lỗi tạm thời (DB sập 30 giây), đẩy message vào DLQ là sai — nó vốn sẽ thành công nếu thử lại sau. Nhưng consumer không phải lúc nào cũng biết lỗi thuộc loại nào. Giải pháp thực tế: retry với backoff (chờ lâu dần) vài lần cho lỗi tạm thời, chỉ chuyển DLQ sau khi backoff hết mà vẫn fail. Chọn N (số lần retry) và thời gian backoff là cân bằng: quá ít thì đẩy nhầm lỗi tạm thời vào DLQ, quá nhiều thì poison message chặn hàng đợi lâu trước khi bị tách.
Retry tại chỗ vẫn chặn hàng đợi trong lúc retry. Trong demo, retry xảy ra đồng bộ ngay trong consumer — nghĩa là trong lúc retry 3 lần một message độc, các message sau vẫn phải chờ. Với lỗi tạm thời cần backoff dài (chờ 1 phút), chặn như vậy giết throughput. Mẫu nâng cao: dùng retry topic riêng (gửi message cần retry sang một topic khác với delay) để consumer chính không bị chặn — nhưng điều này có thể làm mất thứ tự (bài sau). Đánh đổi giữa giữ thứ tự và không chặn.
Mất thứ tự khi tách message. Khi một message được tách sang DLQ và consumer đi tiếp, các message sau nó được xử lý trước message độc (vốn sẽ xử lý sau khi điều tra/replay). Nếu nghiệp vụ cần thứ tự nghiêm ngặt (ví dụ các sự kiện của cùng một tài khoản), tách message có thể làm sai thứ tự. Với dữ liệu cần thứ tự, cân nhắc dừng hẳn partition đó thay vì skip — đánh đổi giữa tính sẵn sàng (không kẹt) và tính đúng đắn (giữ thứ tự).
Ba ý mang về
- Một poison message chặn cả hàng đợi nếu retry vô hạn: đo thật consumer không DLQ kẹt tại message độc đầu tiên (order-3,
amount="XXX") → chỉ 2/10 xử lý, 8 message bị chặn vĩnh viễn vì offset không tiến. - DLQ cô lập lỗi thay vì để nó lan: đo thật consumer có DLQ xử lý trọn 8 message tốt và tách 2 message độc sang
mq06-dlqsau N lần retry — hàng đợi chính không kẹt, lỗi biến từ "chặn toàn bộ" thành "cô lập 2 message". - DLQ là hàng chờ điều tra, cần giám sát và xử lý đánh đổi: phải cảnh báo + đọc + replay DLQ chứ không để nó thành bãi rác; phân biệt lỗi tạm thời (retry/backoff) với vĩnh viễn (DLQ); và tách message có thể làm mất thứ tự — cân nhắc theo nghiệp vụ.
Nguồn
- Confluent — Error handling patterns & dead letter queues: https://www.confluent.io/blog/error-handling-patterns-in-kafka/
- AWS — Dead-letter queues: https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-dead-letter-queues.html
- Uber Engineering — Reliable reprocessing & dead letter queues with Kafka: https://www.uber.com/blog/reliable-reprocessing/
Phần sau ta đo sức khoẻ vận hành quan trọng nhất của consumer: lag — khi producer nhanh hơn consumer, message dồn lại; đo thật lag tăng qua offset và vì sao đây là tín hiệu cảnh báo số một của hệ message queue.