Skip to content

11.6 - Groups và Targeting ​

Tóm tắt: Nhóm trong SignalR là tập các ConnectionId, và ba đặc điểm của nó quyết định cách bạn thiết kế. Một: nhóm không bền vững — không có danh sách thành viên lưu ở đâu cả, và mọi thành viên biến mất khi kết nối đứt; không có API nào hỏi "ai đang trong nhóm này". Hai: tên nhóm là chuỗi tự do — không có quyền nào gắn với nó, nên Clients.Group("tenant-42") gửi cho bất kỳ ai đã vào nhóm đó, kể cả người vào nhầm. Ba: Clients.User(id) khác hẳn nhóm — SignalR duy trì ánh xạ người dùng sang kết nối tự động và bền qua reconnect, nên với "gửi cho một người" thì luôn dùng nó.

Mục tiêu bài học ​

Sau bài này bạn có thể:

  • Chọn đúng giữa User, Group, Client và All.
  • Đặt tên nhóm an toàn trong hệ thống nhiều khách hàng.
  • Xử lý việc nhóm mất sau khi kết nối lại.
  • Dùng Others và Except đúng chỗ.
  • Ước lượng chi phí broadcast.

Nội dung bài học ​

11.6.1 — Các cách nhắm đích ​

csharp
await Clients.All.Receive(dto);                          // moi ket noi
await Clients.Caller.Receive(dto);                       // dung tab vua goi
await Clients.Others.Receive(dto);                       // tat ca TRU tab vua goi
await Clients.Client(connectionId).Receive(dto);         // mot ket noi cu the
await Clients.User(userId).Receive(dto);                 // MỌI thiết bị của một người
await Clients.Users([id1, id2]).Receive(dto);            // nhiều người
await Clients.Group("tenant-42").Receive(dto);           // mot nhom
await Clients.GroupExcept("tenant-42", connectionId).Receive(dto);
await Clients.OthersInGroup("tenant-42").Receive(dto);   // trong nhom, tru tab goi
DùngKhi
CallerTrả lời trực tiếp một lời gọi
User(id)Gửi cho một người — mặc định cho thông báo cá nhân
Group(name)Chủ đề: tenant, phòng chat, bản ghi đang xem chung
Others / OthersInGroupTin nhắn chat: người gửi đã thấy tin của mình rồi
AllBảo trì hệ thống — hiếm khi đúng

Clients.All gần như luôn là lựa chọn sai trong hệ thống nhiều khách hàng: nó gửi cho mọi người đang kết nối, kể cả khách hàng khác.

Others và OthersInGroup giải quyết một vấn đề cụ thể trong chat: người gửi đã hiển thị tin nhắn của mình ngay khi bấm gửi (optimistic update), nên nhận lại chính tin đó sẽ tạo bản sao.

11.6.2 — Nhóm không bền vững ​

Ba hệ quả cần thiết kế quanh chúng:

Nhóm biến mất khi kết nối đứt. Không lưu ở đâu cả — SignalR chỉ giữ ánh xạ trong bộ nhớ (hoặc trong backplane).

Không hỏi được "ai đang trong nhóm". SignalR không có API nào trả về danh sách thành viên. Cần biết thì phải tự lưu (bài 11.9).

Không có quyền gắn với tên nhóm. AddToGroupAsync không kiểm tra gì. Ai gọi được Hub method đó đều vào được nhóm bất kỳ.

Cách xử lý đúng cho hệ quả thứ nhất: server tự gán nhóm trong OnConnectedAsync dựa trên claim (bài 11.5).

csharp
public override async Task OnConnectedAsync()
{
    var tenantId = Context.User?.FindFirstValue("tenant_id");
    if (tenantId is null) { Context.Abort(); return; }

    await Groups.AddToGroupAsync(Context.ConnectionId, $"tenant:{tenantId}");

    var teamId = Context.User!.FindFirstValue("team_id");
    if (teamId is not null)
        await Groups.AddToGroupAsync(Context.ConnectionId, $"tenant:{tenantId}:team:{teamId}");

    await base.OnConnectedAsync();
}

