Level 3: thiết kế Notification Service

5 câu hỏi~35 phútTừ khóa: queue per channel, priority/SLA, worker = consumer, retry + backoff + jitter, circuit breaker, outbound rate limit, provider failover, idempotent notificationId, DLQ, sent ≠ delivered, Kafka vs RabbitMQ

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:

  1. Provider SMS chết thì sao?
  2. Gửi SMS timeout thì sao?
  3. Provider giới hạn 1000 SMS/s nhưng có 100K SMS cần gửi?
  4. Message bị xử lý 2 lần, làm sao user không nhận 2 SMS?
  5. Retry 5 lần vẫn fail thì làm gì?
  6. 1 triệu email marketing đang chờ, OTP cần gửi ngay?
  7. 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ốngHợ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 messageRabbitMQ

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òAWSAzure
Event bus / pub-subEventBridge / SNSEvent Grid / Service Bus Topic
Queue + DLQSQS + DLQService Bus Queue + dead-letter
WorkerLambda / ECSFunctions / Container Apps
Email/SMSSES / SNS SMSAzure Communication Services
MonitoringCloudWatchAzure 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 4Problem 10
Câu hỏiSMS 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áchChannel: provider nào xử lý?Priority/SLA: cần nhanh đến mức nào?
Kết quảEmail | SMS | PushSMS 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).

RabbitMQKafka
Priority nativeCó (x-max-priority)Không
Tách queue/topic theo SLACóCó (cách chính)
Ordered logKhông phải trọng tâmRấ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


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.