Ứng dụng chat realtime
Hiểu đề
Yêu cầu chức năng
- Người dùng có thể gửi tin nhắn 1-1 cho người khác
- Người dùng có thể chat trong nhóm (tối đa 1.000 thành viên)
- Người dùng nhận được tin gửi lúc mình offline khi mở lại app, đúng thứ tự
- Người dùng thấy trạng thái đã gửi, đã nhận, đã đọc
- Người dùng có thể gửi ảnh, video, file
Yêu cầu phi chức năng
- Tin đến người nhận đang online p95 < 300 ms trong cùng region
- Tin đã được server ack thì không mất (ghi ít nhất 2 bản sao trước khi ack)
- Thứ tự tin trong một cuộc trò chuyện giống nhau 100% trên mọi thiết bị
- Sẵn sàng 99,95%, gateway chết thì client tự kết nối lại trong < 10 s
- Đọc lại lịch sử 50 tin gần nhất p95 < 200 ms
Ngoài phạm vi: Gọi thoại, gọi video; Chi tiết giao thức mã hoá đầu cuối (chỉ nhắc ở mức ý tưởng); Tìm kiếm full-text trong lịch sử chat; Story, status, bot.
Thực thể và API
| Method | Endpoint | Ghi chú |
|---|---|---|
| WS | /v1/ws | Kết nối lâu dài. Frame: send, ack, read, typing. Server đẩy new_message, receipt. |
| POST | /v1/conversations | Tạo chat 1-1 hoặc nhóm, trả về conversation_id |
| GET | /v1/conversations?cursor= | Danh sách cuộc trò chuyện, sắp theo tin mới nhất, phân trang bằng cursor |
| GET | /v1/conversations/{id}/messages?after_seq=&limit=50 | Kéo tin bị lỡ hoặc cuộn lịch sử theo seq |
| POST | /v1/media/upload-url | Xin presigned URL để client upload thẳng lên blob store |
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) Người dùng có thể gửi tin nhắn 1-1 cho người khác
Hai người thường nối vào hai gateway khác nhau. Gateway của người gửi không biết người nhận ở đâu, nên cần một registry ghi user đang ở gateway nào và một kênh để chuyển tin giữa các gateway.
- 1Client A gửi frame send {conversation_id, client_msg_id, body} qua WebSocket
- 2Message service gán seq tiếp theo của cuộc trò chuyện, ghi DB, rồi ack cho A (trạng thái đã gửi)
- 3Tra registry thấy B đang ở gateway 7, đẩy tin sang gateway 7
- 4Gateway 7 gửi tin xuống B, B ack, server báo A trạng thái đã nhận
Nguồn: Grokking Modern System Design · ch.36 WhatsApp ↗ · Detailed Design of WhatsApp · mục "Connection with a WebSocket server" ; Grokking Modern System Design · ch.36 WhatsApp ↗ · Detailed Design of WhatsApp · mục "Send or receive messages" (WebSocket server tra thông tin người nhận) ; Grokking Modern System Design · ch.36 WhatsApp ↗ · High-level Design of WhatsApp · mục "API design" (Send message) ; Chưa có nguồn trong thư viện: seq theo conversation và connection registry riêng là thiết kế của khoá học (WhatsApp trong Grokking dùng WebSocket manager và Redis)
2) Người dùng nhận được tin gửi lúc mình offline khi mở lại app, đúng thứ tự
Tin luôn được lưu trước khi giao, nên offline chỉ là chưa giao. Mỗi thiết bị nhớ seq cuối đã nhận của từng cuộc trò chuyện, khi online lại thì kéo phần còn thiếu.
- 1Tra registry không thấy B, gửi push notification qua FCM/APNs
- 2B mở app, nối WebSocket, gọi sync với danh sách {conversation_id: last_seq}
- 3Server trả các tin có seq lớn hơn, client sắp theo seq và bỏ trùng
- 4Client ack, server cập nhật last_delivered_seq của thiết bị
Nguồn: Grokking Modern System Design · ch.36 WhatsApp ↗ · Detailed Design of WhatsApp · mục "Send or receive messages" (tin của người offline được lưu rồi giao qua push notification khi online lại, xóa sau 30 ngày) ; Grokking Modern System Design · ch.36 WhatsApp ↗ · Requirements of WhatsApp's Design · mục "Functional requirements" (Push notifications) ; Chưa có nguồn trong thư viện: đồng bộ theo last_seq mỗi thiết bị là thiết kế của khoá học
3) Người dùng có thể chat trong nhóm (tối đa 1.000 thành viên)
Tin nhóm chỉ lưu một lần theo conversation. Việc nhân bản ra từng thành viên chỉ xảy ra ở bước giao, chạy bất đồng bộ để người gửi không phải chờ.
- 1Message service lưu tin một lần với seq của nhóm và ack người gửi
- 2Đẩy sự kiện vào queue/Kafka, key là conversation_id
- 3Worker lấy danh sách thành viên (có cache), gom theo gateway rồi đẩy một lần cho mỗi gateway
- 4Thành viên offline nhận push notification gộp, khi mở app thì sync như chat 1-1
Nguồn: Grokking Modern System Design · ch.36 WhatsApp ↗ · Detailed Design of WhatsApp · mục "Support for group messages" (group message handler, group service, Kafka) ; Grokking Modern System Design · ch.17 Distributed Messaging Queue ↗ · mục "Motivation" (Improved performance, Granular scalability) ; Grokking Modern System Design · ch.18 Pub-sub ↗ · mục "High-level design"
4) Người dùng có thể gửi ảnh, video, file
File lớn không đi qua WebSocket. Client upload thẳng lên blob store bằng presigned URL, tin nhắn chỉ mang media_key.
- 1Client gọi /media/upload-url, nhận URL có hạn 15 phút
- 2Client upload lên S3/GCS, có thể kèm hash để bỏ file trùng
- 3Client gửi tin chứa media_key và thumbnail nhỏ
- 4Người nhận tải file qua CDN
Nguồn: Grokking Modern System Design · ch.36 WhatsApp ↗ · Detailed Design of WhatsApp · mục "Send or receive media files" ; Grokking Modern System Design · ch.20 Blob Store ↗ · mục "High-level design" ; Grokking Modern System Design · ch.11 CDN ↗ · mục "Pull CDN" ; Chưa có nguồn trong thư viện: presigned URL là tính năng của nhà cung cấp object storage
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.
Hai instance Node vừa giữ WebSocket vừa phục vụ REST, một Postgres managed và một Redis nhỏ để chuyển tin giữa hai instance. Tải còn rất nhỏ, ưu tiên làm đúng thứ tự và ack.
Ước lượng
- Tin nhắn: 10.000 × 40 tin / 86.400 ≈ 4,6 tin/s, đỉnh ×10 ≈ 46 tin/s
- Kết nối: 30% × 10.000 = 3.000 WebSocket, 1 instance chịu 20.000 → 1, giữ 2 để dự phòng
- HTTP (lịch sử, danh sách chat): 10.000 × 30 / 86.400 ≈ 3,5 req/s, đỉnh ×10 ≈ 35 req/s → ceil(35 / 600) = 1, tối thiểu 2
- Lưu trữ tin: 400.000 tin/ngày × 200 B = 80 MB/ngày × 365 ≈ 29 GB/năm
- Media: 5% × 400.000 = 20.000 ảnh × 200 KB = 4 GB/ngày ≈ 1,46 TB/năm trên S3
Nguồn con số: Grokking Modern System Design · ch.5 Back-of-the-envelope Calculations ↗ · mục "Examples of Resource Estimation" (Number of servers required, Storage requirements, Bandwidth requirements) ; Grokking Modern System Design · ch.36 WhatsApp ↗ · Requirements of WhatsApp's Design · mục "Resource estimation" (30 ngày lưu tin, 100 B mỗi tin, một server giữ khoảng 10 triệu kết nối nhờ tune sâu) ; Quy tắc ước lượng của khoá học · /hoc/chon-server (1.000 req/s mỗi máy 2 vCPU, chạy tối đa 60% CPU, Redis ×1,3) ; Chưa có nguồn trong thư viện: 40 tin/người/ngày và 30% đang online là giả định của khoá học
Bảng hạ tầng
| Thành phần | Chọn | Số lượng | Cấu hình | Vì sao |
|---|---|---|---|---|
| Load balancer | ALB / Cloud Load Balancing (hỗ trợ WebSocket) | 1 | managed | Kết nối WebSocket tự dính vào một instance suốt phiên, không cần sticky cookie. |
| Chat server | Node.js (NestJS + ws), Docker | 2 | 2 vCPU · 4 GB RAM | 3.000 kết nối và 46 tin/s chỉ dùng một phần nhỏ, instance thứ hai để không sập khi deploy. |
| Pub/sub | Redis managed | 1 | 1 GB | Khi A và B nằm ở hai instance khác nhau, tin đi qua kênh user:{id}. Kèm presence có TTL. |
| Database | PostgreSQL managed | 1 (+ backup hằng ngày) | 2 vCPU · 8 GB RAM · 100 GB | 29 GB/năm vừa thoải mái, last_seq của conversation tăng trong cùng transaction với insert tin. |
| Media | S3 / GCS, presigned URL | 1 bucket | ≈ 1,5 TB/năm | File không đi qua server, không bao giờ để trong DB. |
| Push | FCM / APNs | dịch vụ ngoài | miễn phí | Báo cho người offline có tin mới. |
- Điểm xuất phát
- Gộp WebSocket và REST vào một service cho dễ vận hành
- Dùng Redis pub/sub từ đầu vì đã có 2 instance
Gateway và logic nghiệp vụ chung một process: deploy là ngắt hết kết nối, và khi lên hàng trăm nghìn kết nối thì RAM gateway và một kênh Redis cho mỗi user bắt đầu đắt.
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.000.000 DAU | 50.000.000 DAU |
| Chi phí | ≈ 80–200 USD/tháng (ước lượng) | ≈ 3.000–6.000 USD/tháng (ước lượng) | ≈ 150.000–300.000 USD/tháng (ước lượng) |
| Load balancer | ALB / Cloud Load Balancing (hỗ trợ WebSocket) 1 × managed | NLB (L4) cho WebSocket, ALB cho REST 2 × managed | Chưa cần |
| Chat server | Node.js (NestJS + ws), Docker 2 × 2 vCPU · 4 GB RAM | Chưa cần | Chưa cần |
| Pub/sub | Redis managed 1 × 1 GB | Chưa cần | Chưa cần |
| Database | PostgreSQL managed 1 (+ backup hằng ngày) × 2 vCPU · 8 GB RAM · 100 GB | PostgreSQL managed, partition theo tháng 1 primary + 1 replica × 8 vCPU · 32 GB · 2 TB | Chưa cần |
| Media | S3 / GCS, presigned URL 1 bucket × ≈ 1,5 TB/năm | S3 + CDN 1 bucket + CDN × theo dung lượng | S3 + CDN, dedupe theo hash multi-region × theo dung lượng |
| Push | FCM / APNs dịch vụ ngoài × miễn phí | Chưa cần | Chưa cần |
| WS Gateway | Chưa cần | Node.js (uWebSockets.js), autoscale theo số kết nối 15 (autoscale 15–20) × 2 vCPU · 4 GB RAM | Node.js (uWebSockets.js) trên Kubernetes, HPA theo số kết nối 375 pod (125/region) × 2 vCPU · 4 GB RAM |
| Message service | Chưa cần | Node.js, gọi từ gateway qua gRPC 4 × 2 vCPU · 4 GB RAM | Node.js hoặc Go, gRPC 117 pod × 2 vCPU · 4 GB RAM |
| Chat API | Chưa cần | Node.js (NestJS) 3 × 2 vCPU · 4 GB RAM | Chưa cần |
| Redis | Chưa cần | ElastiCache / Memorystore, 1 primary + 1 replica 2 × 2 vCPU · 6 GB | Chưa cần |
| Queue | Chưa cần | SQS / Pub/Sub + push worker 1 queue, 2 worker × 2 vCPU · 4 GB RAM | Chưa cần |
| Edge | Chưa cần | Chưa cần | GeoDNS + NLB mỗi region 3 region × managed |
| Delivery worker | Chưa cần | Chưa cần | Consumer Kafka, gọi gRPC tới gateway 42 pod × 2 vCPU · 4 GB RAM |
| Redis Cluster | Chưa cần | Chưa cần | Registry, presence, seq, cache thành viên nhóm 12 shard + 12 replica × 4 vCPU · 16 GB |
| Message store | Chưa cần | Chưa cần | ScyllaDB, key (conversation_id, bucket), clustering seq 120 node (40/region) × 16 vCPU · 128 GB · 7,5 TB NVMe |
| Kafka | Chưa cần | Chưa cần | Topic messages key = conversation_id, topic push 6 broker, 48 partition × 8 vCPU · 32 GB |
| User/Group DB | Chưa cần | Chưa cần | PostgreSQL, primary + replica mỗi region 1 primary + 3 replica × 16 vCPU · 64 GB |
Đà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) Làm sao giao tin cho người đang online khi họ nối vào một trong hàng trăm gateway?
Client gọi API hỏi có tin mới không. Đơn giản nhưng phần lớn request trả về rỗng, độ trễ bằng chu kỳ poll.
Đánh đổi: 1 triệu người poll mỗi 3 s là 333.000 req/s chỉ để nghe ngóng, tốn tiền và vẫn chậm.
Nguồn: Grokking Modern System Design · ch.18 Pub-sub ↗ · mục "Motivation" (đẩy thay vì hỏi liên tục) ; Grokking Modern System Design · ch.36 WhatsApp ↗ · Detailed Design of WhatsApp · mục "Connection with a WebSocket server" ; Grokking Modern System Design · ch.5 Back-of-the-envelope Calculations ↗ · mục "Examples of Resource Estimation" (Number of servers required, Storage requirements, Bandwidth requirements) (phép tính req/s)
Mỗi gateway subscribe kênh user:{id} cho từng người đang nối vào nó. Message service publish vào kênh của người nhận, gateway nào giữ kết nối sẽ nhận được.
Đánh đổi: Dễ làm, nhưng số kênh bằng số kết nối và Redis pub/sub không lưu tin, gateway chết giữa chừng là mất bản đẩy (vẫn còn trong DB).
Nguồn: Grokking Modern System Design · ch.18 Pub-sub ↗ · mục "High-level design" (broker, topic) ; Grokking Modern System Design · ch.36 WhatsApp ↗ · Detailed Design of WhatsApp · mục "Connection with a WebSocket server" ; Chưa có nguồn trong thư viện: Redis pub/sub không có độ bền tin và số kênh bằng số kết nối là nhận xét của khoá học (Redis in Action ch.3 §3.6 Publish/subscribe chỉ mô tả cơ chế)
Khi kết nối, gateway ghi user/device → gateway_id vào Redis với TTL và gia hạn bằng heartbeat. Worker giao tin tra registry, gom người nhận theo gateway và gọi gRPC hoặc publish vào kênh gw:{id} của đúng gateway đó. Gateway cache kết quả tra cứu và nhận tín hiệu xoá cache khi user đổi gateway.
Đánh đổi: Thêm một hệ thống phải giữ đúng (registry có thể cũ vài giây), nhưng chính DB mới là nguồn sự thật nên tin không mất, chỉ có thể đến chậm qua đường sync.
Nguồn: Grokking Modern System Design · ch.36 WhatsApp ↗ · Detailed Design of WhatsApp · mục "Connection with a WebSocket server" (WebSocket manager và Redis lưu ánh xạ người dùng - server) ; Redis in Action ↗ · ch.5 · §5.4 Service discovery and configuration (tr. 104) ; Chưa có nguồn trong thư viện: gia hạn TTL bằng heartbeat và gRPC giữa worker và gateway là thiết kế của khoá học
2) Người nhận offline hoặc có nhiều thiết bị: làm sao không mất tin và đúng thứ tự?
Đồng hồ điện thoại lệch nhau nên thứ tự sai. Gateway restart là mất hàng đợi.
Đánh đổi: Không cần DB cho tin chờ, đổi lại mất tin và thứ tự lộn xộn.
Nguồn: Grokking Modern System Design · ch.36 WhatsApp ↗ · Requirements of WhatsApp's Design · mục "Non-functional requirements" (Consistency: tin phải theo thứ tự gửi) ; Grokking Modern System Design · ch.12 Sequencer ↗ · mục "Use UNIX time stamps" (Cons: đồng hồ lệch) và "Causality"
Lưu tin vào bảng inbox của người nhận, giao xong thì xoá, giống cách WhatsApp trong Grokking giữ tin tối đa 30 ngày trên server. ID tin sinh bởi server (Snowflake) nên thứ tự toàn cục gần đúng.
Đánh đổi: Hợp với app không lưu lịch sử trên server, nhưng nhóm 1.000 người phải ghi 1.000 bản và thiết bị thứ hai không đọc lại được lịch sử.
Nguồn: Grokking Modern System Design · ch.36 WhatsApp ↗ · Detailed Design of WhatsApp · mục "Send or receive messages" (tin offline được lưu rồi xóa sau 30 ngày) ; Grokking Modern System Design · ch.12 Sequencer ↗ · mục "Twitter Snowflake" (ID tăng theo thời gian)
Mỗi tin có seq liên tục trong cuộc trò chuyện (Redis INCR hoặc cột last_seq trong cùng transaction). Mỗi thiết bị giữ last_delivered_seq theo conversation, khi kết nối lại thì gọi sync after_seq. Client gửi kèm client_msg_id để retry không tạo tin trùng, thấy seq bị hổng thì tự kéo lại.
Đánh đổi: Seq chỉ đúng trong một conversation, không có thứ tự toàn cục giữa các cuộc trò chuyện (và không cần). Bộ đếm seq của nhóm rất sôi động là điểm nóng phải theo dõi.
Nguồn: Grokking Modern System Design · ch.36 WhatsApp ↗ · Evaluation of WhatsApp's Design · mục "The trade-off between consistency and availability" ; Grokking Modern System Design · ch.12 Sequencer ↗ · mục "Lamport clocks" ; Redis in Action ↗ · ch.3 · §3.1 Strings (INCR, tr. 40) ; Chưa có nguồn trong thư viện: seq liên tục theo conversation và client_msg_id chống trùng là thiết kế của khoá học
3) Nhóm lớn, đã đọc và trạng thái online: làm sao không làm nổ tải?
Nhóm 1.000 người: 1 tin thành 1.000 bản ghi. Mỗi lượt đọc gửi receipt cho 999 người còn lại, mỗi lần ai đó online/offline báo cho mọi liên hệ.
Đánh đổi: Một nhóm sôi nổi 10 tin/phút sinh khoảng 10 × 1.000 × 999 ≈ 10 triệu receipt/phút.
Nguồn: Grokking Modern System Design · ch.36 WhatsApp ↗ · Detailed Design of WhatsApp · mục "Support for group messages" ; Grokking Modern System Design · ch.17 Distributed Messaging Queue ↗ · mục "Motivation" (Improved performance, Granular scalability) ; Chưa có nguồn trong thư viện: phép tính 10 triệu receipt/phút là ví dụ minh họa của khoá học
Tin nhóm ghi một lần theo conversation. Worker đọc danh sách thành viên từ cache, gom theo gateway rồi đẩy. Receipt lưu dạng last_read_seq cho mỗi thành viên, không lưu theo từng tin.
Đánh đổi: Ghi DB giảm mạnh, nhưng realtime receipt cho nhóm lớn vẫn tốn băng thông.
Nguồn: Grokking Modern System Design · ch.36 WhatsApp ↗ · Detailed Design of WhatsApp · mục "Support for group messages" (danh sách thành viên từ group service có Redis cache) ; Grokking Modern System Design · ch.16 Distributed Cache ↗ · Background of Distributed Cache · mục "Writing policies" (write-around: ghi vào cache sau lần trượt)
Đã đọc chỉ là một số last_read_seq, client gửi gộp mỗi vài giây. Nhóm trên 100 người không đẩy receipt realtime, chỉ hiện số người đã đọc khi mở chi tiết tin. Presence dùng heartbeat có TTL trong Redis và chỉ đẩy cho người đang mở đúng cuộc trò chuyện, phần còn lại hỏi khi cần.
Đánh đổi: Trạng thái đã đọc và online trong nhóm lớn chậm vài giây hoặc phải bấm mới thấy, đổi lại tải giảm cả trăm lần.
Nguồn: Grokking Modern System Design · ch.36 WhatsApp ↗ · Evaluation of WhatsApp's Design · mục "Fulfill the requirements" ; Chưa có nguồn trong thư viện: watermark last_read_seq và ngưỡng 100 người là thiết kế của khoá học
4) Lưu hàng tỷ tin nhắn mỗi ngày ở đâu?
Ổn ở vài chục GB. Khi lên vài TB thì index không còn vừa RAM, vacuum và backup kéo dài hàng giờ.
Đánh đổi: Đơn giản nhất nhưng có trần rõ ràng ở khoảng 1-2 TB trên một node.
Nguồn: Grokking Modern System Design · ch.4 Non-functional System Characteristics ↗ · Scalability · mục "Vertical scalability--scaling up" và "Horizontal scalability--scaling out" ; Grokking Modern System Design · ch.9 Databases ↗ · Data Partitioning · mục "Why do we partition data?" ; Quy tắc ước lượng của khoá học · /hoc/chon-server (1.000 req/s mỗi máy 2 vCPU, chạy tối đa 60% CPU, Redis ×1,3)
Partition theo thời gian, giữ nóng vài tháng, partition cũ đẩy sang object storage. Index (conversation_id, seq) nằm trong từng partition.
Đánh đổi: Kéo dài được tuổi thọ Postgres ở mức 1M DAU, nhưng vẫn một primary nhận toàn bộ lượng ghi.
Nguồn: Grokking Modern System Design · ch.9 Databases ↗ · Data Partitioning · mục "Sharding" (Key-range based sharding theo thời gian) ; Grokking Modern System Design · ch.20 Blob Store ↗ · mục "Replication" ; Chưa có nguồn trong thư viện: partition theo tháng của PostgreSQL
Truy cập luôn là đọc một cuộc trò chuyện theo seq, đúng kiểu wide-row. Bucket theo tháng hoặc theo 10.000 tin để partition không phình quá lớn. Ghi phân tán đều trên nhiều node, thêm node là tăng cả dung lượng và throughput. Thông tin user, nhóm vẫn để Postgres vì cần quan hệ và transaction.
Đánh đổi: Mất JOIN và transaction nhiều dòng, phải thiết kế bảng theo truy vấn và chấp nhận vận hành một cụm NoSQL.
Nguồn: Grokking Modern System Design · ch.9 Databases ↗ · mục "Types of NoSQL databases" (Columnar database) ; Grokking Modern System Design · ch.9 Databases ↗ · Data Partitioning · mục "Sharding" (Key-range based sharding, Hash-based sharding, Consistent hashing) (hash-based sharding) ; Grokking Modern System Design · ch.10 Key-value Store ↗ · Design of a Key-value Store · mục "Consistent hashing" và "Use virtual nodes" ; Chưa có nguồn trong thư viện: wide-row, bucket của Cassandra/ScyllaDB không có sách trong thư viện
Trong phỏng vấn
- “Tôi luôn lưu tin rồi mới giao. Đẩy realtime chỉ là đường nhanh, sync theo seq là đường đảm bảo.”
- “Thứ tự chỉ cần đúng trong một cuộc trò chuyện, nên tôi dùng seq theo conversation thay vì thứ tự toàn cục.”
- “Gateway chỉ giữ kết nối, mọi logic nằm ở service phía sau để deploy không làm rớt hàng triệu kết nối.”
- “Đã đọc lưu thành một watermark last_read_seq cho mỗi thành viên, không lưu theo từng tin.”
- Sắp tin theo timestamp của điện thoại
- Ghi một bản tin cho mỗi thành viên nhóm và broadcast mọi receipt
- Quên client retry: thiếu client_msg_id thì mạng chập chờn sinh tin trùng
- Cho file media chạy qua WebSocket hoặc lưu trong DB
Đọ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.