11.1 - Tại sao cần Real-time?
Tóm tắt: Trước khi chọn real-time, hãy tính chi phí của cách đơn giản hơn. Polling mỗi 5 giây với 1.000 người dùng là 200 request mỗi giây, và 99% trong số đó trả về "không có gì mới" — bạn trả tiền cho toàn bộ chi phí HTTP, xác thực và truy vấn database để nhận về một mảng rỗng. Ngược lại, kết nối bền vững cũng có giá: mỗi kết nối chiếm bộ nhớ trên server suốt thời gian nó mở, và khi scale ngang, người gửi và người nhận có thể ở hai instance khác nhau — bài toán mà chỉ backplane giải được. Real-time đúng khi server có thông tin mà client không thể biết trước khi nào có.
Mục tiêu bài học
Sau bài này bạn có thể:
- Tính chi phí thật của polling và so sánh với kết nối bền vững.
- Chọn đúng giữa polling, SSE và WebSocket.
- Giải thích SignalR là gì và không phải gì.
- Nhận ra khi real-time là lựa chọn sai.
Nội dung bài học
11.1.1 — Chi phí của polling
setInterval(() => fetch('/api/notifications/unread'), 5000);Với 1.000 người dùng đang mở trang:
1.000 người / 5 giây = 200 request/giây
Mỗi request: bắt tay TLS (nếu chưa giữ kết nối), parse JWT,
truy van database, serialize JSON
99% trả về: []Ba loại chi phí, và loại thứ ba thường bị bỏ qua:
- Băng thông — header HTTP cộng cookie thường lớn hơn phần thân rỗng.
- CPU — xác thực token và truy vấn database cho mỗi lần hỏi.
- Độ trễ vẫn tệ — trung bình 2,5 giây, tệ nhất 5 giây. Giảm xuống 1 giây thì chi phí tăng gấp 5.
Mấu chốt: bạn trả đủ chi phí của 200 request mỗi giây để nhận về gần như toàn bộ là mảng rỗng.
Nhưng polling không phải luôn sai. Nó thắng khi:
- Dữ liệu đổi đều đặn và dự đoán được (giá cổ phiếu cập nhật mỗi phút).
- Số người dùng đồng thời nhỏ.
- Độ trễ vài giây chấp nhận được.
- Bạn không muốn thêm hạ tầng (backplane, sticky session).
Với một trang quản trị nội bộ 20 người dùng, polling mỗi 10 giây là lựa chọn đúng — đơn giản, không thêm gì, và không ai để ý độ trễ.
11.1.2 — Bốn kỹ thuật
| Chiều | Độ trễ | Chi phí mỗi tin | Kết nối giữ mở | |
|---|---|---|---|---|
| Short polling | Client hỏi | Nửa chu kỳ | Cao | Không |
| Long polling | Client hỏi, server giữ | Thấp | Trung bình | Có, từng lượt |
| SSE | Chỉ server → client | Rất thấp | Rất thấp | Có |
| WebSocket | Hai chiều | Rất thấp | Rất thấp | Có |
Long polling: client gửi request, server không trả lời cho tới khi có dữ liệu hoặc hết thời gian chờ. Nó mô phỏng push bằng HTTP thuần, nên chạy được qua mọi proxy — đó là lý do nó vẫn tồn tại như phương án dự phòng cuối cùng.
SSE (text/event-stream) là một chiều: server đẩy, client chỉ nghe. Nó đơn giản hơn WebSocket rất nhiều, chạy trên HTTP thường, tự kết nối lại, và đủ cho phần lớn nhu cầu thông báo. Nếu bạn chỉ cần "server báo cho client biết có gì mới", SSE thường là câu trả lời đúng mà ít người nghĩ tới.
Giới hạn của SSE: qua HTTP/1.1, trình duyệt chỉ cho 6 kết nối mỗi tên miền, nên mở nhiều tab là hết chỗ. Qua HTTP/2 thì giới hạn này biến mất.
WebSocket là giao thức riêng, nâng cấp từ HTTP, hai chiều và chi phí mỗi khung tin chỉ vài byte. Cần nó khi client cũng phải gửi liên tục: chat, trạng thái đang gõ, chỉnh sửa cộng tác, game.
11.1.3 — SignalR là gì
SignalR không phải một giao thức. Nó là một lớp trừu tượng làm ba việc:
- Tự chọn transport — thử WebSocket, rồi SSE, rồi long polling.
- Cho bạn gọi hàm thay vì gửi chuỗi:
Clients.User(id).ReceiveNotification(dto). - Lo phần khó: kết nối lại, giữ nhịp, phát hiện đứt, nhóm kết nối, và backplane khi scale.
Giá trị lớn nhất nằm ở mục 3. Viết WebSocket thuần thì bạn phải tự làm hết: phát hiện kết nối chết (TCP có thể không báo trong nhiều phút), kết nối lại có backoff, gộp nhiều kết nối của một người dùng, và đồng bộ giữa các instance.
Cái giá: thêm một phụ thuộc, một giao thức riêng (client phải dùng thư viện SignalR), và một lớp trừu tượng che bớt những gì đang thật sự xảy ra.
11.1.4 — Cái giá của kết nối bền vững
Đây là phần các bài giới thiệu SignalR thường bỏ qua.
Bộ nhớ. Mỗi kết nối chiếm bộ đệm và trạng thái trên server suốt thời gian mở. 10.000 kết nối là chi phí thường trực, kể cả khi không ai gửi gì.
Scale ngang cần backplane. Người gửi ở pod A, người nhận kết nối vào pod B — pod A không biết pod B đang giữ ai:
Pod A: user-1, user-3
Pod B: user-2
user-1 gửi tin cho user-2 -> Pod A không có kết nối nào của user-2
=> Tin nhắn biến mất, không có lỗi nàoGiải quyết bằng Redis backplane hoặc Azure SignalR Service (bài 11.10).
Sticky session với một số transport. Long polling và SSE cần request đi về đúng instance đang giữ kết nối. WebSocket thì không cần sau khi bắt tay xong, nhưng bước negotiate vẫn cần.
Deploy làm đứt mọi kết nối. Mỗi lần triển khai, toàn bộ client mất kết nối và cùng kết nối lại — một đợt tải đột biến ngay sau khi instance mới khởi động.
Trạng thái trên server. Bạn giữ danh sách ai đang online, ai ở nhóm nào. Điều đó vi phạm nguyên tắc stateless của REST (bài 9.1) và là lý do những thứ này phải nằm ở kho dùng chung.
11.1.5 — Khi nào thật sự cần
Tiêu chí duy nhất: server có thông tin mà client không thể biết trước khi nào sẽ có.
| Tình huống | Nên dùng |
|---|---|
| Thông báo khi có lead mới được giao | SSE hoặc SignalR |
| Bảng điều khiển cập nhật mỗi phút | Polling — đơn giản hơn |
| Chat, trạng thái đang gõ | WebSocket / SignalR |
| Ai đang xem cùng hồ sơ này | SignalR (cần trạng thái nhóm) |
| Theo dõi tiến độ một job dài | SSE — một chiều là đủ |
| Đồng bộ dữ liệu giữa hai hệ thống | Webhook hoặc message queue, không phải real-time tới trình duyệt |
Hàng cuối đáng nhấn mạnh: real-time là để đẩy tới giao diện người dùng. Giao tiếp giữa hai service dùng message broker — nó có độ bền, retry và dead-letter, những thứ SignalR không có (bài 6.8).
Lộ trình thực dụng: bắt đầu bằng polling. Khi nó thật sự thành vấn đề — đo được, không phải phỏng đoán — hãy chuyển. Rất nhiều hệ thống thêm SignalR cho một tính năng thông báo mà polling mỗi 30 giây đã đủ.
11.1.6 — Rà lại code của bạn
Danh sách rà soát lựa chọn real-time
- Đã tính chi phí polling bằng số thật trước khi chuyển sang real-time.
- Đã cân nhắc SSE nếu chỉ cần đẩy một chiều từ server.
- Biết rõ độ trễ mà người dùng thật sự cần, không phải độ trễ nghe hay.
- Có kế hoạch backplane trước khi scale lên nhiều instance.
- Biết transport nào cần sticky session trong hạ tầng của mình.
- Đã tính tới đợt kết nối lại đồng loạt sau mỗi lần deploy.
- Giao tiếp giữa các service dùng message broker, không dùng SignalR.
Bài tập áp dụng
Bài 1 — Đo chi phí thật của polling
Mô phỏng 500 client poll mỗi 5 giây vào một endpoint có xác thực và truy vấn database. Đo request mỗi giây, CPU, và tỷ lệ response rỗng.
Tiêu chí hoàn thành: bạn nêu được vì sao tỷ lệ response rỗng mới là con số quan trọng nhất, chứ không phải CPU.
Gợi ý và lời giải — Bài 1
Gợi ý. CPU nói bạn tốn bao nhiêu. Tỷ lệ rỗng nói bạn tốn một cách vô ích bao nhiêu.
Lời giải — mô phỏng:
# 500 client, mỗi client poll mỗi 5 giây => 100 request/giây
bombardier -c 100 -r 100 -d 60s \
-H "Authorization: Bearer $TOKEN" \
http://localhost:5000/api/v1/notifications/unreadKết quả điển hình:
Requests/sec: 100.02
Latencies avg: 18ms
CPU (dotnet-counters): 14%
Truy vấn database: 100/giâyĐếm tỷ lệ rỗng:
app.Use(async (ctx, next) =>
{
await next(ctx);
if (ctx.Request.Path.StartsWithSegments("/api/v1/notifications/unread"))
_metrics.Polls.Add(1, new KeyValuePair<string, object?>(
"empty", ctx.Response.Headers.ContentLength <= 2)); // "[]"
});Tổng request: 6.000
Trả về có dữ liệu: 41
Trả về rỗng: 5.959 -> 99,3%Vì sao tỷ lệ rỗng quan trọng hơn CPU. 14% CPU nghe chấp nhận được, và đó chính là cái bẫy: con số đó không nói cho bạn biết công việc này có ích hay không. Tỷ lệ rỗng 99,3% nói thẳng rằng cứ 1.000 đồng bạn bỏ ra thì 993 đồng không mang lại gì.
Và nó cho bạn tính được tác động của mọi thay đổi:
| Request/giây | CPU | Có ích | |
|---|---|---|---|
| Poll 5 giây | 100 | 14% | 0,7% |
| Poll 30 giây | 17 | ~3% | 4% |
| Poll 1 giây | 500 | ~60% | 0,14% |
| SignalR | ~0 | ~1% | 100% |
Chi phí mỗi request không chỉ là truy vấn. Mỗi lần poll đi qua:
Bắt tay TLS (nếu chưa giữ kết nối), parse JWT, xác thực chữ ký,
tra quyền, mở kết nối database từ pool, chạy truy vấn,
tuần tự hoá, ghi log, trả về "[]"Phần lớn chi phí nằm ở những bước trước truy vấn — nên tối ưu câu SQL gần như không giúp gì.
Và đây là điều tệ nhất của polling: nó bắt bạn chọn giữa hai thứ đều xấu.
Poll thưa -> rẻ, nhưng người dùng thấy thông báo muộn 30 giây
Poll dày -> nhanh, nhưng chi phí tăng tuyến tính và vẫn 99% vô íchReal-time bỏ hẳn lựa chọn này: độ trễ gần như bằng 0 và không có request vô ích, vì server chỉ gửi khi thật sự có gì.
Khi nào polling vẫn đúng:
- Dữ liệu đổi đều đặn và bạn biết chu kỳ (giá cập nhật mỗi phút) — lúc đó tỷ lệ rỗng thấp.
- Số client nhỏ và độ trễ 30 giây chấp nhận được.
- Bạn không kiểm soát được hạ tầng để chạy WebSocket.
Ngoài ba trường hợp đó, hãy đo tỷ lệ rỗng của mình trước khi quyết định.
Bài 2 — SSE trong hai mươi dòng
Viết một endpoint text/event-stream đẩy thông báo, và một client dùng EventSource. So sánh số dòng code với một Hub SignalR làm cùng việc.
Tiêu chí hoàn thành: bạn nêu được ba thứ SignalR làm mà SSE không làm — và một tình huống SSE vẫn là lựa chọn đúng.
Gợi ý và lời giải — Bài 2
Gợi ý. SSE là một chiều. Hãy nghĩ xem điều đó chặn những tình huống nào.
Lời giải — server:
app.MapGet("/api/v1/notifications/stream",
async (HttpContext ctx, INotificationStream stream, CancellationToken ct) =>
{
ctx.Response.Headers.ContentType = "text/event-stream";
ctx.Response.Headers.CacheControl = "no-cache";
ctx.Response.Headers["X-Accel-Buffering"] = "no"; // tắt buffer của Nginx
var userId = ctx.User.FindFirstValue(ClaimTypes.NameIdentifier)!;
await foreach (var n in stream.SubscribeAsync(userId, ct))
{
await ctx.Response.WriteAsync($"data: {JsonSerializer.Serialize(n)}\n\n", ct);
await ctx.Response.Body.FlushAsync(ct); // BẮT BUỘC
}
}).RequireAuthorization();Client:
const es = new EventSource('/api/v1/notifications/stream');
es.onmessage = e => showNotification(JSON.parse(e.data));
es.onerror = () => console.log('mất kết nối — trình duyệt sẽ tự thử lại');Khoảng 20 dòng, không thư viện nào. Bản SignalR tương đương cũng khoảng ngần ấy, nhưng cộng thêm một gói NuGet, một gói npm và một Hub.
Ba thứ SignalR làm mà SSE không làm:
1. Giao tiếp hai chiều. EventSource chỉ nhận. Muốn client gửi gì lên — tham gia phòng chat, báo "đang gõ", xác nhận đã đọc — bạn phải gọi thêm một REST endpoint. Với SignalR, connection.invoke("MarkAsRead", id) đi trên cùng kết nối.
2. Gửi theo nhóm và theo người dùng. SignalR có sẵn Clients.Group(...), Clients.User(...), và với backplane Redis thì nó hoạt động xuyên nhiều instance. Với SSE bạn tự dựng toàn bộ: bảng ánh xạ người dùng sang kết nối, cơ chế phát tán giữa các pod, dọn dẹp khi ngắt.
3. Tự chọn phương thức truyền. SignalR thử WebSocket trước, và nếu môi trường chặn thì tự lùi về SSE rồi Long Polling — client không cần biết. Với SSE thuần, môi trường nào chặn text/event-stream thì bạn hết cách.
Thêm hai khác biệt nhỏ nhưng hay gây bất ngờ:
| SSE | SignalR | |
|---|---|---|
| Header tuỳ chỉnh | Không — EventSource không cho đặt | Có |
| Dữ liệu nhị phân | Không, chỉ text | Có (MessagePack) |
| Kết nối lại | Trình duyệt tự làm | Có, kèm sự kiện để bạn xử lý |
Dòng đầu quan trọng với xác thực: EventSource không gửi được header Authorization, nên bạn buộc phải dùng cookie hoặc đưa token vào query string — vấn đề y hệt bài 11.13.
Khi nào SSE vẫn là lựa chọn đúng:
Khi bạn chỉ cần một chiều, một instance, và muốn không thêm phụ thuộc nào. Ví dụ điển hình: trang theo dõi tiến độ một job dài, hoặc log đẩy về thời gian thực cho trang quản trị. Ở đó SSE gọn hơn hẳn, và mọi thứ SignalR thêm vào đều là thứ bạn không dùng.
Ngược lại, khi đã có hai chiều, hoặc đã chạy nhiều instance, thì tự dựng lại những thứ SignalR có sẵn tốn hơn nhiều so với việc thêm một gói.
Bài 3 — Giới hạn sáu kết nối của HTTP/1.1
Mở 8 tab cùng kết nối SSE qua HTTP/1.1 và xác nhận hai tab cuối bị treo. Bật HTTP/2 và kiểm chứng lại.
Tiêu chí hoàn thành: bạn nêu được vì sao giới hạn này không ảnh hưởng tới WebSocket, dù WebSocket cũng giữ kết nối lâu dài.
Gợi ý và lời giải — Bài 3
Gợi ý. Giới hạn đếm kết nối HTTP tới một origin. WebSocket bắt đầu bằng HTTP nhưng kết thúc ở một trạng thái khác.
Lời giải — với HTTP/1.1:
dotnet run --urls "http://localhost:5000" # HTTP/1.1, không TLSMở 8 tab cùng trỏ vào trang có new EventSource('/api/v1/notifications/stream').
Tab 1..6: kết nối, nhận thông báo bình thường
Tab 7, 8: trạng thái pending — không có gì xảy raTrong DevTools của tab 7, request nằm mãi ở (pending). Không lỗi, không timeout — chỉ là xếp hàng.
Trình duyệt giới hạn 6 kết nối HTTP/1.1 đồng thời cho mỗi origin. Mỗi EventSource giữ một kết nối vĩnh viễn, nên 6 tab là hết chỗ. Và vấn đề lan rộng hơn: tab thứ bảy cũng không tải được ảnh, CSS hay gọi được API nào tới cùng origin — chúng xếp hàng sau các kết nối SSE không bao giờ đóng.
Với HTTP/2:
dotnet dev-certs https --trust
dotnet run --urls "https://localhost:5001"Tất cả 8 tab: kết nối và nhận thông báo bình thườngHTTP/2 ghép nhiều luồng (multiplexing) trên một kết nối TCP, với giới hạn mặc định khoảng 100 luồng đồng thời. Tám tab dùng chung một kết nối.
Lưu ý: trình duyệt chỉ dùng HTTP/2 qua HTTPS. Chạy http://localhost là quay về HTTP/1.1 và lỗi trở lại — đây là lý do lỗi này hay chỉ xuất hiện ở môi trường phát triển.
Vì sao WebSocket không bị ảnh hưởng. WebSocket bắt đầu bằng một request HTTP có Upgrade: websocket. Sau khi bắt tay xong, kết nối rời khỏi phạm vi HTTP và trở thành một kênh song công. Trình duyệt không còn tính nó vào hạn ngạch 6 kết nối HTTP nữa — Chrome cho phép khoảng 255 WebSocket mỗi origin.
SSE: là request HTTP đang mở -> tính vào giới hạn 6
WebSocket: đã nâng cấp khỏi HTTP -> không tínhBảng tóm tắt:
| HTTP/1.1 | HTTP/2 | |
|---|---|---|
| SSE | Tối đa 6 tab | ~100 luồng |
| WebSocket | Không giới hạn thực tế | Không giới hạn thực tế |
| Long Polling | Tối đa 6 tab | ~100 luồng |
Ba điều rút ra cho thực tế:
- Dùng SSE hay Long Polling thì bắt buộc bật HTTP/2, và nhớ rằng reverse proxy phải nói HTTP/2 với client.
- Đừng mở một kết nối cho mỗi tab. Dùng
BroadcastChannelhoặcSharedWorkerđể mọi tab chia sẻ một kết nối — vừa tránh giới hạn, vừa giảm tải server theo số lần bằng số tab. - SignalR ưu tiên WebSocket là có lý do. Nó tránh hẳn lớp vấn đề này, và chỉ lùi về SSE hay Long Polling khi môi trường không cho dùng WebSocket.
Tự kiểm tra
Chi phí thật của polling là gì?
Với 1.000 người dùng poll mỗi 5 giây là 200 request mỗi giây, và khoảng 99% trả về mảng rỗng. Bạn trả đủ chi phí băng thông, xác thực token và truy vấn database cho từng request đó. Header HTTP cộng cookie thường còn lớn hơn phần thân rỗng, và độ trễ trung bình vẫn là nửa chu kỳ.
Khi nào polling vẫn là lựa chọn đúng?
Khi dữ liệu đổi đều đặn và dự đoán được, số người dùng đồng thời nhỏ, độ trễ vài giây chấp nhận được, và bạn không muốn thêm hạ tầng như backplane hay sticky session. Với một trang quản trị nội bộ 20 người, polling mỗi 10 giây là đúng.
SSE khác WebSocket thế nào?
SSE chỉ một chiều từ server tới client, chạy trên HTTP thường, tự kết nối lại và đơn giản hơn nhiều. WebSocket là giao thức riêng nâng cấp từ HTTP và hai chiều. Nếu chỉ cần server báo cho client biết có gì mới thì SSE thường là câu trả lời đúng mà ít người nghĩ tới.
SSE có giới hạn gì?
Qua HTTP/1.1 trình duyệt chỉ cho 6 kết nối mỗi tên miền, nên mở nhiều tab là hết chỗ và các tab sau bị treo. Qua HTTP/2 thì giới hạn này biến mất.
SignalR mang lại giá trị gì ngoài việc chọn transport?
Phần khó nhất là phát hiện kết nối chết vì TCP có thể không báo trong nhiều phút, kết nối lại có backoff, gộp nhiều kết nối của một người dùng, quản lý nhóm, và đồng bộ giữa các instance qua backplane. Viết WebSocket thuần thì bạn phải tự làm hết những thứ đó.
Kết nối bền vững có cái giá gì?
Mỗi kết nối chiếm bộ nhớ suốt thời gian mở kể cả khi không ai gửi gì. Scale ngang cần backplane vì người gửi và người nhận có thể ở hai instance khác nhau. Một số transport cần sticky session. Mỗi lần deploy làm đứt mọi kết nối và tạo đợt kết nối lại đồng loạt. Và bạn giữ trạng thái trên server, vi phạm nguyên tắc stateless.
Kết luận
Ba điều đáng nhớ nhất:
- Tính chi phí polling bằng số thật trước. Với quy mô nhỏ, nó vẫn là lựa chọn đúng.
- SSE đủ cho phần lớn nhu cầu thông báo và đơn giản hơn WebSocket rất nhiều.
- Kết nối bền vững đưa trạng thái vào server — backplane và sticky session là cái giá phải trả.
Tham khảo
- Introduction to SignalR
- WebSockets support in ASP.NET Core
- RFC 6455 — The WebSocket Protocol
- Server-Sent Events (MDN)
Điều hướng
- Bài trước: Không có (bài mở đầu module).
- Bài tiếp theo: 11.2 — SignalR Architecture
- Về module: Trang mục lục