11.12 - Mini case study
Tóm tắt: Tính năng thông báo thời gian thực chạy hoàn hảo suốt 6 tháng với một instance. Ngày scale lên 3 instance, nó vẫn chạy — nhưng chỉ khoảng một phần ba người dùng nhận được mỗi thông báo. Không có lỗi nào trong log; SignalR báo gửi thành công. Nguyên nhân: mỗi instance chỉ biết những kết nối của chính nó, nên
Clients.Allchỉ nghĩa là "tất cả client đang nối vào instance này". Cách sửa là backplane, nhưng bài này quan trọng hơn ở hai lỗi tiếp theo xuất hiện ngay sau khi thêm backplane: WebSocket không thiết lập được do reverse proxy thiếu cấu hình, và người dùng bị ngắt kết nối mỗi lần triển khai.
Mục tiêu bài học
Sau bài này bạn có thể:
- Giải thích vì sao SignalR cần backplane khi scale out.
- Cấu hình reverse proxy cho WebSocket.
- Xử lý mất kết nối khi triển khai.
- Chọn giữa Redis backplane và Azure SignalR Service.
Nội dung bài học
11.12.1 — Sự cố
Trước: 1 instance, 1.200 kết nối -> thông báo tới 100% người dùng
Sau: 3 instance, 1.200 kết nối -> thông báo tới ~33% người dùng
Log: không có lỗi nào. SignalR báo gửi thành công.// Code gửi thông báo
public async Task ThongBaoLeadMoiAsync(LeadDto lead, CancellationToken ct)
{
await _hubContext.Clients.All.SendAsync("LeadCreated", lead, ct);
}Instance A: giu 400 ket noi -> Clients.All = 400 ket noi CUA A
Instance B: giữ 400 kết nối -> không biết gì về A
Instance C: giữ 400 kết nối -> không biết gì về A và B
Request tạo lead rơi vào instance A -> chỉ 400/1.200 người nhận đượcClients.All hoạt động đúng như thiết kế — nó gửi tới tất cả kết nối mà instance hiện tại đang giữ. Nó không có cách nào biết về kết nối ở instance khác.
11.12.2 — Sửa: backplane
builder.Services.AddSignalR()
.AddStackExchangeRedis(builder.Configuration.GetConnectionString("Redis")!, options =>
{
options.Configuration.ChannelPrefix = RedisChannel.Literal("crm-signalr");
});Instance A gửi thông báo
-> Dang len Redis pub/sub
-> Instance B và C nhận được
-> Mỗi instance gửi tới kết nối CỦA MÌNH
-> 1.200/1.200 người nhận đượcChannelPrefix cần thiết nếu nhiều ứng dụng dùng chung một Redis — không có nó, thông báo của ứng dụng này sẽ tới client của ứng dụng kia.
Cái giá của backplane: mỗi thông báo giờ đi qua Redis, nên độ trễ tăng vài mili giây và Redis trở thành một phụ thuộc bắt buộc. Với tính năng thời gian thực thì đó là đánh đổi hợp lý.
11.12.3 — Lỗi tiếp theo: WebSocket không thiết lập được
Sau khi thêm backplane, một phần người dùng vẫn chậm — và trong DevTools thấy request /hubs/notifications liên tục lặp lại thay vì một kết nối WebSocket duy trì.
Nginx mặc định KHÔNG chuyển tiếp header Upgrade
-> WebSocket handshake thất bại
-> SignalR tự động chuyển sang Server-Sent Events hoặc Long Polling
-> Vẫn "chạy", nhưng chậm hơn nhiều và tốn tài nguyên hơn nhiềuĐây là đặc tính khiến lỗi khó phát hiện: SignalR tự động lùi về cơ chế khác khi WebSocket không dùng được, nên mọi thứ vẫn hoạt động và không có lỗi nào.
location /hubs/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; # BAT BUOC
proxy_set_header Connection "upgrade"; # BAT BUOC
proxy_set_header Host $host;
proxy_cache off;
proxy_read_timeout 100s; # dai hon keep-alive cua SignalR
}Kiểm tra bằng DevTools: tab Network, lọc WS. Nếu thấy một kết nối ở trạng thái 101 Switching Protocols thì WebSocket đang hoạt động. Nếu thấy nhiều request HTTP lặp lại, nó đang dùng long polling.
Với YARP, chuyển tiếp WebSocket hoạt động mặc định — chỉ cần chú ý timeout (bài 18.3).
11.12.4 — Lỗi thứ ba: mất kết nối khi triển khai
Triển khai phiên bản mới -> pod cũ bị dừng
-> 400 ket noi dut dot ngot
-> Client tự kết nối lại, nhưng MẤT thông báo trong khoảng 2-5 giâySignalR không tự gửi lại thông báo bị lỡ — nó không lưu lịch sử. Ba cách xử lý, theo mức độ đảm bảo tăng dần:
Cách 1 — dừng nhẹ nhàng. Cho pod thời gian đóng kết nối tử tế:
terminationGracePeriodSeconds: 60
lifecycle:
preStop:
exec:
command: ["sleep", "15"] # ngừng nhận kết nối mới trước khi dừngpreStop cho load balancer thời gian gỡ pod khỏi vòng phân phối trước khi tiến trình dừng, nên kết nối mới không rơi vào pod sắp chết.
Cách 2 — client tự đồng bộ lại sau khi kết nối lại:
connection.onreconnected(async (connectionId) => {
// Lấy những gì bị lỡ trong lúc mất kết nối
const missed = await fetch(`/api/notifications?since=${lastReceivedAt}`);
render(await missed.json());
});Đây là cách thực dụng nhất: SignalR lo phần thời gian thực, còn API thường lo phần đảm bảo không mất.
Cách 3 — lưu thông báo vào database và dùng SignalR chỉ để báo có cái mới:
// Lưu trước — đây mới là nguồn sự thật
var notification = await _notificationService.CreateAsync(userId, content, ct);
// Rồi báo — nếu không tới thì client vẫn thấy khi tải lại trang
await _hubContext.Clients.User(userId).SendAsync("NewNotification", notification.Id, ct);Với thông báo quan trọng, cách 3 là bắt buộc. SignalR là kênh tăng tốc, không phải kênh đảm bảo (bài 11.8).
11.12.5 — Redis backplane hay Azure SignalR Service
| Redis backplane | Azure SignalR Service | |
|---|---|---|
| Kết nối giữ ở | Server của bạn | Dịch vụ của Azure |
| Giới hạn kết nối | Bộ nhớ và socket của server | Theo gói dịch vụ |
| Chi phí | Chi phí Redis | Theo số kết nối và tin nhắn |
| Vận hành | Bạn lo | Azure lo |
| Phù hợp | Vài nghìn kết nối | Hàng chục nghìn trở lên |
Điểm quyết định nằm ở hàng đầu: với Azure SignalR Service, kết nối WebSocket không giữ ở server ứng dụng của bạn. Điều đó giải phóng bộ nhớ và số socket — và cũng nghĩa là triển khai không làm đứt kết nối, vì client nối vào dịch vụ chứ không nối vào pod.
Con số tham khảo: mỗi kết nối SignalR tốn khoảng vài chục KB bộ nhớ trên server. Với 10.000 kết nối đó là vài trăm MB chỉ để giữ kết nối, chưa tính xử lý.
11.12.6 — Giám sát
// Chỉ số quan trọng nhất
_meter.CreateObservableGauge("signalr_connections", () => _connectionTracker.Count);
_meter.CreateCounter<long>("signalr_messages_sent");
_meter.CreateCounter<long>("signalr_reconnects");Ba dấu hiệu cần cảnh báo:
| Dấu hiệu | Nghĩa là |
|---|---|
| Số kết nối giảm đột ngột | Instance chết hoặc mạng có vấn đề |
| Số lần kết nối lại tăng cao | Kết nối không ổn định — thường do proxy hoặc timeout |
| Chênh lệch kết nối giữa các instance | Load balancer phân phối không đều |
Dấu hiệu thứ hai đáng chú ý: nếu client liên tục kết nối lại, có thể proxy_read_timeout ngắn hơn chu kỳ keep-alive của SignalR, nên proxy cắt kết nối đều đặn (bài 18.5).
11.12.7 — Rà lại code của bạn
Danh sách rà soát scale SignalR
- Có backplane nếu chạy nhiều hơn một instance.
- ChannelPrefix được đặt nếu dùng chung Redis với ứng dụng khác.
- Reverse proxy chuyển tiếp header Upgrade và Connection.
- proxy_read_timeout dài hơn chu kỳ keep-alive của SignalR.
- Đã xác nhận WebSocket thật sự hoạt động, không lùi về long polling.
- Pod có preStop và terminationGracePeriodSeconds đủ dài.
- Client đồng bộ lại dữ liệu sau khi kết nối lại.
- Thông báo quan trọng được lưu database, SignalR chỉ để báo.
- Có giám sát số kết nối và số lần kết nối lại.
Bài tập áp dụng
Bài 1 — Tái hiện: Clients.All chỉ tới một phần người dùng
Chạy hai instance không có backplane, kết nối hai client vào hai instance khác nhau và gửi Clients.All.
Tiêu chí hoàn thành: bạn chỉ ra được vì sao cái tên Clients.All là thứ khiến lỗi này khó nghi ngờ.
Gợi ý và lời giải — Bài 1
Gợi ý. Đọc Clients.All và tự hỏi: "tất cả" là tất cả của ai?
Lời giải — dựng lại:
dotnet run --urls http://localhost:5001 &
dotnet run --urls http://localhost:5002 &upstream crm { server localhost:5001; server localhost:5002; }
server { listen 8080; location / { proxy_pass http://crm;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade"; } }Mở hai trình duyệt, xác nhận chúng rơi vào hai instance khác nhau:
public override Task OnConnectedAsync()
{
_logger.LogInformation("Kết nối mới trên {Machine}", Environment.MachineName);
return base.OnConnectedAsync();
}Gửi thông báo:
curl -s -X POST http://localhost:5001/api/v1/notifications/broadcast -d '{"text":"xin chào"}'Client A (instance 1): nhận "xin chào"
Client B (instance 2): KHÔNG nhận gìCâu trả lời cho tiêu chí. Clients.All đọc lên như một lời hứa tuyệt đối — "tất cả client". Nhưng ý nghĩa thật của nó là "tất cả kết nối mà instance này đang giữ". Khi chỉ có một instance, hai cách hiểu trùng nhau, nên lập trình viên xây dựng trực giác sai ngay từ đầu mà không hề biết.
Đến lúc scale ra nhiều instance, trực giác đó sụp đổ — nhưng tên phương thức không đổi, code không đổi, không có cảnh báo nào. Người gỡ rối sẽ đi tìm lỗi ở phía client, ở proxy, ở dữ liệu, mà không nghi ngờ chính dòng Clients.All.
Cùng điều đó đúng với Clients.Group và Clients.User:
Instance A: giữ 400 kết nối -> không biết gì về B
Instance B: giữ 400 kết nối -> không biết gì về A và C
Instance C: giữ 400 kết nối
Request tạo lead rơi vào instance A -> chỉ 400/1.200 người nhận đượcVà triệu chứng đặc biệt khó chịu: tỷ lệ nhận được thay đổi theo từng lần, vì load balancer phân phối request tạo lead. Người dùng báo "lúc thấy lúc không", và việc thử lại đôi khi thành công — đủ để kết luận nhầm là do mạng.
Sửa bằng backplane:
builder.Services.AddSignalR()
.AddStackExchangeRedis(cs, o => o.Configuration.AbortOnConnectFail = false);Instance A gửi thông báo
-> đẩy lên Redis pub/sub
-> Instance B và C nhận được
-> Mỗi instance gửi tới kết nối CỦA MÌNH
-> 1.200/1.200 người nhận đượcCách phát hiện sớm mà không cần chờ sự cố. Thêm một chỉ số đếm số kết nối mỗi instance và so với tổng số người dùng đang hoạt động; hoặc đơn giản hơn, luôn chạy hai instance trên môi trường phát triển. Chi phí là một dòng trong compose.yaml, và nó lộ ra cả lớp lỗi này lẫn lỗi Data Protection ở bài 10.11.
Bài 2 — Xác nhận transport thật sự là WebSocket
Mở DevTools tab Network lọc WS và xác nhận có kết nối 101 Switching Protocols.
Tiêu chí hoàn thành: bạn biết cách kiểm từ phía server chứ không chỉ từ DevTools — vì bạn không mở DevTools trên máy người dùng được.
Gợi ý và lời giải — Bài 2
Gợi ý. DevTools chỉ cho biết transport của chính bạn, ở mạng của bạn. Người dùng sau proxy công ty có thể đang dùng thứ khác.
Lời giải — kiểm ở DevTools:
Network -> lọc WS
Đúng:
notifications?id=vXk3Lm9Q 101 Switching Protocols
(một dòng duy nhất, giữ mãi)
Đang lùi về long polling:
(tab WS trống)
Network -> lọc XHR:
?id=... 200
?id=... 200 (request mới liên tục mỗi vài giây)Kiểm bằng dòng lệnh, không cần trình duyệt:
curl -i -N -H "Connection: Upgrade" -H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
http://localhost:8080/hubs/notifications | head -1HTTP/1.1 101 Switching Protocols <- đúng
HTTP/1.1 200 OK <- proxy đang nuốt header UpgradeKiểm từ phía server — đây mới là thứ dùng được trên production:
public override Task OnConnectedAsync()
{
var transport = Context.Features
.Get<IHttpTransportFeature>()?.TransportType.ToString() ?? "unknown";
_metrics.Connections.Add(1,
new KeyValuePair<string, object?>("transport", transport));
_logger.LogInformation("Kết nối {ConnectionId} qua {Transport}",
Context.ConnectionId, transport);
return base.OnConnectedAsync();
}transport=WebSockets 1.184
transport=LongPolling 16
transport=ServerSentEvents 0Cảnh báo nên đặt:
CẢNH BÁO khi: tỷ lệ kết nối không phải WebSocket > 5% trong 10 phútNgưỡng 5% cho phép một số ít người dùng ở mạng doanh nghiệp chặn WebSocket — đó là chuyện bình thường và bạn không sửa được. Nhưng nó bắt ngay trường hợp cả hệ thống lùi về long polling vì một dòng cấu hình proxy bị xoá trong lần sửa gần nhất.
Vì sao con số này đáng theo dõi thường xuyên. Lùi transport không gây lỗi — ứng dụng vẫn chạy đúng chức năng. Hậu quả chỉ hiện ra dưới dạng tải server cao hơn và độ trễ lớn hơn, hai thứ dễ bị quy cho nguyên nhân khác. Không có chỉ số này thì bạn không có cách nào biết, cho tới khi đi tìm.
Ba nguyên nhân phổ biến làm lùi transport:
| Nguyên nhân | Dấu hiệu |
|---|---|
Proxy thiếu header Upgrade | Toàn bộ kết nối đều lùi |
| Proxy công ty của khách hàng chặn WebSocket | Một nhóm nhỏ người dùng, ổn định theo thời gian |
skipNegotiation sai cấu hình | Lỗi kết nối chứ không lùi |
Phân biệt được ba trường hợp này chỉ cần nhìn tỷ lệ: toàn bộ thì là lỗi của bạn, một nhóm nhỏ ổn định thì là môi trường của người dùng.
Bài 3 — Khởi động lại pod và đo khoảng mất kết nối
Kết nối một client rồi khởi động lại pod, đo thời gian mất kết nối và kiểm tra client có đồng bộ lại không.
Tiêu chí hoàn thành: bạn đo được khoảng mất kết nối và xác nhận không mất thông báo nào trong khoảng đó.
Gợi ý và lời giải — Bài 3
Gợi ý. Mất kết nối vài giây là chuyện bình thường khi deploy. Câu hỏi đúng không phải "có mất kết nối không" mà là "có mất dữ liệu không".
Lời giải — đo:
let downAt = null;
connection.onreconnecting(() => { downAt = Date.now(); console.log('mất kết nối'); });
connection.onreconnected(async () => {
console.log(`kết nối lại sau ${Date.now() - downAt}ms`);
await joinRooms();
await syncMissed();
});# Trong lúc pod restart, đẩy 5 thông báo
kubectl rollout restart deployment/crm-api
for i in 1 2 3 4 5; do
curl -s -X POST .../api/v1/notifications -d "{\"userId\":\"8f3a...\",\"text\":\"tin $i\"}"
sleep 1
donemất kết nối
kết nối lại sau 4180ms
GET /api/v1/notifications?after=018f3a1b...
(5 thông báo hiện ra)Mất kết nối 4,2 giây, không mất thông báo nào.
Vì sao không mất — nhờ ba thứ cùng hoạt động:
- Lưu trước, đẩy sau. Cả 5 thông báo đã nằm trong database trước khi bước đẩy chạy, nên việc đẩy thất bại không làm mất gì (bài 11.4).
withAutomaticReconnect()tự thử lại theo chu kỳ 0s, 2s, 10s, 30s — không cần code gì thêm.syncMissed()trongonreconnectedkéo về những gì đã bỏ lỡ. Thiếu bước này thì 5 thông báo kia không bao giờ tới, dù chúng vẫn nằm an toàn trong database.
Rút ngắn khoảng mất kết nối bằng cấu hình triển khai:
spec:
strategy:
rollingUpdate:
maxSurge: 1
maxUnavailable: 0 # KHÔNG giảm số pod đang phục vụ
template:
spec:
terminationGracePeriodSeconds: 60 # tổng thời gian cho phép
containers:
- name: crm-api
lifecycle:
preStop:
exec:
command: ["sleep", "15"] # chờ load balancer cập nhậtmaxUnavailable: 0 giữ pod cũ phục vụ cho tới khi pod mới sẵn sàng, nên client kết nối lại được ngay thay vì phải chờ pod mới khởi động.
Và preStop giải quyết một vấn đề tinh tế: Kubernetes gỡ pod khỏi Service và gửi SIGTERM gần như cùng lúc, nhưng việc gỡ mất vài giây để lan tới mọi node. Không có preStop, pod ngừng nhận kết nối mới trong khi load balancer vẫn còn gửi request tới — chi tiết ở bài 15.13.
Ba chỉ số nên theo dõi quanh mỗi lần deploy:
| Chỉ số | Giá trị mong đợi |
|---|---|
| Thời gian kết nối lại trung bình | < 5 giây |
| Số thông báo mất | 0 |
Số lần onclose (bỏ cuộc hẳn) | 0 |
Dòng thứ ba đáng chú ý: withAutomaticReconnect() mặc định chỉ thử 4 lần rồi bỏ cuộc. Với lần deploy lâu hơn dự kiến, client sẽ dừng hẳn và người dùng phải tải lại trang. Nới ra nếu cần:
.withAutomaticReconnect([0, 2000, 10000, 30000, 60000, 60000, 60000])Tự kiểm tra
Vì sao Clients.All chỉ tới một phần người dùng khi scale out?
Vì mỗi instance chỉ biết những kết nối của chính nó. Clients.All nghĩa là tất cả client đang nối vào instance hiện tại, không phải toàn hệ thống. Nó hoạt động đúng như thiết kế, chỉ là thiết kế đó cần backplane khi có nhiều instance.
Backplane hoạt động thế nào?
Instance gửi thông báo đăng nó lên Redis pub/sub, các instance khác nhận được và gửi tới kết nối của mình. Cái giá là mỗi thông báo đi qua Redis nên độ trễ tăng vài mili giây và Redis thành phụ thuộc bắt buộc.
Vì sao lỗi thiếu cấu hình WebSocket ở proxy khó phát hiện?
Vì SignalR tự động lùi về Server-Sent Events hoặc long polling khi WebSocket không thiết lập được. Mọi thứ vẫn chạy và không có lỗi nào, chỉ là chậm hơn và tốn tài nguyên hơn nhiều.
Làm sao xác nhận WebSocket đang thật sự hoạt động?
Mở DevTools tab Network lọc WS. Một kết nối ở trạng thái 101 Switching Protocols nghĩa là WebSocket hoạt động. Nhiều request HTTP lặp lại nghĩa là nó đang dùng long polling.
Vì sao thông báo quan trọng phải lưu database?
Vì SignalR không lưu lịch sử và không tự gửi lại thông báo bị lỡ khi client mất kết nối. SignalR là kênh tăng tốc, còn nguồn sự thật phải là database mà client đọc lại được.
Khi nào nên dùng Azure SignalR Service thay vì Redis backplane?
Khi số kết nối lên tới hàng chục nghìn, vì khi đó bộ nhớ và số socket trên server ứng dụng thành vấn đề. Nó cũng khiến triển khai không làm đứt kết nối, vì client nối vào dịch vụ chứ không nối vào pod.
Kết luận
Ba điều đáng nhớ nhất:
Clients.Allchỉ là tất cả client của instance hiện tại — backplane là bắt buộc khi scale out.- WebSocket lùi về long polling một cách im lặng khi proxy thiếu cấu hình.
- SignalR là kênh tăng tốc, không phải kênh đảm bảo. Thông báo quan trọng phải có trong database.
Tham khảo
Điều hướng
- Bài trước: 11.11 — Mở rộng và đào sâu
- Bài tiếp theo: 11.13 — Ví dụ thực tế nhanh
- Về module: Trang mục lục