Vì OnConnectedAsync chạy lại sau mỗi lần kết nối lại, nhóm tự khôi phục — client không cần nhớ gọi lại gì cả.

Với nhóm động (phòng chat, bản ghi đang xem), client phải join lại, và bạn phải kiểm tra quyền mỗi lần:

csharp
public async Task JoinDealRoom(Guid dealId)
{
    await using var scope = _scopeFactory.CreateAsyncScope();
    var deals = scope.ServiceProvider.GetRequiredService<IDealRepository>();

    if (!await deals.CanAccessAsync(dealId, Context.UserIdentifier!, Context.ConnectionAborted))
        throw new HubException("Không có quyền truy cập");

    await Groups.AddToGroupAsync(Context.ConnectionId, GroupNames.DealRoom(tenantId, dealId));
}

11.6.3 — Đặt tên nhóm ​

Tên nhóm là chuỗi tự do, nên đặt tên sai là rò rỉ dữ liệu giữa các khách hàng:

csharp
public static class GroupNames
{
    // LUÔN có tenant ở đầu — cách ly cứng
    public static string Tenant(Guid tenantId) => $"t:{tenantId}";
    public static string Team(Guid tenantId, Guid teamId) => $"t:{tenantId}:team:{teamId}";
    public static string DealRoom(Guid tenantId, Guid dealId) => $"t:{tenantId}:deal:{dealId}";
    public static string Role(Guid tenantId, string role) => $"t:{tenantId}:role:{role}";
}

Ba nguyên tắc:

Luôn có tenant id ở đầu. Nhóm deal:42 có thể va chạm giữa hai khách hàng nếu id không toàn cục duy nhất. t:{tenant}:deal:42 thì không.

Dùng hằng số, không nối chuỗi tại chỗ. $"tenant-{id}" ở một nơi và $"tenant:{id}" ở nơi khác là hai nhóm khác nhau — và tin nhắn im lặng không tới ai.

Đừng đưa dữ liệu nhạy cảm vào tên nhóm. Tên nhóm xuất hiện trong log và trong backplane.

11.6.4 — Clients.User khác nhóm ở đâu ​

Với "gửi cho một người", bạn có hai lựa chọn:

csharp
// Cách 1 — nhóm một người (tự quản lý)
await Groups.AddToGroupAsync(Context.ConnectionId, $"user:{userId}");
await Clients.Group($"user:{userId}").Receive(dto);

// Cách 2 — Clients.User (SignalR tự quản lý)
await Clients.User(userId).Receive(dto);

Cách 2 tốt hơn ở mọi mặt:

  • SignalR tự duy trì ánh xạ người dùng sang kết nối, dựa trên IUserIdProvider.
  • Nó tự khôi phục sau mỗi lần kết nối lại, không cần OnConnectedAsync làm gì.
  • Nó hoạt động xuyên instance qua backplane mà không cần bạn làm gì.

Cách 1 chỉ đáng dùng khi bạn cần một biến thể đặc biệt của "một người" — ví dụ chỉ gửi tới thiết bị di động của họ.

11.6.5 — Chi phí broadcast ​

Broadcast tới nhóm lớn có ba loại chi phí:

Serialize một lần, gửi N lần. SignalR serialize payload một lần rồi gửi tới từng kết nối. 10.000 kết nối vẫn là 10.000 lượt ghi socket.

Backplane nhân lên. Với Redis backplane, mỗi lần gửi nhóm là một lần publish lên Redis, và mọi instance nhận rồi mới lọc ra kết nối của mình. Broadcast thường xuyên tới nhóm lớn làm Redis thành nút cổ chai (bài 11.10).

Client không xử lý kịp. Gửi 1.000 tin mỗi giây tới một trình duyệt là nó treo.

Ba cách giảm:

csharp
// 1. Gom nhiều thay đổi thành một tin
await Clients.Group(g).ReceiveBatch(changes);          // thay vi N lan goi

// 2. Gửi tín hiệu, client tự lấy chi tiết
await Clients.Group(g).DataChanged("deals");           // client goi REST

