Retry, backoff và dead letter queue: ví dụ mẫu từng bước trên hai công cụ
Mục tiêu bài học
- Phân biệt được lỗi tạm thời (transient) và lỗi vĩnh viễn (permanent) để quyết định có retry hay không.
- Cấu hình được retry với exponential backoff và jitter trên BullMQ.
- Xây được luồng retry thủ công kèm dead letter queue trên Kafka.
- Nêu được nội dung tối thiểu phải giữ lại trong một job hoặc message ở dead letter queue, và cách vận hành nó.
Ôn nhanh
Bài trước đã chốt: at-least-once là mặc định thực dụng, còn exactly-once trong Kafka chỉ đúng cho vòng read-process-write nội bộ và không bảo vệ side effect bên ngoài như gửi email hay gọi API thanh toán. Hệ quả trực tiếp: mọi lần retry đều có thể khiến side effect chạy hai lần, nên retry luôn phải đi kèm idempotency. Bài này biến hai khái niệm đó thành cấu hình cụ thể.
Lý thuyết
Phân loại lỗi trước khi nghĩ đến retry
Đây là bước junior hay bỏ qua nhất. Retry một lỗi vĩnh viễn chỉ tạo ra poison message, tốn CPU và làm đầy DLQ bằng rác.
| Loại lỗi | Ví dụ | Hành động |
|---|---|---|
| Transient | timeout, connection reset, downstream trả 503, deadlock DB | Retry với backoff |
| Permanent | payload sai schema, lỗi 400/422, user không tồn tại, template không hợp lệ | Không retry, đẩy thẳng DLQ |
| Unknown | lỗi không rõ nguyên nhân | Retry vài lần rồi DLQ |
Exponential backoff và jitter
Công thức chuẩn:
$$ delay = \min(base \times 2^{attempt-1},\ maxDelay) $$
Với base = 1s: 1s, 2s, 4s, 8s, 16s, rồi chặn ở maxDelay (thường 30s tới 1 phút). Thêm jitter, tức một lượng ngẫu nhiên, vì nếu 10.000 job cùng fail tại một thời điểm, không jitter thì tất cả cùng retry ở giây thứ 1, rồi giây thứ 2, tạo retry storm đánh sập downstream lần hai.
Dead letter queue không phải thùng rác
DLQ là nơi chứa message hoặc job đã thử quá số lần cho phép, hoặc lỗi permanent. Một DLQ chỉ có giá trị khi có ba thứ: alert khi có message vào, dashboard để xem, và quy trình replay hoặc xử lý tay. Không có ba thứ đó thì DLQ chỉ là nghĩa địa dữ liệu.
flowchart TD
A["📥 Message hoặc job"] --> B{"Xử lý thành công?"}
B -->|Có| C["✅ Commit offset hoặc job done"]
B -->|Không| D{"Lỗi tạm thời?"}
D -->|Không| E["☠️ Đẩy thẳng DLQ"]
D -->|Có| F{"Còn lượt retry?"}
F -->|Có| G["⏳ Backoff cộng jitter rồi thử lại"] --> A
F -->|Không| E
E --> H["🚨 Alert, dashboard, replay"]
Tự kiểm tra nhanh
Trong BullMQ, số lần thử lại tối đa của một job được đặt qua option {{c1::attempts}}.
Backoff kiểu {{c2::exponential}} tăng delay theo cấp số nhân sau mỗi lần thất bại.
Thêm {{c3::jitter}} vào delay để nhiều job không retry cùng thời điểm, tránh retry storm.
Kafka không có retry built-in, nên message hỏng vượt số lần cho phép phải được produce sang topic {{c4::DLQ}}.
Muốn retry thủ công trên Kafka, consumer phải tắt {{c5::auto-commit}}.
Ví dụ mẫu từng bước
Ví dụ 1: BullMQ, retry 5 lần, backoff exponential, giữ job hỏng lại
Đề bài: job send-email, tối đa 5 lần thử, nền 1 giây, lỗi permanent fail ngay, và job hỏng phải còn dấu vết để xem lại.
Bước 1. Khai báo option của job. attempts là tổng số lần chạy, removeOnFail: false giữ job hỏng lại. Chính failed set này là DLQ của BullMQ.
import { Queue, Worker, UnrecoverableError } from 'bullmq';
const connection = { host: '127.0.0.1', port: 6379 };
const emailQueue = new Queue('send-email', { connection });
await emailQueue.add(
'welcome',
{ userId: 42, template: 'welcome' },
{
attempts: 5,
backoff: { type: 'exponential', delay: 1000 },
removeOnComplete: 1000,
removeOnFail: { count: 5000 },
}
);
Bước 2. Worker phân loại lỗi. UnrecoverableError khiến BullMQ đánh dấu fail ngay, không tốn 5 lần thử vô nghĩa. Mọi lỗi khác cứ throw ra để BullMQ retry theo backoff.
const worker = new Worker(
'send-email',
async (job) => {
try {
await sendEmail(job.data);
} catch (err) {
if (err.status === 422 || err.code === 'INVALID_TEMPLATE') {
throw new UnrecoverableError(err.message);
}
throw err;
}
},
{ connection }
);
Bước 3. Muốn jitter thì dùng custom backoff. Đặt backoff: { type: 'custom' } trên job, rồi cung cấp hàm tính delay trong settings của Worker.
settings: {
backoffStrategy: (attemptsMade) => {
const base = Math.min(1000 * 2 ** (attemptsMade - 1), 60000);
return Math.floor(base / 2 + Math.random() * (base / 2));
},
}
Bước 4. Replay từ failed set. Sau khi sửa bug, lấy job hỏng ra và đẩy lại.
const failed = await emailQueue.getFailed(0, 99);
for (const job of failed) {
if (isFixed(job.failedReason)) await job.retry();
}
Cạm bẫy: để removeOnFail: true thì job hỏng bị xóa ngay, bạn mất toàn bộ dấu vết và không có DLQ nào cả.
Ví dụ 2: Kafka, retry tại chỗ rồi forward sang DLQ topic
Đề bài: topic orders.created, mỗi message gọi service kho. Kafka không có retry built-in, bạn phải tự viết.
Bước 1. Tắt auto-commit để offset chỉ được commit khi xử lý xong thật sự.
Bước 2. Retry có giới hạn ngay trong handler. Thành công thì commit, hết lượt thì forward sang DLQ rồi commit offset gốc.
await consumer.run({
autoCommit: false,
eachMessage: async ({ topic, partition, message }) => {
let lastErr;
for (let attempt = 0; attempt < 4; attempt++) {
try {
await reserveInventory(JSON.parse(message.value.toString()));
await commit(message);
return;
} catch (err) {
lastErr = err;
if (isPermanent(err)) break;
await sleep(Math.min(1000 * 2 ** attempt, 30000));
}
}
await producer.send({
topic: 'orders.dlq',
messages: [{
value: message.value,
headers: {
...message.headers,
'x-error': lastErr.message,
'x-origin': `${topic}:${partition}:${message.offset}`,
},
}],
});
await commit(message); // commit để không lặp vô hạn message hỏng
},
});
Bước 3. Với delay dài, đừng sleep. Sleep trong eachMessage sẽ chặn partition đó, và nếu vượt max.poll.interval.ms thì consumer bị đá khỏi group, gây rebalance liên tục. Cách làm production là chuỗi retry topic có delay nằm ngay trong tên: orders.retry.1m, orders.retry.10m, rồi orders.dlq. Mỗi topic có consumer group riêng, message được produce sang đó kèm header x-origin và số lần thử, rồi offset gốc được commit.
Bước 4. Vận hành DLQ. DLQ là topic có retention dài, có consumer riêng để đếm và alert, và khi replay thì đọc từ đầu DLQ để đẩy ngược về topic gốc.
Thảo luận và suy ngẫm
- Downstream sập 30 phút, còn bạn retry 5 lần trong vòng 31 giây. Chiến lược đó có cứu được gì không, và bạn sẽ sửa thế nào?
- Ai đọc DLQ của bạn? Nếu không có consumer nào, làm sao bạn biết có message đang nằm trong đó?
- Vì sao thêm retry vào bất kỳ hệ thống nào cũng biến nó thành at-least-once?
Góc chuyên gia
Quy tắc ngón tay. Max attempts 5, base delay 1 giây, cap 1 phút, luôn có jitter. Retry cách quãng quá ngắn chỉ là cách khuếch đại sự cố.
Chỉ retry thứ idempotent. Nếu handler không idempotent, mỗi lần retry là một lần side effect thứ hai. Có idempotency key ở tầng nghiệp vụ trước, rồi mới bật retry.
Đừng để DLQ im lặng. Mỗi message vào DLQ nên bắn alert. DLQ không ai xem thì sáu tháng sau bạn có một topic đầy dữ liệu và không ai biết nó từ đâu ra.
Giữ đủ ngữ cảnh khi forward. Payload gốc, topic hoặc partition hoặc offset gốc, thông báo lỗi, số lần thử. Thiếu x-origin thì replay gần như không thể.
Với BullMQ, failed set vẫn nằm trong Redis. Dùng removeOnFail: { count: 5000 } để giới hạn số job giữ lại, tránh phình bộ nhớ.
Với Kafka, retry tại chỗ chỉ hợp với delay ngắn. Delay từ vài phút trở lên thì dùng retry topic.
Tóm tắt và bước tiếp theo
- Phân loại lỗi trước, retry sau: transient thì retry, permanent thì vào DLQ ngay.
- Exponential backoff cần jitter, nếu không sẽ có retry storm.
- BullMQ có retry và failed set sẵn; failed set chính là DLQ.
- Kafka không có retry built-in: tắt auto-commit, tự giới hạn số lần thử, forward sang retry topic hoặc DLQ topic.
- DLQ chỉ có nghĩa khi có consumer đọc nó, có alert khi có message rơi vào, và có quy trình replay rõ ràng.
- Bước tiếp theo: bạn đã có đủ mảnh ghép về retry, backoff và DLQ. Bài kế tiếp là checkpoint của module, nơi bạn sẽ ráp chúng thành một chiến lược xử lý lỗi hoàn chỉnh và tự kiểm tra lại toàn bộ module.
Điền nhanh để tự kiểm tra trí nhớ:
Trước khi bật retry, việc đầu tiên là phân loại lỗi thành lỗi {{c1::transient}} và lỗi {{c2::permanent}}.
Exponential backoff mà thiếu {{c3::jitter}} sẽ khiến nhiều worker retry cùng lúc và tạo thành retry storm.
Trong BullMQ, job vượt quá số lần thử sẽ nằm trong {{c4::failed set}} của Redis, và đây chính là DLQ mặc định.
Với Kafka, consumer phải tự đặt `enable.auto.commit` thành {{c5::false}} để có thể kiểm soát việc retry và forward message.
Một DLQ không có {{c6::consumer}} đọc và không có alert thì coi như không tồn tại.
Thẻ ôn tập
[
{ "front": "Vì sao phải phân loại lỗi trước khi quyết định retry?", "back": "Vì retry một lỗi permanent chỉ tốn tài nguyên và làm chậm việc phát hiện sự cố thật." },
{ "front": "Transient error là gì? Cho ví dụ.", "back": "Lỗi tạm thời có thể tự hết khi thử lại, ví dụ timeout mạng, HTTP 503, hoặc DB connection bị reset." },
{ "front": "Permanent error là gì? Cho ví dụ.", "back": "Lỗi không tự hết dù thử lại, ví dụ payload sai schema, HTTP 400, hoặc tài khoản không tồn tại." },
{ "front": "Exponential backoff là gì?", "back": "Delay giữa các lần thử tăng theo cấp số nhân, ví dụ 1 giây, 2 giây, 4 giây, 8 giây." },
{ "front": "Vì sao bắt buộc phải có jitter trong backoff?", "back": "Để các worker không retry đồng loạt, tránh retry storm đánh sập hệ thống phía sau." },
{ "front": "BullMQ lưu job thất bại ở đâu?", "back": "Trong failed set của Redis, và failed set này đóng vai trò DLQ mặc định." },
{ "front": "Cấu hình retry trong BullMQ gồm những tham số nào?", "back": "attempts, backoff với type và delay, cùng removeOnFail để giới hạn số job giữ lại trong Redis." },
{ "front": "Kafka có cơ chế retry built-in không?", "back": "Không. Consumer phải tự tắt auto-commit, tự đếm số lần thử và tự forward message đi nơi khác." },
{ "front": "Retry tại chỗ trong Kafka phù hợp với loại delay nào?", "back": "Chỉ với delay ngắn, vì consumer đang block partition và mọi partition khác phải chờ." },
{ "front": "Retry topic là gì?", "back": "Một topic riêng chứa message cần thử lại sau, thường dùng cho delay dài, giữ partition chính chạy tiếp." },
{ "front": "DLQ chỉ có nghĩa khi nào?", "back": "Khi có consumer đọc nó, có alert khi có message rơi vào, và có quy trình replay rõ ràng." },
{ "front": "Cần kèm những gì khi forward message sang DLQ?", "back": "Payload gốc, topic hoặc partition hoặc offset gốc, thông báo lỗi và số lần đã thử." },
{ "front": "Vì sao thêm retry vào bất kỳ hệ thống nào cũng biến nó thành at-least-once?", "back": "Vì message có thể được xử lý nhiều lần, nên handler bắt buộc phải idempotent." }
]
Ghi nhớ một câu duy nhất: retry là để chống lỗi tạm thời, DLQ là để không mất dữ liệu khi retry thất bại, và cả hai chỉ an toàn khi handler của bạn idempotent.