M6 · Notification
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ơ:
- Auto ack: consumer nhận message rồi chết khi đang gọi SMTP → email mất.
- Requeue ngay khi lỗi: SMTP down → message quay vòng hàng nghìn lần/giây, đốt CPU (hot loop).
- Không dedupe: retry sau timeout SMTP → khách nhận 3 email vé giống nhau.
- Chung một hàng đợi FIFO: email vé đứng sau 200k email marketing, tới trễ 30 phút.
- Classic queue một node: node RabbitMQ chết → mất message.
2. Ôn lại trước khi code
- Notification: tách kênh, template, ưu tiên, rate limit nhà cung cấp, dedupe.
- Exchange & routing: topic exchange, routing key theo loại thông báo.
- ACK & publisher confirm: manual ack, nack/reject, prefetch, confirm phía producer.
- Quorum queue: Raft,
x-delivery-limit, quorum queue không hỗ trợ priority kiểu classic như thế nào. - Retry, TTL, DLX: retry có delay bằng TTL + DLX, DLQ sau N lần.
- RabbitMQ hay Kafka: vì sao saga dùng Kafka còn notification dùng RabbitMQ.
- M5 · Saga + Outbox: event
BookingConfirmedđến từ outbox; inbox dùng lại ý tưởng dedupe.
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
| Method | Path | Body | Response |
|---|---|---|---|
| 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
- Mỗi
BookingConfirmedsinh đúng 1 dòngnotificationsvà tối đa 1 email gửi thành công, kể cả khi event Kafka tới 5 lần. - Queue chính và DLQ là quorum queue (
rabbitmqctl list_queues name typecho thấyquorum). - Consumer dùng manual ack; kill notification-service khi đang gửi → message được giao lại, không mất.
- 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. - Lỗi vĩnh viễn (email sai định dạng) vào DLQ ngay, không retry.
- Khi 50k marketing đang xếp hàng, email vé từ lúc CONFIRMED tới lúc gửi p95 < 5s.
- 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.
- 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
| ID | Kịch bản | Input / điều kiện | Kỳ vọng |
|---|---|---|---|
| T6.1 | Happy path | BookingConfirmed | 1 email, status SENT |
| T6.2 | Event trùng | Cùng booking 5 lần | 1 dòng, 1 email |
| T6.3 | Lỗi tạm thời rồi khỏi | SMTP lỗi 2 lần đầu | SENT ở lần 3, attempts=3 |
| T6.4 | Vào DLQ | SMTP lỗi mãi, N=4 | 4 lần thử, vào DLQ, DEAD |
| T6.5 | Lỗi vĩnh viễn | Địa chỉ email sai | DLQ sau 1 lần |
| T6.6 | Ưu tiên | Đổ 50k marketing rồi 100 ticket | Ticket p95 < 5s |
| T6.7 | Chết giữa lúc gửi | Kill sau khi SMTP nhận, trước ack | Giao lại, dedupe chặn gửi lần 2 (hoặc provider idempotency key) |
| T6.8 | Publisher confirm | Chặn RabbitMQ (Toxiproxy) khi publish | Khô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ạo | Hành vi mong đợi |
|---|---|---|
| SMTP down | POST /admin/mail/faults {"errorRate":1.0} | Retry theo delay, rồi DLQ; không hot loop |
| SMTP chậm 10s | Toxiproxy latency trước mock SMTP | Timeout gửi, retry; consumer marketing không chiếm hết kết nối SMTP |
| Notification-service chết | docker 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ết | Cluster 3 node, docker stop rabbitmq-2 | Quorum queue vẫn phục vụ, không mất message đã confirm |
| Mất kết nối RabbitMQ khi publish | Toxiproxy cắt proxy rabbit | Publish không confirm → không commit Kafka offset → gửi lại sau |
| Message độc | Publish JSON hỏng | Vào DLQ ngay, không chặn queue |
7. Benchmark / số liệu cần thu
- Độ trễ email vé (CONFIRMED → SENT) p50/p95 khi không tải và khi có 50k marketing đang chờ.
- Throughput mỗi queue (msg/s) với prefetch 1/10/50.
- Thời gian từ lỗi đầu tiên tới lúc vào DLQ, so với tổng delay cấu hình.
- Lưu tại
docs/benchmarks/m06-notification.md.
8. Checklist hoàn thành
- ☐ Acceptance criteria có test tự động và xanh.
- ☐ Mọi failure scenario đã chạy thật, ghi kết quả.
- ☐ Benchmark trong
docs/benchmarks/. - ☐ ADR: cách làm priority trên quorum queue; vì sao bridge Kafka → RabbitMQ.
- ☐ Không hardcode password RabbitMQ/SMTP, dùng
${ENV}. - ☐ Topology khai báo bằng code (Declarables), không tạo tay trên UI.
- ☐ Có runbook replay DLQ trong
docs/. - ☐ Đã qua review và phỏng vấn của AI.
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 ý:
- Requeue đặt message về đầu queue, không có khái niệm "chờ". Delay phải đến từ chỗ khác.
- Hướng 1: queue retry không có consumer, đặt
x-message-ttl+x-dead-letter-exchangetrỏ về exchange gốc. Hết TTL, message tự quay lại. Một queue cho mỗi mức delay. - Hướng 2: plugin
rabbitmq_delayed_message_exchange. Tự tìm hiểu giới hạn của nó (lưu ở đâu, có replicate không) trước khi chọn. - Đếm số lần: header
x-deathdo broker thêm, hoặc headerx-attempttự quản. Quorum queue còn cóx-delivery-limit— tự kiểm nó đếm cái gì và khácx-deathra sao.
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ĩ:
- Gửi email là hiệu ứng ngoài DB, giống gọi PSP ở M3. Bạn không thể đặt nó vào cùng transaction.
- Thu hẹp cửa sổ: chuyển
PENDING → SENDINGcó điều kiện trước khi gửi; lần giao lại thấy SENDING quá lâu thì quyết định gì? Gửi lại (chấp nhận trùng hiếm) hay hỏi provider theoidempotencyKey? - Nhiều provider nhận header idempotency/message-id; mock SMTP của bạn nên hỗ trợ để test T6.7.
- Viết rõ trong ADR: email vé chấp nhận "at-least-once, trùng hiếm" hơn là "có thể mất".
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 ý:
- Nghĩ lại bulkhead ở M4: mỗi loại thông báo một pool kết nối/semaphore tới SMTP riêng.
- Rate limit marketing (token bucket) để luôn chừa dung lượng cho email vé.
- Đo T6.6 trước và sau khi chia pool, ghi số vào benchmark.
Mình làm xong rồi, review và phỏng vấn mình đi.
Checklist review code AI sẽ soi
acknowledge-mode: manual, ack sau khi cập nhật DB; mọi nhánh đều ack hoặc nack (không để message unacked mãi).- Không
requeue=truecho lỗi;default-requeue-rejected: false. - Phân loại lỗi tạm thời / vĩnh viễn rõ ràng.
- Bridge Kafka → RabbitMQ chờ publisher confirm rồi mới commit offset Kafka.
- Queue quorum, khai báo bằng code, có DLX; DLQ có consumer cảnh báo hoặc alert theo độ dài.
- Unique
dedupe_key; trạng tháiSENDINGcó điều kiện; idempotency key truyền cho provider. - Prefetch hợp lý, consumer marketing không chiếm hết pool SMTP.
- Không log nội dung email/PII đầy đủ.
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.