11.7 - IHubContext - Server push từ ngoài Hub
Tóm tắt:
IHubContext<THub, TClient>cho phép bất kỳ đâu trong ứng dụng đẩy tin tới client — controller, service, background job — mà không cần client gọi Hub trước. Nó là singleton, an toàn để tiêm vào mọi nơi, kể cảBackgroundService. Hai quyết định thiết kế quan trọng hơn cú pháp. Một: đừng push bên trong transaction database — nếu transaction rollback sau đó, client đã nhận thông báo về một thay đổi không hề xảy ra. Hai: push là "gửi rồi quên" — nó không đảm bảo tới nơi, không retry, và client offline thì mất hẳn; nên ghi vào database trước, push sau, và để client đồng bộ qua REST.
Mục tiêu bài học
Sau bài này bạn có thể:
- Tiêm và dùng
IHubContexttừ service, controller và background job. - Đặt push đúng chỗ so với transaction database.
- Xử lý lỗi khi push thất bại mà không làm hỏng nghiệp vụ.
- Áp dụng mẫu outbox cho thông báo không được mất.
- Tránh để SignalR trở thành phụ thuộc cứng của nghiệp vụ.
Nội dung bài học
11.7.1 — Cách dùng
public sealed class LeadService(
IHubContext<NotificationHub, INotificationClient> hub,
ILeadRepository leads,
INotificationRepository notifications,
ILogger<LeadService> logger)
{
public async Task AssignAsync(Guid leadId, string assigneeId, CancellationToken ct)
{
var lead = await leads.GetAsync(leadId, ct)
?? throw new NotFoundException(nameof(Lead), leadId);
lead.AssignTo(assigneeId);
var notification = Notification.Create(
userId: assigneeId,
message: $"Lead '{lead.Name}' đã được giao cho bạn",
entityType: nameof(Lead), entityId: leadId.ToString());
await notifications.AddAsync(notification, ct);
await leads.SaveChangesAsync(ct); // LƯU XONG mới push
await PushSafelyAsync(assigneeId, notification.ToDto(), ct);
}
}IHubContext là singleton, nên tiêm vào bất cứ đâu đều được — kể cả BackgroundService, thứ cũng là singleton (bài 9.9).
Dùng phiên bản hai tham số generic IHubContext<THub, TClient> để giữ kiểm tra lúc biên dịch:
// Một generic — chuỗi ma thuật, gõ sai là im lặng
IHubContext<NotificationHub> hub;
await hub.Clients.User(id).SendAsync("ReceiveNotification", dto);
// Hai generic — kiểm tra lúc biên dịch
IHubContext<NotificationHub, INotificationClient> hub;
await hub.Clients.User(id).ReceiveNotification(dto);11.7.2 — Đừng push trong transaction
// SAI
await using var tx = await db.Database.BeginTransactionAsync(ct);
await db.SaveChangesAsync(ct);
await hub.Clients.User(userId).ReceiveNotification(dto); // PUSH SOM
await SomethingElseAsync(ct); // neu cai nay nem -> rollback
await tx.CommitAsync(ct);Nếu SomethingElseAsync ném exception, transaction rollback và thay đổi biến mất — nhưng client đã nhận thông báo. Họ thấy "Lead đã được giao cho bạn", bấm vào, và không có gì cả.
Loại lỗi này rất khó chẩn đoán vì nó chỉ xảy ra khi có lỗi ở nhánh sau, và log thì ghi rằng thông báo đã gửi thành công.
// DUNG — commit xong moi push
await using var tx = await db.Database.BeginTransactionAsync(ct);
await db.SaveChangesAsync(ct);
await SomethingElseAsync(ct);
await tx.CommitAsync(ct);
await PushSafelyAsync(userId, dto, ct); // sau commitQuy tắc: push là tác dụng phụ bên ngoài, nên nó thuộc về sau khi transaction đã commit — cùng nhóm với gửi email và gọi API bên thứ ba.
11.7.3 — Push thất bại không được làm hỏng nghiệp vụ
private async Task PushSafelyAsync(string userId, NotificationDto dto, CancellationToken ct)
{
try
{
await hub.Clients.User(userId).ReceiveNotification(dto);
}
catch (Exception ex)
{
// Đã lưu DB rồi — client sẽ thấy khi đồng bộ qua REST
logger.LogWarning(ex, "Push thất bại cho {UserId}", userId);
}
}Đây là trường hợp hiếm hoi mà nuốt exception là đúng: dữ liệu đã lưu, và push chỉ là lớp tăng tốc (bài 11.4). Để một lỗi Redis backplane làm hỏng việc giao lead là đánh đổi sai.
Nhưng đừng fire-and-forget:
// SAI — exception biến mất, và việc bị cắt khi request kết thúc
_ = hub.Clients.User(userId).ReceiveNotification(dto);Đây đúng là mẫu bị phê phán ở bài 6.6. Push SignalR thường rất nhanh, nên await nó trong try/catch gần như không tốn gì — và bạn giữ được log.
Nếu push thật sự chậm (backplane quá tải), giải pháp đúng là đẩy vào Channel và để một BackgroundService gửi, không phải bỏ await.
11.7.4 — Push từ background job
public sealed class ReportWorker(
IJobQueue queue,
IServiceScopeFactory scopeFactory,
IHubContext<NotificationHub, INotificationClient> hub,
ILogger<ReportWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (var job in queue.ReadAllAsync(stoppingToken))
{
try
{
await using var scope = scopeFactory.CreateAsyncScope();
var reports = scope.ServiceProvider.GetRequiredService<IReportService>();
// Báo tiến độ — client thấy thanh progress chạy
await hub.Clients.User(job.UserId).ReportProgress(job.Id, 0);
var url = await reports.GenerateAsync(job, stoppingToken);
await hub.Clients.User(job.UserId)
.ReceiveNotification(NotificationDto.ReportReady(job.Name, url));
}
catch (Exception ex)
{
logger.LogError(ex, "Tạo báo cáo {JobId} thất bại", job.Id);
await hub.Clients.User(job.UserId)
.ReceiveNotification(NotificationDto.ReportFailed(job.Name));
}
}
}
}IHubContext là singleton nên tiêm thẳng vào constructor của BackgroundService — không cần IServiceScopeFactory cho nó (chỉ cần cho những service scoped khác).
Chú ý thông báo thất bại cũng được gửi. Người dùng bấm "Xuất báo cáo" rồi chờ mãi mà không biết nó hỏng là trải nghiệm tệ hơn nhiều so với một thông báo lỗi.
Và đừng đưa ex.Message vào thông báo gửi client — nó có thể chứa chi tiết nội bộ (bài 8.8).
11.7.5 — Outbox cho thông báo không được mất
Với thông báo quan trọng — đơn hàng được duyệt, thanh toán thành công — mẫu ở trên vẫn có một lỗ hổng: nếu tiến trình chết giữa CommitAsync và PushSafelyAsync, thông báo không bao giờ được gửi.
Mẫu outbox đóng lỗ hổng đó: ghi thông báo trong cùng transaction với dữ liệu nghiệp vụ, rồi một worker riêng đọc và gửi.
// Trong transaction — nguyên tử với dữ liệu nghiệp vụ
db.Outbox.Add(new OutboxMessage
{
Type = nameof(NotificationDto),
Payload = JsonSerializer.Serialize(dto),
TargetUserId = assigneeId,
});
await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);// Worker rieng
var pending = await db.Outbox
.Where(m => m.SentAt == null)
.OrderBy(m => m.CreatedAt)
.Take(100)
.ToListAsync(ct);
foreach (var message in pending)
{
var dto = JsonSerializer.Deserialize<NotificationDto>(message.Payload)!;
await hub.Clients.User(message.TargetUserId).ReceiveNotification(dto);
message.SentAt = DateTime.UtcNow;
}
await db.SaveChangesAsync(ct);Bây giờ hoặc cả hai (dữ liệu và bản ghi outbox) cùng được lưu, hoặc không cái nào — vì chúng nằm trong một transaction.
Cái giá: thêm một bảng, một worker, và độ trễ bằng chu kỳ của worker. Chỉ dùng cho thông báo mà mất đi là có hậu quả thật. Chi tiết ở Module 17 — Distributed Systems.
11.7.6 — Đừng để SignalR thành phụ thuộc cứng
// Không lý tưởng — tầng nghiệp vụ biết về SignalR
public sealed class LeadService(IHubContext<NotificationHub, INotificationClient> hub);LeadService giờ không test được nếu không dựng IHubContext, và không dùng lại được trong một console tool không có SignalR.
// Tot hon — mot interface hep
public interface INotificationPublisher
{
Task PublishAsync(string userId, NotificationDto notification, CancellationToken ct);
}
public sealed class SignalRNotificationPublisher(
IHubContext<NotificationHub, INotificationClient> hub,
ILogger<SignalRNotificationPublisher> logger) : INotificationPublisher
{
public async Task PublishAsync(string userId, NotificationDto dto, CancellationToken ct)
{
try { await hub.Clients.User(userId).ReceiveNotification(dto); }
catch (Exception ex) { logger.LogWarning(ex, "Push thất bại cho {UserId}", userId); }
}
}Lợi ích: nghiệp vụ phụ thuộc vào một interface một phương thức, test bằng mock; việc nuốt lỗi nằm gọn ở một chỗ; và đổi sang SSE hay Web Push sau này chỉ cần thêm một implementation (bài 7.5).
11.7.7 — Rà lại code của bạn
Danh sách rà soát IHubContext
- Dùng IHubContext<THub, TClient> hai generic, không dùng SendAsync với chuỗi.
- Push xảy ra SAU khi transaction đã commit.
- Push được bọc try/catch và không làm hỏng nghiệp vụ khi thất bại.
- Không fire-and-forget bằng dấu gạch dưới; luôn await trong try/catch.
- Thông báo được ghi vào database trước khi push.
- Background job gửi cả thông báo thất bại, không chỉ thành công.
- Không đưa thông điệp exception vào thông báo gửi client.
- Thông báo không được mất dùng mẫu outbox.
- Tầng nghiệp vụ phụ thuộc interface hẹp, không phụ thuộc IHubContext.
Bài tập áp dụng
Bài 1 — Push trước rollback: client thấy thứ không tồn tại
Đặt push bên trong transaction, ném exception ở bước sau, và xác nhận client nhận thông báo về một thay đổi đã bị rollback.
Tiêu chí hoàn thành: bạn nêu được vì sao đẩy thông báo không thể tham gia vào transaction của database.
Gợi ý và lời giải — Bài 1
Gợi ý. Transaction là cơ chế của database. Một gói tin đã rời khỏi tiến trình thì không có cách nào gọi lại.
Lời giải — thứ tự sai:
await using var tx = await _db.Database.BeginTransactionAsync(ct);
var lead = Lead.Create(req.Name, req.Value);
_db.Leads.Add(lead);
await _db.SaveChangesAsync(ct);
await _hub.Clients.Group(HubGroups.Tenant(tenantId))
.LeadCreated(Map(lead)); // PUSH TRONG transaction
await _audit.RecordAsync(lead.Id, ct); // ném exception ở đây
await tx.CommitAsync(ct); // không bao giờ tớiClient: nhận LeadCreated, hiện lead mới trên danh sách
Database: transaction rollback — lead KHÔNG tồn tại
Người dùng bấm vào lead đó -> 404Câu trả lời cho tiêu chí. Transaction của database bảo đảm tính nguyên tử cho những thay đổi nằm trong database. Thông báo SignalR là một gói tin đi qua mạng tới trình duyệt — nó nằm ngoài phạm vi đó hoàn toàn.
Khi ROLLBACK chạy, database hoàn tác các dòng đã ghi. Nó không có cách nào — và cũng không có khái niệm — để thu hồi một gói tin đã tới máy người dùng. Đây chính là bài toán dual-write ở bài 17.2: hai hệ thống, không có transaction chung.
Bản sửa — commit trước, push sau:
await using var tx = await _db.Database.BeginTransactionAsync(ct);
var lead = Lead.Create(req.Name, req.Value);
_db.Leads.Add(lead);
await _db.SaveChangesAsync(ct);
await _audit.RecordAsync(lead.Id, ct);
await tx.CommitAsync(ct); // COMMIT trước
await _hub.Clients.Group(HubGroups.Tenant(tenantId))
.LeadCreated(Map(lead)); // rồi mới pushNhưng thứ tự này đổi loại lỗi chứ không xoá lỗi. Giờ rủi ro nằm ở chiều ngược lại:
Trước: commit thất bại nhưng đã push -> client thấy thứ KHÔNG tồn tại
Sau: commit xong nhưng push thất bại -> client KHÔNG thấy thứ đã tồn tạiVà đây là đánh đổi đúng, vì hai hậu quả không ngang nhau:
| Hậu quả | Khắc phục | |
|---|---|---|
| Push trước commit | Client hiện dữ liệu ma, bấm vào thì 404 | Không tự khỏi — phải xoá tay ở client |
| Commit trước push | Client chưa thấy dữ liệu mới | Tự khỏi khi tải lại trang hoặc lần đồng bộ sau |
Miễn là bạn giữ nguyên tắc ở bài 11.4 — database là nguồn sự thật, SignalR chỉ là lối tắt — thì mất một thông báo chỉ là chậm hơn, không phải sai.
Khi không được phép mất thông báo nào, dùng outbox: ghi thông báo vào database trong cùng transaction, một worker đọc bảng đó và đẩy đi. Xem bài 3.
Bài 2 — Fire-and-forget nuốt luôn lỗi
Dùng _ = hub.Clients... với một backplane đang hỏng, và xác nhận không có log nào. Chuyển sang await trong try/catch và so sánh.
Tiêu chí hoàn thành: bạn nêu được exception đi đâu khi không ai await, và vì sao nó có thể không xuất hiện ở bất kỳ đâu.
Gợi ý và lời giải — Bài 2
Gợi ý. Exception nằm trong Task. Nếu không ai đọc Task đó thì không ai thấy exception.
Lời giải — bản nuốt lỗi:
public async Task<Lead> CreateAsync(CreateLeadRequest req, CancellationToken ct)
{
var lead = Lead.Create(req.Name, req.Value);
_db.Leads.Add(lead);
await _db.SaveChangesAsync(ct);
_ = _hub.Clients.Group(group).LeadCreated(Map(lead)); // không await
return lead;
}Tắt Redis rồi gọi endpoint:
docker stop redis
curl -s -X POST .../api/v1/leads -d '{"name":"Công ty ABC","value":5000000}'
# 201 Created(log ứng dụng: không có dòng nào về SignalR)
(client: không nhận được thông báo)API trả 201, mọi thứ trông bình thường, và thông báo biến mất không dấu vết.
Exception đi đâu. Nó được lưu bên trong đối tượng Task mà _ = vừa vứt đi. Không ai await, không ai đọc .Exception, nên nó nằm im ở đó. Khi bộ thu gom rác dọn Task ấy, .NET phát sự kiện TaskScheduler.UnobservedTaskException.
Vấn đề là sự kiện đó:
- xảy ra muộn — lúc GC chạy, có thể vài phút sau,
- không có ngữ cảnh gì về request đã gây ra nó,
- và nếu bạn chưa đăng ký handler thì không đi đâu cả.
Nên thực tế là exception biến mất hoàn toàn, đúng như phân tích ở bài 6.7.
Bản sửa:
try
{
await _hub.Clients.Group(group).LeadCreated(Map(lead));
}
catch (Exception ex)
{
// Đã lưu DB rồi — client sẽ thấy khi đồng bộ qua REST
_logger.LogWarning(ex, "Push thất bại cho nhóm {Group}", group);
}warn: Crm.Api.Services.LeadService[0]
Push thất bại cho nhóm tenant:acme
RedisConnectionException: No connection is available to service this operationBa thứ có được từ đoạn này:
- Bạn biết chuyện đã xảy ra. Một cảnh báo trong log là đủ để phát hiện backplane hỏng trước khi người dùng báo.
- Request vẫn thành công.
catchgiữ cho lỗi thông báo không làm hỏng nghiệp vụ chính — đúng, vì dữ liệu đã lưu an toàn. - Mức log đúng là
Warning, không phảiError. Đây là thứ suy giảm chấp nhận được, không phải sự cố. Nhưng nếu nó xuất hiện liên tục thì cảnh báo trên số lượng sẽ bắt được.
Một lưới an toàn đáng đặt ở Program.cs:
TaskScheduler.UnobservedTaskException += (_, e) =>
{
logger.LogError(e.Exception, "Unobserved task exception");
e.SetObserved();
};Nó không thay thế việc await đúng chỗ, nhưng nó biến "biến mất hoàn toàn" thành "ít nhất có trong log".
Và một chỉ số đáng theo dõi: tỷ lệ push thất bại. Backplane hỏng thường là hỏng một phần — vài instance mất kết nối Redis trong khi số còn lại vẫn chạy — nên triệu chứng là "một số người không nhận được thông báo", thứ gần như không thể tái hiện bằng tay.
Bài 3 — Outbox: thông báo sống sót qua crash
Cài mẫu outbox, kill tiến trình ngay sau CommitAsync, khởi động lại, và xác nhận thông báo vẫn được gửi.
Tiêu chí hoàn thành: bạn nêu được outbox đánh đổi cái gì để có được bảo đảm đó.
Gợi ý và lời giải — Bài 3
Gợi ý. Nếu thông báo được ghi vào cùng database trong cùng transaction, thì nó được bảo vệ bởi chính cơ chế đã bảo vệ dữ liệu nghiệp vụ.
Lời giải — ghi outbox trong transaction:
await using var tx = await _db.Database.BeginTransactionAsync(ct);
var lead = Lead.Create(req.Name, req.Value);
_db.Leads.Add(lead);
_db.OutboxMessages.Add(new OutboxMessage
{
Id = Guid.CreateVersion7(),
Type = nameof(LeadCreated),
Payload = JsonSerializer.Serialize(Map(lead)),
TargetGroup = HubGroups.Tenant(tenantId),
CreatedUtc = DateTime.UtcNow
});
await _db.SaveChangesAsync(ct); // MỘT transaction cho cả hai
await tx.CommitAsync(ct);Worker đọc bảng và đẩy đi:
protected override async Task ExecuteAsync(CancellationToken ct)
{
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(200));
while (await timer.WaitForNextTickAsync(ct))
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<CrmDbContext>();
var pending = await db.OutboxMessages
.Where(m => m.ProcessedUtc == null)
.OrderBy(m => m.Id)
.Take(50)
.ToListAsync(ct);
foreach (var msg in pending)
{
try
{
await _hub.Clients.Group(msg.TargetGroup)
.ReceiveRaw(msg.Type, msg.Payload);
msg.ProcessedUtc = DateTime.UtcNow;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Outbox {Id} thất bại, sẽ thử lại", msg.Id);
break; // dừng lô này, giữ thứ tự
}
}
await db.SaveChangesAsync(ct);
}
}Kiểm chứng:
curl -s -X POST .../api/v1/leads -d '{"name":"Công ty ABC","value":5000000}' &
sleep 0.05 && kill -9 $(pgrep -f Crm.Api) # kill ngay sau commitSELECT Id, Type, ProcessedUtc FROM OutboxMessages ORDER BY Id DESC;
-- 018f3a1b... LeadCreated NULLdotnet run # khởi động lại(vài trăm mili-giây sau, client nhận LeadCreated)-- ProcessedUtc = 2026-09-25 09:14:22.104Thông báo sống sót qua crash.
Outbox đánh đổi ba thứ:
Độ trễ. Push trực tiếp tới client trong vài mili-giây; qua outbox thì phải chờ vòng quét tiếp theo. Với chu kỳ 200ms, độ trễ trung bình thêm khoảng 100ms — chấp nhận được với thông báo, nhưng không phù hợp cho thứ cần tức thì như con trỏ chuột trong tài liệu dùng chung.
Tải database. Thêm một lần ghi cho mỗi thông báo, cộng một truy vấn mỗi 200ms cho mỗi instance. Cần index lọc để truy vấn không quét cả bảng:
sqlCREATE INDEX IX_Outbox_Pending ON OutboxMessages(Id) WHERE ProcessedUtc IS NULL;Độ phức tạp. Thêm một bảng, một worker, một job dọn bảng, và phải xử lý chuyện nhiều instance cùng đọc — nếu không thì mỗi thông báo bị gửi nhiều lần:
sqlSELECT TOP (50) * FROM OutboxMessages WITH (UPDLOCK, READPAST) WHERE ProcessedUtc IS NULL ORDER BY Id;READPASTbỏ qua hàng instance khác đang khoá, nên hai worker không lấy trùng — chi tiết ở bài 17.3.
Khi nào đáng dùng outbox cho SignalR — và thường là không.
Thông báo real-time vốn đã là thứ có thể mất: client mất mạng là mất tin, và bạn đã phải có cơ chế đồng bộ qua REST rồi. Nếu đã có cơ chế đó, outbox chỉ thêm chi phí mà không thêm bảo đảm nào thật sự.
Outbox đáng dùng khi thông báo kích hoạt một việc khác — gửi email, gọi webhook sang đối tác, ghi vào hệ thống kế toán. Lúc đó "mất" không còn tự khỏi được, và tính bảo đảm mới đáng giá.
Tự kiểm tra
IHubContext có lifetime gì?
Singleton, nên tiêm vào bất cứ đâu đều được, kể cả BackgroundService vốn cũng là singleton. Không cần IServiceScopeFactory cho riêng nó, chỉ cần cho những service scoped khác.
Vì sao không push bên trong transaction?
Vì nếu transaction rollback sau đó, thay đổi biến mất nhưng client đã nhận thông báo về nó, nên họ thấy một thông báo trỏ tới thứ không tồn tại. Push là tác dụng phụ bên ngoài nên thuộc về sau khi commit, cùng nhóm với gửi email và gọi API bên thứ ba.
Nuốt exception khi push có đúng không?
Đúng trong trường hợp này, vì dữ liệu đã được lưu và push chỉ là lớp tăng tốc; client sẽ thấy khi đồng bộ qua REST. Để một lỗi backplane làm hỏng nghiệp vụ là đánh đổi sai. Nhưng phải await trong try catch để giữ log, chứ không fire-and-forget.
Vì sao không nên fire-and-forget khi push?
Vì exception biến mất và công việc có thể bị cắt khi request kết thúc. Push SignalR thường rất nhanh nên await trong try catch gần như không tốn gì. Nếu push thật sự chậm thì giải pháp đúng là đẩy vào Channel cho một BackgroundService gửi, không phải bỏ await.
Mẫu outbox giải quyết lỗ hổng nào?
Nếu tiến trình chết giữa lúc commit transaction và lúc push, thông báo không bao giờ được gửi. Outbox ghi thông báo trong cùng transaction với dữ liệu nghiệp vụ nên hoặc cả hai cùng lưu hoặc không cái nào, rồi một worker riêng đọc và gửi. Cái giá là thêm một bảng, một worker và độ trễ bằng chu kỳ worker.
Vì sao không tiêm IHubContext thẳng vào tầng nghiệp vụ?
Vì service đó không test được nếu không dựng IHubContext và không dùng lại được ở nơi không có SignalR. Gói sau một interface một phương thức thì nghiệp vụ test bằng mock, việc nuốt lỗi nằm gọn một chỗ, và đổi sang SSE hay Web Push chỉ cần thêm một implementation.
Kết luận
Ba điều đáng nhớ nhất:
- Push sau khi commit, không bao giờ bên trong transaction.
- Ghi database trước, push sau. Push thất bại không được làm hỏng nghiệp vụ.
- Thông báo không được mất thì cần outbox — push thuần là "gửi rồi quên".
Tham khảo
- Send messages from outside a hub
- Use hubs in SignalR for ASP.NET Core
- Background tasks with hosted services
- Transactions — EF Core
Điều hướng
- Bài trước: 11.6 — Groups và Targeting
- Bài tiếp theo: 11.8 — Notification Center Pattern
- Về module: Trang mục lục