M6 · Notification

5 câu hỏi~2–3 ngày codeTừ khóa: RabbitMQ, quorum queue, manual ack, publisher confirm, retry delay, DLX, DLQ, priority, idempotent send, delivery-limit

Saga chạy ổn rồi. Giờ làm M6: gửi email xác nhận vé khi booking confirmed, qua RabbitMQ, lỗi thì retry và vào DLQ.

Milestone này nhẹ hơn M5 nhưng có nhiều bẫy nhỏ. Mục tiêu: email xác nhận vé không mất, không gửi 2 lần, không bị marketing chen lên trước.

1. Bối cảnh & bài toán

Sau khi booking CONFIRMED, khách phải nhận email/SMS vé. Cùng lúc team marketing gửi 200k email khuyến mãi. Nếu làm ngây thơ:

2. Ôn lại trước khi code

3. Đề bài

Luồng

booking.events (Kafka) ── BookingConfirmed ──▶ notification-service (bridge consumer)
                                                   │ publish (publisher confirm)
                                                   ▼
                               exchange notify.x (topic)
                     ┌─────────────────┴──────────────────┐
           rk notify.ticket.*                     rk notify.marketing.*
                     ▼                                    ▼
          q.notify.ticket (quorum)               q.notify.marketing (quorum)
          consumers: 8, prefetch 10              consumers: 2, prefetch 50
                     │ lỗi tạm thời                        │
                     ▼                                    ▼
          q.notify.retry.{10s,1m,5m} (TTL → DLX quay về queue gốc)
                     │ quá N lần
                     ▼
          q.notify.dlq (quorum) + alert

Ưu tiên: quorum queue không có priority đầy đủ như classic, nên đề yêu cầu ưu tiên bằng tách queue + chia consumer/prefetch. Nếu bạn muốn dùng x-max-priority trên classic queue hoặc priority của quorum queue (RabbitMQ 4.x), ghi ADR so sánh.

API

MethodPathBodyResponse
POST/api/notifications/marketing (role ADMIN){"campaignId","template","userIds":[…]}202 {"queued":N}
GET/api/notifications?bookingId=–200 [{id,type,status,attempts,sentAt}]
POST/admin/notifications/dlq/replay{"ids":[…]} hoặc {"all":true}202
POST/admin/mail/faults (mock SMTP){"errorRate":1.0,"latencyMs":0}204

Schema

CREATE TABLE notifications (
  id              UUID PRIMARY KEY,
  dedupe_key      TEXT NOT NULL UNIQUE,    -- vd 'ticket:' || booking_id
  type            TEXT NOT NULL,           -- TICKET_CONFIRMATION | MARKETING
  channel         TEXT NOT NULL,           -- EMAIL | SMS
  recipient       TEXT NOT NULL,
  payload         JSONB NOT NULL,
  status          TEXT NOT NULL,           -- PENDING | SENDING | SENT | FAILED | DEAD
  attempts        INT  NOT NULL DEFAULT 0,
  provider_msg_id TEXT,
  last_error      TEXT,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  sent_at         TIMESTAMPTZ
);

Message

{
  "notificationId": "uuid",
  "dedupeKey": "ticket:booking-uuid",
  "type": "TICKET_CONFIRMATION",
  "channel": "EMAIL",
  "to": "user@example.com",
  "template": "ticket-confirmed",
  "data": { "bookingId": "…", "event": "…", "seats": ["A1","A2"] }
}
headers: x-attempt: 2

Cấu hình cần có

spring.rabbitmq:
  host: ${RABBIT_HOST}
  username: ${RABBIT_USER}
  password: ${RABBIT_PASSWORD}
  publisher-confirm-type: correlated
  publisher-returns: true
  listener.simple:
    acknowledge-mode: manual
    default-requeue-rejected: false
notification:
  retry.delays: [10s, 1m, 5m]
  retry.max-attempts: ?
  ticket.concurrency: ?
  marketing.concurrency: ?

Skeleton

@Configuration
class NotificationTopology {
    // TODO: Declarables: notify.x, q.notify.ticket (x-queue-type=quorum, x-dead-letter-exchange=…),
    //       q.notify.marketing, retry queues với x-message-ttl, q.notify.dlq
}