// 3. Giới hạn tần suất — tối đa 1 tin mỗi 2 giây cho một nhóm

Cách 2 đặc biệt đáng dùng cho bảng điều khiển: thay vì đẩy toàn bộ dữ liệu, đẩy một tín hiệu rằng có thay đổi và để client gọi REST lấy dữ liệu mới. Payload nhỏ, và nó tôn trọng phân quyền — mỗi client chỉ nhận phần dữ liệu họ được xem, điều mà broadcast không làm được.

Đây cũng là câu trả lời cho một vấn đề tinh tế: broadcast không lọc theo quyền. Mọi người trong nhóm nhận cùng một payload. Nếu payload chứa thứ chỉ một số người được xem, bạn vừa rò rỉ dữ liệu — và bài 10.6 đã nói việc lọc quyền phải nằm ở tầng truy vấn.

11.6.6 — Rà lại code của bạn ​

Danh sách rà soát nhóm và nhắm đích

  • Dùng Clients.User cho thông báo cá nhân, không tự tạo nhóm một người.
  • Không dùng Clients.All trong hệ thống nhiều khách hàng.
  • Mọi tên nhóm có tenant id ở đầu.
  • Tên nhóm sinh từ hằng số, không nối chuỗi rải rác.
  • Nhóm cố định được server gán trong OnConnectedAsync theo claim.
  • Mọi Hub method cho client tham gia nhóm đều kiểm tra quyền.
  • Payload broadcast không chứa dữ liệu mà chỉ một phần nhóm được xem.
  • Bảng điều khiển đẩy tín hiệu thay đổi thay vì đẩy toàn bộ dữ liệu.
  • Có giới hạn tần suất cho nhóm có lưu lượng cao.
  • Chat dùng Others hoặc OthersInGroup để tránh nhân đôi tin của người gửi.

Bài tập áp dụng ​

Bài 1 — Nhóm join từ client biến mất sau reconnect ​

Join một nhóm bằng Hub method từ client, ngắt mạng rồi kết nối lại, và xác nhận client không còn nhận tin của nhóm. Chuyển sang gán ở OnConnectedAsync và kiểm chứng.

Tiêu chí hoàn thành: bạn nêu được nhóm được lưu theo cái gì, và vì sao điều đó quyết định hành vi sau reconnect.

Gợi ý và lời giải — Bài 1

Gợi ý. Nhóm là một tập hợp các ConnectionId, không phải một tập hợp người dùng.

Lời giải — bản join từ client:

csharp
public Task JoinTenantGroup()
{
    var tenantId = Context.User!.FindFirstValue("tenant_id")!;
    return Groups.AddToGroupAsync(Context.ConnectionId, $"tenant:{tenantId}");
}
js
await connection.start();
await connection.invoke('JoinTenantGroup');     // chỉ gọi MỘT lần
bash
sudo ip link set eth0 down; sleep 20; sudo ip link set eth0 up
text
onreconnecting
onreconnected — ConnectionId mới
(server gửi vào nhóm tenant:acme — client KHÔNG nhận được)

Câu trả lời cho tiêu chí. Nhóm được lưu dưới dạng ánh xạ tên nhóm -> tập ConnectionId. Khi kết nối đứt, SignalR chạy OnDisconnectedAsync và gỡ ConnectionId đó khỏi mọi nhóm — hợp lý, vì id ấy không còn tồn tại. Kết nối lại sinh ra ConnectionId mới, và id mới chưa thuộc nhóm nào.

Nói cách khác: nhóm sống theo kết nối, còn người dùng thì sống lâu hơn kết nối của họ. Mọi thứ gắn vào ConnectionId đều phải được dựng lại sau mỗi lần reconnect.

Bản sửa — gán ở OnConnectedAsync:

csharp
public override async Task OnConnectedAsync()
{
    var tenantId = Context.User!.FindFirstValue("tenant_id");
    if (!string.IsNullOrEmpty(tenantId))
        await Groups.AddToGroupAsync(Context.ConnectionId, $"tenant:{tenantId}");

    await base.OnConnectedAsync();
}
text
onreconnected
(server gửi vào nhóm tenant:acme — client NHẬN ĐƯỢC)

