Thiết kế Notification System 2026 - Fanout, Priority Queue, Idempotency và Template Engine cho triệu push/email/SMS mỗi ngày

Posted on: 4/17/2026 2:10:41 AM

1. Vì sao Notification khó hơn bạn tưởng

Thoạt nhìn, gửi thông báo chỉ là gọi một API của Firebase hay Twilio. Nhưng khi hệ thống của bạn có vài triệu người dùng, hàng chục loại sự kiện, ba-bốn kênh song song (push, email, SMS, in-app, webhook) và phải tuân thủ quiet-hours theo múi giờ của từng user, bức tranh trở thành một hệ thống phân tán phức tạp với hàng loạt ràng buộc đặc trưng: latency khác nhau theo từng kênh, chi phí rất khác nhau, khả năng fail rất khác nhau, và khó khăn lớn nhất là không được gửi trùng nhưng tuyệt đối không được quên.

Bài viết này đi sâu vào kiến trúc Notification Service ở quy mô triệu user: từ data model, pipeline ingest, fanout đa kênh, template engine, rate-limit cá nhân hoá, idempotency, retry & DLQ, đến quan sát hiệu quả chiến dịch và cơ chế unsubscribe/quiet-hours. Ngôn ngữ minh hoạ là .NET 10 và Vue/Nuxt cho dashboard quản trị, nhưng các nguyên lý áp dụng cho bất kỳ stack nào — Java Spring, Node.js, Go hay Python.

4+Kênh (push, email, SMS, in-app, webhook)
~50msIngest-to-queue p99
99.9%Delivery SLA khả thi với retry
10M/dayQuy mô mỗi node worker có thể xử lý

2. Yêu cầu chức năng và phi chức năng

Trước khi vẽ kiến trúc, phải làm rõ hệ thống cần làm gì và không được làm gì. Đây là nơi nhiều đội "làm vội" mắc sai lầm: họ chỉ nghĩ đến việc gửi được, không nghĩ đến việc không gửi nhầm hay không spam người dùng.

Nhóm yêu cầuNội dungVí dụ cụ thể
Chức năngGửi thông báo đa kênh theo sự kiện domain (order, payment, promo, system)Khi order ở trạng thái Shipped, gửi push + email, không gửi SMS
TemplateTemplating đa ngôn ngữ, có biến, A/B test, localizationĐơn {{orderId}} đã giao, {{recipient.firstName}} ơi
Ưu tiênCó mức độ ưu tiên, transactional tách khỏi promotionalOTP P0, order update P1, marketing P3
IdempotencyMột sự kiện chỉ được phát đúng một lần, bất chấp retry upstreamProducer trả lại cùng idempotencyKey ⇒ deliver một lần
Throughput≥ 50k notification/s peak cho kênh push, ≥ 10k/s cho emailBlack Friday, push flash-sale cho 2 triệu user
LatencyOTP e2e ≤ 3s p99, order update ≤ 30s, marketing không bắt buộcOTP gửi SMS nước ngoài vẫn dưới 5s
Độ tin cậyKhông mất message khi process chết, retry thông minh, DLQ có thể replayKafka replication 3, checkpoint offset thủ công
Quyền riêng tưUser opt-out per channel & per category, respect quiet hours và timezoneKhông gửi push promo sau 22h local time
Quan sátMetrics theo kênh/template/campaign, trace end-to-end, open/click rateDashboard Grafana + data warehouse cho marketing

3. Bản đồ các kênh và đặc điểm

Mỗi kênh có hành vi riêng. Thiết kế "one-size-fits-all" sẽ hỏng bét vì SMS thì mất tiền mỗi tin nhưng đến gần như chắc chắn, còn push có thể free nhưng hoàn toàn có thể bị drop nếu user tắt notification hoặc background app refresh. Đây là những khác biệt phải mã hoá vào chiến lược gửi và retry.

