Retry topic và DLQ trong Kafka: xử lý message lỗi mà không chặn partition Trung cấp
Thời gian đọc ước tính: 15 phút
Vấn đề
Phần tiêu đề “Vấn đề”Topic orders có 12 partition. Một event ở partition 3 có payload hỏng, handler ném lỗi mỗi lần đọc. Consumer retry mãi, lag của partition 3 tăng từ 0 lên vài chục nghìn. 11 partition còn lại chạy bình thường, nên dashboard tổng throughput trông vẫn ổn.
Đây là poison message. Nó chặn cả partition vì committed offset là tiến độ liên tục: commit offset 5 nghĩa là “mọi thứ trước 5 đã xong”, nên chưa xử lý được offset 4 thì không thể commit vượt qua nó mà không bỏ mất nó (commit offset). Mọi đơn hàng sau nó trong partition đó đứng chờ.
Khác RabbitMQ, consumer API của Kafka không có DLQ dựng sẵn: không có nack hay dead-letter exchange. Retry và DLQ là pattern bạn tự xây bằng các topic thường. DLQ dựng sẵn chỉ có ở tầng trên: Kafka Connect (sink connector) và Kafka Streams (exception handler mặc định).
Ba lựa chọn
Phần tiêu đề “Ba lựa chọn”| Retry tại chỗ (blocking) | Retry topic có delay | DLQ ngay | |
|---|---|---|---|
| Cách làm | Thử lại trong consumer, backoff giữa các lần | Publish sang orders.retry-5s, orders.retry-1m… rồi commit tiếp |
Publish sang orders.dlq, commit tiếp |
| Thứ tự cùng key | Giữ | Mất (message lỗi bị vượt qua) | Mất (message lỗi bị bỏ lại) |
| Partition chính | Đứng trong lúc retry | Chạy tiếp | Chạy tiếp |
| Độ trễ của message lỗi | Ngắn (giây) | Theo bậc delay (giây tới phút) | Tới khi người xử lý |
| Độ phức tạp | Thấp | Cao: thêm topic, consumer, header, alert | Trung bình |
| Hợp với | Lỗi rất ngắn (timeout, deadlock) | Lỗi tạm thời kéo dài (downstream 503) | Lỗi vĩnh viễn (schema, validation) |
Mặc định nên dùng kết hợp: retry tại chỗ vài lần với backoff ngắn, rồi retry topic cho lỗi tạm thời, cuối cùng DLQ. Lỗi vĩnh viễn đi thẳng DLQ, không qua retry.
Luồng main → retry-5s → retry-1m → DLQ
Phần tiêu đề “Luồng main → retry-5s → retry-1m → DLQ”offset 4Consumer chính nhận order-44 ở offset 4 (attempt 1). Payment service trả 503: lỗi tạm thời.
Mỗi bậc delay là một topic riêng. Vì mọi message trong orders.retry-5s có cùng delay, message ở đầu partition luôn là message tới hạn sớm nhất. Consumer chỉ cần nhìn message đầu.
Delay hiện thực thế nào
Phần tiêu đề “Delay hiện thực thế nào”Kafka không có delayed delivery: broker giao record ngay khi consumer fetch. Consumer của retry topic tự chờ:
- Đọc record, so
due-attrong header với giờ hiện tại. - Chưa tới hạn: pause partition đó, seek lại về record này, resume khi tới hạn.
- Tới hạn: xử lý. Lỗi tiếp thì publish sang bậc sau với
attempt + 1.
Spring Kafka làm đúng như vậy: consumer retry topic “checks the timestamp and if it’s not due it pauses the consumption for that topic’s partition”. Docs của họ nói thêm delay là cận dưới: không bao giờ xử lý sớm, nhưng có thể muộn hơn khi consumer giữ nhiều partition hoặc xử lý record trước đó chậm.
Với Java client, partition đang pause vẫn cần poll() đều đặn (poll trả về rỗng cho partition đó), nên không vi phạm max.poll.interval.ms. Ngược lại, sleep 1 phút trong handler là cách chắc chắn để bị đá khỏi group nếu tổng thời gian vượt max.poll.interval.ms (mặc định 300000 ms) (rebalance).
KafkaJS không có retry topic hay DLQ dựng sẵn trong docs, nhưng có pause() theo partition. Phác thảo consumer retry:
await consumer.run({ eachMessage: async ({ topic, partition, message, pause }) => { const dueAt = Number(message.headers?.['x-due-at']?.toString() ?? 0); const wait = dueAt - Date.now(); if (wait > 0) { const resume = pause(); // chỉ pause topic-partition hiện tại setTimeout(resume, wait); throw new Error('not due yet'); // không resolve offset, record được đọc lại sau resume } await handleWithRetryRouting(topic, partition, message); },});Mẫu “pause, hẹn resume, rồi throw” lấy từ ví dụ trong docs KafkaJS. Đây là phác thảo chưa chạy với broker thật; test lại crash window với đúng phiên bản client.
Header cần mang theo
Phần tiêu đề “Header cần mang theo”Message ở retry topic hay DLQ phải tự mô tả được: nó từ đâu, đã thử mấy lần, lỗi gì.
| Header (tên ví dụ) | Giá trị | Dùng để |
|---|---|---|
x-attempt |
1, 2, 3… |
Quyết định bậc tiếp theo hay vào DLQ |
x-due-at |
epoch ms | Consumer retry biết khi nào được xử lý |
x-original-topic |
orders |
Replay về đúng topic |
x-original-partition |
3 |
Truy vết, so với log |
x-original-offset |
48121 |
Truy vết, dedupe khi điều tra |
x-error-class, x-error-message |
PaymentUnavailable, 503 |
Phân loại nhanh khi xem DLQ |
x-first-failed-at |
epoch ms | Đo tuổi lỗi, alert |
Giữ nguyên key và value gốc, đừng bọc payload vào envelope mới. Giữ key để message vẫn đi theo partition của key, và để replay về topic chính không đổi partition.
Tham chiếu từ hai hiện thực có sẵn:
- Spring Kafka
DeadLetterPublishingRecovererthêm các header nhưDLT_ORIGINAL_TOPIC,DLT_ORIGINAL_PARTITION,DLT_ORIGINAL_OFFSET,DLT_EXCEPTION_FQCN,DLT_EXCEPTION_MESSAGE,DLT_EXCEPTION_STACKTRACE,DLT_ORIGINAL_CONSUMER_GROUP. Mặc định gửi vào topic<topic gốc>-dlt, cùng partition với record gốc, nên DLT cần ít nhất bằng số partition của topic gốc. - Kafka Connect thêm header có prefix
__connect.errors.khi bậterrors.deadletterqueue.context.headers.enable.
Bẫy thứ tự: retry đảo event cùng key
Phần tiêu đề “Bẫy thứ tự: retry đảo event cùng key”Retry topic giải quyết head-of-line blocking bằng cách vượt qua message lỗi. Cái giá: event sau của cùng key được xử lý trước. Spring Kafka nói thẳng: “By using this strategy you lose Kafka’s ordering guarantees for that topic.”
order-42 created (offset 1) lỗi 503. Consumer chính đẩy nó sang retry-5s và đi tiếp.
Thứ tự quan trọng khi consumer áp state theo entity (message key): state machine đơn hàng, số dư, projection. Không quan trọng khi mỗi event độc lập: gửi email, tạo thumbnail, ghi log.
Khi không chấp nhận đảo thứ tự, chọn một trong các cách:
| Cách | Hoạt động | Giá phải trả |
|---|---|---|
| Retry tại chỗ, không có retry topic | Chặn partition tới khi xong hoặc vào DLQ | Lag cả partition trong lúc chờ |
| Park theo key | Key đang có message trong retry thì message sau của key đó cũng bị đẩy vào hàng chờ (bảng DB hoặc retry topic), chỉ chạy khi message trước xong | Phải lưu tập “key đang lỗi” bền vững; phức tạp |
| State machine từ chối transition sai | paid khi đơn chưa created thì không áp mà đưa vào retry |
Cần version hoặc trạng thái trên entity |
| Versioned update | Chỉ ghi khi version của event lớn hơn version đã lưu |
Event phải mang version tăng dần |
Mặc định cho event có state: state machine cộng versioned update. Nó chịu được cả retry lẫn redelivery, không chỉ riêng retry topic.
Phân loại lỗi trước khi retry
Phần tiêu đề “Phân loại lỗi trước khi retry”| Loại lỗi | Ví dụ | Hành động |
|---|---|---|
| Tạm thời, ngắn | Timeout mạng, PostgreSQL deadlock 40P01, serialization failure 40001 |
Retry tại chỗ 2-3 lần, backoff ngắn |
| Tạm thời, dài | Downstream 503/429, circuit breaker mở | Retry topic theo bậc |
| Vĩnh viễn: dữ liệu | Deserialize lỗi, sai schema, validation 400/422 | DLQ ngay |
| Vĩnh viễn: code/cấu hình | Bug, 401/403 do sai credential | DLQ ngay; sửa rồi replay |
| Mơ hồ | “Không tìm thấy entity” (có thể do event tới trước) | Retry topic vài bậc, rồi DLQ |
Spring Kafka DefaultErrorHandler mặc định coi một số exception là fatal và không retry, ví dụ DeserializationException, MessageConversionException, ClassCastException. Hãy tự khai báo danh sách lỗi không retry của nghiệp vụ, đừng retry mọi exception.
Backoff giữa các lần retry tại chỗ nên có jitter để nhiều consumer không đập downstream cùng lúc (retry, backoff, jitter).
Mô phỏng chạy được
Phần tiêu đề “Mô phỏng chạy được”Mô phỏng trong RAM với đồng hồ ảo, không dùng Kafka: topic là mảng FIFO, header là object. Kịch bản: order-42 created lỗi 503 hai lần rồi thành công, order-43 sai schema, order-44 lỗi 503 mãi.
npx --prefix examples ts-node examples/src/kafka-retry-dlq-sim/retry-dlq-sim.tsOutput thật của mode naive:
=== mode: naive ===t= 0s orders order-41:created attempt=1 OKt= 0s orders order-42:created attempt=1 FAIL payment-svc 503 -> orders.retry-5s (due t= 5s)t= 0s orders order-42:paid attempt=1 OKt= 0s orders order-43:created:bad-schema attempt=1 FAIL schema invalid -> DLQt= 0s orders order-44:created attempt=1 FAIL payment-svc 503 -> orders.retry-5s (due t= 5s)t= 0s orders.retry-5s pause partition, chờ tới t= 5st= 5s orders.retry-5s order-42:created attempt=2 FAIL payment-svc 503 -> orders.retry-1m (due t= 65s)t= 5s orders.retry-5s order-44:created attempt=2 FAIL payment-svc 503 -> orders.retry-1m (due t= 65s)t= 5s orders.retry-1m pause partition, chờ tới t= 65st= 65s orders.retry-1m order-42:created attempt=3 OKt= 65s orders.retry-1m order-44:created attempt=3 FAIL payment-svc 503 -> DLQapplied order : order-41:created, order-42:paid, order-42:createdDLQ : order-43:created:bad-schema [attempt=1, orig=orders-0@3, error=schema invalid]; order-44:created [attempt=3, orig=orders-0@4, error=payment-svc 503]order-42 order: ĐẢO (paid trước created)Mode park-by-key chạy cùng kịch bản nhưng giữ order-42:paid lại cho tới khi created xong (trích các dòng khác với mode naive):
t= 0s orders order-42:paid PARK (key đang retry)t= 65s orders.retry-1m order-42:created attempt=3 OKt= 65s unpark order-42:paidt= 65s parked order-42:paid attempt=1 OKapplied order : order-41:created, order-42:created, order-42:paidorder-42 order: giữ đúng (created trước paid)Để ý: order-43 vào DLQ với attempt=1 (không retry lỗi vĩnh viễn), order-44 vào DLQ với attempt=3. Partition chính xử lý hết 5 record ở t=0s dù có 3 record lỗi.
Replay từ DLQ
Phần tiêu đề “Replay từ DLQ”DLQ không phải thùng rác. Mỗi message trong đó là một nghiệp vụ chưa xong.
- Phân loại: nhóm theo
x-error-classvàx-original-topic; tìm nguyên nhân chung. - Sửa nguyên nhân: deploy fix, sửa cấu hình, hoặc chờ downstream hồi phục.
- Kiểm tra idempotency: handler phải chịu được việc xử lý lại, vì message có thể đã áp một phần (idempotent consumer).
- Replay có giới hạn: tool đọc DLQ, publish lại về
x-original-topicvới cùng key, value và event ID, resetx-attempt. Giới hạn tốc độ; chạy thử vài message trước. - Ghi nhận tiến độ: tool replay là một consumer group riêng trên DLQ; commit offset sau khi publish thành công để không replay hai lần khi tool crash.
- Message không cứu được: ghi lý do và người quyết định bỏ, rồi đánh dấu xong. Đừng xoá âm thầm.
Lưu ý thứ tự: replay order-42 created sau khi paid đã chạy vẫn là đảo thứ tự. State machine phải xử lý được.
Alert và giám sát
Phần tiêu đề “Alert và giám sát”| Tín hiệu | Ý nghĩa | Ngưỡng gợi ý |
|---|---|---|
| Message mới vào DLQ (rate) | Có lỗi chưa xử lý được | Bất kỳ message nào với topic quan trọng |
| Số message chưa xử lý trong DLQ (lag của group replay/audit) | Backlog lỗi | Tăng liên tục |
| Tuổi message cũ nhất trong DLQ | Lỗi bị bỏ quên | Gần retention của DLQ |
| Lag của từng retry topic | Retry consumer chậm hoặc chết | Vượt delay của bậc đó nhiều lần |
| Tỉ lệ message vào retry so với tổng | Downstream đang xuống | Tăng đột biến |
Đặt retention của DLQ đủ dài để kịp điều tra (ví dụ vài tuần); retention ngắn biến DLQ thành nơi mất dữ liệu có trì hoãn.
Các lựa chọn có sẵn
Phần tiêu đề “Các lựa chọn có sẵn”| Công cụ | Có gì | Giới hạn |
|---|---|---|
| Kafka Connect (sink) | errors.tolerance=all cộng errors.deadletterqueue.topic.name |
Chỉ sink connector; mặc định tên topic rỗng nghĩa là tắt DLQ |
Spring Kafka @RetryableTopic |
Tự tạo retry topic theo delay (-retry-1000, -retry-2000…) và -dlt, pause partition tới hạn |
Không hỗ trợ batch listener và container transaction |
Spring Kafka DefaultErrorHandler |
Retry tại chỗ (blocking) bằng seek; mặc định FixedBackOff(0L, 9) tức 10 lần, không delay, rồi log |
Chặn partition trong lúc retry |
| Kafka Streams | errors.dead.letter.queue.topic.name (docs 4.2): handler mặc định gửi record lỗi vào topic này |
Mặc định null; bị bỏ qua nếu dùng exception handler tự viết |
| KafkaJS | pause/resume theo partition |
Không có retry topic hay DLQ; tự xây |
| Share groups (Kafka 4.2+) | Đếm số lần giao từng record; group.share.delivery.count.limit mặc định 5 (2 tới 10) |
Không có DLQ; mất thứ tự (share groups) |
Config DLQ của Kafka Connect (Kafka 4.2):
| Config | Mặc định | Ghi chú |
|---|---|---|
errors.tolerance |
none |
none: lỗi là task fail; all: bỏ qua record lỗi |
errors.deadletterqueue.topic.name |
rỗng | Rỗng thì không có DLQ |
errors.deadletterqueue.topic.replication.factor |
3 |
Khi Connect tự tạo topic DLQ |
errors.deadletterqueue.context.headers.enable |
false |
Thêm header __connect.errors.* |
errors.retry.timeout |
0 |
0 không retry, -1 retry vô hạn |
errors.retry.delay.max.ms |
60000 |
Delay tối đa giữa hai lần retry |
Share groups là lựa chọn thay thế cho job độc lập: nhiều worker trên một partition, RELEASE để giao lại, REJECT để dừng, tối đa số lần giao có cấu hình. Docs Kafka 4.2 không mô tả DLQ cho share group; record bị reject hoặc hết lượt giao vẫn cần nơi lưu do bạn thiết kế.
Khi nào dùng / không dùng
Phần tiêu đề “Khi nào dùng / không dùng”- Dùng retry topic: downstream tạm xuống vài phút, event độc lập hoặc đã có state machine/versioning.
- Dùng retry tại chỗ: lỗi ngắn (deadlock, timeout); hoặc thứ tự cùng partition là invariant và chấp nhận lag.
- Dùng DLQ ngay: lỗi dữ liệu và lỗi code, retry không đổi kết quả.
- Không dùng retry topic cho event có state mà chưa có cơ chế chống đảo thứ tự.
- Cân nhắc RabbitMQ khi workload là job queue cần TTL, DLX và retry có đếm dựng sẵn (RabbitMQ dead-letter).
Ví dụ thực tế
Phần tiêu đề “Ví dụ thực tế”Các con số trên là kịch bản minh hoạ, không phải số đo.
Trade-offs
Phần tiêu đề “Trade-offs”Retry topic + DLQ
- Message lỗi không chặn message khác trong partition.
- Backoff dài (phút) mà không vi phạm
max.poll.interval.ms. - Lỗi vĩnh viễn có nơi lưu, có header để điều tra và replay.
Giá phải trả
- Mất thứ tự cùng key; cần state machine hoặc park theo key.
- Thêm topic, consumer group, alert và runbook để vận hành.
- Delay chỉ là cận dưới; không dùng cho lịch hẹn giờ chính xác.
Checklist production
Phần tiêu đề “Checklist production”- Phân loại lỗi rõ: lỗi vĩnh viễn vào DLQ ngay, chỉ lỗi tạm thời đi vòng retry.
- Mỗi bậc delay một topic; số partition của retry topic và DLQ ít nhất bằng topic gốc nếu giữ partition gốc.
- Header bắt buộc: attempt, due-at, topic/partition/offset gốc, lỗi; giữ nguyên key và value.
- Consumer retry dùng pause/resume, không
sleepdài trong handler. - Publish sang retry/DLQ thành công rồi mới commit offset của message gốc.
- Event có state: state machine hoặc versioned update để chịu đảo thứ tự; handler idempotent.
- Alert khi DLQ có message mới và theo tuổi message cũ nhất; DLQ có owner và retention dài.
- Có tool replay giới hạn tốc độ, giữ event ID, đã test trên staging.
Sai lầm thường gặp
Phần tiêu đề “Sai lầm thường gặp”Tóm tắt
Phần tiêu đề “Tóm tắt”Tự kiểm tra: (1) Vì sao commit offset 5 khi offset 4 còn lỗi lại đồng nghĩa với bỏ qua offset 4? (2) Tại sao trộn delay 5 giây và 10 phút trong một retry topic là sai? (3) Replay created sau khi paid đã áp thì cần cơ chế gì ở consumer?
Tham khảo
Phần tiêu đề “Tham khảo”- Kafka Connect Configs — Apache Kafka 4.2
- KIP-298: Error Handling in Connect
- Non-Blocking Retries: How the Pattern Works — Spring for Apache Kafka
- Back Off Delay Precision — Spring for Apache Kafka
- Handling Exceptions — Spring for Apache Kafka
- Consuming Messages — KafkaJS
- Kafka Streams Configs — Apache Kafka 4.2
- Broker Configs — Apache Kafka 4.2