大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
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 全家桶的效果。
延伸
- 官方文档:ASP.NET Core SignalR 简介
- 下一课:测试策略——xUnit 单元测试与 WebApplicationFactory 集成测试,对照 JUnit/Mockito/@SpringBootTest。