Skip to content

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.All chỉ 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.
csharp
// 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 được

Clients.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 ​

csharp
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 được

ChannelPrefix 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.

nginx
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ây

SignalR 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ế:

yaml
terminationGracePeriodSeconds: 60
lifecycle:
  preStop:
    exec:
      command: ["sleep", "15"]    # ngừng nhận kết nối mới trước khi dừng

preStop 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:

typescript
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:

csharp
// 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 backplaneAzure SignalR Service
Kết nối giữ ởServer của bạnDịch vụ của Azure
Giới hạn kết nốiBộ nhớ và socket của serverTheo gói dịch vụ
Chi phíChi phí RedisTheo số kết nối và tin nhắn
Vận hànhBạn loAzure lo
Phù hợpVài nghìn kết nốiHà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 ​

csharp
// 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ệuNghĩa là
Số kết nối giảm đột ngộtInstance chết hoặc mạng có vấn đề
Số lần kết nối lại tăng caoKế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 instanceLoad 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:

bash
dotnet run --urls http://localhost:5001 &
dotnet run --urls http://localhost:5002 &
nginx
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:

csharp
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:

bash
curl -s -X POST http://localhost:5001/api/v1/notifications/broadcast -d '{"text":"xin chào"}'
text
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:

text
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 được

Và 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:

csharp
builder.Services.AddSignalR()
    .AddStackExchangeRedis(cs, o => o.Configuration.AbortOnConnectFail = false);
text
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 được

Cá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:

text
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:

bash
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 -1
text
HTTP/1.1 101 Switching Protocols     <- đúng
HTTP/1.1 200 OK                       <- proxy đang nuốt header Upgrade

Kiểm từ phía server — đây mới là thứ dùng được trên production:

csharp
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();
}
text
transport=WebSockets        1.184
transport=LongPolling          16
transport=ServerSentEvents      0

Cảnh báo nên đặt:

text
CẢNH BÁO khi: tỷ lệ kết nối không phải WebSocket > 5% trong 10 phút

Ngưỡ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ânDấu hiệu
Proxy thiếu header UpgradeToàn bộ kết nối đều lùi
Proxy công ty của khách hàng chặn WebSocketMột nhóm nhỏ người dùng, ổn định theo thời gian
skipNegotiation sai cấu hìnhLỗ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:

js
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();
});
bash
# 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
done
text
mấ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:

  1. 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).
  2. withAutomaticReconnect() tự thử lại theo chu kỳ 0s, 2s, 10s, 30s — không cần code gì thêm.
  3. syncMissed() trong onreconnected ké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:

yaml
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ật

maxUnavailable: 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ất0
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:

js
.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:

  1. Clients.All chỉ là tất cả client của instance hiện tại — backplane là bắt buộc khi scale out.
  2. WebSocket lùi về long polling một cách im lặng khi proxy thiếu cấu hình.
  3. 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 ​

Phát hành theo giấy phép MIT.