Hệ thống thông báo
Hiểu đề
Yêu cầu chức năng
- Service nội bộ có thể gửi thông báo cho một người hoặc một nhóm người qua một hay nhiều kênh (push, email, SMS, in-app)
- Người dùng có thể bật/tắt từng loại thông báo theo kênh và đặt giờ yên lặng
- Service nội bộ có thể hẹn giờ gửi, kể cả theo giờ địa phương của từng người nhận
- Người dùng có thể xem hộp thư in-app và đánh dấu đã đọc
Yêu cầu phi chức năng
- Thông báo giao dịch (OTP, biến động số dư) được chuyển tới provider p95 < 2 s; thông báo marketing được phép trễ tới 30 phút
- Không mất thông báo đã nhận (202): queue bền, API nhận có độ sẵn sàng 99,95%
- Người dùng nhận trùng < 0,01% số thông báo dù hệ thống retry
- Chiến dịch lớn nhất (30 triệu người) gửi xong trong ≤ 30 phút
- Tuân thủ 100% tùy chọn người dùng; marketing tối đa 3 push/ngày/người
Ngoài phạm vi: Công cụ soạn nội dung, A/B testing chiến dịch; Phân tích tỉ lệ mở/click chi tiết; Tự vận hành máy chủ SMTP hay kết nối trực tiếp nhà mạng; Nhắn tin hai chiều (chat).
Thực thể và API
| Method | Endpoint | Ghi chú |
|---|---|---|
| POST | /v1/notifications | Body {idempotencyKey, userIds | segmentId, template, data, category, priority, sendAt?}. Trả 202 kèm notificationId, gửi lại cùng key thì trả lại id cũ. |
| GET | /v1/notifications/{id} | Trạng thái tổng hợp: số Delivery đã gửi, lỗi, đang chờ. |
| PUT | /v1/users/me/preferences | Cập nhật kênh theo category, giờ yên lặng, múi giờ. |
| POST | /v1/users/me/devices | App đăng ký hoặc làm mới push token khi mở. |
| GET | /v1/users/me/inbox?cursor= | Hộp thư in-app phân trang theo thời gian, kèm số chưa đọc. |
| POST | /v1/providers/{name}/callbacks | Provider báo kết quả (delivered, bounce, token hết hạn). Xử lý idempotent theo mã tin của provider. |
Thiết kế tổng thể
Đi lần lượt từng yêu cầu chức năng. Mục tiêu ở đây là chạy đúng, việc tối ưu để dành cho phần đào sâu.
1) Service nội bộ có thể gửi thông báo qua nhiều kênh
API chỉ ghi nhận yêu cầu rồi trả 202 ngay, phần gửi chạy bất đồng bộ. Fan-out worker tách một yêu cầu thành từng Delivery (người × kênh) và đẩy vào queue riêng của từng kênh; channel worker gọi provider.
- 1Service gọi POST /v1/notifications kèm idempotencyKey
- 2API ghi Notification, đẩy notificationId vào queue nhận, trả 202
- 3Fan-out worker đọc người nhận theo lô 1.000, lọc theo preference và giới hạn tần suất
- 4Mỗi người × kênh hợp lệ thành một Delivery, đẩy vào queue của kênh đó
- 5Channel worker render template, gọi FCM/APNs, SES hoặc nhà cung cấp SMS, cập nhật trạng thái
Nguồn: Grokking Modern System Design · ch.17 Distributed Messaging Queue ↗ · bài "System Design: The Distributed Messaging Queue", mục "Motivation" và "Messaging queue use cases" (Sending many emails) ; Microservices Patterns ↗ (.zip) · ch.3 · §3.4.2 Eliminating synchronous interaction (tr. 104–109)
2) Người dùng có thể chọn kênh và giờ yên lặng
Preference được đọc ở bước fan-out, trước khi tạo Delivery, nên một chỗ kiểm tra áp dụng cho mọi team. Dữ liệu nhỏ, đọc rất nhiều nên cache theo user.
- 1App gọi PUT /preferences, API ghi DB rồi xóa cache của user đó
- 2Fan-out worker đọc preference từ cache, thiếu thì đọc DB
- 3Kênh bị tắt thì bỏ qua; đang trong giờ yên lặng thì đổi send_at sang cuối giờ yên lặng
- 4OTP và cảnh báo bảo mật bỏ qua giờ yên lặng nhưng vẫn có giới hạn chống lạm dụng
Nguồn: Grokking Modern System Design · ch.16 Distributed Cache ↗ · bài "Background of Distributed Cache", mục "Writing policies" và "Cache invalidation" ; Grokking Modern System Design · ch.19 Rate Limiter ↗ · bài "Design of a Rate Limiter", mục "High-level design" (luật Lyft giới hạn tin marketing theo ngày)
3) Service có thể hẹn giờ gửi theo giờ địa phương
Yêu cầu có sendAt trong tương lai được lưu thành job chờ. Với kiểu '8h sáng giờ địa phương', fan-out tính send_at UTC riêng cho từng người theo múi giờ của họ.
- 1API lưu Notification với send_at, không đẩy vào queue ngay
- 2Scheduler quét các job đến hạn theo index send_at mỗi vài giây
- 3Job đến hạn được đẩy vào queue nhận và đi tiếp luồng bình thường
- 4Trước khi gửi, worker kiểm tra chiến dịch chưa bị hủy
Nguồn: Grokking Modern System Design · ch.23 Distributed Task Scheduler ↗ · bài "Design of a Distributed Task Scheduler", mục "Components" (lưu task vào DB rồi đẩy vào distributed queue) ; Redis in Action ↗ · ch.6 · §6.4.2 Delayed tasks (tr. 137–140)
4) Người dùng có thể xem hộp thư in-app
In-app là một kênh như các kênh khác: channel worker ghi InboxItem. App đọc hộp thư qua API và nhận push nhắc có tin mới.
- 1Channel worker in-app ghi InboxItem và tăng bộ đếm chưa đọc
- 2App mở màn hình thông báo, gọi GET /inbox?cursor=
- 3Người dùng đọc thì gọi API đánh dấu, giảm bộ đếm
Nguồn: Redis in Action ↗ · ch.3 · §3.1 Strings (INCR/DECR, tr. 40–43) ; Grokking Modern System Design · ch.32 Newsfeed System ↗ · bài "Design of a Newsfeed System", mục "API design" › "Get user's newsfeed"
Hạ tầng theo quy mô
Cùng một sản phẩm, ba mức người dùng. Chọn từng mức để xem sơ đồ, phép tính và cấu hình server thay đổi thế nào.
Một app nhỏ cần gửi push, email và OTP. API và worker chạy chung một codebase Node, hàng đợi job nằm ngay trong Postgres nên chỉ có một hệ thống lưu trữ phải lo.
Ước lượng
- Thông báo: 10.000 DAU × 5 tin = 50.000 tin/ngày / 86.400 ≈ 0,6 tin/s
- Chiến dịch: 50.000 người trong 10 phút = 50.000 / 600 ≈ 83 lượt gửi/s
- Worker: ceil(83 / (1.000 × 0,6)) = 1 → chạy 2 instance để dự phòng
- SMS chỉ dùng cho OTP: 10.000 × 0,2 = 2.000 SMS/ngày, đây là khoản tốn tiền nhất
- Lưu trữ: 50.000 Delivery × 500 B = 25 MB/ngày × 90 ngày ≈ 2,25 GB
Nguồn con số: Grokking Modern System Design · ch.5 Back-of-the-envelope Calculations ↗ · bài "Examples of Resource Estimation", mục "Number of servers required", "Storage requirements" ; Quy tắc ước lượng của khoá học · /hoc/chon-server (500 req/s mỗi vCPU, chạy 60% CPU, Redis ×1,3)
Bảng hạ tầng
| Thành phần | Chọn | Số lượng | Cấu hình | Vì sao |
|---|---|---|---|---|
| API + worker | Node.js (NestJS), Docker trên Cloud Run | 2 | 2 vCPU · 4 GB RAM | 83 lượt gửi/s chỉ cần 1 instance; chạy 2 để không mất luồng gửi khi 1 cái lỗi. |
| Database + job queue | PostgreSQL managed + pg-boss | 1 | 2 vCPU · 8 GB · 50 GB SSD | Bảng job hỗ trợ hẹn giờ, retry và SKIP LOCKED; 2,25 GB dữ liệu là rất nhỏ. |
| Push | Firebase Cloud Messaging (Android) + APNs (iOS) | 1 | Dịch vụ bên ngoài | Miễn phí, là con đường duy nhất để đẩy tin tới điện thoại. |
| Amazon SES / SendGrid | 1 | Dịch vụ bên ngoài | Vài nghìn email mỗi ngày, có sẵn báo bounce qua webhook. | |
| SMS | Nhà cung cấp SMS brandname | 1 | Dịch vụ bên ngoài, gọi qua HTTP | Chỉ cho OTP; chưa cần provider dự phòng ở quy mô này. |
- Điểm xuất phát: một Postgres làm cả DB lẫn hàng đợi job và lịch hẹn giờ
- Worker gửi tuần tự từng kênh, retry bằng cơ chế sẵn có của pg-boss
- Hộp thư in-app là một bảng Postgres, app tải khi mở màn hình
Chiến dịch 1 triệu người tạo hàng triệu job trong bảng Postgres trong vài phút, vừa nặng DB vừa làm OTP phải xếp hàng sau marketing. Cần queue thật và tách luồng theo kênh và độ ưu tiên.
Cấu hình và chi phí là ước lượng để học và trình bày trong phỏng vấn. Xem cách tính ở bài Chọn server.
So sánh nhanh ba mức
| Thành phần | Mức 1 | Mức 2 | Mức 3 |
|---|---|---|---|
| Người dùng | 10.000 DAU | 1 triệu DAU | 50 triệu DAU |
| Chi phí | ≈ 50–120 USD/tháng hạ tầng (ước lượng, chưa tính phí SMS/email theo lượng) | ≈ 1.500–4.000 USD/tháng hạ tầng (ước lượng, chưa tính phí SMS/email) | ≈ 25.000–60.000 USD/tháng hạ tầng (ước lượng, chưa tính phí SMS/email) |
| API + worker | Node.js (NestJS), Docker trên Cloud Run 2 × 2 vCPU · 4 GB RAM | Chưa cần | Chưa cần |
| Database + job queue | PostgreSQL managed + pg-boss 1 × 2 vCPU · 8 GB · 50 GB SSD | Chưa cần | Chưa cần |
| Push | Firebase Cloud Messaging (Android) + APNs (iOS) 1 × Dịch vụ bên ngoài | Chưa cần | Chưa cần |
Amazon SES / SendGrid 1 × Dịch vụ bên ngoài | Chưa cần | Chưa cần | |
| SMS | Nhà cung cấp SMS brandname 1 × Dịch vụ bên ngoài, gọi qua HTTP | Chưa cần | Chưa cần |
| Notification API | Chưa cần | Node.js (NestJS), container autoscale 2–4 (autoscale) × 2 vCPU · 4 GB RAM | Chưa cần |
| Queue | Chưa cần | Amazon SQS / Google Pub/Sub 1 queue nhận + 8 queue (4 kênh × 2 mức ưu tiên) × Managed, có dead-letter queue | Chưa cần |
| Fan-out worker | Chưa cần | Node.js consumer 2–4 (autoscale theo lag) × 1 vCPU · 2 GB RAM | Node.js consumer trên Kubernetes 12–24 pod (scale theo lag) × 2 vCPU · 4 GB mỗi pod |
| Channel worker | Chưa cần | Node.js consumer theo kênh 6–12 (autoscale theo lag) × 1 vCPU · 2 GB RAM | Node.js consumer trên Kubernetes ≈ 102 pod lúc đỉnh (scale theo lag) × 1 vCPU · 2 GB mỗi pod |
| Scheduler | Chưa cần | Node.js poller, bảng job trong Postgres 2 × 1 vCPU · 2 GB RAM | Node.js, bucket theo phút trong ScyllaDB, lease qua Redis 3 pod × 2 vCPU · 4 GB mỗi pod |
| Redis | Chưa cần | ElastiCache / Memorystore 1 primary + 1 replica × 2 vCPU · 8 GB | Chưa cần |
| Database | Chưa cần | PostgreSQL managed (HA) 1 primary + 1 read replica × 4 vCPU · 16 GB · 500 GB SSD | Chưa cần |
| Provider | Chưa cần | FCM/APNs, SES, 2 nhà cung cấp SMS 4 × Dịch vụ bên ngoài | Chưa cần |
| Ingest API | Chưa cần | Chưa cần | Node.js trên Kubernetes, 3 AZ 14 pod (HPA) × 2 vCPU · 4 GB mỗi pod |
| Inbox API | Chưa cần | Chưa cần | Node.js trên Kubernetes 29 pod (HPA) × 2 vCPU · 4 GB mỗi pod |
| Kafka | Chưa cần | Chưa cần | MSK / Confluent 6 broker (2 mỗi AZ) × Topic theo kênh × ưu tiên, 24 partition, RF 3, key = userId |
| Redis Cluster | Chưa cần | Chưa cần | ElastiCache Redis Cluster 6 shard × (1 primary + 1 replica) × 4 vCPU · 32 GB mỗi node |
| Delivery + Inbox store | Chưa cần | Chưa cần | ScyllaDB (hoặc Cassandra), TTL 30 ngày 12 node (4 mỗi AZ) × 8 vCPU · 32 GB · 2 TB NVMe mỗi node |
Đào sâu
Mỗi câu hỏi đi từ cách đơn giản đến cách người phỏng vấn mong đợi ở cấp senior.
1) Một yêu cầu gửi cho 1 triệu người qua 3 kênh. Fan-out thế nào để không nghẽn?
Request của service nội bộ treo đến khi gửi xong. Một provider chậm làm cả vòng lặp chậm theo, và API restart giữa chừng thì không biết đã gửi tới đâu.
Đánh đổi: Chỉ dùng được cho vài chục người nhận.
Nguồn: Microservices Patterns ↗ (.zip) · ch.3 · §3.4.1 Synchronous communication reduces availability (tr. 103–104)
API đẩy yêu cầu vào queue, worker lấy ra và gửi lần lượt từng kênh. Request trả về ngay và worker có thể scale ngang.
Đánh đổi: Kênh SMS chậm hoặc đang lỗi chặn luôn push và email phía sau (head-of-line blocking); OTP phải xếp sau chiến dịch marketing.
Nguồn: Grokking Modern System Design · ch.17 Distributed Messaging Queue ↗ · bài "System Design: The Distributed Messaging Queue", mục "Motivation" (Easy decoupling, Rate limiting) ; Microservices Patterns ↗ (.zip) · ch.3 · §3.3.5 Competing receivers and message ordering (tr. 94–95)
Tầng 1 đọc người nhận theo lô 1.000, lọc preference, tạo Delivery. Tầng 2 có queue riêng cho push, email, SMS, in-app, mỗi kênh tách luồng giao dịch và luồng marketing. Channel worker của mỗi kênh scale độc lập và đặt số request song song theo hạn mức provider. Khi dùng Kafka thì key theo userId để thông báo của một người đi đúng thứ tự (sharded channel, Microservices Patterns §3.3.5).
Đánh đổi: Nhiều queue và worker hơn phải theo dõi; cần đo lag từng queue để biết kênh nào đang chậm.
Nguồn: Grokking Modern System Design · ch.17 Distributed Messaging Queue ↗ · bài "System Design: The Distributed Messaging Queue", mục "Motivation" (Priority queue: mỗi mức ưu tiên một queue) ; Microservices Patterns ↗ (.zip) · ch.3 · §3.3.5 Competing receivers and message ordering (tr. 94–95) ; Grokking Modern System Design · ch.23 Distributed Task Scheduler ↗ · bài "Design Considerations", mục "Prioritization"
2) Tôn trọng tùy chọn người dùng và chống spam thế nào khi nhiều team cùng gửi?
Logic bị sao chép ở nhiều nơi, mỗi nơi hiểu giờ yên lặng một kiểu. Không ai biết tổng số thông báo một người nhận trong ngày.
Đánh đổi: Người dùng bị làm phiền và gỡ app; khó chứng minh tuân thủ khi có khiếu nại.
Nguồn: Grokking Modern System Design · ch.19 Rate Limiter ↗ · bài "System Design: The Rate Limiter", mục "Why do we need a rate limiter?"
Mọi thông báo đi qua một chỗ kiểm tra kênh, category, giờ yên lặng. Logic nhất quán cho mọi team.
Đánh đổi: Chiến dịch 1 triệu người tạo 1 triệu lượt đọc DB trong vài phút, DB thành nút thắt.
Nguồn: Grokking Modern System Design · ch.19 Rate Limiter ↗ · bài "Design of a Rate Limiter", mục "High-level design" (rule đặt tập trung, áp cho mọi request)
Preference lưu hash Redis theo user, xóa key khi người dùng cập nhật. Mỗi lần gửi marketing tăng bộ đếm cửa sổ trượt user:{id}:mkt:{ngày}, quá 3 thì bỏ hoặc gộp thành một tin tóm tắt (sliding window counter, Grokking ch.19). OTP có giới hạn riêng, ví dụ 5 tin/10 phút, để chặn kẻ xấu spam SMS tốn tiền. Giờ yên lặng thì dời send_at thay vì bỏ.
Đánh đổi: Cache có thể lệch vài giây sau khi người dùng vừa tắt kênh; bộ đếm trong Redis mất khi failover thì có thể gửi vượt giới hạn một chút.
Nguồn: Redis in Action ↗ · ch.3 · §3.4 Hashes (tr. 48–50) ; Grokking Modern System Design · ch.16 Distributed Cache ↗ · bài "Background of Distributed Cache", mục "Cache invalidation" ; Grokking Modern System Design · ch.19 Rate Limiter ↗ · bài "Rate Limiter Algorithms", mục "Sliding window counter algorithm" và bài "Design of a Rate Limiter", mục "High-level design" (luật marketing 5 tin/ngày của Lyft)
3) Provider lỗi hoặc chậm thì retry và chuyển provider thế nào?
Provider đang quá tải lại nhận thêm hàng loạt request thử lại. Lỗi vĩnh viễn như email không tồn tại cũng bị thử mãi.
Đánh đổi: Làm sự cố kéo dài hơn và có thể bị provider khóa tài khoản.
Nguồn: Building Microservices ↗ (.zip) · ch.4 Integration · mục "Complexities of Asynchronous Architectures" (tr. 57–58: thiếu giới hạn retry làm job lỗi chạy mãi)
Thử lại sau 1 s, 2 s, 4 s... cộng thêm ngẫu nhiên để các worker không thử cùng lúc. Quá 5 lần thì đưa vào DLQ để xem xét.
Đánh đổi: Khi provider sập lâu, mọi tin đều chờ hết các lần thử rồi mới vào DLQ; OTP đến muộn thì vô dụng.
Nguồn: Building Microservices ↗ (.zip) · ch.4 Integration · mục "Complexities of Asynchronous Architectures" (tr. 57–58: giới hạn số lần retry, message hospital / dead letter queue) ; Grokking Modern System Design · ch.17 Distributed Messaging Queue ↗ · bài "Evaluation", mục "Functional requirements" (dead-letter queue) ; Chưa có nguồn trong thư viện: exponential backoff có jitter không được sách nào trong thư viện mô tả; giữ lại như kỹ thuật cơ bản
Lỗi 4xx vĩnh viễn (token hết hạn, email bounce) không retry, cập nhật DeviceToken hoặc đánh dấu địa chỉ hỏng. Lỗi 5xx/timeout thì backoff. Circuit breaker cho từng provider (Microservices Patterns §3.2.3): tỉ lệ lỗi vượt ngưỡng thì ngắt và chuyển SMS/email sang nhà cung cấp thứ hai. Tin có hạn dùng (OTP 5 phút) mang expires_at, quá hạn thì bỏ thay vì gửi muộn.
Đánh đổi: Duy trì hai provider tốn công đăng ký brandname/mẫu tin; timeout có thể là provider đã gửi thật nên chuyển sang provider khác có thể gây trùng.
Nguồn: Microservices Patterns ↗ (.zip) · ch.3 · §3.2.3 Handling partial failure using the Circuit breaker pattern (tr. 77–80) ; Building Microservices ↗ (.zip) · ch.11 Microservices at Scale · mục "Timeouts", "Circuit Breakers" (tr. 211–214)
4) Queue giao at-least-once và có retry. Làm sao người dùng không nhận một tin hai lần?
Worker gửi xong nhưng chết trước khi ack, queue giao lại và worker khác gửi lần nữa. Service gọi API bị timeout cũng tự gọi lại.
Đánh đổi: Người dùng nhận hai OTP hoặc hai tin trừ tiền, gây hoang mang.
Nguồn: Microservices Patterns ↗ (.zip) · ch.3 · §3.3.6 Handling duplicate messages (tr. 95: broker thường chỉ đảm bảo at-least-once; client chết trước khi ack thì bị giao lại)
Bảng notifications có unique (service, idempotency_key). Gọi lại cùng key thì trả notificationId cũ, không tạo yêu cầu mới.
Đánh đổi: Chỉ chặn trùng ở đầu vào; trùng do worker retry vẫn lọt qua.
Nguồn: Grokking Modern System Design · ch.23 Distributed Task Scheduler ↗ · bài "Design Considerations", mục "Task idempotency" ; Building Microservices ↗ (.zip) · ch.11 Microservices at Scale · mục "Idempotency" (tr. 215)
delivery_id = hash(notificationId, userId, channel) nên fan-out chạy lại cũng sinh đúng id cũ. Channel worker SET dedup:{delivery_id} NX với TTL 24 h trước khi gọi provider, và chuyển Delivery sang SENDING; thấy key đã có thì bỏ qua. Đây là idempotent consumer (Microservices Patterns §3.3.6). Với push, gửi kèm collapse key (FCM) hoặc apns-collapse-id để thiết bị chỉ hiện một bản.
Đánh đổi: Worker chết sau SET NX mà chưa gửi thì tin bị kẹt; cần job quét Delivery SENDING quá 1 phút để gửi lại, chấp nhận khả năng trùng rất nhỏ.
Nguồn: Microservices Patterns ↗ (.zip) · ch.3 · §3.3.6 Handling duplicate messages, mục "Tracking messages and discarding duplicates" (tr. 95–97) ; Redis in Action ↗ · ch.6 · §6.2.3 Building a lock in Redis (SETNX, tr. 120–123)
5) Hẹn giờ gửi lúc 8h sáng giờ địa phương cho 30 triệu người thế nào?
setTimeout mất khi process restart. Cron quét toàn bảng chậm dần theo số job và hai instance cron có thể lấy cùng một job.
Đánh đổi: Mất job hoặc gửi trùng, không scale.
Nguồn: Grokking Modern System Design · ch.23 Distributed Task Scheduler ↗ · bài "Design of a Distributed Task Scheduler", Point to Ponder "Why do we store tasks in a database?"
SELECT ... WHERE send_at <= now() ORDER BY send_at LIMIT 500 FOR UPDATE SKIP LOCKED cho nhiều poller chạy song song không giẫm chân. Ở quy mô vừa có thể dùng Redis sorted set với score là thời điểm gửi (Redis in Action §6.4.2).
Đánh đổi: Đúng và đơn giản, nhưng 30 triệu job đến hạn cùng lúc 8h sáng sẽ dồn thành một đỉnh rất lớn.
Nguồn: Redis in Action ↗ · ch.6 · §6.4.2 Delayed tasks (ZSET với score là thời điểm chạy, tr. 137–140) ; Chưa có nguồn trong thư viện: FOR UPDATE SKIP LOCKED là tính năng của PostgreSQL, thư viện không có sách PostgreSQL
Lưu múi giờ dạng tên IANA, không lưu offset, để tự đúng khi có giờ mùa hè. Fan-out nhóm người nhận theo múi giờ, mỗi nhóm thành job ở bucket phút tương ứng; scheduler gom job đến hạn theo lô rồi đẩy vào hàng đợi theo độ ưu tiên (Grokking ch.23); nhiều instance chia bucket bằng khóa có thời hạn trong Redis (Redis in Action §6.2.5). Marketing có delay tolerance dài hơn giao dịch (Grokking ch.23) nên được rải ngẫu nhiên trong 30 phút sau mốc để tránh đỉnh. Hủy chiến dịch bằng cách đổi trạng thái Notification; worker kiểm tra trước khi gửi.
Đánh đổi: Thêm logic múi giờ và một chiến dịch thành nhiều job; người đổi múi giờ sau khi job đã tạo vẫn nhận theo giờ cũ.
Nguồn: Grokking Modern System Design · ch.23 Distributed Task Scheduler ↗ · bài "Design of a Distributed Task Scheduler", mục "Components" (Batching and prioritization) và bài "Design Considerations", mục "Prioritization" (delay tolerance) ; Redis in Action ↗ · ch.6 · §6.2.5 Locks with timeouts (tr. 126–127)
Trong phỏng vấn
- “Tôi trả 202 ngay và xử lý bất đồng bộ qua hai tầng: fan-out theo người nhận, rồi queue riêng theo kênh và độ ưu tiên để OTP không chờ marketing.”
- “Preference và giới hạn tần suất kiểm tra ở một chỗ duy nhất trước khi tạo Delivery, đọc từ Redis.”
- “Queue là at-least-once nên tôi dedup bằng idempotency key ở API và delivery_id xác định ở worker.”
- “Lỗi vĩnh viễn không retry; lỗi tạm thời thì backoff có jitter, circuit breaker theo provider và chuyển sang provider dự phòng.”
- Gọi provider đồng bộ trong request của service nội bộ.
- Dùng một queue chung cho mọi kênh nên SMS chậm chặn luôn push.
- Retry cả lỗi vĩnh viễn như token hết hạn, email bounce.
- Lưu offset múi giờ thay vì tên múi giờ, và cho 30 triệu job cùng đến hạn đúng một phút.
Đọc thêm
Sách trong thư viện của bạn. Số chương có thể lệch nhẹ so với bản PDF.