OnConnectedAsync chạy mỗi lần kết nối, kể cả lần kết nối lại — nên nhóm tự khôi phục và client không phải làm gì.

Cách này còn an toàn hơn. Client gọi JoinTenantGroup nghĩa là client tham gia vào quyết định nhóm của mình; nếu phương thức nhận tham số thì đó là lỗ hổng ở bài 11.5. Server đọc claim đã ký thì không có chỗ nào để lách.

Với nhóm mà người dùng chủ động chọn — ví dụ phòng của một deal đang mở — thì server không đoán được, nên client vẫn phải join lại:

js
async function joinRooms() {
    if (currentDealId) await connection.invoke('JoinDealRoom', currentDealId);
}

connection.onreconnected(joinRooms);
await connection.start();
await joinRooms();

Bảng phân vai:

Loại nhómAi gánSau reconnect
Suy ra từ claim (tenant, vai trò)Server, trong OnConnectedAsyncTự khôi phục
Do người dùng chọn (phòng, deal)Client gọi, server kiểm quyềnClient phải join lại

Một chi tiết về OnDisconnectedAsync. Nó không chạy khi mạng đứt đột ngột — chỉ chạy khi client đóng kết nối tử tế hoặc khi server phát hiện timeout. Nên đừng đặt logic quan trọng ở đó mà không có cơ chế dọn theo thời gian, ví dụ khoá Redis có TTL như bài 11.9.


Bài 2 — Tên nhóm lệch một ký tự ​

Dùng $"tenant-{id}" ở chỗ gửi và $"tenant:{id}" ở chỗ join. Xác nhận tin nhắn không tới ai và không có lỗi nào.

Tiêu chí hoàn thành: bạn giải thích được vì sao SignalR không thể báo lỗi, và nêu cách làm cho lỗi này không xảy ra được nữa.

Gợi ý và lời giải — Bài 2

Gợi ý. Nhóm trong SignalR không cần khai báo trước. Gửi vào một nhóm chưa ai join là hoàn toàn hợp lệ.

Lời giải — hai chỗ lệch:

csharp
// Lúc join
await Groups.AddToGroupAsync(Context.ConnectionId, $"tenant:{tenantId}");

// Lúc gửi — dấu gạch ngang thay vì hai chấm
await _hub.Clients.Group($"tenant-{tenantId}").ReceiveNotification(dto);
text
(gửi thành công, không exception, không warning, không log)
(không client nào nhận được gì)

Vì sao SignalR không thể báo lỗi. Nhóm được tạo ngầm khi có kết nối đầu tiên join vào, và biến mất khi kết nối cuối cùng rời đi. Không có bước đăng ký nào, nên không có danh sách nhóm hợp lệ để đối chiếu.

Và gửi vào nhóm rỗng là chuyện bình thường: mọi người dùng của một tenant đang offline thì nhóm đó rỗng, nhưng lời gọi vẫn phải thành công. Nếu SignalR cảnh báo "nhóm này không ai nghe", nó sẽ cảnh báo sai suốt ngày.

Kết quả: một lỗi gõ nhầm trở nên hoàn toàn im lặng, và triệu chứng duy nhất là "thông báo không hiện" — thứ rất dễ bị đổ cho mạng hay cho trình duyệt.

Cách làm cho lỗi này không xảy ra được — tập trung việc dựng tên nhóm:

csharp
public static class HubGroups
{
    public static string Tenant(string tenantId) => $"tenant:{tenantId}";
    public static string Deal(Guid dealId)       => $"deal:{dealId}";
    public static string User(Guid userId)       => $"user:{userId}";
}
csharp
await Groups.AddToGroupAsync(Context.ConnectionId, HubGroups.Tenant(tenantId));
await _hub.Clients.Group(HubGroups.Tenant(tenantId)).ReceiveNotification(dto);

Giờ hai chỗ dùng cùng một hàm, nên không thể lệch. Gõ sai tên hàm thì trình biên dịch báo ngay — cùng ý tưởng với strongly-typed Hub ở bài 11.3.

