RabbitMQ: retry có backoff, DLX/DLQ và poison message

4 câu hỏi~30 phútTừ khóa: retry, exponential backoff, RetryInterceptor, TTL, dead letter exchange, DLQ, x-death, poison message, delivery-limit, priority queue, replay

Tiếp đi: retry và DLQ cho notification-service.

Bài 8.3 đã thấy requeue vô điều kiện tạo vòng lặp. Bài này xây chính sách lỗi đầy đủ cho notification-service: thử lại có độ trễ, có giới hạn, hết lượt thì cách ly vào DLQ.

1. Bài toán: email provider chập chờn

notification-service nhận OrderPlaced rồi gọi email provider bên ngoài. Có hai kiểu lỗi:

Lỗi tạm thời (transient)          Lỗi vĩnh viễn (permanent)
- provider 503, timeout           - email null / sai định dạng
- 429 Too Many Requests           - JSON không parse được
- DB connection pool cạn          - user đã bị xóa
→ thử lại sau là được             → thử bao nhiêu lần cũng lỗi

2. Vấn đề

3. Giải pháp

Retry có exponential backoff: chờ 1s, 2s, 4s... giữa các lần thử, có giới hạn số lần. Hai cách làm với RabbitMQ:

CáchCơ chế
A. Spring retry interceptorRetry ngay trong thread consumer, message vẫn unacked
B. Delayed retry queue (TTL + DLX)Đẩy message sang queue chờ có TTL, hết TTL quay lại queue chính

DLX (Dead Letter Exchange): queue cấu hình x-dead-letter-exchange. Message bị "dead-letter" sẽ được broker chuyển sang DLX trong các trường hợp:

DLQ là queue bind vào DLX, chứa message đã thất bại để theo dõi, điều tra và replay. Broker thêm header x-death ghi lý do, queue gốc, số lần.

4. Code từng bước

Bước 1: Dependency: spring-boot-starter-amqp (đã kèm spring-retry cho listener).

Bước 2: Cách A, retry trong consumer

spring:
  rabbitmq:
    listener:
      simple:
        prefetch: 5
        retry:
          enabled: true
          max-attempts: 4          # 1 lần đầu + 3 lần thử lại
          initial-interval: 1s
          multiplier: 2            # 1s → 2s → 4s
          max-interval: 10s
        default-requeue-rejected: false

Hết lượt, Spring Boot dùng RejectAndDontRequeueRecoverer: reject không requeue → message sang DLX của queue.

Bước 3: Khai báo queue chính + DLX + DLQ

@Configuration
public class NotificationRabbitConfig {

    @Bean TopicExchange shopEvents() { return new TopicExchange("shop.events"); }
    @Bean DirectExchange notificationDlx() { return new DirectExchange("notification.dlx"); }

    @Bean
    Queue notificationQueue() {
        return QueueBuilder.durable("notification.order-placed")
                .quorum()
                .deadLetterExchange("notification.dlx")
                .deadLetterRoutingKey("order-placed.dead")
                .build();
    }

    @Bean
    Queue notificationDlq() {
        return QueueBuilder.durable("notification.order-placed.dlq").quorum().build();
    }

    @Bean Binding main(Queue notificationQueue, TopicExchange shopEvents) {
        return BindingBuilder.bind(notificationQueue).to(shopEvents).with("order.placed");
    }
    @Bean Binding dlq(Queue notificationDlq, DirectExchange notificationDlx) {
        return BindingBuilder.bind(notificationDlq).to(notificationDlx).with("order-placed.dead");
    }
}

Listener phân loại lỗi: lỗi vĩnh viễn thì không phí công retry.

@RabbitListener(queues = "notification.order-placed")
public void onOrderPlaced(OrderPlaced event) {
    if (event.userId() == null) {
        throw new AmqpRejectAndDontRequeueException("Invalid event " + event.eventId());
    }
    notificationService.sendOrderConfirmation(event);  // idempotent theo eventId
}

Muốn exception vĩnh viễn bỏ qua retry interceptor, dùng bean RabbitRetryTemplateCustomizer để đặt retry policy chỉ retry các exception tạm thời, hoặc kiểm tra dữ liệu trước khi gọi provider như trên.

Bước 3b: Cách B, delayed retry queue bằng TTL + DLX

notification.order-placed  ──lỗi──> publish vào "notification.retry" (key retry.10s)
                                         │
                       notification.retry.10s  (x-message-ttl=10000,
                                         │      không consumer,
                                         │      DLX = shop.events, DL key = order.placed)
                                   hết 10s
                                         v
                              shop.events → notification.order-placed (thử lại)
