C#.NET SignalR + Redis Backplane 深入解析:多节点部署与跨实例消息同步

0 阅读19分钟

简介

SignalR 在单节点开发环境里通常很好用。

你本地起一个应用,前端连上来,消息一发,聊天室、通知、在线状态、协作编辑似乎都没什么问题。

但很多团队一到生产环境,问题就开始冒出来了:

  • 应用一扩成两个实例,广播突然不全了;
  • Clients.All 看起来发了,但总有人收不到;
  • 某些用户连在节点 A,消息却从节点 B 发出;
  • 组消息、用户消息在多节点下开始表现不一致。

如果你要一句话理解问题本质,可以这样记:

SignalR 默认只知道“当前节点自己维护的连接”,并不知道其他节点上连了谁。

这就是为什么一旦进入多节点部署,很多单机里理所当然能工作的推送逻辑会突然失效。

这篇文章要讲的,就是 .NET 里最常见的一种自托管解决方案:

SignalR + Redis backplane

重点不是只讲怎么配包,而是讲清楚:

  • 单节点和多节点的差别到底在哪;
  • Redis backplane 补齐了什么能力;
  • 多节点部署时负载均衡和粘性会话为什么重要;
  • 哪些能力会自动跨节点同步,哪些不会;
  • 生产环境里真正要防的坑有哪些。

单节点为什么没问题,多节点为什么会出问题?

先看单节点。

在单节点里,所有连接都挂在同一个应用实例上:

Client A -> App Instance 1
Client B -> App Instance 1
Client C -> App Instance 1

这时:

  • Clients.All 很自然就是“给这个节点上的所有连接发消息”;
  • Clients.Group("room-1") 也只需要看本机内存里的组映射;
  • 用户连接关系、组关系、连接生命周期,都只发生在当前进程里。

所以单节点时,很多事情都显得理所当然。

但一旦变成多节点:

Client A -> App Instance 1
Client B -> App Instance 2
Client C -> App Instance 3

问题就来了。

如果消息是从 App Instance 1 发出的,而它只知道自己本机的连接,那么:

  • 它可以把消息发给连在自己这台机器上的客户端;
  • 但它并不知道 Instance 2Instance 3 上有哪些连接;
  • 结果就是“本机广播成功,跨节点广播失效”。

这也是很多团队第一次上多实例时最常见的故障来源。

Redis backplane 到底解决了什么?

一句话先说透:

Redis backplane 解决的不是“连接统一存储”,而是“节点之间的消息同步”。

它最核心的做法是:

  • 每个 SignalR 节点都连接同一个 Redis;
  • 某个节点要广播消息时,先把消息发布到 Redis;
  • 所有节点都订阅这个通道;
  • 每个节点收到消息后,再投递给自己本地维护的客户端连接。

也就是说,真正发生的不是:

  • 所有连接都搬到 Redis 里;

而是:

  • 连接仍然在各自节点本地;
  • 但消息通过 Redis 在节点间传播。

这点非常重要。

你可以把它想成什么?

可以先用一个很实用的心智模型来理解:

每个 SignalR 节点 = 本地连接管理者
Redis backplane = 节点间消息总线

所以它的职责边界大致是:

  • SignalR 节点:维护自己这台机器上的连接和本地投递;
  • Redis:负责把“有一条广播/组播/用户消息要发”这件事通知给其他节点。

一个最典型的消息流

比如聊天室里,客户端 A 连在节点 1 上,客户端 B 连在节点 2 上:

Client A -> Node 1 -> Hub.SendMessage()
Node 1 -> Publish 到 Redis
Redis -> 把消息推给 Node 1 / Node 2 / Node 3
Node 2 -> 发现自己本地有 Client B,于是投递
Node 3 -> 如果本地没有目标连接,就不投递

这时,跨节点广播就成立了。

哪些场景特别适合 Redis backplane?

下面这些场景是最典型的:

  • 自建机房或云主机上的多实例部署;
  • Kubernetes / Docker 多副本部署;
  • 已经有 Redis 基础设施,希望低成本扩容;
  • 连接数和消息量还没大到必须上专门托管服务。

如果你是在 Azure 且更看重托管和省心,通常还要认真比较:

  • Azure SignalR Service

因为它能进一步把很多连接层问题托管出去。

但如果你的部署方式更偏自托管,Redis backplane 仍然是最常见的官方方案之一。

基础依赖怎么配?

最常用的包是:

<PackageReference Include="Microsoft.AspNetCore.SignalR.StackExchangeRedis" Version="*" />

然后在 Program.cs 里配置:

