Level 3: thiết kế Notification Service
Tiếp đi: Level 3, Notification. Đề bài cụ thể là gì vậy? Mình đọc chưa hiểu đề.
Đúng là nếu nhảy thẳng vào solution thì đề hơi mơ hồ. Viết lại đơn giản:
Design a notification system that supports Email, SMS and Push for multiple services.
1. Đề bài và requirements
Hệ thống e-commerce có:
Order Service → Email: "Order #123 created successfully"
Payment Service → Push: "Payment successful"
Security Service → SMS: "Your OTP is 583921"
Marketing Service → 1 triệu Email: "10.10 Sale - 50% OFF"
Không muốn mỗi service tự gọi provider. Muốn một hệ thống trung tâm, và nó phải trả lời được:
- Provider SMS chết thì sao?
- Gửi SMS timeout thì sao?
- Provider giới hạn 1000 SMS/s nhưng có 100K SMS cần gửi?
- Message bị xử lý 2 lần, làm sao user không nhận 2 SMS?
- Retry 5 lần vẫn fail thì làm gì?
- 1 triệu email marketing đang chờ, OTP cần gửi ngay?
- Provider nói "accepted", làm sao biết SMS thực sự tới điện thoại?
2. Estimate
Transactional (order, payment, OTP): ~2,000/s lúc cao điểm
Marketing campaign: 10M message trong vài giờ
SMS provider contract: 1,000 SMS/s
OTP SLA: < 5 giây end-to-end
Hai con số quyết định thiết kế: bulk lớn hơn rất nhiều so với capacity provider (cần buffer + rate limit), và OTP có SLA vài giây (cần isolation).
3. API và message model
POST /notifications (hoặc publish event lên broker)
{
"notificationId": "NOTI-123", ← idempotency key, do producer sinh
"userId": 10,
"channel": "EMAIL",
"priority": "TRANSACTIONAL", ← CRITICAL / TRANSACTIONAL / BULK
"template": "ORDER_CONFIRMED",
"parameters": { "orderId": "ORD-123", "amount": "100" },
"traceId": "abc123"
}
→ 202 Accepted
Service gọi chỉ gửi template + parameters, không gửi HTML. Notification System render template, check preference của user (đã tắt marketing email?), rồi mới gửi.
4. Data model
CREATE TABLE notification (
notification_id VARCHAR(64) PRIMARY KEY, -- chặn trùng
user_id BIGINT NOT NULL,
channel VARCHAR(10) NOT NULL,
template VARCHAR(50) NOT NULL,
status VARCHAR(20) NOT NULL, -- PENDING, PROCESSING, SENT_TO_PROVIDER, DELIVERED, FAILED
provider VARCHAR(20),
provider_msg_id VARCHAR(100), -- để khớp callback delivery
retry_count INT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL,
sent_at TIMESTAMP
);
5. Đi từ đơn giản: từng problem → solution
Problem 1: mỗi service tự gửi. Duplicate code (auth provider, retry, template, log); đổi SMS provider phải sửa nhiều service → tách Notification Service.
Problem 2: gửi đồng bộ làm business chậm. Email provider chậm 10s → API tạo order chậm 10s, provider chết kéo Order chết theo. Notification không nằm trên critical path → async:
Create Order → COMMIT → publish ORDER_CONFIRMED → return 201
↓ async
Notification → Email
Publish sau commit phải đáng tin: dùng Outbox (xem Bài 14.7).
Problem 3: traffic đột biến. 10M marketing không thể thành 10M thread. Queue làm buffer, worker xử lý theo capacity, queue depth tăng thì scale worker.
Problem 4: SMS chết làm Email chậm theo. Chung một queue thì message SMS retry/kẹt chiếm worker của Email → queue riêng theo channel:
Producer → Exchange (routing key notification.email / .sms / .push)
├→ Email Queue → Email Workers → Email Provider
├→ SMS Queue → SMS Workers → SMS Provider 💥
└→ Push Queue → Push Workers → Push Provider
SMS chết: Email ✓, Push ✓
6. Problem 5–7: provider lỗi
Timeout: luôn đặt connect/read timeout, không để worker chờ vô hạn.
Retry + exponential backoff + jitter: 503 có thể là lỗi tạm thời. Đợi 1s, 2s, 4s... cộng ngẫu nhiên để 500 worker không retry cùng lúc (thundering herd). Chỉ retry lỗi tạm thời (timeout, 5xx, 429), không retry 400 số điện thoại sai.
Circuit breaker: provider chết hẳn mà 1000 worker vẫn gọi → phí thread, connection, đè thêm provider. Vượt ngưỡng lỗi → OPEN → fail fast, sau đó HALF-OPEN thử vài request. Khác HTTP request: message không được vứt; nó được requeue/hẹn retry sau.
@Component
@RequiredArgsConstructor
public class SmsSender {
private final SmsProviderClient provider; // RestClient có timeout 2s
@RateLimiter(name = "sms-provider") // outbound: tối đa 1000/s
@CircuitBreaker(name = "sms-provider")
@Retry(name = "sms-provider") // backoff + jitter, chỉ lỗi tạm thời
public String send(Notification n) {
return provider.send(n.phone(), n.body(), n.notificationId()); // idempotency key nếu provider hỗ trợ
}
}
resilience4j:
retry.instances.sms-provider:
max-attempts: 4
wait-duration: 1s
enable-exponential-backoff: true
exponential-backoff-multiplier: 2
enable-randomized-wait: true
circuitbreaker.instances.sms-provider:
failure-rate-threshold: 50
wait-duration-in-open-state: 30s
ratelimiter.instances.sms-provider:
limit-for-period: 1000
limit-refresh-period: 1s
timeout-duration: 5s
Lưu ý: @RateLimiter của Resilience4j chỉ giới hạn trong một instance. 10 pod thì mỗi pod 100/s, hoặc dùng rate limiter chung trên Redis (Bài 15.4).
Provider down 2 tiếng: circuit OPEN, message vẫn nằm durable trong queue. Provider hồi phục → HALF-OPEN → CLOSED. Backlog 5M message phải drain từ từ qua rate limit, không bắn hết cùng lúc làm provider chết lần nữa.
Provider failover A → B: không phải "catch exception → gọi B ngay". Nếu A đã gửi nhưng response bị mất (timeout), gọi B nữa là user nhận 2 SMS. Chỉ failover khi lỗi chắc chắn chưa gửi (connection refused, circuit OPEN), còn timeout là kết quả mơ hồ.
7. Problem 8: retry mãi vẫn không được → DLQ
Main Queue → Worker ─ success → ACK
└ fail → retry (TTL + DLX, 1s/10s/60s)
└ hết lượt → DLQ → alert → điều tra → fix → replay
Poison message (ví dụ "phone": "INVALID") luôn fail; DLQ cô lập nó để không retry vô hạn. Cấu hình chi tiết TTL/DLX xem Bài 8.5.
8. Problem 9: message đến 2 lần → idempotent consumer
Worker gửi EMAIL thành công → crash trước khi ACK
Broker: chưa ACK → redeliver → worker gửi lần 2 → user nhận 2 email
Messaging thực tế là at-least-once + idempotent consumer:
@RabbitListener(queues = "notification.sms.critical", concurrency = "5")
public void consume(NotificationCommand cmd) {
// INSERT ... ON CONFLICT DO NOTHING: chỉ lần đầu trả về 1
int inserted = repo.insertIfAbsent(cmd.notificationId(), cmd.userId(), "SMS", "PROCESSING");
if (inserted == 0 && repo.isFinished(cmd.notificationId())) {
return; // đã gửi rồi → ack, bỏ qua
}
String providerMsgId = smsSender.send(toNotification(cmd));
repo.markSent(cmd.notificationId(), providerMsgId); // SENT_TO_PROVIDER
}
Nuance: provider SUCCESS → app crash trước markSent → retry vẫn có thể gửi trùng. Exactly-once với side effect bên ngoài gần như không có. Nếu provider nhận idempotency key thì truyền notificationId; nếu không, ta giảm trùng và chấp nhận trade-off. Câu này interviewer rất thích.
9. Problem 10: OTP kẹt sau Marketing → priority
SMS Queue: Marketing #1 ... Marketing #1,000,000, OTP #1,000,001
OTP cần vài giây nhưng đứng sau 1 triệu message
Tách theo SLA: sms.critical (OTP, security) và sms.bulk (marketing), worker pool riêng. Chi tiết ở các câu hỏi bên dưới.
10. Problem 11: provider trả 200 ≠ user đã nhận
Our System → Provider (200 OK = "đã nhận yêu cầu") → Telecom → User Phone (vẫn có thể FAIL)
PENDING → SENT_TO_PROVIDER → DELIVERED
└→ FAILED / UNDELIVERED
Theo dõi delivery thật qua callback/webhook của provider, khớp bằng provider_msg_id. Webhook cũng có thể đến trùng hoặc sai thứ tự → update có điều kiện, không lùi trạng thái.
11. Full architecture
Order ─────┐
Payment ───┤ (event / API, kèm notificationId)
Security ──┼──→ Notification API / Router
Marketing ─┘ │ preference + template
▼
┌─────────┼──────────┬──────────┐
Email.trans Email.bulk SMS.critical SMS.bulk Push
│ │ │ │ │
Workers Workers Workers Workers Workers
└───────────┴──── Rate Limiter ─┴───────────┘
Circuit Breaker
┌────────┼────────┐
Email SMS Push (Provider A / B)
│ webhook delivery status
fail → retry → DLQ → alert
Supporting: Notification DB, Template store, User preferences, metrics, tracing
Metrics: queue depth, consumer lag, provider latency, failure rate, retry count, DLQ size, OTP end-to-end latency. Alert ví dụ: DLQ > 100, OTP latency > 5s. Truyền traceId trong message để trace flow async.
12. Kafka hay RabbitMQ?
| Tình huống | Hợp với |
|---|---|
| Mỗi notification là một job, một worker xử lý | Queue: RabbitMQ, SQS, Service Bus |
ORDER_CONFIRMED được nhiều hệ thống nghe (notification, analytics, loyalty) | Event stream: Kafka, mỗi hệ thống một consumer group |
| Cần replay lịch sử | Kafka |
| Cần priority native, routing linh hoạt, retry/DLQ từng message | RabbitMQ |
Hay gặp: Kafka chở domain event, notification service nghe topic rồi chuyển thành job trên queue riêng theo channel. Đừng nói "Kafka nhanh hơn nên chọn Kafka". So sánh đầy đủ ở Bài 8.10.
13. Map sang AWS / Azure
| Vai trò | AWS | Azure |
|---|---|---|
| Event bus / pub-sub | EventBridge / SNS | Event Grid / Service Bus Topic |
| Queue + DLQ | SQS + DLQ | Service Bus Queue + dead-letter |
| Worker | Lambda / ECS | Functions / Container Apps |
| Email/SMS | SES / SNS SMS | Azure Communication Services |
| Monitoring | CloudWatch | Azure Monitor, App Insights |
Vì sao EventBridge → SQS → Lambda thay vì EventBridge → Lambda? Queue cho buffer, durability, kiểm soát retry, DLQ và scale consumer.
Problem 4 "SMS chết làm ảnh hưởng Email" nghĩa là mình có nhiều queue riêng cho mỗi loại?
Vậy nó có mâu thuẫn với Problem 10 "OTP bị kẹt sau Marketing" không, vì đã có queue riêng rồi?
Bạn bắt đúng một điểm. Không mâu thuẫn, nhưng phải nói chặt hơn: tách theo channel thì OTP không còn kẹt sau Email marketing, nhưng vẫn có thể kẹt sau SMS marketing, vì trong SMS Queue vẫn lẫn nhiều loại:
SMS Queue
├── OTP / Security
├── Payment Alert
├── Order Status
└── Marketing × 1,000,000
Hai problem hỏi hai câu khác nhau:
| Problem 4 | Problem 10 | |
|---|---|---|
| Câu hỏi | SMS provider chết sao không kéo Email xuống? | Việc không quan trọng sao không làm chậm việc quan trọng? |
| Chiều tách | Channel: provider nào xử lý? | Priority/SLA: cần nhanh đến mức nào? |
| Kết quả | Email | SMS | Push | SMS Critical | SMS Bulk |
1 Queue
→ Problem: SMS chết ảnh hưởng Email → tách theo Channel
→ Problem: SMS marketing làm chậm OTP → tách theo SLA
SMS
┌──────┴──────┐
SMS Critical SMS Bulk
OTP/Security Marketing
Không phải mỗi type một queue (100 type thành 100 queue). Tách theo behavior / SLA / nhu cầu isolation. Giải xong một problem, solution lại lộ problem tiếp theo: đó chính là System Design.
Kafka hay RabbitMQ có hỗ trợ thuộc tính priority cho từng message không? Hay bạn tính cách khác?
Có, nhưng hai bên rất khác nhau.
RabbitMQ có message priority native. Khai báo queue với max priority, producer đặt priority từng message:
@Bean
Queue smsQueue() {
return QueueBuilder.durable("sms.queue").maxPriority(10).build();
}
// producer
rabbitTemplate.convertAndSend("", "sms.queue", otp, m -> {
m.getMessageProperties().setPriority(10); // marketing: 1
return m;
});
Nhưng priority không "cướp" được message worker đã lấy. 100 worker đều đang bận marketing thì OTP vẫn chờ worker rảnh. Prefetch cao cũng làm priority kém hiệu quả.
Kafka không có priority kiểu đó. Partition là ordered log, offset 100 → 101 → 102. Lấy offset 103 ra trước là phá semantics. Với Kafka, tách topic:
notification.sms.critical → Critical consumer group
notification.sms.bulk → Bulk consumer group
Mình chọn gì? Nếu OTP có SLA riêng, mình thích isolation hơn priority: queue/topic riêng, worker pool riêng (đây là Bulkhead). Thêm một nuance: cùng một SMS provider 1000/s thì bulk vẫn có thể ăn hết quota → reserve capacity cho critical (ví dụ critical 200/s ưu tiên, bulk dùng phần còn lại).
| RabbitMQ | Kafka | |
|---|---|---|
| Priority native | Có (x-max-priority) | Không |
| Tách queue/topic theo SLA | Có | Có (cách chính) |
| Ordered log | Không phải trọng tâm | Rất quan trọng |
Worker ở đây là một consumer đúng không nhỉ? Thường một service consume một queue một lần, vậy muốn nhiều worker thì phải chạy nhiều instance của service đó nhỉ?
Đúng ý chính, chỉ cần chỉnh một điểm: worker ≈ consumer, nhưng một instance không nhất thiết chỉ có một consumer.
@RabbitListener(queues = "sms.queue", concurrency = "5")
public void handleSms(NotificationCommand msg) { ... }
1 instance = 5 consumers
4 instances × 5 consumers = 20 consumers song song trên sms.queue
sms.queue [M1][M2][M3][M4][M5][M6]
┌──────────┼──────────┐
Pod 1 Pod 2 Pod 3
C1..C5 C1..C5 C1..C5 = 15 consumers (competing consumers)
Giống thread pool, chỉ khác task queue nằm ở broker bên ngoài. Hai núm scale: tăng concurrency trong instance và tăng số instance.
Với Kafka khác: trong một consumer group, một partition chỉ gán cho một consumer. 3 partition mà 5 consumer thì 2 consumer ngồi chơi, muốn song song hơn phải thêm partition.
Đừng tăng consumer vô hạn: SMS provider chỉ 1000/s thì 500 consumer cũng chỉ khiến bị 429 nhiều hơn. Queue + worker scaling + rate limit luôn đi cùng nhau.
Phỏng vấn hỏi "How would you design a notification service?" thì trả lời sao?
Q1. High-level design?
🇻🇳 Tiếng Việt:
Em làm gửi notification bất đồng bộ vì email, SMS, push không nên chặn giao dịch chính. Producer publish command hoặc domain event lên messaging bền vững, worker theo từng channel consume và gọi provider. Em tách queue theo channel để provider này chết không ảnh hưởng channel khác, và tách critical như OTP khỏi bulk marketing. Gọi provider có timeout, retry backoff, rate limit và circuit breaker.
🇬🇧 English:
I would make delivery asynchronous because email, SMS and push should not block the main business transaction. Producers publish commands or domain events to a durable broker, and channel-specific workers consume them and call external providers. I separate queues by channel so one provider failure does not affect others, and I isolate critical traffic such as OTPs from bulk marketing. Provider calls have timeouts, retries with exponential backoff, rate limiting and circuit breakers.
Q2. How do you avoid sending the same SMS twice?
🇻🇳 Tiếng Việt:
Message là at-least-once nên consumer phải idempotent theo notificationId, lưu với unique key và bỏ qua nếu đã gửi. Nếu provider hỗ trợ idempotency key thì truyền notificationId. Nhưng exactly-once với side effect bên ngoài rất khó: crash giữa lúc provider đã gửi và lúc mình ghi DB vẫn có thể trùng. Cũng không failover sang provider B khi bị timeout, vì đó là kết quả mơ hồ.
🇬🇧 English:
Delivery is at-least-once, so consumers must be idempotent on a notification ID stored with a unique key. If the provider supports idempotency keys, I pass the notification ID. Exactly-once external side effects are very hard, though: a crash between the provider sending and our database update can still cause a duplicate. I also avoid failing over to provider B on a timeout, because the outcome is ambiguous.
Q3. The SMS provider is down for two hours. What happens?
🇻🇳 Tiếng Việt:
Timeout làm circuit breaker mở, worker fail fast, message vẫn nằm bền trong queue chờ retry với backoff. Provider hồi phục thì half-open thử, rồi đóng. Backlog được xả từ từ qua rate limiter để không đánh sập provider lần nữa. Message hết lượt retry vào DLQ có alert để replay sau.
🇬🇧 English:
Timeouts open the circuit breaker, workers fail fast, and messages stay durable in the queue for retry with backoff. When the provider recovers, the breaker goes half-open, probes, then closes. The backlog is drained gradually through the rate limiter so we don't knock the provider over again. Messages that exhaust retries go to a DLQ with alerts for later replay.
Q4. Kafka or RabbitMQ?
🇻🇳 Tiếng Việt:
Tùy yêu cầu và hạ tầng sẵn có. Notification là job độc lập thì queue như RabbitMQ hay SQS rất tự nhiên, có priority và DLQ. Nếu notification sinh từ domain event mà nhiều hệ thống cùng nghe và cần replay thì Kafka hợp. Em chọn theo delivery semantics, throughput, replay chứ không mặc định Kafka.
🇬🇧 English:
It depends on the requirements and existing infrastructure. If notifications are independent jobs, a queue such as RabbitMQ or SQS is a natural fit, with priority and DLQ support. If they come from domain events consumed by many systems and replay matters, Kafka fits. I choose based on delivery semantics, throughput and replay needs, not Kafka by default.
Cách nhớ ngắn gọn
- Async: notification không nằm trên critical path.
- Tách theo channel (isolation lỗi) rồi theo SLA (OTP vs marketing).
- Provider: timeout + retry backoff/jitter + circuit breaker + outbound rate limit.
- At-least-once + idempotent theo notificationId; hết retry → DLQ.
- 200 từ provider = SENT, chưa phải DELIVERED: chờ webhook.
Tự kiểm tra
Tách queue theo channel giải quyết gì, tách theo SLA giải quyết gì?
Channel: provider SMS chết không kéo Email/Push. SLA: marketing bulk không làm OTP chờ sau 1 triệu message.
Vì sao không failover sang provider B ngay khi provider A timeout?
Timeout là kết quả mơ hồ: A có thể đã gửi. Gọi B sẽ làm user nhận 2 SMS. Chỉ failover khi chắc chắn chưa gửi.
Provider trả 200 có nghĩa user đã nhận chưa?
Chưa. Chỉ là provider đã nhận yêu cầu (SENT_TO_PROVIDER). Trạng thái DELIVERED/FAILED đến qua webhook.
Tăng từ 100 lên 500 consumer có làm SMS gửi nhanh hơn không?
Không nếu provider giới hạn 1000/s: chỉ bị 429 nhiều hơn. Throughput bị chặn bởi downstream, cần rate limit.
RabbitMQ priority có đủ đảm bảo OTP nhanh không?
Không hoàn toàn: priority không cướp message worker đang xử lý, và bulk vẫn có thể ăn hết quota provider. Isolation (queue + worker pool riêng) và reserve capacity chắc hơn.