SignalR Java 客户端连接泄漏排查:从 OkHttpClient 复用到优雅关闭的完整修复路径
发布时间:2026/9/1 8:58:39 作者:尧图编辑部 阅读量:1,286

SignalR Java 客户端连接泄漏排查从 OkHttpClient 复用到优雅关闭的完整修复路径【免费下载链接】aspnetcoreASP.NET Core is a cross-platform .NET framework for building modern cloud-based web applications on Windows, Mac, or Linux.项目地址: https://gitcode.com/GitHub_Trending/as/aspnetcore凌晨三点告警把值班拉起来Java 服务通过 ASP.NET Core SignalR 的 HubConnection 向大屏推送数据ESTABLISHED连接数从 50 一路爬到 2000进程 FD 逼近上限日志开始刷Too many open files。重启后一切正常——但两小时后曲线又以同样的斜率往上走。说白了这不是某个连接坏了而是每次重连都在悄悄新建一套 OkHttp 客户端资源旧的没人回收。把谁在创建、谁负责释放这条链路补全之后连接数会在断开后 5 秒内回落到 0。故障现象清单排查前可以先对照下面这些信号如果中了三四条基本就是客户端侧资源没回收长时间运行后jstack里OkHttp Dispatcher线程数只增不减从个位数涨到几十上百服务器端ss -s显示大量CLOSE-WAIT/半开连接进程ls /proc/pid/fd | wc -l持续增长高并发推送场景下日志反复出现Too many open files随后start()直接失败堆内存里反复出现com.microsoft.signalr.DefaultHttpClient对象无法被 GC 回收Old Gen 缓慢爬升手动把 HubConnectionstop()之后连接数不动说明断开和释放是两回事原理拆解可以这样理解OkHttpClient 像一个后厨 餐桌池。Dispatcher 线程池是后厨的厨师负责处理每一个请求连接池是餐桌用完的桌位会留着等下一位客人空闲连接最多留 5 分钟。SignalR 的 Java 客户端里有一个容易被忽略的事实每调用一次HttpHubConnectionBuilder.build()如果没有显式注入客户端就会 new 一整个新的 OkHttpClient——新后厨、新餐桌池、新线程池。关键源码定位如下// HubConnection.java:144 每次 build() 都新建一个 DefaultHttpClient即新 OkHttpClient this.httpClient new DefaultHttpClient(configureBuilder); // DefaultHttpClient.java:35-39 close() 只关了线程池没清连接池 Override public void close() { if (this.client ! null) { this.client.dispatcher().executorService().shutdown(); } } // OkHttpWebSocketWrapper.java:60-63 stop() 只发 close(1000) 帧 public Completable stop() { websocketClient.close(1000, HubConnection stopped.); return closeSubject; }再看释放链路只有HubConnection.close()HubConnection.java:1747-1756这一处会调用httpClient.close()而且它只关自己内部创建的那个DefaultHttpClient。所以问题收敛成三点一是build()被高频调用客户端实例泛滥二是有人只调stop()不调close()释放根本没发生三是即便调了close()连接池里的空闲 TCP 连接也要等 OkHttp 默认的 5 分钟 keep-alive 到期才真正释放短连接风暴下就是泄漏。分步修复指南1. 复用 HubConnection别为每次重连重新 build业务上一个用户会话只需要一条逻辑连接。断线重连时用同一个连接对象重新start()而不是销毁再新建——这是杜绝客户端实例泛滥最直接的办法。// 应用启动时建好连接后续重连全部复用这同一个对象 final String hubUrl ws://your-signalr-server/hub; // TODO: 替换为你的实际值 HubConnection connection HttpHubConnectionBuilder.create(hubUrl).build(); connection.onReconnecting(cause - { logger.warn(连接断开准备重连: {}, cause.getMessage()); // 同一对象上重新 start内部会复用已配置的 HttpClient connection.start().subscribe(); }); connection.onClosed(cause - logger.error(连接彻底关闭: {}, cause)); connection.start().blockingAwait(10, TimeUnit.SECONDS);改完之后进程内始终只有一个 OkHttp 客户端OkHttp Dispatcher线程数不再随重连次数增长。2. 用 close() 收尾并养成 try-with-resources 习惯stop()只负责和服务器道别close()才负责关线程池。短生命周期的连接比如批处理脚本、定时任务里的临时连接必须走close()import com.microsoft.signalr.*; String hubUrl ws://your-signalr-server/hub; // TODO: 替换为你的实际值 try (HubConnection hub HttpHubConnectionBuilder.create(hubUrl).build()) { hub.start().blockingAwait(10, TimeUnit.SECONDS); hub.invoke(PushEvent, hello).blockingAwait(5, TimeUnit.SECONDS); // 离开 try 块自动调用 hub.close()等价于 stop() httpClient.close() } catch (Exception e) { logger.error(Hub 通信失败, e); }改完之后临时连接用完即焚DefaultHttpClient及其 dispatcher 不再滞留内存。3. 收紧连接池与超时参数如果业务形态就是大量短连接那就要给每个客户端的餐桌池和后厨设上限让资源有明确的过期时间import okhttp3.ConnectionPool; import okhttp3.Dispatcher; import java.util.concurrent.ExecutorService; import java.util.concurrent.TimeUnit; HttpHubConnectionBuilder.create(hubUrl) // TODO: 替换为你的实际值 .setHttpClientBuilderCallback(builder - builder.connectionPool(new ConnectionPool(5, 30, TimeUnit.SECONDS)) // 空闲连接 30 秒后回收别用默认 5 分钟 .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) .writeTimeout(10, TimeUnit.SECONDS) .retryOnConnectionFailure(false) .dispatcher(new Dispatcher() { Override public ExecutorService executorService() { return Executors.newFixedThreadPool(10); // 后厨固定 10 个工位 } })) .build();注意dispatcher()的 executor 由你提供后生命周期也由你管理建议把它放到随close()一起 shutdown 的地方。改完之后即使客户端实例没被及时 close空闲连接最多 30 秒后也会自动蒸发线程数被硬顶在 10。4. 给服务器先挂的场景加兜底上面三条防的是自己忘关还有一类是服务端主动断开、网络闪断。onClosed回调里必须显式收尾尤其是后台常驻连接connection.onClosed(cause - { logger.error(连接关闭cause{}, cause); if (!appShuttingDown.get()) { // 常驻服务里记录现场 按退避策略重建 scheduleReconnectWithBackoff(); // TODO: 实现你的退避重连带上限次数 } });改完之后无论断连发起方是谁客户端侧的清理动作都有确定的触发点不再依赖恰好有人调用 close。效果验证三个手段交叉验证缺一不可写最小并发压测脚本观察连接数能否回落用jstack或 JMX 盯OkHttp Dispatcher线程数压测结束后应回落到固定池大小对比cat /proc/pid/fd | grep socket | wc -l断开后 1 分钟内应回到基线可运行的验证脚本OkHttp 4.x// 并发创建 50 个短连接全部 close 后验证资源回落 int n 50; // TODO: 按你的并发量调整 CountDownLatch latch new CountDownLatch(n); for (int i 0; i n; i) { new Thread(() - { try (HubConnection hub HttpHubConnectionBuilder.create(hubUrl).build()) { hub.start().blockingAwait(10, TimeUnit.SECONDS); Thread.sleep(200); } catch (Exception e) { e.printStackTrace(); } finally { latch.countDown(); } }).start(); } latch.await(1, TimeUnit.MINUTES); Thread.sleep(5000); // 等待连接池空闲超时 System.out.println(activeThreads Thread.activeCount()); // 判定标准回落且稳定怎么算修好了所有连接关闭后 5 秒内压测进程里与 SignalR 相关的 socket FD 数降为 0OkHttp Dispatcher线程数稳定在配置值不再增长再跑 2 小时连接数曲线保持水平Too many open files不再出现。三条都满足才算闭环暂时没报错不算。避坑清单复用 HubConnection— stop 后可重新 start别每次都 build。close 才是最后一步— stop 只道别close 才关客户端。连接池必设上限— 空闲超时别用默认 5 分钟。Dispatcher 线程要有天花板— 默认弹性池在高并发下无上限。onClosed 里做收尾— 服务端先断时清理动作不能缺。压测要带观察窗口— 关闭后等 5 秒再数 FD 才有意义。延伸思考这类问题不止 Java 客户端有.NET 的 HttpClient、Go 的 http.Transport 同样遵循连接池 空闲 keep-alive模型而部署层还会叠加自己的规则——K8s 滚动更新时 Pod 销毁不等连接排空、NAT 网关对空闲连接 5~15 分钟就单方面掐断、Sidecar 代理Envoy 一类有自己的 idle timeout客户端以为连接还活着对端其实早没了。所以调参时建议把服务端超时、代理超时、客户端池超时三层对齐取最小值。想继续深挖可以读客户端源码里HubConnection的ConnectionState重连状态机src/SignalR/clients/java/signalr/core/src/main/java/com/microsoft/signalr/以及 SignalR 模块的整体说明src/SignalR/README.md。【免费下载链接】aspnetcoreASP.NET Core is a cross-platform .NET framework for building modern cloud-based web applications on Windows, Mac, or Linux.项目地址: https://gitcode.com/GitHub_Trending/as/aspnetcore创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考