using StackExchange.Redis;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSignalR()
    .AddStackExchangeRedis(options =>
    {
        options.Configuration = builder.Configuration.GetConnectionString("Redis");
        options.Configuration.ChannelPrefix = RedisChannel.Literal("MyApp:SignalR");
    });

var app = builder.Build();

app.MapHub<ChatHub>("/hubs/chat");

app.Run();

如果你想更稳一些,通常会配成 ConfigurationOptions

builder.Services.AddSignalR()
    .AddStackExchangeRedis(options =>
    {
        options.ConfigurationOptions = new ConfigurationOptions
        {
            EndPoints = { "redis:6379" },
            AbortOnConnectFail = false,
            ConnectTimeout = 5000,
            SyncTimeout = 5000
        };
        options.Configuration.ChannelPrefix = RedisChannel.Literal("MyApp:SignalR");
    });

这里几个关键点要记住:

  • ChannelPrefix 很重要,用来隔离不同应用或不同环境;
  • AbortOnConnectFail = false 在生产里通常更稳,避免 Redis 短暂抖动直接让应用启动失败;
  • 所有节点必须连到同一个 Redis 体系。

多节点部署里,负载均衡为什么不能忽视?

很多人以为:

  • “有了 Redis backplane,就等于多节点问题都解决了”

这并不准确。

因为 Redis backplane 解决的是:

  • 节点间消息传播

而负载均衡器还决定着另一件事:

  • 一个已建立连接的客户端,后续请求和重连流量会不会稳定落到同一节点。

SignalR 来说,这通常非常关键。

为什么经常建议开启粘性会话?

因为很多部署环境下,客户端和某个具体节点之间会建立长连接或半长连接语义。

如果负载均衡器在连接生命周期中频繁把后续请求切到别的节点,就很容易出现:

  • 连接上下文不一致;
  • 协商和实际连接节点不一致;
  • 重连异常;
  • 某些请求命中了并不持有该连接状态的节点。

所以在使用 Redis backplane 的多节点部署里,实践上通常会建议:

  • 开启粘性会话;
  • 或至少确保你的部署拓扑不会打破连接稳定性。

最务实的结论可以这样记:

Redis backplane 解决“消息跨节点传播”,粘性会话解决“连接不要乱漂移”。

这两件事不是替代关系,而是协作关系。

一个常见的 Nginx 思路

例如你用 Nginx 做反向代理时,常见会配置:

upstream signalr_backend {
    ip_hash;
    server 10.0.0.1:5000;
    server 10.0.0.2:5000;
    server 10.0.0.3:5000;
}

server {
    listen 80;

    location /hubs/ {
        proxy_pass http://signalr_backend;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_read_timeout 300s;
    }
}

实际生产里你未必一定使用 ip_hash,也可能是:

  • cookie 粘性;
  • 云负载均衡的 client affinity;
  • Kubernetes ingress 的 sticky 配置;

但核心目标是一致的:

  • 不要让长连接相关流量无规律漂移。

哪些东西会自动跨节点,哪些不会?

这是 Redis backplane 最容易被误解的地方之一。

它会帮助同步的,本质上是:

  • 广播;
  • 用户消息;
  • 组消息;
  • 其他“消息投递意图”的跨节点传播。

它不会自动替你解决的,则包括:

  • 全局在线人数统计;
  • 连接状态集中查询;
  • 历史消息存储;
  • 离线补偿;
  • 可靠投递与重放。

一句话记住:

Redis backplane 是实时消息总线,不是全局连接数据库,也不是可靠消息系统。

从源码和架构视角看:HubLifetimeManager 到底在这套方案里扮演什么角色?

如果你想真正理解 SignalR + Redis backplane,只知道 HubClients.All 还不够,还要知道:

真正负责“把消息发给哪些连接”的关键抽象,其实是 HubLifetimeManager

可以先把它理解成:

  • Hub 是你写业务代码的入口;
  • HubLifetimeManager 是运行时真正管理连接、组和消息投递的核心层。

也就是说,当你在 Hub 里写:

await Clients.All.SendAsync("ReceiveMessage", user, message);

从架构上看,并不是 Hub 自己在全世界找连接,而是它最终把这件事交给底层的 HubLifetimeManager 去做。

这也是为什么:

  • 单节点时,它可以只在本机完成投递;
  • 接上 Redis backplane 后,它又能在多节点里完成跨实例消息传播。

因为变化的不是你的 Hub 代码,而是背后“如何管理和分发消息”的那一层。

单节点下,HubLifetimeManager 大致在做什么?

