Bỏ qua để đến nội dung
Knowledge Hub

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

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

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.

order-44 lỗi 503 ba lần: attempt tăng dần qua từng topic rồi vào DLQ
order-44 lỗi 503 ba lần: attempt tăng dần qua từng topic rồi vào DLQBước 1/6: Consumer chính nhận order-44 ở offset 4 (attempt 1). Payment service trả 503: lỗi tạm thời.orderstopic chínhorders.retry-5sdelay 5 giâyorders.retry-1mdelay 1 phútorders.dlqkhông tự tiêu thụConsumer chính503Consumer 5spause tới hạnConsumer 1mpause tới hạnOn-callalert, sửa, replayoffset 4
orders → Consumer chínhoffset 4
Bước 1/6

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

Kafka không có delayed delivery: broker giao record ngay khi consumer fetch. Consumer của retry topic tự chờ:

  1. Đọc record, so due-at trong header với giờ hiện tại.
  2. Chưa tới hạn: pause partition đó, seek lại về record này, resume khi tới hạn.
  3. 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.

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 DeadLetterPublishingRecoverer thê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ật errors.deadletterqueue.context.headers.enable.

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 lỗi đi vòng retry, paid chạy trước, rồi created đè lên
order-42: created lỗi đi vòng retry, paid chạy trước, rồi created đè lênBước 1/4: order-42 created (offset 1) lỗi 503. Consumer chính đẩy nó sang retry-5s và đi tiếp.thời giancreated đang retry …created @1503Consumer chínhorders, partition 0Bảng orderstrạng thái order-42Consumer retryretry-5s, retry-1m
Bước 1/4

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.

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

Terminal window
npx --prefix examples ts-node examples/src/kafka-retry-dlq-sim/retry-dlq-sim.ts

Output thật của mode naive:

=== mode: naive ===
t= 0s orders order-41:created attempt=1 OK
t= 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 OK
t= 0s orders order-43:created:bad-schema attempt=1 FAIL schema invalid -> DLQ
t= 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= 5s
t= 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= 65s
t= 65s orders.retry-1m order-42:created attempt=3 OK
t= 65s orders.retry-1m order-44:created attempt=3 FAIL payment-svc 503 -> DLQ
applied order : order-41:created, order-42:paid, order-42:created
DLQ : 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 OK
t= 65s unpark order-42:paid
t= 65s parked order-42:paid attempt=1 OK
applied order : order-41:created, order-42:created, order-42:paid
order-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.

DLQ không phải thùng rác. Mỗi message trong đó là một nghiệp vụ chưa xong.

  1. Phân loại: nhóm theo x-error-class và x-original-topic; tìm nguyên nhân chung.
  2. Sửa nguyên nhân: deploy fix, sửa cấu hình, hoặc chờ downstream hồi phục.
  3. 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).
  4. Replay có giới hạn: tool đọc DLQ, publish lại về x-original-topic với cùng key, value và event ID, reset x-attempt. Giới hạn tốc độ; chạy thử vài message trước.
  5. 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.
  6. 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.

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ô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ế.

  • 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).

Các con số trên là kịch bản minh hoạ, không phải số đo.

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.
  1. 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.
  2. 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.
  3. Header bắt buộc: attempt, due-at, topic/partition/offset gốc, lỗi; giữ nguyên key và value.
  4. Consumer retry dùng pause/resume, không sleep dài trong handler.
  5. Publish sang retry/DLQ thành công rồi mới commit offset của message gốc.
  6. Event có state: state machine hoặc versioned update để chịu đảo thứ tự; handler idempotent.
  7. Alert khi DLQ có message mới và theo tuổi message cũ nhất; DLQ có owner và retention dài.
  8. Có tool replay giới hạn tốc độ, giữ event ID, đã test trên staging.

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?