KênhProvider điển hìnhLatencyChi phí/msgTỉ lệ thành côngĐặc thù
Push mobileAPNs (iOS), FCM (Android, Web)~1sMiễn phí80–95%Token hết hạn, user tắt notification, silent fail nhiều
EmailAmazon SES, SendGrid, Postmark, Mailgun2–30s$0.0001–0.00195–99%Bounce & complaint callback async, reputation IP/domain quan trọng
SMSTwilio, Vonage, local carrier2–15s$0.005–0.0597–99%Đắt, giới hạn 160 ký tự, quốc gia nào gửi được phải whitelist
In-appService riêng + WebSocket/SSE~100msHạ tầng nội bộ~100% nếu onlineCần persist inbox cho user offline
WebhookKhách hàng tự host endpoint100ms–10sHạ tầng nội bộ90–99%Endpoint ngoài tầm kiểm soát, cần retry kèm signed payload
ChatSlack, Teams, Zalo, Viber1–5sMiễn phí/Bot95–99%Rate limit chặt, token OAuth cần refresh

Đừng trộn marketing và transactional vào cùng pipeline

OTP và "giảm giá hôm nay" có SLA khác nhau trời vực. Nếu để chung một queue, một chiến dịch marketing 2 triệu SMS có thể đẩy OTP xuống dưới 30 giây p99 thành 5 phút p99 — đủ để user bỏ giỏ hàng. Luôn tách ít nhất hai đường: transactional (ưu tiên cao, không throttle) và marketing (ưu tiên thấp, bị bóp khi hệ thống tải cao).

4. Kiến trúc tổng quan

Trái tim của Notification Service là một event-driven pipeline với đường đi rõ ràng từ sự kiện domain đến từng kênh gửi. Mọi thành phần đều phải idempotent, có thể restart độc lập, và đo đếm được ở từng chặng.

