1. 实时通信的传输协商
一句话总结: SignalR 自动在 WebSocket、Server-Sent Events 与长轮询之间协商,优先用 WebSocket,失败则逐级回退,业务代码无需感知。
浏览器与服务器的实时通道有多种实现,各有前提条件:WebSocket 需要 HTTP/1.1 升级或 HTTP/2 的 CONNECT 支持,某些代理会拦截升级请求;Server-Sent Events 只能服务器单向推送;长轮询兼容性最好但延迟与开销最大。
SignalR 的协商(negotiate) 阶段就是解决这个问题:客户端先请求 /hub/negotiate,服务端返回一个连接令牌和可用的传输列表,客户端从列表中挑选自己支持且环境允许的最优传输。
// 服务端:注册 SignalR 与 Hub 端点
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSignalR(options =>
{
options.EnableDetailedErrors = builder.Environment.IsDevelopment();
options.KeepAliveInterval = TimeSpan.FromSeconds(15);
options.ClientTimeoutInterval = TimeSpan.FromSeconds(30);
options.MaximumReceiveMessageSize = 32 * 1024;
});
var app = builder.Build();
app.MapHub<ChatHub>("/hubs/chat");
app.Run();
// 客户端:观察实际使用的传输
var connection = new HubConnectionBuilder()
.WithUrl("https://localhost:5001/hubs/chat", options =>
{
options.Transports =
HttpTransportType.WebSockets |
HttpTransportType.ServerSentEvents |
HttpTransportType.LongPolling;
})
.WithAutomaticReconnect()
.Build();
connection.StartAsync().Wait();
Console.WriteLine($"传输方式: {connection.Transport?.GetType().Name}");
| 传输 | 方向 | 延迟 | 前提 |
|---|---|---|---|
| WebSockets | 双向 | 最低 | 支持协议升级 |
| ServerSentEvents | 服务端 → 客户端 | 低 | HTTP 流式响应 |
| LongPolling | 双向(模拟) | 高 | 兼容性最好 |
| 跳过协商 | 视传输而定 | — | 已知服务端配置 |
避坑: 反向代理是 WebSocket 最常见的失败点。Nginx 需要显式配置
proxy_set_header Upgrade $http_upgrade与proxy_set_header Connection "upgrade",并且要调大proxy_read_timeout,否则空闲连接会被代理在 60 秒后切断。若确定只走 WebSocket,可用SkipNegotiation = true省一次往返,但必须显式指定Transports = HttpTransportType.WebSockets,否则会失败。
2. Hub 与强类型 Hub
一句话总结: Hub 是服务端与客户端之间的双向 RPC 端点,强类型 Hub 用接口约束客户端方法名,把「拼字符串」变成「编译期检查」。
Hub 基类提供 Clients、Groups、Context 三个核心属性。客户端可以调用 Hub 上的公共方法,服务端可以调用客户端上的方法。默认写法是 Clients.All.SendAsync("ReceiveMessage", user, text)——方法名是字符串,拼错只有运行时才发现。
强类型 Hub 用一个接口描述客户端方法,Hub<T> 让 Clients 变成 IHubClients<T>,调用时变成 Clients.All.ReceiveMessage(user, text)。
// 客户端契约
public interface IChatClient
{
Task ReceiveMessage(string user, string message);
Task UserJoined(string connectionId, string user);
Task UserLeft(string connectionId, string user);
Task ReceiveTyping(string user);
}
// 强类型 Hub
public class ChatHub : Hub<IChatClient>
{
private readonly IChatService _service;
public ChatHub(IChatService service) => _service = service;
public override async Task OnConnectedAsync()
{
await Clients.Others.UserJoined(Context.ConnectionId, "匿名用户");
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception? ex)
{
await Clients.Others.UserLeft(Context.ConnectionId, "匿名用户");
await base.OnDisconnectedAsync(ex);
}
// 客户端可调用
public async Task SendMessage(string user, string message)
{
await _service.SaveAsync(user, message);
await Clients.All.ReceiveMessage(user, message);
}
public Task Typing() => Clients.Others.ReceiveTyping("匿名用户");
}
| 成员 | 用途 |
|---|---|
Clients.All | 广播给所有连接 |
Clients.Caller | 只回给调用者 |
Clients.Others | 除调用者外所有人 |
Clients.Client(id) | 指定连接 |
Context.ConnectionId | 当前连接标识 |
Context.User | 认证后的用户主体 |
避坑: Hub 方法名在客户端是大小写不敏感的,但强类型接口的方法名大小写敏感,两侧要一致。Hub 是瞬态(transient) 服务,每次调用都会新建实例,所以不要在 Hub 里存字段做状态——状态应放在单例服务、
IDistributedCache或外部存储里。Hub 构造函数注入的服务也要注意生命周期,不能注入 Scoped 服务到 Hub 字段长期持有。
3. 组、用户与定向推送
一句话总结: 组是服务端维护的连接集合,用户是认证主体的连接集合,二者都不持久化,重启即丢失,需要重建机制。
Groups.AddToGroupAsync(connectionId, groupName) 把连接加入组,之后 Clients.Group(name) 就能定向推送。用户定向则是 Clients.User(userId),基于 ClaimsPrincipal 的 NameIdentifier。
两者的关键区别:组按连接划分,用户按身份划分。同一个用户可以开多个标签页(多个连接),Clients.User 会推给全部连接。
public class OrderHub : Hub<IOrderClient>
{
public async Task SubscribeToOrder(string orderId)
{
// 加入订单专属组
await Groups.AddToGroupAsync(Context.ConnectionId, $"order:{orderId}");
}
public async Task UnsubscribeFromOrder(string orderId)
{
await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"order:{orderId}");
}
// 服务端主动推送(由后台任务调用)
public static async Task BroadcastStatusAsync(
IHubContext<OrderHub, IOrderClient> hub, string orderId, string status)
{
await hub.Clients.Group($"order:{orderId}").StatusChanged(orderId, status);
}
}
// 用户定向:基于认证身份
public async Task NotifyUser(string userId, string message)
{
await Clients.User(userId).ReceiveMessage("system", message);
}
// 多组/多用户组合
public async Task NotifyMany(string[] users, string message)
{
await Clients.Users(users).ReceiveMessage("system", message);
}
| 目标 | API | 生命周期 |
|---|---|---|
| 单个连接 | Clients.Client(id) | 连接级 |
| 连接集合 | Clients.Clients(ids) | 连接级 |
| 组 | Clients.Group(name) | 内存,不持久 |
| 用户 | Clients.User(id) | 内存,不持久 |
| 全部 | Clients.All | 全局 |
避坑: 组与用户映射只存在内存里,服务重启或横向扩展后需要重建。常见做法是在
OnConnectedAsync里根据数据库或缓存重新加入组。另外Clients.User(id)依赖NameIdentifier声明,若认证配置里改过NameClaimType,用户定向会失效——这是排查「推送不到指定用户」的第一检查点。
4. 连接生命周期与自动重连
一句话总结: 连接有 Connecting、Connected、Reconnecting、Disconnected 四个状态,客户端自动重连只恢复连接,不恢复组与订阅,业务需自行重建。
服务端通过 OnConnectedAsync 与 OnDisconnectedAsync 感知连接变化。客户端则有状态机:断线后若配置了 WithAutomaticReconnect(),会按退避策略尝试重连,期间状态是 Reconnecting。
重连成功后 ConnectionId 会变成新的,之前加入的组全部丢失。
// 客户端:完整的生命周期处理
var connection = new HubConnectionBuilder()
.WithUrl("/hubs/chat")
.WithAutomaticReconnect(new[] {
TimeSpan.Zero,
TimeSpan.FromSeconds(2),
TimeSpan.FromSeconds(5),
TimeSpan.FromSeconds(10)
})
.Build();
connection.Reconnecting += error =>
{
Console.WriteLine($"重连中: {error?.Message}");
return Task.CompletedTask;
};
connection.Reconnected += async connectionId =>
{
Console.WriteLine($"已重连,新连接: {connectionId}");
// 关键:重建订阅
await connection.InvokeAsync("SubscribeToOrder", orderId);
};
connection.Closed += async error =>
{
Console.WriteLine($"连接关闭: {error?.Message}");
await Task.Delay(TimeSpan.FromSeconds(5));
await connection.StartAsync(); // 手动兜底重连
};
await connection.StartAsync();
| 状态 | 触发 | 服务端可见性 |
|---|---|---|
| Connecting | StartAsync 调用 | 否 |
| Connected | 协商与握手完成 | 是(OnConnectedAsync) |
| Reconnecting | 连接断开,自动重试 | 否(旧连接已断) |
| Disconnected | 重试耗尽或 Closed | 是(OnDisconnectedAsync) |
避坑: 自动重连有默认上限(默认 4 次,约 30 秒内),超时后触发
Closed,此时必须手动处理。重连后的ConnectionId变了,任何以连接为键的订阅都要重建。若客户端在Reconnecting期间调用InvokeAsync,会抛异常——业务代码要判断connection.State,或在Reconnected里统一补做。
5. 背压与流式传输
一句话总结: SignalR 支持客户端流、服务端流与双向流,配合
Channel实现背压,避免快生产慢消费把内存撑爆。
普通 Hub 调用是一问一答。流式传输把 IAsyncEnumerable<T> 直接接到 Hub 上:服务端流适合日志推送、进度上报;客户端流适合上传分片;双向流适合实时协作。
背压的关键在服务端流:用有界 Channel 作为生产者与消费者之间的缓冲,生产过快时生产者异步等待。
// 服务端流:持续推送行情
public async IAsyncEnumerable<Quote> StreamQuotes(
string symbol,
[EnumeratorCancellation] CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
yield return await _market.GetQuoteAsync(symbol, ct);
await Task.Delay(1000, ct);
}
}
// 带背压的服务端流:Channel 作缓冲
public async IAsyncEnumerable<LogEntry> StreamLogs(
[EnumeratorCancellation] CancellationToken ct)
{
var channel = Channel.CreateBounded<LogEntry>(new BoundedChannelOptions(100)
{
FullMode = BoundedChannelFullMode.DropOldest
});
_ = _logSource.PumpIntoAsync(channel.Writer, ct);
await foreach (var entry in channel.Reader.ReadAllAsync(ct))
{
yield return entry;
}
}
// 客户端流:上传分片
public async Task UploadAsync(IAsyncEnumerable<Chunk> stream)
{
await foreach (var chunk in stream)
{
await _storage.AppendAsync(chunk);
}
}
| 流类型 | 签名 | 典型场景 |
|---|---|---|
| 服务端流 | IAsyncEnumerable<T> 返回值 | 行情、日志、进度 |
| 客户端流 | IAsyncEnumerable<T> 参数 | 分片上传、批量导入 |
| 双向流 | 参数 + 返回值都是流 | 实时协作、语音转写 |
避坑: 客户端流要求两端都支持——浏览器端 JavaScript 客户端不支持客户端流,必须用 .NET 客户端。取消传播要用
[EnumeratorCancellation]标注CancellationToken参数,否则客户端断开时服务端流不会停止,Channel会一直生产。另外流式方法每次yield都是一次网络往返,高频小消息应批量合并后再yield。
6. Redis 底板与横向扩展
一句话总结: 单机 SignalR 只能推给本进程连接,多实例必须用 Redis 底板(或 Azure SignalR)把消息广播到所有实例。
Clients.All 的语义是「本进程的所有连接」。部署多个实例后,用户在实例 A 连接,消息若在实例 B 产生,就推不到实例 A 的连接上。
Redis 底板(backplane) 解决这个问题:每个实例把要广播的消息发布到 Redis 频道,所有实例订阅该频道,收到后推给本地连接。代价是多一跳 Redis 往返,且组与用户映射仍然各自维护。
// 接入 Redis 底板
builder.Services.AddSignalR()
.AddStackExchangeRedis(options =>
{
options.Configuration.ChannelPrefix = RedisChannel.Literal("MyApp");
options.Configuration.EndPoints.Add("redis:6379");
});
// Azure SignalR Service:托管方案,无自建 Redis
// builder.Services.AddSignalR().AddAzureSignalR(connectionString);
// 多实例下服务端主动推送:通过 IHubContext
public class OrderStatusWorker : BackgroundService
{
private readonly IHubContext<OrderHub, IOrderClient> _hub;
private readonly IConnectionTracker _tracker;
protected override async Task ExecuteAsync(CancellationToken ct)
{
await foreach (var evt in _bus.SubscribeAsync<OrderEvent>(ct))
{
// 底板会把消息路由到持有该组的实例
await _hub.Clients.Group($"order:{evt.OrderId}")
.StatusChanged(evt.OrderId, evt.Status);
}
}
}
| 方案 | 扩展性 | 依赖 | 适用 |
|---|---|---|---|
| 单实例内存 | 无 | 无 | 开发与小规模 |
| Redis 底板 | 水平扩展 | Redis | 自建集群 |
| Azure SignalR | 全托管 | Azure | 云上大规模 |
| 粘性会话 + 单实例 | 有限 | 负载均衡器 | 过渡方案 |
避坑: 底板只转发消息,不共享连接状态。若用
Clients.User定向,而各实例的认证用户映射不一致,会出现漏推。另外 Redis 底板在大规模下有吞吐上限,官方建议单实例组数很多时改用 Azure SignalR。还有一点:使用底板后,Clients.All的语义变成「所有实例的所有连接」,但实现依赖 Redis 广播,网络分区时可能丢消息——重要消息应配合持久化队列。
7. 部署与排错
一句话总结: SignalR 排错集中在传输协商、代理超时、连接数上限与认证配置四类,日志与连接计数器是最快的定位手段。
生产环境的典型问题:代理不支持 WebSocket 导致回退到长轮询(延迟升高);代理空闲超时切断连接(频繁重连);连接数暴涨(内存与句柄耗尽);认证令牌无法通过查询字符串传递。
// 诊断配置:打开详细日志与连接计数
builder.Services.AddSignalR(o => o.EnableDetailedErrors = true);
builder.Services.AddSingleton<IConnectionCounter, ConnectionCounter>();
app.MapHub<ChatHub>("/hubs/chat");
// 连接计数器:便于暴露成指标
public class ConnectionCounter : IConnectionCounter
{
private int _current;
public int Current => Volatile.Read(ref _current);
public void OnConnected() => Interlocked.Increment(ref _current);
public void OnDisconnected() => Interlocked.Decrement(ref _current);
}
{
"Logging": {
"LogLevel": {
"Microsoft.AspNetCore.SignalR": "Debug",
"Microsoft.AspNetCore.Http.Connections": "Debug"
}
}
}
| 症状 | 常见根因 | 检查点 |
|---|---|---|
| 延迟明显升高 | 回退到长轮询 | 代理 Upgrade 头 |
| 每 60 秒断连 | 代理空闲超时 | proxy_read_timeout |
| 401 未授权 | 令牌未随请求发送 | AccessTokenProvider |
| 内存增长 | 连接泄漏 / 组未清理 | 连接计数与 OnDisconnectedAsync |
| 多实例漏推 | 未接底板 | Redis 配置 |
避坑: WebSocket 连接不能带自定义请求头(浏览器的 WebSocket API 限制),所以 JWT 通常通过查询字符串
access_token传递,服务端需要配置JwtBearerEvents.OnMessageReceived从查询串取令牌。另外MaximumReceiveMessageSize默认只有 32KB,大消息会被静默拒绝并断开连接——上传大文件应走独立 HTTP 端点,而不是塞进 Hub 消息。
8. 总结
| 环节 | 要点 |
|---|---|
| 传输协商 | 优先 WebSocket,自动回退,代理要放行 Upgrade |
| Hub 设计 | 强类型 Hub 把客户端方法名变成编译期检查 |
| 定向推送 | 组按连接、用户按身份,两者都不持久化 |
| 生命周期 | 重连后 ConnectionId 变化,订阅必须重建 |
| 流式传输 | 三种流类型 + 有界 Channel 实现背压 |
| 横向扩展 | 多实例必须接 Redis 底板或 Azure SignalR |
| 排错 | 代理超时、令牌传递、消息大小上限是三大高发点 |
SignalR 把实时通信的复杂性——传输协商、重连、编解码、扩展——收敛成一套 Hub 抽象,让开发者专注业务语义。但它不是「开箱即用」:代理配置、组状态重建、底板扩展、背压控制都是上线前必须处理的问题。理解连接生命周期之后,下一步可以看另一类「全栈」方案——Blazor 如何把组件模型同时跑在服务端与浏览器里。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。