简介SignalRWPF 是一份面向 C# 与 WPF 开发者的 SignalR 客户端工程资料适合需要了解实时通信在桌面端如何落地、或希望参考现成项目结构进行练习与二次开发的中级学习者。压缩包共 320 个文件约 3.41MB包含 54 个 cs 源码、45 个 dll 依赖、2 个 xaml 界面文件、2 个 csproj 项目文件及 sln 解决方案同时带有 editorconfig、json、props、targets 等构建与配置项并混有 cache、pdb、exe 等编译运行产物。已有 264 人学习下载。读者可从中观察 WPF 客户端与 SignalR 服务的连接组织方式、消息处理代码、项目依赖和解决方案结构也可借助现有工程文件复现实验环境、对照排查编译与运行问题其中的缓存和中间产物需结合源码甄别使用。1. 桌面端要“秒级刷新”我把轮询换成了 SignalR上一套 WPF 上位机用DispatcherTimer每 2 秒拉一次设备状态200 台设备、8 条产线跑起来局域网里全是重复请求界面还时不时卡半秒。后来换成 SignalR服务端 ASP.NET Core 挂一个 Hub客户端用 C# 写 WPF连上之后由服务端主动推刷新延迟从秒级掉到几十毫秒。这份资源就是那套骨架的可运行版本——一个 Hub 服务端、一个 WPF 客户端、一套共享 DTO外加断线重连、跨线程刷新、批量合并这几块最容易翻车的地方。适合正在做工业上位机、监控看板、告警推送的 C# 开发者。只想找 Hello World 的话它偏重被 Timer 轮询和Invoke搞烦了正好。2. 拆开这套 C# 实时方案Hub、传输协商与客户端生命周期在动手改代码之前得先把三件事想明白服务端到底暴露了什么、连接是怎么建起来的、客户端那个HubConnection对象活多久。这三个问题没搞清楚后面遇到“连上了但收不到消息”就只能靠猜。2.1 Hub 是契约不是接口这是很多人的第一个误解SignalR 的服务端 Hub 看起来像一个普通的 C# 类客户端调用connection.InvokeAsync(PushStatus, payload)也像是调用方法但两者之间没有编译期约束。方法名是字符串参数是序列化后的字节写错一个字母服务端不会报“方法不存在”它只会在运行期悄悄丢掉这次调用或者给你一个含糊的InvalidOperationException。这套工程里用了两个办法把风险压下去。一是把方法名抽成常量类放在共享项目里客户端和服务端都引用同一份二是 DTO 单独一个Contracts类库服务端 Hub 方法签名和客户端OnT的泛型参数都指向同一个类型。共享项目只放 DTO 和常量不引任何 SignalR 包这样服务端和 WPF 客户端可以各自升级传输层依赖而互不影响。Hub 的另一个特点是它无状态且每次调用可能在不同实例上。默认情况下每次客户端调用 Hub 方法框架都会从 DI 容器里取一个新的 Hub 实例所以你没法在 Hub 的字段里存“当前用户订阅了哪条产线”。要存状态只有三个去处Context.Items单次连接生命周期、分组Groups、外部缓存。这套工程选的是分组因为分组天然支持服务端主动推送。2.2 传输协商WebSocket 优先但降级不是白送的SignalR 客户端第一次连接时不是直接开 WebSocket而是先发一个/negotiate的 HTTP POST拿到connectionToken和服务端支持的传输列表然后才决定用哪种方式。这个协商步骤是很多“连不上”问题的根源——你的反向代理可能放行了 WebSocket但没放行/negotiate这个 POST。传输方式建立条件典型耗时适用场景注意点WebSockets服务端与中间代理都放行Upgrade头最低一次握手双向高频推送内网首选代理需透传Connection: UpgradeServerSentEventsWebSocket 不可用中等服务端单向推送为主浏览器下有并发连接数限制LongPolling上面两种都不通最高每次请求一轮兜底例如老旧网关后面延迟高连接开销大工程里客户端默认写了HttpTransportType.WebSockets加SkipNegotiation true这是内网调试期的故意选择跳过协商直接开 WebSocket一旦连不上就立刻报错不会悄悄降级到长轮询让你误以为“性能还行”。上线前如果你的部署环境有网关把这个改回默认允许协商、允许降级更稳妥然后靠日志确认实际用的是哪种传输。提示判断当前走的是不是 WebSocket别靠猜。客户端的HubConnection没有直接暴露传输类型但服务端在OnConnectedAsync里可以通过Context.GetHttpContext().WebSockets.IsWebSocketRequest判断把这个结果打进日志最省事。2.3 客户端生命周期HubConnection是长生命周期对象别乱 newHubConnection内部维护着传输连接、心跳定时器、重连状态机和消息分发队列。它的状态只有四种Connecting、Connected、Reconnecting、DisconnectedClosed。很多人踩的坑是在每个窗口、每个 ViewModel 里各new一个结果一个应用开了七八条连接服务端连接数暴涨重连时互相打架。这套工程的做法是整个应用只注册一个单例RealtimeClient由它持有唯一的HubConnection对外暴露事件。ViewModel 订阅事件、在Dispose时退订但不去销毁连接。连接的生命周期跟应用走跟界面无关。另外HubConnection实现了IAsyncDisposableDisposeAsync会先发Close消息再关传输层。直接让进程退出而不 Dispose服务端要等ClientTimeoutInterval到期才会清理这条连接调试期间会出现“明明关了客户端服务端还显示在线”的错觉。2.4 为什么不直接用裸 WebSocket 或者 gRPC裸 WebSocket 更轻但重连、心跳、分组、序列化、调用应答全得自己写一套下来几百行还容易在“服务端主动推 客户端调用”混合场景下写乱。gRPC 的双向流很强适合服务间通信但 WPF 客户端要处理 HTTP/2、证书、流式重连学习成本比 SignalR 高一截而且分组广播这类“按频道推”的语义要自己造。SignalR 的价值在于它把这四件事做完了传输降级、心跳保活、分组广播、调用/推送双向语义而且在 .NET 客户端和服务端上是同一套 API。代价是它的消息名是字符串、序列化格式默认是 JSON体积偏大以及对代理配置有要求。对桌面端上位机这种“内网、连接数几十到几百、消息频率几十到几百条每秒”的场景这个取舍是划算的。3. 从零跑通Hub 服务端到 WPF 客户端的最小骨架这一章按“服务端 → 共享契约 → 客户端 → ViewModel”的顺序落地每一段都能直接放进工程里跑。默认环境是 .NET 8服务端Microsoft.AspNetCore.App自带 SignalR客户端需要装Microsoft.AspNetCore.SignalR.Client。3.1 服务端路由、超时参数与 Hub 实现先看宿主。SignalR 服务端的可调参数不多但每一个都跟“断线”直接相关我一般会在启动时显式写出来避免用默认值出事之后还要去翻文档。// Program.cs —— 服务端最小宿主 var builder WebApplication.CreateBuilder(args); builder.Services.AddSignalR(o { // 客户端多久没发消息就算掉线默认 30s o.ClientTimeoutInterval TimeSpan.FromSeconds(30); // 服务端心跳间隔默认 15s必须明显小于 ClientTimeoutInterval o.KeepAliveInterval TimeSpan.FromSeconds(15); // 单条消息上限默认 32KB推送大对象时要调 o.MaximumReceiveMessageSize 64 * 1024; // 内网调试期打开异常细节会原样回传客户端上线务必关掉 o.EnableDetailedErrors true; }); builder.Services.AddSingletonIDeviceService, DeviceService(); var app builder.Build(); app.MapHubDeviceHub(/hubs/device); // 客户端 URL 必须和这里完全一致 app.Run();参数含义值得多说两句。KeepAliveInterval是服务端主动发 ping 的间隔ClientTimeoutInterval是服务端多久收不到客户端任何消息就判定掉线两者必须满足KeepAliveInterval小于ClientTimeoutInterval否则会出现“客户端还活着服务端已经判定它死了”的鬼故事。MaximumReceiveMessageSize只限制客户端发给服务端的单条消息服务端推给客户端的大小不受它约束这点很容易记反。Hub 本身保持薄只做三件事连接时入组、按产线订阅、接收上报后转发。// DeviceHub.cs public sealed class DeviceHub : Hub { private readonly IDeviceService _devices; public DeviceHub(IDeviceService devices) _devices devices; public override async Task OnConnectedAsync() { // 全局监控频道所有看板都收 await Groups.AddToGroupAsync(Context.ConnectionId, monitor); await base.OnConnectedAsync(); } public Task SubscribeLineAsync(string line) { if (string.IsNullOrWhiteSpace(line)) throw new HubException(line 不能为空); // HubException 的消息会回传给客户端 return Groups.AddToGroupAsync(Context.ConnectionId, $line:{line}); } public async Task ReportAsync(DeviceStatus status) { // 客户端上报的数据不可信服务端必须校验后再广播 if (string.IsNullOrWhiteSpace(status.DeviceId)) throw new HubException(DeviceId 不能为空); var saved _devices.Save(status); await Clients.Group($line:{saved.Line}).SendAsync(StatusChanged, saved); await Clients.Group(monitor).SendAsync(StatusChanged, saved); } }这里有个设计决定上报和广播用同一个 Hub。好处是连接复用、鉴权统一坏处是所有的上报流量都走同一条连接客户端一旦网络抖动上报和接收一起断。如果上报频率很高每秒上百条更稳的做法是上报走独立的 HTTP 接口Hub 只负责下发两者解耦。3.2 共享契约DTO 和方法名常量单独一个类库前面说过 Hub 调用没有编译期约束那就人为造一个。建一个Contracts类库只放 DTO 和常量服务端和 WPF 客户端都引用它。// Contracts/HubMethods.cs public static class HubMethods { public const string StatusChanged nameof(StatusChanged); public const string SubscribeLine nameof(SubscribeLine); public const string Report nameof(Report); } // Contracts/DeviceStatus.cs public sealed record DeviceStatus( string DeviceId, string Line, int State, // 0 停机 1 运行 2 报警 double Temperature, DateTimeOffset Timestamp);用过nameof之后改方法名只要改常量客户端和服务端一起重编译写错立刻编译不过。这个类库不要引Microsoft.AspNetCore.SignalR.Client否则 WPF 客户端会被迫带上服务端依赖。3.3 客户端连接、订阅、收消息客户端封装成一个单例服务对外只暴露事件和两个方法。public sealed class RealtimeClient : IAsyncDisposable { private HubConnection? _conn; public event ActionDeviceStatus? StatusChanged; public async Task StartAsync(string baseUrl, CancellationToken ct default) { _conn new HubConnectionBuilder() .WithUrl(${baseUrl}/hubs/device, o { // 内网直连跳过协商强制 WebSocket出问题立刻暴露 o.Transports HttpTransportType.WebSockets; o.SkipNegotiation true; }) .AddMessagePackProtocol() // 两端必须一致否则握手阶段就失败 .WithAutomaticReconnect(new RetryPolicy()) .Build(); _conn.OnDeviceStatus(HubMethods.StatusChanged, s StatusChanged?.Invoke(s)); _conn.Reconnecting _ { /* 更新状态栏为“重连中” */ return Task.CompletedTask; }; _conn.Reconnected async _ { // 重连后 ConnectionId 变了分组订阅全部丢失必须重订 await _conn.InvokeAsync(HubMethods.SubscribeLine, L1); }; _conn.Closed _ { /* 触发兜底重启或提示用户 */ return Task.CompletedTask; }; await _conn.StartAsync(ct); } public ValueTask DisposeAsync() _conn?.DisposeAsync() ?? ValueTask.CompletedTask; }WithUrl里的 URL 必须和MapHub的路由一一对应多一层少一层都会在/negotiate阶段返回 404。AddMessagePackProtocol是可选项作用是换掉默认的 JSON 序列化消息体积大约能压到三分之一代价是两端必须同时装包、同时开启只开一边会在握手时直接失败。如果服务端要对接浏览器端就别用 MessagePack老老实实 JSON。3.4 在 ViewModel 里落地订阅、刷新、释放ViewModel 不碰HubConnection只订阅事件。但要注意事件回调是在后台线程触发的直接改ObservableCollection会抛InvalidOperationException所以这里先接一个批处理器下一章展开。public sealed partial class MonitorViewModel : ObservableObject, IAsyncDisposable { private readonly RealtimeClient _client; private readonly UiBatcher _batcher; public ObservableCollectionDeviceRow Rows { get; } new(); public MonitorViewModel(RealtimeClient client) { _client client; _batcher new UiBatcher(Application.Current.Dispatcher, Apply); _client.StatusChanged _batcher.Enqueue; // 这里拿到的是后台线程不能直接改集合 } public Task LoadAsync() _client.StartAsync(http://10.0.0.20:5080); private void Apply(IReadOnlyListDeviceStatus batch) { // 已经回到 UI 线程可以安全操作集合 foreach (var s in batch) { var row Rows.FirstOrDefault(r r.DeviceId s.DeviceId); if (row is null) Rows.Add(new DeviceRow(s)); else row.Update(s); } } public ValueTask DisposeAsync() { _client.StatusChanged - _batcher.Enqueue; // 退订否则窗口关了还在刷 return _client.DisposeAsync(); } }Apply里用FirstOrDefault线性查找行数在两百以内没问题上千行就要在 ViewModel 里挂一个Dictionarystring, DeviceRow做索引否则每次批量刷新都是 O(n·m)卡顿会非常明显。这一点在后面的压测章节还会提到。4. 断线重连与跨线程刷新把消息稳稳送上界面实时推送的难点从来不在“连上”而在“断了之后怎么办”和“消息来得太快怎么办”。这一章是两个问题的具体解法。4.1 自动重连的三个空档以及分组为什么会丢WithAutomaticReconnect()不带参数时用的是内置策略立即重试一次然后 2 秒、10 秒、30 秒各一次之后放弃。对桌面端来说这个策略太短——用户可能把笔记本合盖两小时再打开回来发现连接早就Closed了。工程里换成自定义策略sealed class RetryPolicy : IRetryPolicy { private static readonly TimeSpan[] Delays { TimeSpan.Zero, // 立刻重试应对瞬时抖动 TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(30) }; public TimeSpan? NextRetryDelay(RetryContext ctx) ctx.PreviousRetryCount Delays.Length ? Delays[ctx.PreviousRetryCount] : TimeSpan.FromSeconds(30); // 之后固定 30s 一次永不放弃 }RetryContext里有两个有用的字段PreviousRetryCount第几次重试和ElapsedTime从断开到现在过了多久。生产环境常见的做法是“前 5 次按固定退避之后固定 30 秒”既不会把服务端打爆也不会永久放弃。重连过程中有三个事件必须处理缺一个就会出现“界面状态和实际连接不一致”Reconnecting连接已断正在重试。此时InvokeAsync会抛异常界面上的所有写操作要禁用。Reconnected重连成功但ConnectionId已经变了之前通过Groups.AddToGroupAsync加的组全部丢失。必须在这里重新订阅。Closed重连彻底失败或主动关闭。要弹提示、启动兜底轮询、或者让用户点“重新连接”。第二个最容易被忽略。有人在StartAsync之后订阅了分组测试时拔网线再插上发现只有全局monitor组还在收消息——因为OnConnectedAsync每次连接都会重新执行而手动订阅的line:L1没有。血泪经验是所有订阅逻辑要么写进服务端的OnConnectedAsync按连接携带的参数自动入组要么在Reconnected里重放一遍没有第三种做法。4.2 Dispatcher为什么直接改集合会抛异常WPF 的ObservableCollection不是线程安全的它内部会触发CollectionChanged事件而这个事件会直接驱动ItemsControl生成容器。从非 UI 线程触发轻则抛InvalidOperationException“该类型的 CollectionView 不支持从调度程序线程以外的线程对其 SourceCollection 进行的更改”重则界面元素状态错乱但不报错属于典型的黑匣子问题。最朴素的解法是每条消息都Dispatcher.Invoke一次// 能跑但高频下会卡每来一条消息就排一次 UI 队列 _client.StatusChanged s Application.Current.Dispatcher.Invoke(() Apply(s));这条写法在每秒 5 条时毫无问题每秒 200 条时会把 UI 线程排满用户拖动窗口都会掉帧。改进方向是批量合并消息先进无锁队列再用一个“是否已排队”的开关保证同一时刻只有一个 UI 回调回调执行时一次性把队列抽干。public sealed class UiBatcher { private readonly Dispatcher _dispatcher; private readonly ActionIReadOnlyListDeviceStatus _apply; private readonly ConcurrentQueueDeviceStatus _queue new(); private int _scheduled; // 0 没排队, 1 已排队 public UiBatcher(Dispatcher dispatcher, ActionIReadOnlyListDeviceStatus apply) { _dispatcher dispatcher; _apply apply; } public void Enqueue(DeviceStatus s) { _queue.Enqueue(s); // 只有第一个进入的线程能把 0 换成 1其余的直接把消息丢进队列就返回 if (Interlocked.Exchange(ref _scheduled, 1) 0) _dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(Drain)); } private void Drain() { Interlocked.Exchange(ref _scheduled, 0); // 先复位允许下一轮排队 var batch new ListDeviceStatus(_queue.Count); while (_queue.TryDequeue(out var s)) batch.Add(s); if (batch.Count 0) _apply(batch); // 此时一定在 UI 线程 } }几个细节。Interlocked.Exchange返回旧值只有把 0 换成 1 的那个线程才负责调度避免了重复排队。DispatcherPriority.Background让刷新让位给输入事件用户拖动窗口时不会感觉卡。Drain里先复位再抽队列看起来反直觉但这样在_apply执行期间新到的消息会触发下一轮调度不会丢。_queue.Count只用来预估容量ConcurrentQueue的Count在高并发下不精确不影响正确性。4.3 帧预算一次刷新多少条才不卡批量合并之后真正的瓶颈变成“单次Apply处理多少条”。经验数据是DataGrid开启虚拟化里更新 50100 行的单个字段UI 线程耗时大约 38 毫秒超过 300 行就容易掉到 30 fps 以下。所以批处理不能无脑抽干得设个上限。单批条数单次耗时实测区间界面观感建议10 以内12 ms完全无感小规模看板直接用5010038 ms流畅大多数场景的甜点区2003001025 ms轻微掉帧需要配合虚拟化500 以上30 ms明显卡顿、拖动窗口迟滞必须做按 Key 合并超过上限的部分不要丢留在队列里等下一轮。Drain改成while (batch.Count 100 _queue.TryDequeue(out var s))即可配合“先复位再抽”的写法剩下的消息会自动进入下一轮调度不会饿死。4.4 把连接状态暴露出来别让它成为黑匣子桌面端程序最怕的是“界面看着正常其实数据早就停了”。HubConnection.State有四种取值直接绑到状态栏上用户能自己看出问题你排查也少一半沟通成本。_conn.Reconnecting _ { _dispatcher.BeginInvoke(() ConnectionText 重连中…); return Task.CompletedTask; };再配一条规则连接不在Connected状态时界面上的数据加一层灰底或者打上“数据已冻结”的时间戳。这个小细节能省掉大量“你们系统数据不准”的扯皮。5. 避坑与排查握手失败、重连丢组、内存上涨这几个坑下面五条都是我在实际部署里撞过的按“现象 → 原因 → 解决”写遇到同类问题可以直接对照。5.1 连接与握手连不上、连上就断、悄悄降级坑一本地跑得好好的部署到服务器后StartAsync报 404。现象是客户端抛HttpRequestException日志里能看到/hubs/device/negotiate?negotiateVersion1返回 404。原因通常不是 SignalR 配置错而是反向代理把路径前缀吃掉了——比如外部访问是https://host/app/hubs/device转发到内网却只给了/hubs/device而服务端MapHub注册的是/hubs/device看起来对得上实际上代理没配X-Forwarded-Prefix。解决方式是让代理做路径重写后直连或者服务端显式app.UsePathBase(/app)并在客户端WithUrl里带上同样的前缀。排查时先用浏览器或curl手工 POST 一次/negotiate能拿到 JSON 就说明服务端没问题锅在代理。坑二连接建立后大约 3060 秒必断一次日志里没有异常。现象是Closed事件规律触发间隔接近ClientTimeoutInterval。原因是客户端所在机器的网络有 NAT 会话老化或者服务端线程池饥饿导致心跳 ping 没能按时发出。前者靠把KeepAliveInterval调到 10 秒以内、让心跳频率高于 NAT 老化时间来缓解后者要看服务端有没有同步阻塞调用占满线程池——async方法里混了.Result或.Wait()在并发上来之后会把线程池抽干心跳自然发不出去。这条属于玄学问题但只要日志里能看到心跳时间戳就不是玄学。坑三内网一切正常接到客户网关后吞吐骤降延迟从几十毫秒变成一两秒。现象是功能都对就是慢。原因是协商阶段 WebSocket 握手失败客户端静默降级到了 LongPolling。解决分两步先在客户端日志里确认实际传输方式服务端OnConnectedAsync里判断IsWebSocketRequest并打日志再去网关放行Upgrade和Connection两个头。如果网关确实不支持就把SkipNegotiation true打开让它快速失败而不是让业务在降级模式下慢慢跑——慢比断更难排查。5.2 消息与状态重连丢组、状态栏永远停在“连接中”坑四拔网线再插上全局广播能收到按产线订阅的消息全没了。现象很典型重连成功monitor组的推送正常到达但line:L1的一条都不来而且服务端不报错。原因前面提过——重连后ConnectionId变了Groups是按ConnectionId索引的旧订阅随旧连接一起消失。解决是把订阅逻辑放进Reconnected回调重放一遍或者更彻底一点在OnConnectedAsync里根据Context.GetHttpContext().Request.Query[line]自动入组客户端把产线号放在连接 URL 的查询串里重连时自然带上。后者更省心但要接受“连接参数变了必须重建连接”这个约束。还有一种变体Reconnected里await InvokeAsync抛超时。原因是重连刚完成、服务端还没完全就绪或者同一条连接上并发了太多调用。稳妥的写法是在Reconnected里加个短延迟再订阅并对失败做一次重试别把异常吞掉。5.3 线程与内存跨线程异常、内存缓慢上涨坑五连续开关窗口十几次之后内存涨了两三百兆关闭窗口也不释放。现象是任务管理器里内存曲线一路上台阶最终可能 OOM。原因几乎总是事件订阅没退订_client.StatusChanged ...加了上去窗口关掉时没有-RealtimeClient是单例它持有的委托链把整个 ViewModel 连同里面的ObservableCollection、图片、绑定对象全部钉在内存里。解决是在Dispose/OnClosed里成对退订并且养成“凡是在构造函数里的必须在同一处写下对应的-”的习惯。如果 ViewModel 数量多可以统一用一个CompositeDisposable管理订阅窗口关闭时一次性清空。跨线程那个坑单独说一句InvalidOperationException至少还会抛最怕的是不抛但界面状态错乱——比如某一行的颜色停在报警色不恢复重启又好了。这种基本都是后台线程直接改了绑定属性但没走Dispatcher。定位办法是在Apply开头断言_dispatcher.CheckAccess()调试期一旦为 false 就直接抛比线上慢慢查便宜得多。6. 上线前的压测与自检把每秒 200 条推送压成 20 帧骨架跑通之后最后一步是把推送压到界面能承受的节奏。批量合并解决的是“UI 线程被排队打满”但还有一种浪费同一台设备在 100 毫秒内报了 5 次状态前 4 次对用户毫无意义只有最后一次有效。这类中间态应该在Drain里就地合并。// 按 DeviceId 做 last-write-wins中间态直接丢弃 private readonly Dictionarystring, DeviceStatus _latest new(); private void Drain() { Interlocked.Exchange(ref _scheduled, 0); while (_queue.TryDequeue(out var s)) _latest[s.DeviceId] s; // 后到的覆盖先到的 if (_latest.Count 0) return; _apply(_latest.Values.ToList()); _latest.Clear(); }改了之后200 条每秒、同一批设备反复上报的场景实际进 UI 的批次数会从每秒上百次降到每秒十几次而且每批的条数受设备总数约束不再随上报频率线性增长。代价是丢掉了中间态——如果业务需要画温度曲线那就不能这么合并得改成按时间窗口采样比如每 200 毫秒取一个点保留趋势而不是保留全部原始值。压测时我会用一个简单的计时器卡住Drain的耗时超过 16 毫秒就写日志带上批次数和队列长度注意压测要在真实网络下做别用本机回环。回环的 RTT 接近 0永远压不出心跳超时和重连丢组这两类问题。最后是一张上线前我会逐条过的参数表抄下来按顺序核一遍能省掉大部分“上线才出现”的问题参数位置默认值上线建议KeepAliveInterval服务端15s10s 以内抵消 NAT 老化ClientTimeoutInterval服务端30s保持为心跳间隔的 23 倍MaximumReceiveMessageSize服务端32KB按上报报文实测值 ×2EnableDetailedErrors服务端false调试开、上线关SkipNegotiation客户端false内网可开有网关必须关单批上限客户端—50100 条配合虚拟化订阅重放客户端—Reconnected里必须重放分组订阅这套东西我从轮询时代一路改过来最大的教训是实时推送的 bug 基本不在“推”的那一步而在重连、订阅、线程和退订这四个边角上。后来每次接新项目我都会先把参数表和这三条订阅规则写进设计文档再动第一行业务代码——不然等到客户现场拔网线才发现组丢了代价就不是改几行代码的事了。希望帮到你。本文还有配套的精品资源点击获取