Thêm một bài test chặn việc ai đó lại viết chuỗi trực tiếp:

csharp
[Fact]
public void KhongNoiNaoTuGhepTenNhom()
{
    var offenders = Directory
        .EnumerateFiles("../../../../src", "*.cs", SearchOption.AllDirectories)
        .SelectMany(f => File.ReadLines(f).Select((line, i) => (f, i: i + 1, line)))
        .Where(x => Regex.IsMatch(x.line, @"(AddToGroupAsync|Clients\.Group)\s*\([^)]*\$""") )
        .Select(x => $"{Path.GetFileName(x.f)}:{x.i}")
        .ToList();

    Assert.Empty(offenders);
}

Một cái bẫy nữa cùng họ: nhóm phân biệt hoa thường. "Tenant:acme" và "tenant:acme" là hai nhóm khác nhau. Dùng hàm tập trung thì vấn đề này cũng biến mất.

Và luôn đặt tenant ở đầu tên nhóm. $"tenant:{tenantId}:deal:{dealId}" khiến một lỗi gõ nhầm dealId cùng lắm là gửi nhầm trong nội bộ một tổ chức, chứ không bao giờ vượt sang tổ chức khác — cách ly cứng ngay trong quy ước đặt tên.


Bài 3 — Chi phí broadcast và mẫu gửi tín hiệu ​

Tạo 500 kết nối vào một nhóm, đẩy 100 tin mỗi giây, và đo CPU server cùng lưu lượng Redis. Chuyển sang gửi tín hiệu và so sánh.

Tiêu chí hoàn thành: bạn nêu được chi phí broadcast tăng theo tích của những đại lượng nào.

Gợi ý và lời giải — Bài 3

Gợi ý. Mỗi tin phải được tuần tự hoá, đi qua backplane, rồi ghi ra từng socket một.

Lời giải — đo bản broadcast đầy đủ:

csharp
await _hub.Clients.Group(HubGroups.Tenant(tenantId))
    .ReceiveDealUpdate(fullDealDto);        // DTO đầy đủ, ~2 KB
text
Kết nối trong nhóm:        500
Tin gửi mỗi giây:          100
Kích thước mỗi tin:        ~2 KB

CPU server:                62%
Băng thông ra:             500 × 100 × 2 KB = 100 MB/s
Lưu lượng Redis backplane: 100 × 2 KB × (số instance - 1)

Chi phí tăng theo tích của ba đại lượng:

text
số kết nối × số tin mỗi giây × kích thước mỗi tin

Đây là câu trả lời cho tiêu chí, và nó giải thích vì sao hệ thống "chạy tốt lúc thử" lại sập khi lên thật: thử với 5 kết nối thì tích nhỏ hơn 100 lần.

Riêng Redis chỉ nhân theo số instance, không theo số kết nối — mỗi instance nhận một bản rồi tự phát cho kết nối của mình.

Ba cách giảm, theo thứ tự hiệu quả:

1. Gửi tín hiệu thay vì gửi dữ liệu.

csharp
// Chỉ báo "có thay đổi", client tự lấy chi tiết
await _hub.Clients.Group(HubGroups.Tenant(tenantId))
    .DealChanged(dealId);                   // ~40 byte
js
connection.on('DealChanged', async id => {
    const deal = await fetch(`/api/v1/deals/${id}`).then(r => r.json());
    store.update(deal);
});
text
Băng thông ra: 500 × 100 × 40 B = 2 MB/s      (giảm 50 lần)
CPU server:    11%

Đổi lại là mỗi client gọi thêm một REST request — nhưng chỉ khi họ thật sự đang xem deal đó. Phần lớn client bỏ qua tín hiệu không liên quan, nên tổng tải giảm mạnh.

2. Gom nhiều thay đổi thành một tin.

csharp
// Thay vì 100 tin mỗi giây, gom lại và gửi mỗi 500ms
private readonly Channel<Guid> _pending = Channel.CreateUnbounded<Guid>();

