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
Table of contents
- 1. Vì sao Notification khó hơn bạn tưởng
- 2. Yêu cầu chức năng và phi chức năng
- 3. Bản đồ các kênh và đặc điểm
- 4. Kiến trúc tổng quan
- 5. Data model cốt lõi
- 6. Pipeline ingest — từ API tới queue
- 7. Fanout — một sự kiện, nhiều delivery
- 8. Template engine — tách logic khỏi code
- 9. Priority queue & back-pressure
- 10. Retry, DLQ và cơ chế tự hồi phục
- 11. Dedupe, suppression và rate limit per-user
- 12. Quiet hours, time zone và localization
- 13. Delivery callback — sự thật nằm ở provider
- 14. In-app inbox và realtime qua WebSocket
- 15. Observability — metrics, trace, và analytics
- 16. Campaign scheduler — gửi hàng triệu tin theo giờ của từng user
- 17. Bảo mật và chống lạm dụng
- 18. Capacity planning thực tế
- 19. Case study — cách các công ty lớn giải quyết
- 20. Checklist triển khai cho team
- 21. Kết luận
- Nguồn tham khảo
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.
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ầu | Nội dung | Ví dụ cụ thể |
|---|---|---|
| Chức năng | Gử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 |
| Template | Templating đa ngôn ngữ, có biến, A/B test, localization | Đơn {{orderId}} đã giao, {{recipient.firstName}} ơi |
| Ưu tiên | Có mức độ ưu tiên, transactional tách khỏi promotional | OTP P0, order update P1, marketing P3 |
| Idempotency | Một sự kiện chỉ được phát đúng một lần, bất chấp retry upstream | Producer trả lại cùng idempotencyKey ⇒ deliver một lần |
| Throughput | ≥ 50k notification/s peak cho kênh push, ≥ 10k/s cho email | Black Friday, push flash-sale cho 2 triệu user |
| Latency | OTP e2e ≤ 3s p99, order update ≤ 30s, marketing không bắt buộc | OTP gửi SMS nước ngoài vẫn dưới 5s |
| Độ tin cậy | Không mất message khi process chết, retry thông minh, DLQ có thể replay | Kafka replication 3, checkpoint offset thủ công |
| Quyền riêng tư | User opt-out per channel & per category, respect quiet hours và timezone | Không gửi push promo sau 22h local time |
| Quan sát | Metrics theo kênh/template/campaign, trace end-to-end, open/click rate | Dashboard 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ênh | Provider điển hình | Latency | Chi phí/msg | Tỉ lệ thành công | Đặc thù |
|---|---|---|---|---|---|
| Push mobile | APNs (iOS), FCM (Android, Web) | ~1s | Miễn phí | 80–95% | Token hết hạn, user tắt notification, silent fail nhiều |
| Amazon SES, SendGrid, Postmark, Mailgun | 2–30s | $0.0001–0.001 | 95–99% | Bounce & complaint callback async, reputation IP/domain quan trọng | |
| SMS | Twilio, Vonage, local carrier | 2–15s | $0.005–0.05 | 97–99% | Đắt, giới hạn 160 ký tự, quốc gia nào gửi được phải whitelist |
| In-app | Service riêng + WebSocket/SSE | ~100ms | Hạ tầng nội bộ | ~100% nếu online | Cần persist inbox cho user offline |
| Webhook | Khách hàng tự host endpoint | 100ms–10s | Hạ tầng nội bộ | 90–99% | Endpoint ngoài tầm kiểm soát, cần retry kèm signed payload |
| Chat | Slack, Teams, Zalo, Viber | 1–5s | Miễn phí/Bot | 95–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")]
Vài điều quan trọng về sơ đồ này:
- Topic tách theo ưu tiên: ít nhất hai topic
notif.transactionalvànotif.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
}
Lưu ý thiết kế:
NOTIFICATION_EVENT.idempotency_keylà khoá 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ũ.DELIVERYtách khỏiEVENTđể 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.PREFERENCEphả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ộtis_subscribedduy nhất là không đủ.- Sharding: bảng
DELIVERYluôn phình rất nhanh. Ngay từ đầu nên shard theouser_idhoặccreated_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_channelsNó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
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ỗi | Ví dụ | Xử lý |
|---|---|---|
| Transient | 5xx provider, timeout, rate-limit | Retry với exponential backoff + jitter, giới hạn 5 lần |
| Permanent | Token không hợp lệ, email bounce hard, số điện thoại sai format | Không retry. Ghi lại, disable target, notify cơ chế cleanup |
| Ambiguous | Provider trả 202 không kèm delivery status | Retry sau delivery callback không đến trong TTL |
| Critical bug | Template 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.timezonedạ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
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ầng | Metric | Dùng khi |
|---|---|---|
| Pipeline | events_in/s, fanout_ratio, queue_lag, worker_throughput | Theo dõi health hệ thống, cảnh báo SRE |
| Channel | send_rate, success_rate, bounce_rate, latency p50/p95/p99 | So sánh provider, alert khi degrade |
| Business | delivery_rate, open_rate, CTR theo template/campaign | Marketing, 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
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ần | Công suất một node | Ghi chú |
|---|---|---|
| API ingest (.NET 10 Minimal) | ~20–30k req/s | Phụ thuộc CPU, validate và Redis I/O |
| Kafka broker | ~100MB/s write, 3 replica | Tối ưu batch size, ack=all cho transactional |
| Worker fanout | ~2k events/s | Nếu fanout ratio ~3 thì ~6k delivery/s |
| FCM push | ~600 req/s qua HTTP/2 connection | Scale bằng nhiều connection + batch 500 token |
| SES email | 100/s mặc định, request tăng dần lên 10k/s | Quota theo account, cần request sớm |
| Twilio SMS | 10/s/number | Nhiều number để tăng throughput, hoặc Messaging Service |
| PostgreSQL delivery ledger | ~20k write/s với batching | Partition 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
- Google Cloud — Building a large-scale notification system
- Firebase Cloud Messaging documentation
- Apple Developer — Setting up a remote notification server (APNs)
- Amazon SES — Email deliverability concepts
- Twilio — Messaging Services and high-throughput SMS
- LinkedIn Engineering — Air Traffic Controller: Member-First Notifications
- Slack Engineering — Messaging and inbox architecture
- Uber Engineering — Real-time push platform
- Apache Kafka — Delivery semantics and exactly-once
- Microsoft Learn — Background tasks with IHostedService (.NET)
- OpenTelemetry — Messaging semantic conventions
Blazor trên .NET 10 năm 2026 - Làm chủ Render Modes, Stream Rendering và Enhanced Navigation cho Full-stack C#
ClickHouse 2026 - Kiến trúc OLAP Sub-second với SharedMergeTree, Parallel Replicas và Storage-Compute Separation cho Analytics Petabyte
Disclaimer: The opinions expressed in this blog are solely my own and do not reflect the views or opinions of my employer or any affiliated organizations. The content provided is for informational and educational purposes only and should not be taken as professional advice. While I strive to provide accurate and up-to-date information, I make no warranties or guarantees about the completeness, reliability, or accuracy of the content. Readers are encouraged to verify the information and seek independent advice as needed. I disclaim any liability for decisions or actions taken based on the content of this blog.