11.11 - Mở rộng và đào sâu
Tóm tắt: Những chủ đề nối tiếp Module 11, kèm điều kiện. Điều đáng cân nhắc trước tiên: với phần lớn nhu cầu backend — thông báo, cập nhật trạng thái, tiến độ job — Server-Sent Events đơn giản hơn SignalR đáng kể và có thể đủ. SSE chạy trên HTTP thường, tự kết nối lại, không cần backplane, và mọi proxy đều hỗ trợ sẵn. SignalR đáng dùng khi bạn cần giao tiếp hai chiều hoặc muốn framework lo phần lùi cơ chế và quản lý nhóm. Trong các mục dưới, thứ đáng bật ngay nếu đã dùng SignalR là giới hạn kết nối và kích thước message — thiếu nó, một client có thể chiếm hết bộ nhớ server.
Mục tiêu bài học
Sau bài này bạn có thể:
- Chọn giữa SignalR và Server-Sent Events.
- Dùng streaming cho dữ liệu dài.
- Giới hạn kết nối và kích thước message.
- Biết khi nào MessagePack đáng dùng.
Nội dung bài học
11.11.1 — Server-Sent Events: cân nhắc trước
Điều kiện kích hoạt: chỉ cần đẩy dữ liệu một chiều từ server.
[HttpGet("stream")]
public async Task Stream(CancellationToken ct)
{
Response.Headers.ContentType = "text/event-stream";
Response.Headers.CacheControl = "no-cache";
await foreach (var notification in _service.SubscribeAsync(ct))
{
await Response.WriteAsync($"data: {JsonSerializer.Serialize(notification)}\n\n", ct);
await Response.Body.FlushAsync(ct);
}
}const source = new EventSource('/api/notifications/stream');
source.onmessage = (e) => render(JSON.parse(e.data));
// Tự động kết nối lại khi đứt — không cần cấu hình gì| SignalR | Server-Sent Events | |
|---|---|---|
| Hướng | Hai chiều | Một chiều (server → client) |
| Giao thức | WebSocket, SSE, long polling | HTTP thường |
| Cần backplane khi scale | Có | Không (nếu chỉ đọc từ nguồn chung) |
| Cấu hình proxy | Cần header Upgrade | Không cần gì |
| Tự kết nối lại | Có (cấu hình) | Có sẵn |
| Quản lý nhóm | Có sẵn | Tự làm |
Với thông báo, tiến độ job, hoặc bảng số liệu cập nhật — những thứ chỉ đi một chiều — SSE loại bỏ được toàn bộ nhóm vấn đề về proxy và backplane ở bài 11.12.
Hạn chế của SSE: trên HTTP/1.1, trình duyệt giới hạn 6 kết nối mỗi tên miền, và một SSE chiếm một kết nối. Trên HTTP/2 giới hạn đó biến mất.
11.11.2 — Streaming trong SignalR
Điều kiện kích hoạt: gửi một chuỗi dữ liệu dài mà client muốn xử lý dần.
public async IAsyncEnumerable<LeadDto> StreamLeads(
[EnumeratorCancellation] CancellationToken ct)
{
await foreach (var lead in _service.GetAllAsync(ct))
yield return lead;
}connection.stream('StreamLeads').subscribe({
next: (lead) => appendRow(lead), // hien thi ngay tung dong
complete: () => console.log('Xong'),
error: (err) => console.error(err)
});Khác biệt so với gửi một mảng lớn: client thấy dữ liệu ngay từ bản ghi đầu tiên thay vì chờ toàn bộ. Với 50.000 bản ghi, đó là khác biệt giữa "màn hình trắng 8 giây" và "dữ liệu hiện dần ngay lập tức".
Server cũng không phải giữ toàn bộ tập dữ liệu trong bộ nhớ (bài 5.9).
Client cũng stream lên server được — hữu ích cho upload theo khối hoặc dữ liệu cảm biến:
public async Task UploadStream(IAsyncEnumerable<ChunkDto> stream)
{
await foreach (var chunk in stream)
await _storage.AppendAsync(chunk);
}11.11.3 — Giới hạn kết nối và message: bật ngay
Điều kiện kích hoạt: ngay khi đưa SignalR lên production.
builder.Services.AddSignalR(options =>
{
options.MaximumReceiveMessageSize = 32 * 1024; // 32 KB
options.StreamBufferCapacity = 10;
options.ClientTimeoutInterval = TimeSpan.FromSeconds(60);
options.KeepAliveInterval = TimeSpan.FromSeconds(15);
options.EnableDetailedErrors = false; // KHÔNG bật trên production
});| Tuỳ chọn | Vì sao |
|---|---|
MaximumReceiveMessageSize | Chặn client gửi payload khổng lồ làm cạn bộ nhớ |
StreamBufferCapacity | Giới hạn số item đệm cho mỗi stream |
ClientTimeoutInterval | Đóng kết nối chết để giải phóng tài nguyên |
KeepAliveInterval | Phải nhỏ hơn timeout của proxy |
EnableDetailedErrors | Bật trên production sẽ lộ stack trace cho client |
Hai hàng cuối đáng chú ý. KeepAliveInterval lớn hơn proxy_read_timeout khiến proxy cắt kết nối trước khi SignalR kịp gửi ping — biểu hiện là client liên tục kết nối lại. Quy tắc: ClientTimeoutInterval nên gấp khoảng hai lần KeepAliveInterval.
Và EnableDetailedErrors lộ thông tin nội bộ giống hệt vấn đề ở bài 8.13.
Giới hạn số kết nối mỗi người dùng cũng đáng có, vì một script mở 1.000 kết nối là cách tấn công rất rẻ:
public override async Task OnConnectedAsync()
{
var userId = Context.UserIdentifier!;
var count = _tracker.GetConnectionCount(userId);
if (count >= 5) // toi da 5 thiet bi hoac tab
{
Context.Abort();
return;
}
_tracker.Add(userId, Context.ConnectionId);
await base.OnConnectedAsync();
}11.11.4 — MessagePack
Điều kiện kích hoạt: message rất nhiều hoặc rất lớn, và đã đo được băng thông là vấn đề.
builder.Services.AddSignalR().AddMessagePackProtocol();const connection = new signalR.HubConnectionBuilder()
.withUrl('/hubs/notifications')
.withHubProtocol(new signalR.protocols.msgpack.MessagePackHubProtocol())
.build();Nhỏ hơn JSON khoảng 30–50% và nhanh hơn khi serialize.
Đánh đổi thật: message nhị phân không đọc được trong DevTools. Khi cần gỡ lỗi "vì sao client không nhận được gì", bạn mất công cụ hữu ích nhất.
Với phần lớn ứng dụng CRM, lượng message không đủ lớn để bù cho việc mất khả năng debug. Bật nó khi có số liệu chứng minh, không bật mặc định.
11.11.5 — Azure SignalR Service
Điều kiện kích hoạt: hàng chục nghìn kết nối, hoặc muốn triển khai không làm đứt kết nối.
builder.Services.AddSignalR().AddAzureSignalR(connectionString);Kết nối WebSocket giữ ở dịch vụ, không ở server ứng dụng. Ba hệ quả:
- Bộ nhớ và socket của server được giải phóng — mỗi kết nối tốn vài chục KB, nên với 10.000 kết nối đó là vài trăm MB.
- 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.
- Không cần backplane, dịch vụ tự lo việc phân phối.
Chi phí tính theo số kết nối và số tin nhắn, nên cần tính trước với quy mô dự kiến (bài 11.10).
11.11.6 — Những chủ đề khác
| Chủ đề | Nội dung | Ưu tiên |
|---|---|---|
| Giới hạn kết nối và message | Chống lạm dụng tài nguyên | Cao |
| Server-Sent Events | Đẩy một chiều, đơn giản hơn | Cao |
IHubFilter | Middleware cho lời gọi hub | Trung bình |
| Streaming | Dữ liệu dài, hiển thị dần | Trung bình |
| Presence (ai đang online) | Cần lưu trạng thái chung | Trung bình |
| MessagePack | Giảm băng thông | Thấp |
IHubFilter là cơ chế hữu ích cho việc áp dụng chung — log, đo thời gian, xử lý exception — cho mọi phương thức hub:
public sealed class LoggingHubFilter(ILogger<LoggingHubFilter> logger) : IHubFilter
{
public async ValueTask<object?> InvokeMethodAsync(
HubInvocationContext context, Func<HubInvocationContext, ValueTask<object?>> next)
{
var sw = Stopwatch.StartNew();
try
{
return await next(context);
}
finally
{
logger.LogDebug("Hub {Method} mất {Ms}ms",
context.HubMethodName, sw.ElapsedMilliseconds);
}
}
}Presence đáng lưu ý vì nó dễ làm sai: trạng thái online không lưu được trong bộ nhớ instance, vì mỗi instance chỉ biết kết nối của mình. Phải lưu vào Redis với TTL, và làm mới TTL theo nhịp keep-alive (bài 11.9).
11.11.7 — Thứ tự nên học
| Ưu tiên | Chủ đề | Vì sao |
|---|---|---|
| Cao | Giới hạn kết nối và message | Chống lạm dụng, bật ngay |
| Cao | Cân nhắc SSE trước | Đơn giản hơn nhiều nếu một chiều |
| Trung bình | IHubFilter | Áp dụng chung cho mọi phương thức |
| Trung bình | Streaming | Khi có dữ liệu dài |
| Thấp | MessagePack | Chỉ khi đo được băng thông là vấn đề |
| Thấp | Azure SignalR | Khi kết nối lên hàng chục nghìn |
Sau Module 11, bước tiếp theo là Module 12 — SQL Deep Dive.
11.11.8 — Rà lại code của bạn
Danh sách rà soát SignalR nâng cao
- Đã cân nhắc Server-Sent Events nếu chỉ cần đẩy một chiều.
- MaximumReceiveMessageSize được đặt, không để mặc định.
- KeepAliveInterval nhỏ hơn timeout của proxy.
- ClientTimeoutInterval khoảng gấp đôi KeepAliveInterval.
- EnableDetailedErrors tắt trên production.
- Có giới hạn số kết nối mỗi người dùng.
- Trạng thái online lưu ở Redis, không lưu trong bộ nhớ instance.
- Chỉ bật MessagePack khi có số liệu chứng minh.
Bài tập áp dụng
Bài 1 — Đếm chi phí cấu hình của SSE so với SignalR
Cài cùng một tính năng thông báo bằng cả hai và so sánh số dòng cấu hình cần thiết.
Tiêu chí hoàn thành: bạn so cả hai kịch bản — một instance và nhiều instance — vì kết luận đảo chiều giữa hai kịch bản.
Gợi ý và lời giải — Bài 1
Gợi ý. Với một instance, SignalR làm hộ bạn những thứ bạn chưa cần. Với nhiều instance, nó làm hộ đúng những thứ khó nhất.
Lời giải — kịch bản một instance.
SSE:
app.MapGet("/notifications/stream", async (HttpContext ctx, INotificationStream s, CancellationToken ct) =>
{
ctx.Response.Headers.ContentType = "text/event-stream";
ctx.Response.Headers.CacheControl = "no-cache";
ctx.Response.Headers["X-Accel-Buffering"] = "no";
await foreach (var n in s.SubscribeAsync(ctx.User.GetUserId(), ct))
{
await ctx.Response.WriteAsync($"data: {JsonSerializer.Serialize(n)}\n\n", ct);
await ctx.Response.Body.FlushAsync(ct);
}
}).RequireAuthorization();new EventSource('/notifications/stream').onmessage = e => showToast(JSON.parse(e.data));SignalR:
builder.Services.AddSignalR();
app.MapHub<NotificationHub>("/hubs/notifications").RequireAuthorization();public class NotificationHub : Hub<INotificationClient> { }
public interface INotificationClient { Task ReceiveNotification(NotificationDto dto); }const c = new signalR.HubConnectionBuilder().withUrl('/hubs/notifications')
.withAutomaticReconnect().build();
c.on('ReceiveNotification', showToast);
await c.start();| SSE | SignalR | |
|---|---|---|
| Dòng code | ~16 | ~14 |
| Gói phụ thuộc | 0 | 1 NuGet + 1 npm |
| Cấu hình proxy | proxy_buffering off | Upgrade, Connection, proxy_read_timeout |
Gần như hoà, và SSE nhỉnh hơn ở chỗ không thêm phụ thuộc nào.
Kịch bản nhiều instance — kết luận đảo chiều.
SignalR chỉ thêm một khối:
builder.Services.AddSignalR()
.AddStackExchangeRedis(cs, o => o.Configuration.AbortOnConnectFail = false);Ba dòng. Xong: gửi theo người dùng, theo nhóm, xuyên instance, tự dọn khi ngắt.
Với SSE, bạn phải tự viết toàn bộ phần đó:
// 1. Đăng ký kết nối cục bộ
private static readonly ConcurrentDictionary<string, Channel<Notification>> _local = new();
// 2. Lắng nghe Redis pub/sub để nhận tin từ instance khác
var sub = _redis.GetSubscriber();
await sub.SubscribeAsync(RedisChannel.Literal("notify"), (_, msg) =>
{
var env = JsonSerializer.Deserialize<Envelope>(msg!)!;
if (_local.TryGetValue(env.UserId, out var ch)) ch.Writer.TryWrite(env.Payload);
});
// 3. Phát tin ra mọi instance
public Task PublishAsync(string userId, Notification n) =>
sub.PublishAsync(RedisChannel.Literal("notify"),
JsonSerializer.Serialize(new Envelope(userId, n)));
// 4. Dọn khi client ngắt
// 5. Quản lý nhóm — tự dựng ánh xạ nhóm sang kết nối
// 6. Xử lý một người dùng có nhiều tab| SSE | SignalR | |
|---|---|---|
| Dòng code cho phần phân tán | ~120 | 3 |
| Gửi theo nhóm | Tự viết | Có sẵn |
| Nhiều thiết bị một người | Tự viết | Clients.User |
| Tự lùi transport khi bị chặn | Không có | Có sẵn |
Kết luận theo kịch bản:
- Một instance, một chiều, không muốn thêm phụ thuộc — SSE gọn hơn và đủ dùng. Ví dụ: trang theo dõi tiến độ một job, log đẩy về trang quản trị.
- Nhiều instance, hoặc cần hai chiều, hoặc cần nhóm — SignalR rẻ hơn hẳn, vì thứ bạn phải tự viết chính là phần khó và dễ sai nhất.
Sai lầm hay gặp là chọn SSE vì "gọn hơn" ở giai đoạn một instance, rồi phải viết lại toàn bộ khi scale ra — đúng lúc hệ thống đang chịu tải và không có thời gian viết lại.
Bài 2 — Streaming mười nghìn bản ghi
Cài IAsyncEnumerable trả 10.000 bản ghi và quan sát client hiển thị dần.
Tiêu chí hoàn thành: bạn nêu được streaming khác gì với việc gửi một mảng lớn, và một trường hợp nó không phù hợp.
Gợi ý và lời giải — Bài 2
Gợi ý. So sánh thời điểm client nhận được phần tử đầu tiên, không phải thời điểm nhận xong.
Lời giải — Hub method trả luồng:
public async IAsyncEnumerable<LeadDto> StreamLeads(
[EnumeratorCancellation] CancellationToken ct)
{
await foreach (var lead in _db.Leads.AsNoTracking()
.AsAsyncEnumerable()
.WithCancellation(ct))
{
yield return Map(lead);
}
}const stream = connection.stream('StreamLeads');
let count = 0;
stream.subscribe({
next: lead => { table.addRow(lead); if (++count % 1000 === 0) console.log(count); },
complete: () => console.log('xong', count),
error: err => console.error(err)
});t=0.08s 1000
t=0.15s 2000
...
t=0.71s 10000
t=0.71s xong 10000Bảng bắt đầu hiện dữ liệu từ 80 mili-giây, thay vì trắng trong 700 mili-giây rồi hiện tất cả.
So với gửi một mảng lớn:
| Gửi mảng | Streaming | |
|---|---|---|
| Byte đầu tiên tới client | Sau khi gom xong tất cả | Ngay bản ghi đầu |
| Bộ nhớ server | Cả 10.000 bản ghi | Một bản ghi |
| Bộ nhớ client | Cả mảng | Tăng dần |
| Huỷ giữa chừng | Không làm được | subscription.dispose() |
Dòng cuối đáng chú ý: người dùng đóng bảng thì dispose() huỷ CancellationToken ở server, await foreach dừng, và truy vấn database bị huỷ theo. Với cách gửi mảng, server vẫn chạy hết rồi mới phát hiện không ai cần nữa.
Cái bẫy thường gặp — [EnumeratorCancellation]. Thiếu attribute này, token truyền qua WithCancellation không tới được thân phương thức, và huỷ không có tác dụng — đúng vấn đề ở bài 5.9.
Khi nào streaming KHÔNG phù hợp:
Khi client cần toàn bộ dữ liệu trước khi làm được gì. Ví dụ: tải danh sách để sắp xếp rồi mới hiển thị, hoặc tính một tổng từ tất cả bản ghi. Lúc đó streaming chỉ thêm phức tạp mà không đem lại gì — client vẫn phải đợi phần tử cuối.
Và với dữ liệu thật sự lớn, câu hỏi đúng thường không phải "streaming hay không" mà là "vì sao client cần 10.000 bản ghi?". Phân trang hoặc tổng hợp phía server gần như luôn là câu trả lời tốt hơn — xem bài 9.4.
Streaming hợp nhất cho: kết quả tính toán dần (tiến độ import), dữ liệu đến theo thời gian (log trực tiếp), và bảng lớn mà người dùng bắt đầu đọc được ngay từ dòng đầu.
Bài 3 — Giới hạn kích thước message
Gửi một message 1 MB từ client và xác nhận server từ chối.
Tiêu chí hoàn thành: bạn nêu được vì sao giới hạn mặc định 32 KB là con số hợp lý, và điều gì xảy ra nếu bạn nới nó lên quá tay.
Gợi ý và lời giải — Bài 3
Gợi ý. Nghĩ xem bộ nhớ cần bao nhiêu khi 1.000 client cùng gửi một message lớn.
Lời giải — gửi message vượt giới hạn:
const huge = 'x'.repeat(1024 * 1024); // 1 MB
await connection.invoke('Echo', huge);Error: Connection closed with an error.warn: Microsoft.AspNetCore.SignalR.HubConnectionHandler[0]
Error processing request. The maximum message size of 32768B was exceeded.
The message size can be configured in AddHubOptions.Kết nối bị đóng, không chỉ message bị từ chối — SignalR coi đây là lỗi giao thức.
Vì sao 32 KB là con số hợp lý. Bộ đệm được cấp cho mỗi kết nối đang nhận dở:
1.000 client đồng thời × 32 KB = 32 MB (chấp nhận được)
1.000 client đồng thời × 1 MB = 1 GB (đủ để bị OOM killer giết)Và đây là bộ nhớ do client kiểm soát: bất kỳ ai cũng mở được nhiều kết nối rồi gửi message lớn. Không có giới hạn chặt, đó là một đường tấn công từ chối dịch vụ rất rẻ — kẻ tấn công chỉ tốn băng thông, còn bạn tốn RAM.
Ngoài ra, SignalR là kênh cho tin nhắn nhỏ và thường xuyên. Một message 1 MB chiếm kênh đủ lâu để làm trễ mọi tin khác trên cùng kết nối, vì lời gọi từ một client được xử lý tuần tự — xem bài 11.3.
Nới giới hạn khi thật sự cần, và nới có chủ đích:
builder.Services.AddSignalR(o =>
{
o.MaximumReceiveMessageSize = 128 * 1024; // 128 KB, không phải 10 MB
o.StreamBufferCapacity = 10;
o.EnableDetailedErrors = builder.Environment.IsDevelopment();
});Nếu bạn thấy mình muốn nới lên hàng MB, gần như chắc chắn bạn đang dùng sai công cụ. Dữ liệu lớn nên đi qua HTTP:
// SAI — đẩy cả file qua SignalR
await connection.invoke('UploadFile', base64Content);
// ĐÚNG — upload qua HTTP, báo kết quả qua SignalR
const { id } = await fetch('/api/v1/files', { method: 'POST', body: formData })
.then(r => r.json());
// server xử lý xong sẽ đẩy FileProcessed(id) qua hubCách này tốt hơn ở ba điểm: HTTP có sẵn cơ chế upload theo luồng không tốn bộ nhớ (bài 9.5), có thể tiếp tục khi đứt giữa chừng, và không chặn kênh real-time.
Ba giới hạn khác đáng đặt cùng lúc:
builder.Services.AddSignalR(o =>
{
o.MaximumReceiveMessageSize = 32 * 1024;
o.MaximumParallelInvocationsPerClient = 1; // mặc định, giữ nguyên
o.ClientTimeoutInterval = TimeSpan.FromSeconds(30);
o.KeepAliveInterval = TimeSpan.FromSeconds(15);
});Và ở tầng hạ tầng, giới hạn số kết nối mỗi IP tại reverse proxy — vì bản thân việc mở hàng nghìn kết nối cũng đã là một cách gây quá tải, không cần message nào lớn cả.
Tự kiểm tra
Khi nào Server-Sent Events đủ thay cho SignalR?
Khi chỉ cần đẩy dữ liệu một chiều từ server, như thông báo, tiến độ job hay bảng số liệu. SSE chạy trên HTTP thường, tự kết nối lại, không cần backplane và mọi proxy đều hỗ trợ sẵn.
Hạn chế của SSE là gì?
Nó chỉ một chiều, và trên HTTP/1.1 trình duyệt giới hạn sáu kết nối mỗi tên miền mà một SSE chiếm một kết nối. Trên HTTP/2 giới hạn đó biến mất.
Streaming khác gửi một mảng lớn thế nào?
Client thấy dữ liệu ngay từ bản ghi đầu tiên thay vì chờ toàn bộ, và server không phải giữ cả tập dữ liệu trong bộ nhớ. Với năm mươi nghìn bản ghi đó là khác biệt giữa màn hình trắng vài giây và dữ liệu hiện dần ngay.
Vì sao KeepAliveInterval phải nhỏ hơn timeout của proxy?
Vì nếu lớn hơn, proxy cắt kết nối trước khi SignalR kịp gửi ping giữ kết nối. Biểu hiện là client liên tục kết nối lại. Quy tắc là ClientTimeoutInterval gấp khoảng hai lần KeepAliveInterval.
Đánh đổi thật của MessagePack là gì?
Message nhị phân không đọc được trong DevTools, nên khi cần gỡ lỗi vì sao client không nhận được gì thì bạn mất công cụ hữu ích nhất. Với phần lớn ứng dụng CRM, lợi ích băng thông không bù được điều đó.
Vì sao trạng thái online không lưu được trong bộ nhớ instance?
Vì mỗi instance chỉ biết kết nối của chính nó, nên nó không biết người dùng có đang online ở instance khác hay không. Phải lưu vào Redis với TTL và làm mới TTL theo nhịp keep-alive.
Kết luận
Ba điều đáng nhớ nhất:
- Cân nhắc SSE trước — nếu chỉ một chiều, nó loại bỏ cả nhóm vấn đề về proxy và backplane.
- Giới hạn kích thước message và số kết nối là thứ phải bật ngay trên production.
- MessagePack đánh đổi khả năng debug lấy băng thông — chỉ bật khi đo được.
Tham khảo
Điều hướng
- Bài trước: 11.10 — Scale-out và Production
- Bài tiếp theo: 11.12 — Mini case study
- Về module: Trang mục lục