flowchart LR
    subgraph Producers
      ORD["Order Service"]
      PAY["Payment Service"]
      AUTH["Auth Service (OTP)"]
      MKT["Marketing Campaign"]
    end

    Producers --> API["Notification API
(.NET 10 Minimal API)"] API --> VAL["Validator + Dedup
(Redis idempotency cache)"] VAL --> TOPIC{"Kafka topics"} TOPIC -->|transactional| WKT["Transactional Worker Pool"] TOPIC -->|marketing| WKM["Marketing Worker Pool"] WKT --> FAN["Fanout & Preferences"] WKM --> FAN FAN --> TMPL["Template Engine"] TMPL --> ROUTE{"Per-channel router"} ROUTE --> APNs ROUTE --> FCM ROUTE --> SES["SES / SendGrid"] ROUTE --> SMS["Twilio / Local SMS"] ROUTE --> WS["WebSocket / SSE"] ROUTE --> HOOK["Webhook dispatcher"] APNs --> CB1["Delivery callback"] FCM --> CB1 SES --> CB1 SMS --> CB1 CB1 --> LEDGER[("Delivery ledger
PostgreSQL / OLTP")] CB1 --> ANALYTICS[("Analytics warehouse")]
Notification System — event in, multi-channel out, mọi chặng đều đo đếm

Vài điều quan trọng về sơ đồ này:

  • Topic tách theo ưu tiên: ít nhất hai topic notif.transactionalnotif.marketing. Worker pool riêng, resource riêng.
  • Fanout đặt sau ingest: producer chỉ cần biết một user cần được thông báo về sự kiện A. Việc nở ra thành N kênh × M devices xảy ra bên trong service, không để producer phải biết user có bao nhiêu thiết bị.
  • Delivery ledger riêng: đây là bảng lịch sử có authoritative truth — ai đã được gửi gì, khi nào, kết quả ra sao. Bảng này cấp dữ liệu cho cả debugging, UI lịch sử thông báo của user lẫn compliance.

5. Data model cốt lõi

Giản đồ dữ liệu của Notification Service không nhiều bảng, nhưng quan hệ giữa chúng quyết định khả năng mở rộng về sau. Dưới đây là mô hình tối thiểu — có thể phình thêm khi bạn cần A/B test, campaign scheduler, journey orchestration…

erDiagram
    USER ||--o{ DEVICE : owns
    USER ||--o{ PREFERENCE : has
    USER ||--o{ DELIVERY : receives
    TEMPLATE ||--o{ CAMPAIGN : used_in
    CAMPAIGN ||--o{ NOTIFICATION_EVENT : produces
    NOTIFICATION_EVENT ||--o{ DELIVERY : fans_out_to
    DEVICE ||--o{ DELIVERY : targeted_by
    CHANNEL ||--o{ DELIVERY : uses

    USER {
      uuid id
      string locale
      string timezone
    }
    DEVICE {
      uuid id
      uuid user_id
      string platform
      string push_token
      datetime last_seen
      bool active
    }
    PREFERENCE {
      uuid user_id
      string category
      string channel
      bool enabled
      time quiet_start
      time quiet_end
    }
    TEMPLATE {
      uuid id
      string code
      string channel
      string locale
      text body
      json schema
    }
    NOTIFICATION_EVENT {
      uuid id
      string idempotency_key
      string type
      int priority
      json payload
      datetime created_at
    }
    DELIVERY {
      uuid id
      uuid event_id
      uuid user_id
      string channel
      string provider_msg_id
      string status
      datetime sent_at
      datetime delivered_at
    }
Giản đồ dữ liệu Notification Service — tối thiểu để scale nhưng đủ audit

Lưu ý thiết kế:

  • NOTIFICATION_EVENT.idempotency_keykhoá chặn trùng đầu vào, tạo bởi producer (ví dụ order:1234:shipped). Insert có constraint UNIQUE, trùng thì reject 200 OK kèm event cũ.
  • DELIVERY tách khỏi EVENT để một event có thể đẻ ra nhiều delivery (push device A, push device B, email). Mỗi delivery có vòng đời riêng: queued → sent → delivered → opened → clicked.
  • PREFERENCE phải chia tới mức category × channel. User có thể muốn nhận email promotion nhưng không muốn push promotion. Một cột is_subscribed duy nhất là không đủ.
  • Sharding: bảng DELIVERY luôn phình rất nhanh. Ngay từ đầu nên shard theo user_id hoặc created_at (partition theo ngày). Đừng đợi tới lúc 500 triệu row mới xử lý.

6. Pipeline ingest — từ API tới queue

Producer gọi API Notification Service thay vì gọi thẳng FCM/SES. Điều này cho phép quản lý thống nhất: rate limit, template, preference, idempotency, audit đều ở một chỗ. Đoạn code .NET 10 Minimal API dưới đây minh hoạ ingest endpoint tối giản nhưng đầy đủ các bước bắt buộc:

app.MapPost("/v1/notifications", async (
    NotifyRequest req,
    IValidator<NotifyRequest> validator,
    IIdempotencyStore idem,
    IPublisher publisher,
    CancellationToken ct) =>
{
    // 1. Validate payload
    var result = await validator.ValidateAsync(req, ct);
    if (!result.IsValid) return Results.ValidationProblem(result.ToDictionary());

    // 2. Idempotency: nếu key đã có, trả về event cũ
    var existing = await idem.GetAsync(req.IdempotencyKey, ct);
    if (existing is not null) return Results.Accepted($"/v1/notifications/{existing.Id}", existing);

    // 3. Tạo event, chọn topic theo priority
    var evt = NotificationEvent.Create(req);
    var topic = req.Priority <= 1 ? "notif.transactional" : "notif.marketing";

    // 4. Publish Kafka (transactional producer, exactly-once)
    await publisher.PublishAsync(topic, evt, ct);

    // 5. Cache idempotency 24h
    await idem.SetAsync(req.IdempotencyKey, evt, TimeSpan.FromHours(24), ct);

    return Results.Accepted($"/v1/notifications/{evt.Id}", evt);
})
.RequireAuthorization("NotificationProducer")
.WithName("SubmitNotification");

Idempotency đúng cách

Redis SET key value NX EX 86400 là đủ. Nếu muốn chắc chắn tuyệt đối, đi kèm UNIQUE constraint trong DB — nhưng tránh chờ DB round-trip trong hot path; để DB chỉ bắt lỗi trùng mà Redis đã bỏ sót trong lúc flush cache. Với sự kiện critical (OTP, payment), có thể thêm sequence number theo user để bắt out-of-order do retry nhiều lần.

7. Fanout — một sự kiện, nhiều delivery

Worker tiêu thụ topic sẽ tách một sự kiện thành một chuỗi delivery theo công thức:

delivery_set =
  (user.devices ∪ user.emails ∪ user.phone)
  ∩ template.channels
  ∩ user.preferences
  \ user.quiet_hours_violating_channels

Nói cách khác, bạn nhận giao của ba tập hợp, bỏ đi những kênh đang trong khoảng quiet hours. Kết quả là danh sách (channel, target) cụ thể để gửi. Với user có 2 điện thoại + 1 email + đăng ký web push, một sự kiện có thể nở thành 4 delivery khác nhau.

public async Task<IReadOnlyList<Delivery>> FanoutAsync(NotificationEvent evt, CancellationToken ct)
{
    var user = await users.GetAsync(evt.UserId, ct);
    var prefs = await prefs.GetAsync(evt.UserId, evt.Category, ct);
    var template = await templates.GetAsync(evt.TemplateCode, user.Locale, ct);

    var deliveries = new List<Delivery>();
    var nowLocal = DateTimeOffset.UtcNow.ToTimeZone(user.TimeZone);

    foreach (var channel in template.Channels)
    {
        if (!prefs.IsEnabled(channel)) continue;
        if (prefs.InQuietHours(channel, nowLocal) && evt.Priority > 1) continue;

        foreach (var target in user.TargetsFor(channel))
        {
            deliveries.Add(Delivery.New(evt.Id, user.Id, channel, target, template));
        }
    }

    return deliveries;
}

Điểm tinh tế: ưu tiên P0/P1 vượt qua quiet-hours. Không ai muốn bỏ lỡ OTP chỉ vì mình đang ngủ.

8. Template engine — tách logic khỏi code

Template nằm trong DB và được preload vào cache (Redis hoặc in-memory). Mỗi template có schema để validate dữ liệu đầu vào: nếu sự kiện gửi thiếu biến, reject ngay tại ingest thay vì để worker phát hiện và chết giữa chừng.

code: order.shipped
locale: vi-VN
channel: push
body: |
  {{recipient.firstName}} ơi, đơn {{orderId}} đang được giao
  đến {{shippingAddress.short}}. Dự kiến {{eta | date:"HH:mm, dd/MM"}}.
schema:
  required: [recipient.firstName, orderId, shippingAddress.short, eta]

Template versioning và A/B test

Mỗi template có version. Khi cập nhật, tạo version mới, giữ version cũ đang chạy. Gán phân bổ 10% lưu lượng cho version mới trong 24 giờ, theo dõi CTR và open rate trên warehouse. Nếu tốt hơn, cutover toàn bộ. Đây là cùng nguyên lý feature flag nhưng áp cho nội dung.

9. Priority queue & back-pressure

Hệ thống sẽ đôi lúc bị dồn. Một service upstream fail và retry toàn bộ, một campaign marketing bấm Send xong tất cả 2 triệu user cùng một nhịp, một carrier SMS chậm 500ms/msg. Nếu không có cơ chế ưu tiên và back-pressure, mọi lớp đều chịu hậu quả — từ CPU đến provider limit.

flowchart TB
    IN["Kafka:
notif.transactional
notif.marketing"] --> WK["Worker dispatcher"] WK --> P0["P0 pool (OTP, auth)
concurrency cao, không throttle"] WK --> P1["P1 pool (order, payment)
concurrency vừa"] WK --> P3["P3 pool (marketing)
concurrency thấp, throttle theo token bucket"] P0 --> PROV["Provider pool"] P1 --> PROV P3 --> PROV PROV --> RL["Global rate limit theo provider"] RL -->|block| RET["Retry queue (delay)"] RET -. sau 2^n giây .-> PROV
Tách pool theo ưu tiên, thêm retry với backoff mũ

Một vài nguyên tắc đúc kết từ incident thực tế:

  • Worker pool riêng biệt theo priority: dùng thread pool riêng hoặc process riêng, KHÔNG chung. Nếu chung, một burst marketing sẽ đẩy OTP ra sau hàng chục ngàn tin.
  • Back-pressure từ provider quay ngược về queue: khi SES trả 429, worker phải dừng consume thêm một khoảng, không vô tình đẩy message vào DLQ.
  • Token bucket per provider: FCM cho 600 req/s, SES cho 100/s mặc định. Áp hạn mức ngay trong worker để tránh bị provider cut.
  • Graceful degradation: nếu provider SMS primary chết, fail-over sang secondary cho P0/P1 nhưng chấp nhận drop P3. Thông báo marketing có thể chậm 1 giờ không ai chết, OTP chậm 1 phút là mất order.

10. Retry, DLQ và cơ chế tự hồi phục

Gửi sai kênh, token hết hạn, endpoint ngoài timeout — tất cả đều là transient failure hoặc permanent failure. Phân biệt đúng hai loại này là chìa khoá để không spam retry vô ích.

Loại lỗiVí dụXử lý
Transient5xx provider, timeout, rate-limitRetry với exponential backoff + jitter, giới hạn 5 lần
PermanentToken không hợp lệ, email bounce hard, số điện thoại sai formatKhông retry. Ghi lại, disable target, notify cơ chế cleanup
AmbiguousProvider trả 202 không kèm delivery statusRetry sau delivery callback không đến trong TTL
Critical bugTemplate format sai, worker crash loopĐẩy vào DLQ, alert on-call, không đụng queue chính

DLQ không phải "nơi message đi chết". Nó phải có công cụ replay. Một CLI đơn giản cho phép liệt kê, sửa metadata, replay vào queue chính là đủ để team on-call xử lý đa số incident.

// Exponential backoff với jitter
public static TimeSpan NextRetryDelay(int attempt)
{
    var baseMs = Math.Min(30_000, 500 * Math.Pow(2, attempt));
    var jitter = Random.Shared.NextDouble() * 0.3; // ±30%
    return TimeSpan.FromMilliseconds(baseMs * (1 + jitter));
}

11. Dedupe, suppression và rate limit per-user

Một user không nên nhận 47 thông báo trong 10 phút. Nhưng bạn cũng không muốn chặn cứng vì có lúc họ thực sự cần (ví dụ một chuỗi sự kiện order, payment, shipped trong vài giây). Giải pháp: per-user rate limit theo category.

public async Task<bool> ShouldSuppressAsync(Guid userId, string category, CancellationToken ct)
{
    // Leaky bucket trong Redis: 5 push/marketing trong 1 giờ
    var key = $"ratelimit:push:{userId}:{category}";
    var count = await redis.StringIncrementAsync(key);
    if (count == 1) await redis.KeyExpireAsync(key, TimeSpan.FromHours(1));
    return category == "marketing" && count > 5;
}

Kèm theo là digest pattern: khi phát hiện sắp vi phạm rate limit, thay vì drop, gom 10 thông báo nhỏ thành một tin "Bạn có 10 cập nhật mới". Pattern này rất hiệu quả cho app social và collaboration.

12. Quiet hours, time zone và localization

Một dự án tôi từng tham gia gửi push khuyến mãi 3h sáng vì server đặt UTC và campaign lên lịch theo UTC. Hậu quả: hàng ngàn user 1-star review. Lesson: mọi thời điểm liên quan tới người dùng phải được diễn giải trong timezone của họ, không phải server.

Ba quy tắc tối thiểu:

  • Lưu user.timezone dạng IANA (Asia/Ho_Chi_Minh), không lưu offset thô.
  • Quiet hours mặc định 22:00–07:00 local cho marketing (các nghiên cứu cho thấy mở mức rất thấp ngoài khoảng 8:00–21:00).
  • Với campaign gửi theo batch "mỗi user vào 9:00 sáng giờ địa phương", cần scheduler riêng: rải campaign ra nhiều bucket theo timezone, enqueue từng bucket đúng giờ.

13. Delivery callback — sự thật nằm ở provider

Bạn gọi SES và nhận 202 — đừng tưởng thế là xong. 202 chỉ nghĩa là SES đã nhận. Email có thể bounce, có thể complaint, có thể đến tức thì, có thể rơi vào promotions tab. Sự thật nằm trong delivery callback mà provider gửi ngược về webhook của bạn.

app.MapPost("/webhooks/ses", async (SesEvent evt, IDeliveryService svc, CancellationToken ct) =>
{
    // Verify SNS signature trước tiên
    if (!SesSignature.Verify(evt.RawPayload, evt.Signature)) return Results.Unauthorized();

    var deliveryId = evt.Tags["delivery_id"];
    var status = evt.Type switch
    {
        "Delivery" => DeliveryStatus.Delivered,
        "Bounce" => DeliveryStatus.Bounced,
        "Complaint" => DeliveryStatus.Complaint,
        "Open" => DeliveryStatus.Opened,
        "Click" => DeliveryStatus.Clicked,
        _ => DeliveryStatus.Unknown
    };
    await svc.UpdateAsync(Guid.Parse(deliveryId), status, evt.Timestamp, ct);
    return Results.Ok();
});

Khi status là Bounce dạng hard, worker cleanup cần disable email đó. Tiếp tục gửi sẽ phá reputation sender của bạn — SES, SendGrid đánh giá rất nhanh và bạn có thể bị chặn gửi cho tới khi liên hệ support.

14. In-app inbox và realtime qua WebSocket

Push được dùng để báo ngay. Nhưng khi user mở app, họ muốn xem lại lịch sử — đó là vai trò của in-app inbox. Kho này có hai yêu cầu: (1) truy xuất nhanh theo user, (2) cập nhật realtime khi có tin mới.

Kiến trúc phổ biến:

sequenceDiagram
    participant W as Worker
    participant DB as Postgres (inbox)
    participant R as Redis Pub/Sub
    participant GW as Realtime Gateway
    participant APP as Mobile/Web App
    W->>DB: INSERT inbox row
    W->>R: PUBLISH user:{id} new_msg
    R->>GW: subscription event
    GW->>APP: WebSocket/SSE push
    APP->>APP: Cập nhật badge count
    Note over APP: User tap, gọi GET /inbox?limit=50
    APP->>DB: SELECT với cursor pagination
In-app inbox — persist ở DB, realtime qua pub/sub

Một vài chi tiết hay bị bỏ qua:

  • Badge count cần tính trên server. Không dựa vào client đếm vì multi-device sẽ lệch.
  • Đánh dấu đã đọc cần event "read" ngược lên server để đồng bộ giữa các thiết bị — mở trên mobile thì badge ở web cũng giảm.
  • Pagination dùng cursor (WHERE created_at < :lastSeen), không dùng OFFSET vì bảng inbox lớn sẽ rất chậm.
  • TTL: inbox cũ hơn 90 ngày có thể archive sang cold storage hoặc xoá theo chính sách dữ liệu.

15. Observability — metrics, trace, và analytics

Một Notification Service không quan sát được gần như chắc chắn sẽ lặng lẽ hỏng: bạn vẫn gửi được 99% nhưng 1% đó là những user quan trọng nhất. Đo lường ba tầng:

TầngMetricDùng khi
Pipelineevents_in/s, fanout_ratio, queue_lag, worker_throughputTheo dõi health hệ thống, cảnh báo SRE
Channelsend_rate, success_rate, bounce_rate, latency p50/p95/p99So sánh provider, alert khi degrade
Businessdelivery_rate, open_rate, CTR theo template/campaignMarketing, product tối ưu nội dung

Trace end-to-end nên có thuộc tính event.id, user.id, template.code, channel để debug một message cụ thể có thể follow từ ingest tới callback. OpenTelemetry tự động bắt trace Kafka và HTTP client với ít config, công lao lớn nhất là đặt attribute đúng tại điểm fanout — nơi 1 event biến thành N span.

SLI/SLO cho Notification Service

Ví dụ: 99.5% OTP SMS được provider nhận trong 3 giây ingest→callback. Ghi lại delta delivered_at - event_created_at, tính percentile hàng giờ, alert khi SLO cháy >30% của error budget trong tuần. Đây là cách biến "chắc chắn nó tốt" thành một con số có thể defend với stakeholder.

16. Campaign scheduler — gửi hàng triệu tin theo giờ của từng user

Marketing muốn gửi "thứ Hai 9h sáng giờ địa phương" cho 2 triệu user. Nghe đơn giản nhưng user ở hàng chục timezone khác nhau. Cách làm ngây thơ là enqueue toàn bộ 2 triệu user lúc 00:00 UTC và để worker hold từng message — không chỉ tốn memory mà còn không chịu được restart.

Giải pháp gọn hơn: time-bucketed scheduler.

flowchart LR
    CAMP["Campaign 'Weekly promo'
send at 9:00 local"] --> BUCKET["Bucket theo timezone"] BUCKET --> B1["bucket +7 (Asia/Ho_Chi_Minh)"] BUCKET --> B2["bucket 0 (UTC, London)"] BUCKET --> B3["bucket -5 (America/New_York)"] B1 --> C["Cron at 02:00 UTC = 09:00 VN"] B2 --> D["Cron at 09:00 UTC"] B3 --> E["Cron at 14:00 UTC"] C --> ENQ["Enqueue vào Kafka"] D --> ENQ E --> ENQ
Time-bucketed scheduler cho campaign đa timezone

Bên trong mỗi bucket, enqueue theo batch ~10k user với rate cố định để không spike provider. Nếu user thay đổi timezone, khi đọc ra check lại và trượt bucket nếu cần — không phải cả user base, chỉ vài user đó.

17. Bảo mật và chống lạm dụng

Notification là bề mặt tấn công ít người để ý. Cho đến khi có người dùng API nội bộ để gửi hàng loạt SMS tới một số điện thoại nạn nhân, hoặc gửi email giả mạo domain công ty. Vài biện pháp bắt buộc:

  • Authenticated producer: chỉ service backend nội bộ (mTLS hoặc OAuth2 service-to-service) được gọi API. Không bao giờ expose trực tiếp cho client.
  • Template whitelisting: body phải là template code định danh, không cho phép gửi text tự do. Khóa lại free-text chính là cách ngăn phishing nội bộ.
  • Rate limit per-tenant: mỗi producer có hạn mức. Ngăn một service lỗi đẩy vỡ toàn pipeline.
  • PII minimisation: payload chỉ chứa khoá (userId, orderId). Worker tự resolve dữ liệu cá nhân từ user service. Log không được in email/phone plaintext.
  • DKIM, SPF, DMARC cho email; reputable sending IP cho SES/SendGrid; signed payload cho webhook outbound (HMAC-SHA256).
  • Opt-out honor cứng: khi user nhấn unsubscribe, worker phải chặn tại bước fanout, không chỉ ẩn UI. Quy định như CAN-SPAM, GDPR, Nghị định 91/2020 của Việt Nam đều yêu cầu điều này.

18. Capacity planning thực tế

Con số tham chiếu để bạn ước lượng:

Thành phầnCông suất một nodeGhi chú
API ingest (.NET 10 Minimal)~20–30k req/sPhụ thuộc CPU, validate và Redis I/O
Kafka broker~100MB/s write, 3 replicaTối ưu batch size, ack=all cho transactional
Worker fanout~2k events/sNếu fanout ratio ~3 thì ~6k delivery/s
FCM push~600 req/s qua HTTP/2 connectionScale bằng nhiều connection + batch 500 token
SES email100/s mặc định, request tăng dần lên 10k/sQuota theo account, cần request sớm
Twilio SMS10/s/numberNhiều number để tăng throughput, hoặc Messaging Service
PostgreSQL delivery ledger~20k write/s với batchingPartition theo ngày, vacuum chủ động

Với quy mô 50 triệu notification/ngày (~580/s trung bình, peak 5k/s), một cluster 3 broker Kafka, 6–8 node worker, 2 node API là khá dư. Đừng bỏ qua chi phí: 50M SMS × $0.02 = $1M/tháng. Optimising ra kênh miễn phí (push + in-app inbox) cho 80% nội dung không critical là đòn bẩy lớn nhất, không phải tối ưu code worker.

19. Case study — cách các công ty lớn giải quyết

Vài mẫu kiến trúc công khai đáng để học hỏi:

  • Slack: in-app inbox là nguồn truth. Push chỉ là teaser. Họ dùng pattern "fanout-on-read" cho channel lớn: không push cho 10k thành viên cùng lúc, mà gửi theo presence và active subscriber.
  • Uber: tách transactional (trip events) khỏi promotional hoàn toàn, dùng pipeline Kafka riêng. Marketing chạy trên một service khác với quota cứng.
  • LinkedIn: "Air Traffic Controller" cân bằng giữa nhiều loại notification, ngăn một user nhận nhiều tin cùng chủ đề trong 24 giờ. Đây là bài học rõ nhất về digest và frequency capping.
  • Pinterest: dùng ML để dự đoán thời điểm user mở app, gửi đúng lúc đó thay vì spam. Ý tưởng tuyệt đẹp, nhưng cần dữ liệu behavior đủ lớn mới đáng làm.

20. Checklist triển khai cho team

Pre-launch

  • Idempotency key được thống nhất giữa producer.
  • Template versioning, schema validation bật mặc định.
  • DLQ có công cụ replay, runbook on-call có bước xử lý.
  • SLO được define cho từng priority (OTP, transactional, marketing).
  • Token refresh cho chat/webhook, rotation cho signing key.

Trong 90 ngày đầu production

  • Theo dõi bounce rate email và complaint rate SES hằng ngày. Cắt ngay target xấu.
  • Audit PII log, bảo đảm không lộ email/phone plaintext.
  • Chạy game-day: giả lập FCM/SES down, verify hệ thống degrade gracefully.
  • Review số tin marketing/user/tuần — nếu trung vị vượt 5, rủi ro user opt-out rất cao.

21. Kết luận

Notification Service là một trong những backend bị coi thường bậc nhất. Vẻ ngoài nó chỉ là "gọi FCM với SES", nhưng khi đi vào chi tiết, nó là tập hợp của gần như mọi pattern hệ thống phân tán: event-driven, idempotency, priority queue, retry với backoff, fanout, rate limit, scheduler, observability, security. Xây dựng đúng từ đầu tiết kiệm cho team hàng tháng firefighting; xây vội vàng thì bạn sẽ đang trả giá ở mỗi lần Black Friday, mỗi lần user report "không nhận được OTP".

Hy vọng bài này cho bạn một bản đồ đủ chi tiết để không phải vừa code vừa học bài. Điểm quan trọng nhất để nhớ lại: tách transactional khỏi marketing, idempotency từ ingest, template có schema, quiet hours theo timezone user, và quan sát được từng message. Năm nguyên tắc ấy một mình đã đủ để phân biệt một Notification Service "chạy được" với một Notification Service "tin được".

Nguồn tham khảo