public interface EmailSender {
    /** Trả providerMsgId. Ném TransientSendException (retry được) hoặc PermanentSendException (vào DLQ ngay). */
    String send(String to, String template, Map<String, Object> data, String idempotencyKey);
}

@Component
class TicketNotificationListener {
    // @RabbitListener(queues = "q.notify.ticket", concurrency = "${notification.ticket.concurrency}")
    void on(NotificationMessage m, Channel ch, @Header(AmqpHeaders.DELIVERY_TAG) long tag) {
        // TODO: claim dedupe (PENDING → SENDING), gửi, SENT, basicAck
        // TODO: lỗi tạm thời → publish sang retry queue theo x-attempt rồi ack
        // TODO: quá N lần / lỗi vĩnh viễn → DLQ
    }
}

4. Acceptance criteria

  1. Mỗi BookingConfirmed sinh đúng 1 dòng notifications và tối đa 1 email gửi thành công, kể cả khi event Kafka tới 5 lần.
  2. Queue chính và DLQ là quorum queue (rabbitmqctl list_queues name type cho thấy quorum).
  3. Consumer dùng manual ack; kill notification-service khi đang gửi → message được giao lại, không mất.
  4. Mock SMTP lỗi 100%: message thử đúng N lần với độ trễ tăng dần (10s, 1m, 5m), sau đó vào q.notify.dlq, status = DEAD, có alert.
  5. Lỗi vĩnh viễn (email sai định dạng) vào DLQ ngay, không retry.
  6. Khi 50k marketing đang xếp hàng, email vé từ lúc CONFIRMED tới lúc gửi p95 < 5s.
  7. Replay từ DLQ gửi lại được và vẫn không trùng với lần đã gửi thành công.
  8. Không hot loop: khi SMTP down, CPU notification-service < 30% và số lần giao lại tuân theo delay.

5. Test cases

IDKịch bảnInput / điều kiệnKỳ vọng
T6.1Happy pathBookingConfirmed1 email, status SENT
T6.2Event trùngCùng booking 5 lần1 dòng, 1 email
T6.3Lỗi tạm thời rồi khỏiSMTP lỗi 2 lần đầuSENT ở lần 3, attempts=3
T6.4Vào DLQSMTP lỗi mãi, N=44 lần thử, vào DLQ, DEAD
T6.5Lỗi vĩnh viễnĐịa chỉ email saiDLQ sau 1 lần
T6.6Ưu tiênĐổ 50k marketing rồi 100 ticketTicket p95 < 5s
T6.7Chết giữa lúc gửiKill sau khi SMTP nhận, trước ackGiao lại, dedupe chặn gửi lần 2 (hoặc provider idempotency key)
T6.8Publisher confirmChặn RabbitMQ (Toxiproxy) khi publishKhông ack Kafka offset cho tới khi có confirm
@Testcontainers
@SpringBootTest
class NotificationRetryDlqIT {
    @Container static RabbitMQContainer rabbit = new RabbitMQContainer("rabbitmq:4.0-management");
    @Container static PostgreSQLContainer<?> pg = new PostgreSQLContainer<>("postgres:16");

    @MockitoBean EmailSender sender;

    @Test
    void permanentFailure_goesToDlqAfterNAttempts() {
        // TODO: sender luôn ném TransientSendException
        // TODO: rút ngắn delay qua property (vd 200ms) cho test
        // TODO: await: DLQ có 1 message, verify(sender, times(N)), status DEAD
    }

    @Test
    void duplicateEvent_sendsOnce() { /* TODO */ }
}

6. Failure scenarios

Sự cốCách tạoHành vi mong đợi
SMTP downPOST /admin/mail/faults {"errorRate":1.0}Retry theo delay, rồi DLQ; không hot loop
SMTP chậm 10sToxiproxy latency trước mock SMTPTimeout gửi, retry; consumer marketing không chiếm hết kết nối SMTP
Notification-service chếtdocker kill notification-service khi đang xử lýMessage unacked được giao lại; không mất, không trùng
Một node RabbitMQ chếtCluster 3 node, docker stop rabbitmq-2Quorum queue vẫn phục vụ, không mất message đã confirm
Mất kết nối RabbitMQ khi publishToxiproxy cắt proxy rabbitPublish không confirm → không commit Kafka offset → gửi lại sau
Message độcPublish JSON hỏngVào DLQ ngay, không chặn queue

