SignalR 是 .NET 平台下非常流行的实时通信库,底层默认优先使用 WebSocket 传输。但在实际项目中,经常遇到这样的情况:客户端调用 StartAsync 成功,服务端也能正常执行 Hub 方法,可客户端就是收不到服务端推送的消息。这类问题往往不是单一原因造成的,本文将结合常见踩坑场景,逐一分析原因并给出解决方案。

一、确认连接是否真正建立在了 WebSocket 上
很多人以为 StartAsync 没抛异常就是连接成功了,其实 SignalR 在握手阶段有一个协商(negotiate)过程。如果服务端不支持 WebSocket,客户端会自动降级到 Server-Sent Events 或长轮询,而某些降级场景在反向代理配置不当时会出现半连接状态,表现为连接成功但消息收不到。
排查的第一步是查看客户端当前实际使用的传输方式。以 JavaScript 客户端为例,可以通过传入日志回调观察协商过程:
const connection = new signalR.HubConnectionBuilder()
.withUrl("https://ipipp.com/chathub", {
// 强制只使用 WebSocket,便于暴露问题
skipNegotiation: true,
transport: signalR.HttpTransportType.WebSockets
})
.configureLogging(signalR.LogLevel.Debug)
.build();
注意 skipNegotiation 只能配合 WebSocket 传输使用。如果设置了跳过协商但服务端地址或证书有问题,连接会直接失败,这反而有助于快速暴露配置错误。对于 .NET 客户端,可以注册自定义日志或监听 Closed、Reconnecting 事件,确认连接生命周期是否符合预期。
二、方法名与处理器注册不匹配
这是最高频的原因。SignalR 的消息分发依赖方法名字符串完全匹配,客户端注册的处理器名称必须和服务端 Clients.xxx.SendAsync 的第一个参数一致,并且区分大小写。
服务端代码:
public async Task SendMessage(string user, string message)
{
await Clients.All.SendAsync("ReceiveMessage", user, message);
}
JavaScript 客户端注册时必须写成同样的名字:
connection.on("ReceiveMessage", (user, message) => {
console.log(`${user}: ${message}`);
});
有几个容易出错的细节:一是 .on 必须在 .start() 之前注册,虽然多数版本支持连接后再注册,但提前注册最稳妥;二是方法名中的大小写、下划线、拼写都要逐字核对,比如把 ReceiveMessage 写成 receiveMessage 会静默失败,不会报任何错误;三是服务端如果用了强类型 Hub,接口上的方法名默认就是客户端要监听的名字,改名时要两边同步。
另外,如果服务端自定义了协议方法名映射(比如通过 HubMethodName 特性),客户端注册的名称必须与特性中指定的名称一致,而不是 C# 方法名。
三、反向代理与粘性会话问题
当应用部署在 Nginx、IIS ARR 或云负载均衡后面时,WebSocket 消息收不到十有八九和代理配置有关。典型表现是:本地开发一切正常,一上生产就收不到消息。
首先,Nginx 需要显式支持 WebSocket 协议升级,否则握手会被拦截:
location /chathub {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_cache off;
proxy_read_timeout 3600s;
}
其中 proxy_read_timeout 很关键,默认的 60 秒超时会在空闲时切断 WebSocket 连接,虽然 SignalR 有重连机制,但频繁断连期间的消息会丢失。其次,如果服务端是多实例部署,必须配置粘性会话(sticky session),或者使用 Redis 缓存回平面(backplane)。因为客户端的协商请求可能落在 A 实例,而 WebSocket 连接落在 B 实例,发送方调用的 Clients.All 只会推送给连接在本实例上的客户端。
Redis 回平面的配置示例如下:
builder.Services.AddSignalR()
.AddStackExchangeRedis("127.0.0.1:6379", options =>
{
options.Configuration.ChannelPrefix = "SignalR";
});
配置后,跨实例的消息会通过 Redis 发布订阅转发,所有连接在任意实例上的客户端都能收到推送。
四、序列化与参数类型不匹配
SignalR 默认使用 System.Text.Json 序列化,它对大小写敏感且默认使用 camelCase 命名。如果客户端解析时按 PascalCase 取属性,可能拿到 undefined,导致回调虽然触发了但数据看起来是空的,让人误以为没收到消息。
p>解决方式有两种,一是让客户端按 camelCase 取值,二是统一服务端序列化行为:
builder.Services.AddSignalR()
.AddJsonProtocol(options =>
{
options.PayloadSerializerOptions.PropertyNamingPolicy = null;
});
如果是 .NET 到 .NET 的通信,也可以改用 MessagePack 协议获得更好的性能,但两端必须同时安装并启用对应的包,否则会在协议协商时直接失败。
五、跨域与认证配置遗漏
浏览器环境下,跨域没配好时 WebSocket 请求会被拦截,或者连接成功但带上错误凭证后被服务端拒绝。服务端需要显式允许凭据并写明来源,不能用通配符:
builder.Services.AddCors(options =>
{
options.AddPolicy("SignalRPolicy", policy =>
{
policy.WithOrigins("https://ipipp.com")
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials();
});
});
同时注意 Hub 的授权配置,如果 Hub 要求认证而客户端没带 token,连接可能在协商阶段就被拒绝。使用 JWT 时推荐通过 AccessTokenFactory 传递:
const connection = new signalR.HubConnectionBuilder()
.withUrl("https://ipipp.com/chathub", {
accessTokenFactory: () => localStorage.getItem("token")
})
.build();
排查这类问题最有效的手段是打开浏览器开发者工具的 Network 面板,筛选 WS 类型请求,观察握手状态码和帧(Frames)数据。如果握手是 101 但 Frames 里没有任何推送帧,问题多半在方法名匹配或多实例部署;如果握手本身就失败,则重点检查代理、跨域和认证配置。
总结
SignalR 消息收不到的问题看似玄学,其实归结起来就是几个环节:连接是否真正建立在 WebSocket 上、方法名是否严格匹配、代理与多实例部署是否正确、序列化是否一致、跨域认证是否完整。建议按本文顺序逐层排查,先看协商与传输方式,再看方法名,最后检查部署架构,绝大多数问题都能在短时间内定位并解决。