在单节点里,它最重要的事情就是:

  • 维护本机连接;
  • 维护本机组成员关系;
  • 根据目标类型决定把消息发给谁。

例如下面这些调用:

  • Clients.All
  • Clients.Client(connectionId)
  • Clients.User(userId)
  • Clients.Group(groupName)

在单节点里,本质上都可以理解成:

  • 先在本机连接表里筛目标;
  • 再把消息直接投递给这些连接。

所以单机时,它像一个“本地消息分发器”。

接上 Redis backplane 后,变化发生在哪?

关键变化不在 Hub,而在“本地分发器”升级成了“本地分发 + 节点间同步”。

也就是说,接入 Redis backplane 后,这一层的职责变成了两段:

  • 本机仍然维护自己的连接和本地组关系;
  • 但跨节点的消息意图,要通过 Redis 再广播给其他节点。

换句话说,Redis backplane 不是取代本地连接管理,而是在 HubLifetimeManager 这一层后面又补了一条:

  • 节点间同步路径。

这也是理解整个方案最核心的一点。

Redis Pub/Sub 路径到底是怎么走的?

很多文章会简单写成:

  • “SignalR 把消息发到 Redis”

这句话没错,但还不够具体。

从架构角度更准确的理解是:

  1. 某个节点上的 Hub 发起一次发送请求。
  2. 底层生命周期管理层判断这不是只在本机可完成的事。
  3. 节点把这条“消息投递意图”发布到 Redis 的对应通道。
  4. 其他节点订阅到这条消息后,各自回到本机连接表里查找目标连接。
  5. 找到就本地投递,找不到就跳过。

这条路径里,Redis 承担的是:

  • 节点间广播消息意图;

而不是:

  • 直接替每个客户端发消息;
  • 维护完整连接字典;
  • 保存消息历史。

所以你可以把 Redis Pub/Sub 路径记成:

Hub -> HubLifetimeManager -> Redis Publish
Redis -> 各节点订阅者
各节点 -> 本地连接匹配 -> 本地客户端投递

这个链条一旦搞懂,很多误解都会自动消失。

GroupUser 跨节点到底怎么理解?

可以把它们统一理解成:

  • Redis backplane 负责把“给某个组/某个用户发消息”的意图同步到所有节点;
  • 各节点再用自己本地的连接和组成员关系做匹配;
  • 有匹配就投递,没有就跳过。

所以:

  • Group 能跨节点工作,不等于 Redis 保存了一份全局组成员表;
  • User 能跨节点工作,也不等于你天然拥有一份可查询的全局在线用户表。

一句话记住:

backplane 保证的是跨节点消息投递能力,不自动保证全局状态统一可查询。

Redis backplane 和 Azure SignalR Service 怎么选?

两者都能解决多节点 SignalR 问题,但职责分工不同:

  • Redis backplane:应用节点自己维护连接,Redis 只负责节点间消息同步;
  • Azure SignalR Service:把连接层本身托管出去,应用节点更像业务入口和消息生产者。

所以很务实的判断可以这样记:

Redis backplane 更像“自己维护连接层,只借 Redis 做节点间同步”;Azure SignalR Service 更像“把连接层整体托管出去”。

前者更适合自托管、已有 Redis、规模可控的场景;后者更适合 Azure 环境下希望把连接保持、扩容和高可用整体交给平台的场景。

一个很容易追问的问题:是不是每个节点都配一套 AddStackExchangeRedis 就够了?

如果你的目标是:

  • 让多节点 SignalR 具备跨实例广播能力;
  • Clients.AllClients.Group(...)Clients.User(...) 这类发送行为能跨节点生效;

那么从接入层面看,答案基本就是:

  • 是的,每个 SignalR 应用节点都要接同一个 Redis backplane;
  • 也就是每个节点都要配置:
builder.Services.AddSignalR()
    .AddStackExchangeRedis(...);

这里真正重要的不是“每台机器代码写一遍”这个动作本身,而是:

  • 所有节点都接入同一套 Redis;
  • 所有节点都使用兼容的 backplane 配置;
  • 所有节点都在同一个消息同步体系里。

你可以把它理解成:

不是只有发送消息的那个节点接 Redis,而是所有参与实时通信的节点都要同时接入,才能同时发布和订阅。

具体是谁在发布、谁在订阅?

不是浏览器客户端去订阅 Redis,也不是你的业务代码手写 Redis SUBSCRIBE

真正做这件事的,是每个 SignalR 服务节点内部的 backplane 实现:

  • 客户端仍然只连接 SignalR 节点;
  • SignalR 节点既是 Redis 发布者,也是 Redis 订阅者;
  • Redis 负责在各节点之间传播消息意图。

