Harbor

Hệ thống thông báo

Trung bìnhFan-out đa kênhQueue & retryIdempotencyHẹn giờGiống Firebase Cloud Messaging, Amazon SNS, OneSignal

Hiểu đề

Hệ thống thông báo là gì? Dịch vụ dùng chung trong công ty để gửi thông báo qua push, email, SMS và hộp thư in-app. Các team khác chỉ gọi một API; ví dụ app ví điện tử gửi OTP, báo biến động số dư và chạy chiến dịch khuyến mãi cho hàng triệu người.

Yêu cầu chức năng

  1. 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)
  2. 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
  3. 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
  4. 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

Notification
Một yêu cầu gửi từ service nội bộ: idempotency key, template, dữ liệu, người nhận (danh sách hoặc segment), category, độ ưu tiên, send_at.
Template
Nội dung theo kênh và ngôn ngữ (tiêu đề push, HTML email, text SMS ≤ 160 ký tự) với biến cần điền.
UserPreference
Kênh bật/tắt theo category, giờ yên lặng, múi giờ (tên IANA như Asia/Ho_Chi_Minh), ngôn ngữ.
DeviceToken
Token push của từng thiết bị (FCM/APNs), nền tảng, lần cuối hoạt động; xóa khi provider báo token hết hạn.
Delivery
Một lần gửi tới một người qua một kênh: delivery_id xác định, trạng thái QUEUED/SENDING/SENT/DELIVERED/FAILED, provider, số lần thử.
InboxItem
Thông báo hiện trong app: user_id, thời gian, nội dung rút gọn, đã đọc hay chưa.
MethodEndpointGhi chú
POST/v1/notificationsBody {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/preferencesCập nhật kênh theo category, giờ yên lặng, múi giờ.
POST/v1/users/me/devicesApp đă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}/callbacksProvider 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.

  1. 1Service gọi POST /v1/notifications kèm idempotencyKey
  2. 2API ghi Notification, đẩy notificationId vào queue nhận, trả 202
  3. 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
  4. 4Mỗi người × kênh hợp lệ thành một Delivery, đẩy vào queue của kênh đó
  5. 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.

  1. 1App gọi PUT /preferences, API ghi DB rồi xóa cache của user đó
  2. 2Fan-out worker đọc preference từ cache, thiếu thì đọc DB
  3. 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
  4. 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ọ.

  1. 1API lưu Notification với send_at, không đẩy vào queue ngay
  2. 2Scheduler quét các job đến hạn theo index send_at mỗi vài giây
  3. 3Job đến hạn được đẩy vào queue nhận và đi tiếp luồng bình thường
  4. 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.

  1. 1Channel worker in-app ghi InboxItem và tăng bộ đếm chưa đọc
  2. 2App mở màn hình thông báo, gọi GET /inbox?cursor=
  3. 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.

Người dùng
10.000 DAU
Tải đỉnh
≈ 85 lượt gửi/s khi chạy chiến dịch
Chi phí ước lượng
≈ 50–120 USD/tháng hạ tầng (ước lượng, chưa tính phí SMS/email theo lượng)

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.

Người dùngService nội bộNgười dùngApp người dùngDịch vụAPI Node.jsDatabasePostgres + jobWorkerWorker gửiBên ngoàiFCM / APNsBên ngoàiEmail (SES)Bên ngoàiSMS brandnamePOST /notifyinbox, preferenceghi jobSKIP LOCKEDOTP

Ướ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ầnChọnSố lượngCấu hìnhVì sao
API + workerNode.js (NestJS), Docker trên Cloud Run22 vCPU · 4 GB RAM83 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 queuePostgreSQL managed + pg-boss12 vCPU · 8 GB · 50 GB SSDBảng job hỗ trợ hẹn giờ, retry và SKIP LOCKED; 2,25 GB dữ liệu là rất nhỏ.
PushFirebase Cloud Messaging (Android) + APNs (iOS)1Dịch vụ bên ngoàiMiễn phí, là con đường duy nhất để đẩy tin tới điện thoại.
EmailAmazon SES / SendGrid1Dịch vụ bên ngoàiVài nghìn email mỗi ngày, có sẵn báo bounce qua webhook.
SMSNhà cung cấp SMS brandname1Dịch vụ bên ngoài, gọi qua HTTPChỉ cho OTP; chưa cần provider dự phòng ở quy mô này.
Thay đổi so với mức trước
  • Đ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
