SignalR 实时通信

全面掌握 ASP.NET Core SignalR,覆盖 Hub 与强类型 Hub 的写法、组与用户定向、连接生命周期与自动重连、背压与流式传输、Redis 底板横向扩展,以及 WebSocket 回退与传输协商机制。

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();
状态触发服务端可见性
ConnectingStartAsync 调用否
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 如何把组件模型同时跑在服务端与浏览器里。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「csharp」更多文章

  1. .NET 机器学习实战
  2. 内存剖析与 dump 分析
  3. 分布式事务与 Saga 编排