所以从职责边界上看:

  • Redis 提供的是 Pub/Sub 消息总线;
  • AddStackExchangeRedis 提供的是把 SignalR 接到这条总线上的完整适配层。

所以业务代码到底要不要自己管发布订阅?

正常情况下,不需要。

只要你接的是官方提供的:

AddStackExchangeRedis(...)

那么:

  • 如何发布;
  • 如何订阅;
  • 如何选通道;
  • 如何序列化和反序列化;
  • 如何把跨节点消息重新路由回本地连接;

这些都已经由框架内部处理好了。

你的业务代码通常仍然只写:

await Clients.All.SendAsync(...);
await Clients.Group(...).SendAsync(...);
await Clients.User(...).SendAsync(...);

而不需要手工写:

  • ISubscriber.PublishAsync(...)
  • ISubscriber.Subscribe(...)

如果你已经开始自己写这些,通常说明你在做的已经不是“官方 Redis backplane 用法”,而是在自己实现另一层分布式消息逻辑了。

Redis 里到底存了什么?

这个问题最容易被误解成:“Redis 里是不是有一堆 key 保存所有连接和组成员?”

对 Redis backplane 来说,更准确的答案是:

  • 它的核心不是“存”,而是“发”;
  • 主要承载的是 Pub/Sub 通道上的内部消息;
  • 不是给你一份可直接查询的全局连接表、用户表或组成员表。

如果你只是想建立正确心智模型,可以把 Redis 通道里流动的消息粗略理解成下面这种“概念结构”:

{
  "kind": "group-send",
  "group": "room-1",
  "method": "ReceiveMessage",
  "args": ["panfeng", "hello"],
  "excludedConnectionIds": []
}

这个结构最想表达的不是具体字段名,而是:

  • 这是一条什么类型的投递意图;
  • 目标是谁;
  • 客户端要调用哪个方法;
  • 参数是什么;
  • 有没有排除连接。

也就是说,你完全可以把它理解成:

  • kind:广播、指定连接、指定用户、指定组、排除型发送等目标类型;
  • group / userId / connectionId:目标标识;
  • method:客户端方法名;
  • args:方法参数;
  • excludedConnectionIds:本地投递前需要排除的连接。

但这里一定要强调:

这只是帮助理解 backplane 内部消息意图的“概念模型”,不是框架公开承诺的真实 Redis payload 协议。

真正的实现细节可能会随着版本变化而变化,所以:

  • 可以用这个模型帮助理解;
  • 不要在业务代码里依赖它的真实字段名和序列化格式。

为什么不建议你自己去监听这些 Redis 通道做业务逻辑?

因为这些通道首先是框架内部协议,不是你的业务契约。

更实际地说,有三个问题:

  • 它们可能随版本调整,稳定性不应由业务依赖;
  • backplane 消息表达的是“推送意图”,不等于你的业务事件;
  • 它仍然只是 Pub/Sub,不提供可靠投递、回放和确认。

所以这些通道更适合:

  • 调试;
  • 观测;
  • 学习内部机制;

而不适合作为正式业务集成面。

如果你真有审计、补偿、统计、下游联动这类需求,更稳妥的方向通常是:

业务事件 -> 数据库 / MQ / 事件总线 -> SignalR 推送

而不是:

SignalR backplane 通道 -> 倒推业务事件

所以你不应该预期 Redis backplane 会天然替你提供这些查询能力:

  • 当前全局在线人数;
  • 某个用户所有连接列表;
  • 某个组完整成员快照;
  • 历史消息记录。

这些如果业务真需要,还是要你自己单独设计存储方案。

Redis backplane 不适合什么场景?

讲到这里,最容易产生的错觉就是:

  • “既然多节点广播能打通,那是不是大多数实时系统都可以直接上它?”

答案是否定的。

Redis backplane 很实用,但它的适用边界其实很明确。

如果边界没看清,项目后面会越来越痛苦。

它的边界可以压成四句话:

  • 它不是可靠消息系统,不解决确认、补偿、重放;
  • 它不是全局在线状态中心,不提供完整 presence 查询能力;
  • 它不适合超大规模连接托管,连接层压力仍然在你的应用节点;
  • 它也不适合跨区域高延迟、超大消息和高频全量广播场景。

所以更稳妥的原则通常是:

  • 推送层负责“尽快通知”;
  • 业务状态层负责“最终真相”;
  • 可靠消息、在线状态、补偿能力要由数据库、消息队列或独立状态服务承担。

生产环境里最应该注意的几个问题