Nút thắt tiếp theo

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ầnMức 1Mức 2Mức 3
Người dùng10.000 DAU1 triệu DAU50 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ầnChưa cần
Database + job queue
PostgreSQL managed + pg-boss
1 × 2 vCPU · 8 GB · 50 GB SSD
Chưa cầnChưa cần
Push
Firebase Cloud Messaging (Android) + APNs (iOS)
1 × Dịch vụ bên ngoài
Chưa cầnChưa cần
Email
Amazon SES / SendGrid
1 × Dịch vụ bên ngoài
Chưa cầnChư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ầnChưa cần
Notification APIChưa cần
Node.js (NestJS), container autoscale
2–4 (autoscale) × 2 vCPU · 4 GB RAM
Chưa cần
QueueChư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 workerChư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 workerChư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
SchedulerChư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
RedisChưa cần
ElastiCache / Memorystore
1 primary + 1 replica × 2 vCPU · 8 GB
Chưa cần
DatabaseChưa cần
PostgreSQL managed (HA)
1 primary + 1 read replica × 4 vCPU · 16 GB · 500 GB SSD
Chưa cần
ProviderChư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 APIChưa cầnChưa cần
Node.js trên Kubernetes, 3 AZ
14 pod (HPA) × 2 vCPU · 4 GB mỗi pod
Inbox APIChưa cầnChưa cần
Node.js trên Kubernetes
29 pod (HPA) × 2 vCPU · 4 GB mỗi pod
KafkaChưa cầnChư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 ClusterChưa cầnChưa cần
ElastiCache Redis Cluster
6 shard × (1 primary + 1 replica) × 4 vCPU · 32 GB mỗi node
Delivery + Inbox storeChưa cầnChư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?

Cách chưa tốt
API lặp qua người nhận và gọi provider đồng bộ

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)

Cách tốt
Một queue chung, worker xử lý mọi kênh

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)

Cách tốt nhất
Hai tầng: fan-out worker và queue riêng theo kênh × độ ưu tiên

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?

Cách chưa tốt
Mỗi team tự kiểm tra preference trước khi 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?"

Cách tốt
Kiểm tra tập trung ở fan-out, đọc preference từ DB

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)

Cách tốt nhất
Preference cache trong Redis + giới hạn tần suất theo người và category

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?

Cách chưa tốt
Retry ngay lập tức cho đến khi thành công

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)

Cách tốt
Exponential backoff có jitter, giới hạn số lần, dead-letter queue

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

Cách tốt nhất
Phân loại lỗi + circuit breaker theo provider + provider dự phòng

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?

Cách chưa tốt
Tin rằng queue giao đúng một 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)

Cách tốt
Idempotency key ở API

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)

Cách tốt nhất
Dedup hai lớp: idempotency key ở API + delivery_id xác định ở worker

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?

Cách chưa tốt
setTimeout trong process hoặc cron quét toàn bảng mỗi phút

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?"

Cách tốt
Bảng job có index send_at, poller dùng SKIP LOCKED

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

Cách tốt nhất
Tính send_at UTC theo múi giờ từng người, chia bucket theo phút, rải đều

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

Nên nói
  • “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.”
Bẫy hay gặp
  • 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

Kafka
Kafka là log phân tán bền vững: topic chia partition, consumer đọc theo offset và có thể đọc lại. Bài này đi qua thứ tự theo partition, consumer group, replication với ISR và acks, giới hạn của exactly-once, cách chọn số partition và so sánh với SQS, Pub/Sub, RabbitMQ.
Redis
Redis là kho key-value trong RAM với nhiều cấu trúc dữ liệu: dùng làm cache, bộ đếm, leaderboard, hàng đợi, GEO và pub/sub. Bài này đi qua mô hình dữ liệu, persistence, replication, Cluster, Lua và cách tính RAM.
Cassandra
Cassandra là database wide-column phân tán, ghi rất nhanh nhờ LSM tree, mở rộng gần tuyến tính bằng ring và consistent hashing. Bài này giải thích partition key, clustering column, cách mô hình hoá theo câu truy vấn, tunable consistency và những cái bẫy như tombstone.
Scale ghi
Khi lượng ghi vượt sức một database: gom batch, ghi bất đồng bộ qua hàng đợi, chọn storage tối ưu cho ghi, chia dữ liệu theo key, xử lý partition nóng bằng salting, sharded counter và tổng hợp trước. Kèm con số cho từng quy mô.
Tác vụ chạy lâu
Cách xử lý việc mất vài giây tới vài giờ mà không giữ request: nhận job, trả 202 kèm job id, worker xử lý, báo kết quả qua polling, webhook hoặc SSE. Kèm hàng đợi, retry có backoff và jitter, DLQ, idempotency, heartbeat, lập lịch, workflow engine và cách tính số worker.
API Gateway
API gateway là cửa vào duy nhất cho client: định tuyến, xác thực, rate limit, gộp response và kết thúc TLS. Bài này phân biệt gateway với load balancer và service mesh, giải thích pattern BFF, so sánh Kong, Envoy, AWS API Gateway, Nginx và cách tính overhead, sizing.

Sách trong thư viện của bạn. Số chương có thể lệch nhẹ so với bản PDF.