Cập nhật realtime
Đẩy thay đổi tới client trong vài trăm mili giây: chọn giữa polling, long polling, SSE, WebSocket, rồi fan-out qua nhiều gateway, xử lý reconnect và backpressure.
Bài toán: server có dữ liệu mới, client chưa biết
HTTP thông thường đi một chiều: client hỏi, server trả lời; HTTP(S) không giữ kết nối mở để server chủ động gửi dữ liệu xuống.1 Node.js in Practice mô tả đúng nhu cầu này: server biết có thư mới nhưng trình duyệt không có cách dễ dàng để biết, trừ khi hỏi định kỳ.2 Rất nhiều sản phẩm lại cần chiều ngược lại. Tin nhắn chat vừa tới, tài xế vừa đổi vị trí, giá cổ phiếu vừa nhảy, bình luận mới trong buổi livestream. Người dùng muốn thấy thay đổi trong khoảng 100 ms tới 1 s, không muốn bấm F5.
Pattern này giải hai việc tách biệt:
- Kênh client ↔ server: giữ một đường truyền để server chủ động gửi dữ liệu xuống.
- Fan-out phía server: khi có sự kiện, tìm đúng những kết nối đang quan tâm và đẩy tới chúng, dù các kết nối đó nằm rải rác trên hàng chục máy gateway.
Grokking Design WhatsApp cũng tách hai việc này: mỗi thiết bị nối với một WebSocket server, và một "WebSocket manager" lưu ánh xạ giữa server, cổng và người dùng.1 Phần lớn ứng viên chỉ nói tới việc thứ nhất ("dùng WebSocket"). Người phỏng vấn thường đào vào việc thứ hai: user A nối vào gateway 3, user B nối vào gateway 17, tin nhắn của A tới B đi đường nào?
Cách ngây thơ: client hỏi liên tục
Cách đơn giản nhất là short polling. Client gọi GET /messages?since=... mỗi 2 giây.
setInterval(async () => {
const res = await fetch(`/api/messages?since=${lastId}`);
const items = await res.json();
if (items.length) { render(items); lastId = items.at(-1).id; }
}, 2000);
Cách này chạy được và dễ debug. Vấn đề nằm ở chi phí:
- Tốn tài nguyên vô ích. Grokking nhận xét polling liên tục tốn tài nguyên và gây độ trễ.1 Phép tính: 100.000 người đang mở app, poll 2 s một lần, tạo ra 50.000 req/s. Đa số request trả về mảng rỗng. Theo quy tắc khoảng 1.000 req/s cho một instance Node.js 2 vCPU (quy tắc ước lượng của khoá học, xem Chọn server),3 bạn cần khoảng 80 instance chỉ để trả lời "chưa có gì".
- Độ trễ trung bình bằng nửa chu kỳ. Poll 2 s nghĩa là trung bình chậm 1 s, tệ nhất 2 s. Giảm chu kỳ thì tải tăng tỉ lệ nghịch.
- Database bị đập liên tục bởi query
since=..., kể cả khi không ai gửi gì.
Short polling vẫn hợp lý khi dữ liệu đổi chậm (trạng thái đơn hàng, cập nhật mỗi vài phút) và số người dùng nhỏ (nhận định của khoá học). Với mọi thứ cần dưới 1 s, ta cần kênh đẩy thật sự.
Giải pháp 1: Long polling và Server-Sent Events
Phần SSE, long polling và WebRTC dưới đây là kiến thức giao thức web mà thư viện sách không có chương riêng; Node.js Design Patterns ch.9 chỉ giải thích vì sao Node.js giữ được nhiều kết nối treo: khi gọi thao tác bất đồng bộ, stack trả quyền về event loop nên process rảnh để phục vụ request khác.4 Các chi tiết giao thức (header Last-Event-ID, giới hạn kết nối HTTP/1.1, STUN/TURN) chưa có nguồn trong thư viện.5
Long polling
Client gửi request, server giữ request mở cho tới khi có dữ liệu mới hoặc hết timeout (ví dụ 30 s). Trả lời xong, client gửi ngay request kế tiếp.
- Ưu điểm: chỉ dùng HTTP thường, đi qua mọi proxy và firewall công ty.
- Nhược điểm: mỗi lần nhận dữ liệu tốn một vòng request mới (header, TLS nếu không keep-alive). Nếu hai sự kiện đến sát nhau, sự kiện thứ hai phải chờ request mới. Server phải giữ nhiều request treo, cần runtime xử lý I/O bất đồng bộ tốt. Node.js làm tốt việc này.
Server-Sent Events (SSE)
SSE là một response HTTP không bao giờ kết thúc, content type text/event-stream. Server ghi từng dòng data: ... khi có sự kiện. Trình duyệt có sẵn EventSource, tự reconnect và tự gửi header Last-Event-ID khi nối lại.
// Express: endpoint SSE tối giản
app.get("/stream", async (req, res) => {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});
const lastId = req.header("Last-Event-ID");
for (const ev of await loadEventsAfter(req.user.id, lastId)) {
res.write(`id: ${ev.id}\ndata: ${JSON.stringify(ev)}\n\n`); // gửi bù phần bị lỡ
}
const unsubscribe = bus.subscribe(req.user.id, (ev) =>
res.write(`id: ${ev.id}\ndata: ${JSON.stringify(ev)}\n\n`));
req.on("close", unsubscribe);
});
SSE hợp với luồng một chiều server → client: feed, thông báo, bảng giá, bình luận live, token streaming của chatbot. Client gửi gì lên thì dùng POST thường. Với HTTP/2, nhiều stream SSE dùng chung một kết nối TCP nên giới hạn 6 kết nối mỗi domain của HTTP/1.1 không còn là vấn đề.
Giải pháp 2: WebSocket, và WebRTC khi cần peer-to-peer
Grokking Design WhatsApp chọn WebSocket thay vì HTTP(S) vì WebSocket giữ kết nối bền vững giữa client và server, đẩy dữ liệu xuống ngay khi có và là kênh hai chiều.1 Node.js in Practice mô tả WebSocket như một cầu nối hai chiều giữa client và server, sống lâu, không có endpoint hay method như HTTP, và thường chạy cạnh server HTTP thường trong cùng ứng dụng.2
WebSocket
WebSocket bắt đầu bằng một request HTTP Upgrade, sau đó trở thành kênh TCP hai chiều, gửi frame nhỏ với overhead vài byte. Đây là lựa chọn mặc định cho chat, game nhiều người, cộng tác thời gian thực, ứng dụng giao dịch.
Cái giá phải trả:
- Stateful. Mỗi kết nối gắn với một process cụ thể, nên phải có nơi ghi user đang nối vào server nào (xem phần connection registry bên dưới).1 Load balancer phải hỗ trợ kết nối dài (L4, hoặc L7 có hỗ trợ upgrade), và deploy phải drain kết nối từ từ.5
- Tự làm nhiều thứ. Không có reconnect, không có ID sự kiện, không có heartbeat sẵn. Bạn tự định nghĩa protocol: ping/pong, ack, số thứ tự.
- Khó cache, khó quan sát bằng công cụ HTTP thông thường.
WebRTC
WebRTC dùng cho media và dữ liệu đi thẳng giữa hai trình duyệt (UDP, có NAT traversal qua STUN/TURN). Hợp với gọi video, chia sẻ màn hình, game cần độ trễ cực thấp giữa hai người. Server vẫn cần một kênh signaling, thường là WebSocket, để hai bên trao đổi thông tin kết nối. Trong đa số bài phỏng vấn, chỉ nhắc WebRTC khi có video/voice.
So sánh
| Kỹ thuật | Chiều | Độ trễ | Độ phức tạp server | Hợp với |
|---|---|---|---|---|
| Short polling | client hỏi | nửa chu kỳ poll | thấp | dữ liệu đổi chậm, ít user |
| Long polling | gần như đẩy | thấp, chậm khi dồn sự kiện | trung bình | môi trường chặn WebSocket |
| SSE | server → client | thấp | trung bình | feed, thông báo, live comment, streaming LLM |
| WebSocket | hai chiều | rất thấp | cao | chat, game, cộng tác, trading |
| WebRTC | peer ↔ peer | thấp nhất | cao, cần TURN | video, voice |
Bảng này là tổng hợp của khoá học từ các nguồn ở trên; các cột "độ trễ" và "độ phức tạp" là nhận định, không phải số đo từ sách.5
Trong phỏng vấn: Nói rõ vì sao chọn. "Bình luận live chủ yếu đi một chiều, mình chọn SSE để dùng lại hạ tầng HTTP và có reconnect miễn phí. Gửi bình luận thì dùng POST." Câu này cho thấy bạn không mặc định WebSocket cho mọi thứ.
Phía server: fan-out qua nhiều gateway
Khi có hơn một máy gateway, sự kiện sinh ra ở service A cần tới đúng kết nối trên gateway B. Có hai mảnh ghép: biết user đang ở đâu, và chuyển message tới đó. Node.js Design Patterns ch.11 chỉ ra đúng vấn đề này với ứng dụng chat: chạy hai instance thì tin nhắn chỉ tới client nối vào chính instance đó, hai server không "nói chuyện" với nhau, nên cần tích hợp chúng.6
Connection registry
Mỗi gateway khi nhận kết nối ghi user:{id} → gateway-17 vào Redis, kèm TTL vài chục giây và gia hạn bằng heartbeat. Khi gateway chết, TTL hết hạn và bản ghi tự biến mất. Service gửi tin tra registry để biết user có online không và nằm ở gateway nào, rồi gửi thẳng tới kênh riêng của gateway đó (gw:17). Cách này tiết kiệm băng thông khi mỗi message chỉ có vài người nhận, như chat 1-1. Grokking Design WhatsApp mô tả cùng ý tưởng: WebSocket manager nằm trên cụm Redis lưu ánh xạ user với WebSocket server, người gửi hỏi manager để tìm server của người nhận, và mỗi WebSocket server cache thông tin hội thoại gần đây, được manager vô hiệu hoá cache khi user đổi server.7 TTL vài chục giây gia hạn bằng heartbeat là cách làm của khoá học (sách chỉ nói manager cập nhật khi user kết nối sang server khác).3
Pub/sub theo topic
Cách thứ hai: mỗi gateway subscribe vào các topic mà client của nó quan tâm (room:42, match:991). Service chỉ cần PUBLISH room:42 payload, ai đang nghe thì nhận. Redis in Action §3.6 mô tả cơ chế PUBLISH/SUBSCRIBE này: người nghe đăng ký vào channel, publisher gửi vào channel, ai đang nghe channel thì nhận mọi message gửi tới trong lúc còn kết nối.8 Node.js Design Patterns ch.11 có ví dụ chat dùng Redis làm broker giữa nhiều instance WebSocket: mỗi instance vừa publish message nhận từ client của mình lên broker, vừa subscribe để nhận message từ instance khác, rồi phát tới các client đang nối.9
// Gateway: một subscriber Redis cho cả process, nhiều socket bên dưới
const sub = redis.duplicate();
const rooms = new Map<string, Set<WebSocket>>();
function join(ws: WebSocket, room: string) {
if (!rooms.has(room)) { rooms.set(room, new Set()); sub.subscribe(`room:${room}`); }
rooms.get(room)!.add(ws);
}
sub.on("message", (channel, payload) => {
for (const ws of rooms.get(channel.slice(5)) ?? []) ws.send(payload);
});
Redis pub/sub hay Kafka?
Cột Redis dựa trên Redis in Action §3.6 (không lưu message cho subscriber offline) và con số 100.000 thao tác/s ở §6.2.2;810 phần Kafka và các so sánh độ trễ, số topic chưa có nguồn trong thư viện.11
| Redis pub/sub | Kafka | |
|---|---|---|
| Lưu message | không, subscriber offline là mất | có, giữ theo retention |
| Độ trễ | dưới 1 ms trong DC | vài ms tới vài chục ms |
| Số topic | rất nhiều channel, rẻ | partition đắt, không làm topic theo từng phòng |
| Replay khi reconnect | phải lưu riêng | đọc lại theo offset |
| Hợp với | fan-out tới gateway | luồng sự kiện bền, nguồn để fan-out |
Thiết kế hay gặp: service ghi sự kiện vào Kafka (bền, có thứ tự theo partition), một lớp fan-out worker đọc Kafka rồi publish sang Redis pub/sub hoặc gửi thẳng tới gateway theo registry. Kafka lo độ bền, Redis lo tốc độ phát.
Lưu ý: Redis pub/sub là fire-and-forget. Redis in Action nói rõ: nếu subscriber bị ngắt kết nối khi có message được gửi thì nó không nhận được message đó, và đó là lý do sách xây phương thức giao message đáng tin cậy riêng ở ch.6.8 Nếu gateway mất kết nối Redis 3 giây, mọi message trong 3 giây đó biến mất. Luôn có nguồn lưu bền (database, Kafka) để client lấy bù. Grokking Design WhatsApp lưu tin cho người dùng offline trong một queue trên cụm Mnesia và gửi lại theo thứ tự khi họ online.12 Redis Streams chưa có nguồn trong thư viện.11
Reconnect, catch-up và backpressure
Reconnect và catch-up
Kết nối di động rớt liên tục: vào thang máy, đổi Wi-Fi sang 4G, app chạy nền. Về phía sách, Grokking Design WhatsApp nói khi một WebSocket server hỏng, phiên được tạo lại qua load balancer tới server khác, và tin của người offline được gửi sau theo thứ tự khi họ online;12 việc thứ tự cần ID do Sequencer cấp cho mỗi tin.12 Các bước client dưới đây là cách làm của khoá học (backoff, jitter, ID sự kiện cuối cùng chưa có sách riêng).5 Client phải:
- Reconnect với exponential backoff có jitter (1 s, 2 s, 4 s… cộng ngẫu nhiên). Không có jitter, khi một gateway restart, 30.000 client nối lại cùng một giây và đánh sập gateway kế bên.
- Gửi kèm ID sự kiện cuối cùng đã nhận. SSE làm sẵn qua
Last-Event-ID. WebSocket thì tự gửi trong message đầu tiên. - Server đọc phần còn thiếu từ nguồn bền (bảng messages, Redis Stream, Kafka offset), gửi bù, rồi mới chuyển sang chế độ live.
ID sự kiện cần tăng dần theo từng luồng (từng phòng, từng user). Có thể dùng sequence trong database, ID của Redis Stream, hoặc offset Kafka. Nếu khoảng trống quá lớn (offline 3 ngày), trả về "hãy tải lại toàn bộ" thay vì phát lại hàng nghìn sự kiện.
Heartbeat
Heartbeat là kỹ thuật vận hành chưa có nguồn trong thư viện.5 Proxy và NAT cắt kết nối im lặng sau 30 tới 120 giây. Gửi ping mỗi 20 tới 30 giây. Server đóng socket nếu không nhận pong sau 2 chu kỳ, xóa khỏi registry.
Backpressure
Một client mạng yếu đọc chậm hơn tốc độ server ghi. Nếu gateway cứ ws.send(), buffer trong RAM phình lên cho tới khi process hết bộ nhớ. Node.js Design Patterns ch.5 giải thích cơ chế này với stream: nếu stream không báo ngược cho bên ghi thì dữ liệu cứ tích vào buffer nội bộ và bộ nhớ phình lên; write() trả false khi buffer vượt highWaterMark, và bên ghi phải đợi sự kiện drain, gọi là back-pressure.13 Với WebSocket, kiểm tra ws.bufferedAmount:
const MAX_BUFFER = 1_000_000; // 1 MB mỗi kết nối
function safeSend(ws: WebSocket, payload: string) {
if (ws.bufferedAmount > MAX_BUFFER) {
ws.close(4008, "slow consumer"); // client reconnect rồi catch-up
return;
}
ws.send(payload);
}
Cách đóng kết nối "slow consumer" gần với việc Redis in Action mô tả: Redis hiện đại ngắt các subscriber không theo kịp theo giới hạn client-output-buffer-limit pubsub.8 Các chính sách khác (của khoá học): bỏ message cũ và chỉ giữ trạng thái mới nhất (vị trí tài xế, giá cổ phiếu), gộp nhiều cập nhật thành một batch mỗi 100 ms, hoặc hạ mức chi tiết (gửi "có 57 bình luận mới" thay vì 57 bình luận).
Sai lầm hay gặp: Gửi mọi cập nhật vị trí 1 lần/giây cho mọi người xem. Với dữ liệu mà chỉ giá trị mới nhất có nghĩa, hãy coalesce: giữ bản mới nhất theo key, gửi theo nhịp cố định, bỏ bản cũ.
Scale theo quy mô
Quy tắc ước lượng của khoá học: một gateway 2 vCPU · 4 GB giữ khoảng 20k–50k kết nối gần như rảnh. Lấy 30k làm số an toàn. Số kết nối đồng thời thường bằng 10–30% số user hoạt động hằng ngày, tùy loại app. Đây không phải số trong sách và cần load test.3 Bảng dưới và các phép tính sau đó đều dùng quy tắc này, cùng với mốc Redis khoảng 100.000 thao tác/s một node.10
| Quy mô | Kết nối đồng thời (ước tính) | Gateway | Fan-out | Ghi chú |
|---|---|---|---|---|
| ~10k user | 1k–3k | 2 instance để dự phòng | trong process, hoặc 1 Redis | SSE đủ dùng; một Postgres lưu sự kiện để catch-up |
| ~1M user | 100k–300k | 5–10, cộng 50% dư cho deploy | Redis pub/sub, registry trong Redis | backoff có jitter là bắt buộc; drain khi deploy |
| 50M+ user | 5M–15M | 200–500 | Kafka làm log bền, nhiều cụm Redis pub/sub theo shard | gateway theo vùng địa lý; tách gateway khỏi logic nghiệp vụ |
Một vài phép tính cần nói ra (phép tính thuần số học trên các giả định ở trên):3
- Throughput Redis. Một node Redis khoảng 100.000 ops/s (ước tính). Ở 1M user, nếu có 20.000 message/s và mỗi message được publish một lần tới một channel, một node vẫn chịu được. Khi một message tới phòng có 50 gateway đang subscribe, Redis phải đẩy 50 bản. Phòng lớn nhân tải lên nhanh, nên tính theo tổng số lần giao (message × subscriber), không theo số message.
- Phòng siêu lớn. Buổi livestream 2 triệu người xem trải trên mọi gateway. Đừng gửi từng bình luận. Gom theo cửa sổ 200–500 ms, lấy mẫu, gửi một batch tới mỗi gateway, gateway tự phát cho client.
- Deploy. 500 gateway mỗi máy 30k kết nối. Restart đồng loạt nghĩa là 15M client nối lại. Rolling deploy theo đợt nhỏ, gửi message "hãy reconnect sau X giây" với X ngẫu nhiên.
- Băng thông. 10M kết nối, mỗi kết nối nhận 1 message 200 byte mỗi giây là khoảng 2 GB/s ra ngoài. Nén (permessage-deflate) và gộp batch đáng giá ở quy mô này.
Bài nào dùng pattern này
- Chat: WebSocket hai chiều, registry user → gateway, catch-up tin nhắn khi online lại.
- Bình luận live: SSE đủ dùng, fan-out tới phòng rất lớn, gom batch và lấy mẫu.
- Gọi xe và Giao hàng nội thành: đẩy vị trí tài xế, coalesce chỉ giữ bản mới nhất.
- Google Docs: WebSocket theo từng tài liệu, thứ tự thao tác quan trọng.
- Cờ vua online và Đấu giá online: độ trễ thấp, mọi người trong phòng thấy cùng trạng thái.
- Giao dịch chứng khoán: stream giá, backpressure với client chậm.
- Hệ thống thông báo và ChatGPT: SSE cho thông báo trong app và streaming token.
Danh sách trên là cách khoá học áp dụng pattern; trong sách, ví dụ gần nhất là thiết kế WhatsApp với WebSocket manager và tin nhắn offline.712
Đọc thêm: Redis cho pub/sub và Streams, Kafka cho log sự kiện bền, Mạng cơ bản cho TCP, HTTP/2 và load balancer L4/L7.
Nguồn trích dẫn
-
Grokking Modern System Design · ch.36 Design WhatsApp · mục "Connection with a WebSocket server" (kèm "Point to Ponder": vì sao WebSocket được ưu tiên hơn HTTP(S)). ↩ ↩2 ↩3 ↩4 ↩5
-
Node.js in Practice (file .zip, tải về giải nén) · ch.9 The Web · §9.2 Server-side techniques · Technique 74 "Using sessions with WebSockets" (tr. 238–239). ↩ ↩2
-
Quy tắc ước lượng của khoá học, xem bài Chọn server. Không phải số liệu trích từ sách; cần load test với ứng dụng thật. ↩ ↩2 ↩3 ↩4
-
Node.js Design Patterns 2e (file .zip, tải về giải nén) · ch.9 Advanced Asynchronous Recipes · mục "Running CPU-bound tasks" (tr. 371). ↩
-
Chưa có nguồn trong thư viện: thư viện không có sách về giao thức SSE, long polling, WebRTC, HTTP/2 hay vận hành kết nối dài (heartbeat, backoff có jitter); phần này chỉ nêu khái niệm cơ bản, không đưa số liệu cụ thể của sản phẩm. ↩ ↩2 ↩3 ↩4 ↩5
-
Node.js Design Patterns 2e (file .zip, tải về giải nén) · ch.11 Messaging and Integration Patterns · mục "Publish/subscribe pattern" › "Running and scaling the chat application" (tr. 446–447). ↩
-
Grokking Modern System Design · ch.36 Design WhatsApp · mục "Send or receive messages" (WebSocket manager, cache ở WebSocket server và vô hiệu hoá cache). ↩ ↩2
-
Redis in Action · ch.3 Commands in Redis · §3.6 Publish/subscribe (tr. 54–57), gồm đoạn giải thích vì sao sách dùng cơ chế khác cho giao message đáng tin cậy. ↩ ↩2 ↩3 ↩4
-
Node.js Design Patterns 2e (file .zip, tải về giải nén) · ch.11 Messaging and Integration Patterns · mục "Publish/subscribe pattern" › "Using Redis as a message broker" (tr. 447–450). ↩
-
Redis in Action · ch.6 Application components in Redis · §6.2 Distributed locking › 6.2.2 Simple locks (tr. 120–121; Redis thực hiện 100.000 thao tác/s trên phần cứng gần đây). ↩ ↩2
-
Chưa có nguồn trong thư viện: thư viện không có sách về Kafka hay Redis Streams; chỉ nêu vai trò cơ bản (log bền để fan-out), chi tiết ở bài Kafka. ↩ ↩2
-
Grokking Modern System Design · ch.36 Design WhatsApp · phần đánh giá thiết kế, mục "Consistency" và "Availability" (queue FIFO, Sequencer, lưu tin cho người offline trên cụm Mnesia, tạo lại phiên qua load balancer). ↩ ↩2 ↩3 ↩4
-
Node.js Design Patterns 2e (file .zip, tải về giải nén) · ch.5 Coding with Streams · mục "Back-pressure" (tr. 155). ↩