SignalR 实时通信
WebSocket、Hub 与推送模式。
SignalR 实时推送(对照 WebSocket/STOMP)
这一课你将能做到
- 建立 Hub,实现「判题完成 → 秒级推送到做题页面」的实时链路
- 从后台判题服务(非 HTTP 请求上下文)用
IHubContext 向指定用户/分组推送
- 说清 SignalR 与原生 WebSocket 的关系,以及多实例部署时为什么要 Redis backplane
为什么要学
判题结果不能靠前端轮询:用户提交后盯着页面,轮询 2 秒一次既浪费又延迟。实时推送是标配——Java 生态里你用 WebSocket + STOMP(@MessageMapping/SimpMessagingTemplate),.NET 生态里的对应物是 SignalR:一个「RPC 风格的双向通信框架」,WebSocket 只是它的传输层之一。
人话:SignalR = WebSocket 的上层封装 + 自动降级(浏览器不支持 WS 时退到 SSE/长轮询)+ 强类型客户端代理 + 断线自动重连。你可以把它理解为「Spring 里的 STOMP 全家桶被官方做成了开箱即用的产品」。
先建立图像
浏览器做题页 ASP.NET Core 服务 判题 Worker(后台)
│ 协商(negotiate) │ │
│ ─────────────────────→ │ 分配连接 │
│ ←═══ WebSocket 长连接 ═══→│ │
│ joinGroup("user-alice") │ │
│ │ IHubContext.Clients │
│ ←─ judgeResult {AC,...} ──│ ←───── 推送指令 ────────────│
关键点:推消息的人不需要持有连接。判题 Worker 是独立后台进程,它通过 IHubContext 抽象向「某个组」广播,SignalR 负责找到活着的连接。这正是 Spring 里 SimpMessagingTemplate.convertAndSend("/topic/xxx", msg) 的角色。
跟着做
步骤 1:定义 Hub(连接的驻留地)
using Microsoft.AspNetCore.SignalR;
[Authorize] // JWT/cookie 鉴权后才允许连
public class JudgeHub : Hub
{
// 连接建立:把连接挂到按用户命名的组,等价 STOMP 的 /user/queue 目的地
public override async Task OnConnectedAsync()
{
var user = Context.User!.Identity!.Name!;
await Groups.AddToGroupAsync(Context.ConnectionId, $"user-{user}");
await base.OnConnectedAsync();
}
// 连接断开:清理组,防止泄漏
public override async Task OnDisconnectedAsync(Exception? ex)
{
var user = Context.User!.Identity!.Name!;
await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"user-{user}");
await base.OnDisconnectedAsync(ex);
}
// 客户端可主动调用的方法(RPC 服务端入口),等价 @MessageMapping
public Task WatchSubmission(long submissionId) =>
Groups.AddToGroupAsync(Context.ConnectionId, $"submission-{submissionId}");
}
// Program.cs
builder.Services.AddSignalR();
app.MapHub<JudgeHub>("/hubs/judge");
步骤 2:从判题 Worker 推送结果
Worker 是 BackgroundService(无 HTTP 请求上下文),注入 :
IHubContext<JudgeHub>
public class JudgeWorker(IServiceScopeFactory scopeFactory, IHubContext<JudgeHub> hub,
ILogger<JudgeWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
var submissionId = await _queue.DequeueAsync(ct); // 简化:内存队列
var result = await JudgeAsync(submissionId, ct); // 沙箱执行(省略)
using var scope = scopeFactory.CreateScope(); // 后台无请求作用域,手动开
var db = scope.ServiceProvider.GetRequiredService<OjDbContext>();
await db.Submissions.Where(s => s.Id == submissionId)
.ExecuteUpdateAsync(u => u
.SetProperty(s => s.Status, result.Status)
.SetProperty(s => s.TimeMs, result.TimeMs), ct);
// 推给两种组:关注该提交的页面 + 该用户的所有标签页
await hub.Clients.Group($"submission-{submissionId}")
.SendAsync("judgeResult", result, ct);
await hub.Clients.Group($"user-{result.UserName}")
.SendAsync("submissionUpdated", result, ct);
}
}
}
SendAsync("judgeResult", payload) 的第一个参数是客户端要监听的事件名,第二个参数起是参数列表——SignalR 用 JSON/MessagePack 序列化传给浏览器。
步骤 3:浏览器客户端接入
npm install @microsoft/signalr
import * as signalR from "@microsoft/signalr";
const conn = new signalR.HubConnectionBuilder()
.withUrl("/hubs/judge", { accessTokenFactory: () => getToken() }) // WebSocket 场景经 query 传 token
.withAutomaticReconnect() // 断线自动重连(指数退避)
.build();
conn.on("judgeResult", (r) => renderVerdict(r)); // 订阅事件,对应 STOMP 的 subscribe
await conn.start();
await conn.invoke("WatchSubmission", 1001); // 调用服务端 Hub 方法,对应 @SendTo 前先发 STOMP 帧
步骤 4:多实例部署加 Redis backplane
两台服务器各自持有连接,A 机的 Worker 推消息时 B 机的连接收不到——加 Redis 背板让所有实例共享广播:
dotnet add package Microsoft.AspNetCore.SignalR.StackExchangeRedis
builder.Services.AddSignalR()
.AddStackExchangeRedis("localhost:6379");
这与 Spring WebSocket 集群里「外部 Redis pub/sub 转发 STOMP 消息」的 broker relay 方案解决同一个问题。
讲清楚要点
- 连接、连接 ID、组三层抽象:
ConnectionId 是物理连接,组(Group)是逻辑订阅,Users 是按身份的自动组。业务上永远面向组编程,不要在服务端长期保存 connectionId 映射表。
- Hub 是 RPC 不是 REST:客户端→服务端是方法调用(
invoke),服务端→客户端是事件广播(on)。没有请求响应配对的 URL 概念,强类型场景可用接口约束 Hub 方法(IHub<IClient>)。
- 协议协商链:
/hubs/judge/negotiate 先握手,优先 WebSocket,不行退 SSE,再退长轮询——老内网/代理环境也能用,这是 SignalR 相对裸 WebSocket 的最大卖点。
- 并发与线程安全:Hub 实例每次调用临时创建(Transient 语义),可在 Hub 构造函数注入 Scoped 服务;而
IHubContext 是 Singleton,从任何地方(Worker、控制器、消息消费者)都能拿。
你可能会懵的地方
-
SignalR 和 WebSocket 是什么关系?
WebSocket 是传输协议,SignalR 是基于它的应用层框架(RPC 语义 + 重连 + 降级 + 分组)。直接用裸 WebSocket(.NET 也有 WebSockets 中间件)就像 Java 用 javax.websocket.Endpoint——能跑,但重连、分组、序列化都要自己造。
-
横向扩展为什么必须 backplane?
连接黏在建立的实例上,A 机的 IHubContext 只能找到本机连接。Redis backplane 把「发送指令」经 Redis pub/sub 扇出到所有实例,每台再找自己持有的连接投递。也可以用 Azure SignalR Service 托管这一层。
-
怎么只推给某一个用户?
Clients.User(userId),前提是配置了 IUserIdProvider(默认取 ClaimTypes.NameIdentifier)。JWT 登录的用户天然支持,等价 STOMP 的 /user/{id}/queue 单播。
-
消息丢了怎么办?
SignalR 不保证可靠投递(本质是 pub/sub)。关键结果(判题通过)要「推送 + 持久化」双轨:前端收到推送刷新 UI,页面加载时仍以数据库为准;推送只是加速显示,不是数据源。
-
连接数上限和资源消耗?
每连接占一个 socket 与少量内存,Kestrel 单机数万连接没问题;瓶颈通常在 CPU(序列化)与下游服务。压测用 dotnet-counters 看 SignalR 计数器。
错法对照
| 错法 | 问题 | 正解 |
|---|
| 在 Hub 里存静态字典维护 userId→connectionId | 断线泄漏、多实例失效 | 用 Groups / Clients.User |
| Worker 直接 new JudgeHub 调方法 | Hub 不是这样用的,没有连接上下文 | 注入 IHubContext,经 Clients 推送 |
| 推送当唯一数据源,前端不落库 | 连接断开期间消息丢失 | 数据库为准,推送只做加速 |
| 多实例部署不加 backplane | 一半用户收不到推送 | AddStackExchangeRedis 背板 |
| 在 Hub 方法里长时间 await 业务逻辑 | 占住连接线程、超时断连 | Hub 只做转发,业务放服务层/队列 |
卡点急救
- 前端连不上(401/404):404 检查
MapHub 路径与客户端 withUrl 一致;401 是鉴权问题——WebSocket 握手不能带自定义 header,用 accessTokenFactory 传 query token,服务端配 JWT 事件从 query 读。
- 能连上但收不到消息:确认事件名大小写一致;确认双方在同一组;多实例环境确认 backplane 配置生效(Redis 里能看到 signalr 前缀的 pub/sub 通道)。
- 断线后重复收消息:
withAutomaticReconnect 重连成功后 OnConnectedAsync 重新执行、连接 ID 变化,组需要重新加入——把组维护逻辑放在 OnConnectedAsync 里(客户端重连后重新 invoke WatchSubmission)。
面试常问
-
SignalR 相比裸 WebSocket 提供了什么?
传输协商与自动降级(WS→SSE→长轮询)、断线自动重连、基于组/用户的寻址抽象、Hub RPC 编程模型(双向方法调用而非裸帧)、客户端代理生成、与 ASP.NET Core 鉴权/DI 无缝集成。裸 WebSocket 适合单一明确的推送场景;需要多客户端、多事件、复杂订阅时 SignalR 明显省事。
-
SignalR 如何水平扩展?Spring 的 WebSocket 集群怎么解决同样问题?
两者都需要「消息扇出层」:SignalR 用 Redis backplane(AddStackExchangeRedis)或 Azure SignalR Service;Spring WebSocket 用外部消息代理(RabbitMQ STOMP relay / Redis pub/sub 手动转发)。原理一致——把「谁的连接在哪」的投递问题交给共享的广播通道。
-
实时推送的可靠性如何设计?
定位推送为「通知加速」而非「数据传输」:业务结果先持久化,推送失败不影响正确性;客户端重连后拉一次全量状态对齐;高频事件可带版本号/时间戳让客户端去重。判题场景即「页面加载以库为准,在线期间靠推送刷新」。
自测三问
- Hub、ConnectionId、Group 三者分别对应什么?业务该面向哪层编程?
- 为什么 Worker 里用
IHubContext 而不是注入 JudgeHub?
- 多实例部署不加 backplane 会出现什么现象?
小结一句话
SignalR 把 WebSocket 工程化:连接管理、分组寻址、断线重连、协议降级全部内置,判题结果推送这类场景用 Hub + IHubContext 十几行代码达到 Spring STOMP 全家桶的效果。
延伸