1. Redis 必须尽量靠近应用节点

因为 backplane 走的是实时消息同步路径。

如果 Redis 和应用节点之间网络延迟很高,那么:

  • 跨节点消息延迟会直接放大;
  • 实时体验会变差;
  • 高峰期抖动会更明显。

很务实的建议是:

  • 尽量放在同机房、同可用区、同集群网络环境里。

2. 不要把它当成可靠消息系统

Redis Pub/Sub 的重点是快,不是可靠投递。

所以你不要期待它天然提供:

  • 消息落盘确认;
  • 消费重试;
  • 断线重放;
  • 严格顺序保证。

如果你的业务要求这些能力,Redis backplane 只能解决“实时在线推送”,不是“最终一致可靠消息”。

3. 控制消息大小和广播频率

如果你疯狂做这些事:

  • 高频全量广播;
  • 大对象直接推送;
  • 把大 JSON 每秒发几十上百次;

那么压力点不只在 SignalR,还会落到:

  • Redis 网络带宽;
  • 节点序列化开销;
  • 客户端处理能力。

实战里更推荐:

  • 推增量,不推全量;
  • 推事件,不推大块冗余数据;
  • 对群发频率做节流。

4. 在线用户统计不要只靠 Hub 内存

单节点时,有些人会把在线用户表直接维护在应用内存里。

多节点后,这通常就不成立了。

如果你要全局统计:

  • 在线人数;
  • 用户在哪个设备在线;
  • 某个房间当前成员数量;

通常应该单独引入:

  • Redis 结构化存储;
  • 数据库;
  • 独立在线状态服务。

一个更完整的部署思路

如果你想在生产里落地,一个更稳的部署模型通常像这样:

Client
-> Load Balancer / Ingress(保持会话稳定)
-> 多个 ASP.NET Core SignalR 实例
-> Redis backplane(消息同步)
-> 数据库 / 缓存(业务状态、在线状态、消息持久化)

也就是说:

  • SignalR 负责连接和实时推送;
  • Redis backplane 负责跨节点同步消息;
  • 数据库或缓存负责业务真实状态;
  • 负载均衡负责稳定地承接连接流量。

这四层职责最好分清。

什么时候应该考虑别的方案?

如果你遇到下面这些情况,就应该认真评估替代方案了:

  • 在 Azure 上,且更看重托管和省心;
  • 连接规模非常大;
  • 需要全局分布式接入;
  • 需要更强的连接层托管能力;
  • 不想自己维护 Redis 和负载均衡细节。

这时通常要认真考虑:

  • Azure SignalR Service

而不是默认把 Redis backplane 一路用到底。

一个非常实用的判断标准

如果你准备在多节点里用 SignalR + Redis backplane,至少先确认这五件事:

  1. 你要解决的是跨节点实时广播,而不是可靠消息系统。
  2. 负载均衡能保证连接稳定,不会乱漂移。
  3. Redis 距离应用节点足够近。
  4. 在线状态、离线消息、未读消息另有存储方案。
  5. 你的消息模型是轻量实时事件,而不是大对象高频全量广播。

面试里高频怎么答?

如果面试官问:

“为什么 SignalR 多节点需要 Redis backplane?”

一个总的回答可以是:

因为 SignalR 默认只维护当前节点本地的连接和组信息,单机时没问题,但多节点后,某个节点发出的广播并不知道其他节点上的客户端连接。Redis backplane 通过 Pub/Sub 在各 SignalR 节点之间同步消息意图,各节点收到后再按自己本地的连接、用户或组做匹配和投递,从而补齐跨实例广播能力。它解决的是节点间消息同步,不解决全局连接存储、可靠消息、离线补偿和业务状态持久化;实际落地时通常还要配合粘性会话和独立状态存储。

总结

SignalR + Redis backplane 的本质,不是“把 SignalR 配成分布式”,而是:

给原本只会在本机内存里广播消息的 SignalR,补上一条跨节点传播消息的总线。

最值得记住的其实只有四句话:

  • 单节点下 SignalR 只需要本机连接状态,多节点下消息会天然形成孤岛;
  • Redis backplane 解决的是跨实例消息同步,不是全局状态统一存储;
  • 多节点上线时,Redis 之外还要认真处理负载均衡和连接稳定性;
  • 如果业务要求可靠消息、离线补偿和超大规模托管,就不能只靠 backplane。

如果把它理解成“加个 Redis 包就能无脑分布式”,通常迟早会踩坑。

如果把它理解成“多节点实时推送中的消息总线层”,你的部署和架构判断会稳很多。