@Bean
Queue retry10s() {
    return QueueBuilder.durable("notification.retry.10s")
            .ttl(10_000)
            .deadLetterExchange("shop.events")
            .deadLetterRoutingKey("order.placed")
            .build();
}

Lưu ý: dead-letter về shop.events với key order.placed thì mọi queue bind order.placed (cả inventory) đều nhận lại. Thực tế nên dead-letter về default exchange với key là tên queue notification.order-placed, hoặc một exchange riêng của notification, để chỉ queue này nhận.

Listener đếm số lần dựa vào header x-death hoặc một header tự đặt x-retry-count; quá giới hạn thì gửi sang DLQ. Mỗi mức trễ (10s, 1m, 10m) là một queue riêng, vì TTL theo queue. Đặt TTL theo từng message thì bị kẹt: RabbitMQ chỉ hết hạn message ở đầu queue, message 1 phút đứng trước sẽ chặn message 10 giây phía sau.

Bước 4: Test

# Giả lập provider lỗi: wiremock trả 503
curl -X POST http://localhost:8081/orders -d '{"userId":10,...}' -H "Content-Type: application/json"
# log notification: attempt 1 fail, 1s, attempt 2 fail, 2s, attempt 3, 4s, attempt 4 → reject

# Management UI: notification.order-placed.dlq → Ready=1 → Get messages
# header x-death: reason=rejected, queue=notification.order-placed, count=1

# Event thiếu userId → vào DLQ ngay, không retry

5. Cách hoạt động

shop.events ── order.placed ──> notification.order-placed
                                   │
                         consumer xử lý
                     ┌─────────────┼───────────────────┐
                  thành công   lỗi tạm thời          lỗi vĩnh viễn
                     │          retry 1s,2s,4s            │
                    ACK           │                       │
                           vẫn lỗi (hết lượt)             │
                                  └──── reject(false) ────┘
                                              │
                                     notification.dlx
                                              │
                                notification.order-placed.dlq
                                              │
                              alert → điều tra → sửa → replay

6. So sánh hai cách retry

Tiêu chíA. Spring retry interceptorB. TTL + DLX retry queue
Nơi chờTrong thread consumer (sleep)Trong broker
Message trong lúc chờUnacked, giữ chỗ prefetchĐã ACK ở queue chính, nằm ở queue chờ
Consumer restart giữa chừngMất bộ đếm, bắt đầu lạiKhông mất, đếm qua header
Độ trễ phù hợpVài giâyVài giây đến vài giờ
Độ phức tạpChỉ configThêm queue, code đếm retry
Ảnh hưởng message khácThread bị chiếm, các message sau chờKhông chặn

Còn có plugin rabbitmq_delayed_message_exchange (header x-delay), tiện nhưng là plugin cộng đồng, message chờ chỉ lưu trên một node, không hợp với dữ liệu lớn.

7. Khi nào nên dùng / không nên dùng

Message vào DLQ rồi thì xử lý sao? Cứ để đó hay xóa luôn?

Không phải vứt đi. DLQ là "phòng cách ly" để con người hoặc công cụ xử lý tiếp:

DLQ → Monitoring (queue depth) → Alert → Điều tra (x-death, log, trace)
    → Sửa nguyên nhân → Replay hoặc loại bỏ có ghi nhận

Ví dụ shop-app: 3.000 email vào DLQ vì provider đổi API key. Đội vận hành cập nhật secret, rồi replay:

@PostMapping("/admin/dlq/order-placed/replay")
public int replay(@RequestParam(defaultValue = "100") int limit) {
    int count = 0;
    Message msg;
    while (count < limit
           && (msg = rabbitTemplate.receive("notification.order-placed.dlq")) != null) {
        rabbitTemplate.send("", "notification.order-placed", msg); // về đúng queue gốc
        count++;
    }
    return count;
}

OTP với email marketing cùng đi qua notification-service. Nếu marketing đẩy 1 triệu message vào trước thì OTP phải chờ à?

Đúng, nếu dùng chung một queue thì OTP đứng sau 1 triệu message marketing, user chờ đăng nhập vài chục phút. Có hai hướng:

1. Priority queue: queue cho phép message có độ ưu tiên.

// classic queue
QueueBuilder.durable("notification.send").maxPriority(10).build();