7. Benchmark / số liệu cần thu

8. Checklist hoàn thành

Mình dùng basicNack(requeue=true) để retry nhưng message quay lại ngay lập tức, log chạy như điên. Làm sao có delay?

Đúng triệu chứng hot loop. Gợi ý:

Mình đã có unique dedupe_key, nhưng case chết sau khi SMTP nhận và trước khi update SENT thì vẫn gửi 2 lần. Có cách nào chặn tuyệt đối không?

Câu trả lời thật: không tuyệt đối nếu provider không hỗ trợ idempotency. Gợi ý hướng nghĩ:

Tách 2 queue thì marketing vẫn dùng chung pool SMTP, lúc nghẽn email vé vẫn chậm. Mình nên làm gì?

Bạn vừa phát hiện ưu tiên ở queue chưa đủ, tài nguyên phía sau cũng phải chia. Gợi ý:

Mình làm xong rồi, review và phỏng vấn mình đi.

Checklist review code AI sẽ soi

Câu hỏi phỏng vấn

1. Vì sao saga dùng Kafka mà notification dùng RabbitMQ?

🇻🇳 Saga cần log có thứ tự theo booking và replay được, Kafka hợp. Notification là hàng đợi công việc: cần ack từng message, retry có delay, DLQ, chia ưu tiên — RabbitMQ làm sẵn những thứ đó.

🇬🇧 The saga needs an ordered, replayable log per booking, which suits Kafka. Notifications are a work queue needing per-message ack, delayed retry, DLQ and priority, which RabbitMQ gives out of the box.

2. Bạn làm retry có delay và DLQ thế nào?

🇻🇳 Lỗi tạm thời thì đẩy sang queue retry có TTL, hết TTL dead-letter về queue gốc. Đếm số lần bằng header; quá N lần hoặc lỗi vĩnh viễn thì vào DLQ và bắn alert, có API replay.

🇬🇧 On a transient error I move the message to a retry queue with a TTL that dead-letters back to the main queue. I count attempts in a header; after N attempts or a permanent error it goes to the DLQ with an alert and a replay API.

3. Làm sao khách không nhận 2 email vé?

🇻🇳 Dedupe key duy nhất theo booking trong DB, chuyển trạng thái có điều kiện trước khi gửi, và truyền idempotency key cho provider. Vẫn còn cửa sổ nhỏ nếu provider không hỗ trợ, tôi chấp nhận trùng hiếm hơn là mất email.

🇬🇧 A unique dedupe key per booking, a conditional state change before sending, and an idempotency key passed to the provider. There's still a tiny window if the provider doesn't support it; I prefer a rare duplicate over a lost ticket email.

4. Quorum queue khác classic queue thế nào?

🇻🇳 Quorum queue replicate qua Raft trên nhiều node, an toàn khi một node chết, có delivery-limit sẵn. Đổi lại tốn tài nguyên hơn và một số tính năng như priority hạn chế hơn classic.

🇬🇧 Quorum queues replicate via Raft across nodes, survive a node failure and have a built-in delivery limit. The trade-off is more resource use and more limited features like priority compared to classic queues.

Cách nhớ ngắn gọn

Ack thủ công, chờ có hẹn, quá N thì DLQ, vé đi làn riêng, gửi có khoá dedupe.


Tự kiểm tra

Vì sao basicNack(requeue=true) không phải là retry tốt?

Message quay lại ngay không có delay, gây hot loop và chặn các message khác khi dependency đang down.

Retry có delay trên RabbitMQ không cần plugin làm thế nào?

Queue retry có x-message-ttl và x-dead-letter-exchange trỏ về exchange gốc; hết TTL message quay lại để xử lý.

Ack nên gửi trước hay sau khi cập nhật DB?

Sau. Ack trước rồi chết thì message mất; ack sau thì có thể giao lại, và dedupe xử lý trùng.

Tách queue có đủ để ưu tiên email vé không?

Chưa chắc. Tài nguyên phía sau (pool SMTP, rate limit provider) cũng phải chia, nếu không marketing vẫn chiếm hết.

Bridge Kafka → RabbitMQ commit offset Kafka khi nào?

Sau khi RabbitMQ publisher confirm, để không mất event khi broker từ chối hoặc mất kết nối.