using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(500));
while (await timer.WaitForNextTickAsync(ct))
{
    var ids = DrainPending();
    if (ids.Count > 0)
        await _hub.Clients.Group(group).DealsChanged(ids);
}

100 tin/giây thành 2 tin/giây, mỗi tin mang danh sách id. Người dùng không phân biệt được độ trễ 500ms, còn chi phí giảm 50 lần nữa.

3. Thu hẹp nhóm.

csharp
// Thay vì gửi cho cả tenant, chỉ gửi cho người đang mở deal đó
await _hub.Clients.Group(HubGroups.Deal(dealId)).DealChanged(dealId);

Nhóm 500 người xuống còn 3–5 người đang thật sự xem. Đây là cách giảm mạnh nhất, và thường cũng đúng về mặt nghiệp vụ — người không mở deal thì không cần cập nhật tức thì.

Bảng so sánh:

CáchBăng thôngĐánh đổi
Broadcast DTO đầy đủ100 MB/s—
Gửi tín hiệu2 MB/sThêm REST request cho client đang xem
Tín hiệu + gom40 KB/sTrễ tối đa 500ms
Tín hiệu + gom + nhóm hẹp~1 KB/sPhải quản lý nhóm theo deal

Một chỉ số nên theo dõi: băng thông ra chia cho số kết nối. Nếu con số đó tăng khi số người dùng tăng, bạn đang broadcast thứ lẽ ra nên gửi có chọn lọc.

Tự kiểm tra ​

Nhóm trong SignalR có bền vững không?

Không. Nhóm là tập các ConnectionId giữ trong bộ nhớ hoặc backplane, và mọi thành viên biến mất khi kết nối đứt. Cũng không có API nào hỏi ai đang trong nhóm; cần biết thì phải tự lưu.

Vì sao nên dùng Clients.User thay vì tự tạo nhóm một người?

Vì SignalR tự duy trì ánh xạ người dùng sang kết nối dựa trên IUserIdProvider, tự khôi phục sau mỗi lần kết nối lại mà không cần OnConnectedAsync làm gì, và hoạt động xuyên instance qua backplane. Nhóm tự quản lý thì bạn phải tự lo tất cả.

Đặt tên nhóm cần theo nguyên tắc nào?

Luôn có tenant id ở đầu để tránh va chạm giữa các khách hàng, sinh tên từ hằng số thay vì nối chuỗi rải rác vì một dấu gạch ngang thay cho dấu hai chấm tạo ra hai nhóm khác nhau, và không đưa dữ liệu nhạy cảm vào tên nhóm vì nó xuất hiện trong log và backplane.

AddToGroupAsync có kiểm tra quyền không?

Không, tên nhóm là chuỗi tự do và không có quyền nào gắn với nó. Ai gọi được Hub method đó đều vào được nhóm bất kỳ, nên mọi method cho client tham gia nhóm đều phải tự kiểm tra quyền.

Broadcast tới nhóm lớn tốn kém ở đâu?

Payload được serialize một lần nhưng vẫn gửi N lượt ghi socket. Với backplane, mỗi lần gửi nhóm là một lần publish và mọi instance đều nhận rồi mới lọc, nên Redis dễ thành nút cổ chai. Và client cũng có thể không xử lý kịp nếu tần suất quá cao.

Vì sao nên đẩy tín hiệu thay vì đẩy dữ liệu cho bảng điều khiển?

Payload nhỏ hơn nhiều, và quan trọng hơn là nó tôn trọng phân quyền: mỗi client gọi REST và chỉ nhận phần dữ liệu họ được xem. Broadcast thì mọi người trong nhóm nhận cùng một payload, nên nếu nó chứa thứ chỉ một số người được xem thì bạn vừa rò rỉ dữ liệu.

Kết luận ​

Ba điều đáng nhớ nhất:

  1. Nhóm biến mất khi kết nối đứt. Gán nhóm cố định ở OnConnectedAsync.
  2. Clients.User bền qua reconnect và xuyên instance — luôn dùng nó cho "gửi cho một người".
  3. Broadcast không lọc theo quyền. Đẩy tín hiệu, để client gọi REST.

Tham khảo ​

Điều hướng ​

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