rabbitTemplate.convertAndSend("", "notification.send", otp, m -> {
    m.getMessageProperties().setPriority(9);
    return m;
});

Hạn chế: chỉ sắp xếp những message đang chờ trong queue; message đã prefetch về consumer thì không chen được, nên prefetch phải thấp. Quorum queue từ RabbitMQ 4.0 chỉ có 2 mức (cao / thường). Priority nhiều mức tốn CPU.

2. Tách queue theo mức ưu tiên (thường được ưa hơn):

notification.otp        ──> 5 consumer riêng, prefetch thấp, không bao giờ bị chặn
notification.order      ──> 3 consumer
notification.marketing  ──> 1 consumer, rate limit theo hạn mức provider

Mỗi loại có worker, rate limit, retry policy, DLQ riêng. Marketing nghẽn không ảnh hưởng OTP. Đây cũng là thiết kế hay gặp trong câu system design "Notification".

Phỏng vấn hỏi về retry và DLQ thì trả lời sao?

Q1. Consumer xử lý lỗi thì em retry thế nào?

🇻🇳 Tiếng Việt:

Em phân loại lỗi trước. Lỗi vĩnh viễn như dữ liệu sai thì reject không requeue, đưa vào DLQ ngay. Lỗi tạm thời thì retry có exponential backoff và giới hạn số lần. Lỗi ngắn em dùng retry interceptor của Spring; nếu cần chờ lâu như provider chết vài phút thì dùng retry queue có TTL và dead letter quay về queue chính, để không chiếm thread consumer. Consumer luôn idempotent.

🇬🇧 English:

First I classify the error. Permanent errors such as invalid data are rejected without requeue and go straight to the DLQ. Transient errors are retried with exponential backoff and a maximum number of attempts. For short failures I use Spring's retry interceptor; for longer waits, like a provider being down for minutes, I use retry queues with a TTL that dead-letter back to the main queue, so consumer threads are not blocked. The consumer is always idempotent.

Q2. DLX, DLQ là gì? Poison message là gì?

🇻🇳 Tiếng Việt:

DLX là exchange nhận message bị dead-letter: bị reject không requeue, hết TTL, queue đầy, hoặc vượt delivery limit của quorum queue. DLQ là queue bind vào DLX để lưu các message đó. Poison message là message luôn làm consumer lỗi; không có giới hạn retry thì nó quay vòng mãi. DLQ cách ly nó, sau đó em alert, điều tra, sửa rồi replay theo lô.

🇬🇧 English:

A DLX is an exchange that receives dead-lettered messages: rejected without requeue, expired by TTL, dropped by a full queue, or over a quorum queue's delivery limit. A DLQ is a queue bound to the DLX that stores them. A poison message always makes the consumer fail and would loop forever without a retry limit. The DLQ isolates it; then I alert, investigate, fix the cause and replay in batches.

Q3. Làm sao để OTP không bị chậm vì marketing?

🇻🇳 Tiếng Việt:

Có thể dùng priority queue, nhưng nó chỉ ưu tiên message đang chờ và cần prefetch thấp. Em thường tách queue theo loại: OTP, giao dịch, marketing, mỗi queue có consumer, rate limit và DLQ riêng, để backlog marketing không chặn OTP.

🇬🇧 English:

A priority queue is one option, but it only reorders waiting messages and needs a low prefetch. I usually split queues by type: OTP, transactional and marketing, each with its own consumers, rate limit and DLQ, so a marketing backlog never blocks OTP messages.

Cách nhớ ngắn gọn


Tự kiểm tra

Bốn trường hợp message bị dead-letter?

Reject/nack với requeue=false; hết TTL; queue vượt giới hạn độ dài; vượt delivery-limit của quorum queue.

Vì sao không đặt TTL khác nhau cho từng message trong cùng một retry queue?

RabbitMQ chỉ hết hạn message ở đầu queue; message TTL dài đứng trước sẽ chặn message TTL ngắn phía sau. Mỗi mức trễ nên là một queue.

Retry interceptor của Spring có nhược điểm gì với backoff dài?

Chiếm thread consumer và giữ message unacked suốt thời gian chờ; consumer restart thì mất bộ đếm.

Replay từ DLQ cần chú ý gì?

Sửa nguyên nhân trước, replay theo lô có giới hạn, gửi về đúng queue gốc, consumer phải idempotent.

Priority queue có giúp OTP chen trước message đã prefetch không?

Không. Chỉ sắp xếp message còn trong queue; cần prefetch thấp hoặc tách queue riêng.