资讯动态

Netty WebSocket长连接聊天系统:空闲检测与心跳机制实践

发布时间:2026/10/15 18:02:57 来源:尧图企业网站定制
做在线聊天、消息推送这类功能时HTTP 那一套短连接方案很快就撑不住了。我之前调一个内部聊天模块一开始用的 HTTP 轮询机器资源还能扛但消息延迟、连接浪费、在线状态同步这些问题轮番折磨人。后来整体切到 Netty WebSocket 长连接把读写空闲检测、心跳机制这些细节都补齐之后效果才真正落到了实处。这篇不是复述官方文档而是把服务端骨架、聊天路由、空闲检测、心跳机制从搭建到踩坑的完整过程整理出来适合已经会用基础 Handler、正准备把 Netty WebSocket 聊天一类功能落到实际项目的人。1. 为什么不用轮询从在线状态推演到长连接1.1 长短轮询的瓶颈在哪HTTP 短轮询就是客户端定时打一个请求问有新消息吗服务端查一下有就返回没有就返回空。这种方式最大的问题有两个一是轮询间隔短了请求量爆炸式增长二是间隔长了消息延迟明显。比如 3 秒一次轮询在聊天场景里用户能明显感受到对方消息过了几秒才刷出来。后来很多项目退一步用长轮询客户端发请求后服务端先挂住这个请求等有新消息或者超过约定时间再返回。它能减少一部分空请求但每个挂住的请求都要占一个 Tomcat 线程或连接资源在连接数几千上万的时候后端线程池很容易被打满。而且网络中间设备、负载均衡层对长连接空闲时间有各种限制经常出现莫名其妙的请求被中断然后又重连。1.2 WebSocket 为什么适合聊天类场景WebSocket 是在一次 HTTP Upgrade 握手之后把 TCP 连接升级成全双工长连接。也就是说客户端和服务端可以随时互发消息不需要像 HTTP 那样一轮请求对应一轮响应。放到聊天场景里这个价值很直观服务端有消息时可以直接推给客户端不用再等客户端发起查询。连接建立后只需要维护一条 TCP 连接省掉大量 HTTP 连接建立/销毁的开销。在线状态可以精确到连接存在 心跳正常比最近 5 分钟内活跃这种推断准确得多。用 Netty 做 WebSocket 服务端是因为它本身就是一个高性能 NIO 框架事件驱动的模型适合承载大量长连接。常见的 Tomcat、Jetty 也能做 WebSocket但连接规模、吞吐量、对协议底层细节的控制力在很多实践场景里还是 Netty 更顺手的多。1.3 这个方案解决的核心问题标题里写到的几个关键词其实对应了生产环境里必须处理的几个问题长连接建立之后不再被 HTTP 语义束缚消息可以实时双向推送。网络聊天需要一套连接管理、用户绑定、消息路由的机制保证消息能准确送到目标用户。读写空闲检测长连接建起来之后不能放任不管需要识别连接还在但已经死掉的半开连接。心跳机制链路保活既防止网络中间设备回收空闲连接也让服务端能快速感知对端是否还活着。下面从服务端骨架开始一步步把这些东西搭起来。2. 第一个能跑的骨架Pipeline 管道编排与启动类2.1 每层 Handler 到底在干什么Netty 的 ChannelPipeline 本质上是一串 Handler 处理链。连接建立后数据从网络进来先经过一个个入站 Handler再经过出站 Handler 发出去。针对 WebSocket 服务端初始化器里至少要编排这几层Pipeline 顺序Handler职责1HttpServerCodec解析 HTTP 请求WebSocket 握手依赖 HTTP Upgrade 报文2HttpObjectAggregator把 HTTP 请求/响应聚合为完整报文握手时必须有完整 Upgrade 头3WebSocketServerProtocolHandler处理 WebSocket 握手、帧编解码、Ping/Pong 自动应答4IdleStateHandler检测读写空闲产生 IdleStateEvent5业务 Handler处理聊天消息、维护用户会话、处理空闲事件很多新手在这里犯的第一个错就是漏掉 HttpObjectAggregator。HTTP 请求在 TCP 层是可能被拆成多个报文到达的HttpServerCodec 读到的可能只是 DefaultHttpRequest、LastHttpContent 这样的碎片。如果少了聚合器WebSocket 握手经常会跑到请求还没读完就处理结果就是握手失败连接被客户端关闭。2.2 基础版服务端启动与初始化代码先看一个可以直接跑的启动类public class WebSocketChatServer { private static final int PORT 8080; public static void main(String[] args) throws Exception { EventLoopGroup bossGroup new NioEventLoopGroup(1); EventLoopGroup workerGroup new NioEventLoopGroup(); try { ServerBootstrap bootstrap new ServerBootstrap(); bootstrap.group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) .option(ChannelOption.SO_BACKLOG, 1024) .childOption(ChannelOption.TCP_NODELAY, true) .childOption(ChannelOption.SO_KEEPALIVE, true) .childHandler(new WebSocketChatInitializer()); Channel channel bootstrap.bind(PORT).sync().channel(); System.out.println(WebSocket 聊天服务已启动端口: PORT); channel.closeFuture().sync(); } finally { bossGroup.shutdownGracefully(); workerGroup.shutdownGracefully(); } } }这里有两个细节值得注意TCP_NODELAY 和 SO_KEEPALIVE。聊天消息大多是短消息如果不关 Nagle 算法小消息会被延迟一段时间合并发送体感就是一句话要隔一会儿才发出去。SO_KEEPALIVE 开启后TCP 层会做一些基础探活但它只是兜底后面第 5 章会详细说。初始化器是核心链路顺序如下public class WebSocketChatInitializer extends ChannelInitializerSocketChannel { Override protected void initChannel(SocketChannel ch) { ChannelPipeline pipeline ch.pipeline(); pipeline.addLast(new HttpServerCodec()); pipeline.addLast(new HttpObjectAggregator(65536)); pipeline.addLast(new WebSocketServerProtocolHandler(/chat, null, true, 65536 * 10)); pipeline.addLast(new IdleStateHandler(60, 30, 0, TimeUnit.SECONDS)); pipeline.addLast(new HeartbeatHandler()); pipeline.addLast(new ChatMessageHandler()); } }WebSocketServerProtocolHandler 的四个参数分别是握手路径 /chat子协议列表这里不指定是否允许扩展单帧最大字节数。maxFrameSize 默认 64KB聊天文本一般够用但如果要传截图或者大段富文本可以根据业务调大。同时限制帧大小也能避免有人用超大帧直接把内存打爆。有一点我在调试时亲自验证过WebSocketServerProtocolHandler 在握手成功后会从 pipeline 里把 HttpServerCodec 和 HttpObjectAggregator 移除掉后续纯走 WebSocket 帧编解码。这一步不需要你用代码做清理Netty 内部在 handshakeCompleted 阶段就处理了。2.3 握手阶段不能省的两个小动作第一是 Origin 校验。WebSocket 是可以跨域连接的如果不做校验别人随便在你的站外页面里 new 一个 WebSocket就能连到你的服务端这等于把接口裸奔给别人。虽然真实项目中还会有 token 鉴权但第一道防线在握手阶段拦截更干净。实现方式是在聚合器之后、协议握手处理器之前加一个只处理 FullHttpRequest 的 Handlerpipeline.addLast(new ChannelInboundHandlerAdapter() { Override public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { if (msg instanceof FullHttpRequest) { FullHttpRequest request (FullHttpRequest) msg; String origin request.headers().get(HttpHeaderNames.ORIGIN); if (!isAllowedOrigin(origin)) { FullHttpResponse forbidden new DefaultFullHttpResponse( HttpVersion.HTTP_1_1, HttpResponseStatus.FORBIDDEN); ctx.writeAndFlush(forbidden).addListener(ChannelFutureListener.CLOSE); return; } } ctx.fireChannelRead(msg); } });isAllowedOrigin 就是业务侧配一个域名白名单不在名单里直接返回 403 并关闭连接。这里注意校验完一定要调用ctx.fireChannelRead(msg)让请求继续往下走否则握手流程会被截断。第二是连接数控制。长连接服务最怕的是无上限接收连接。连接数一旦失控文件描述符耗尽整个进程都可能变得不可用。我习惯在业务 Handler 的 channelActive 里维护一个 AtomicInteger 计数器超过阈值就回一条提示消息然后关闭连接。3. 聊天室的消息路由从登录、单聊到群聊的代码设计3.1 先定协议再写代码聊天业务最忌讳的是还没定消息协议就开始写 Handler。前后端各写各的联调时对字段名、类型、状态码能活活对出一个通宵来。我推荐最朴素的 JSON 文本协议用字段 type 区分消息类型// 客户端 - 服务端 {type:login,user:小张,room:room-a} {type:chat,to:小李,content:你好} {type:group,room:room-a,content:大家周末有空吗} {type:heartbeat} // 服务端 - 客户端 {type:loginResp,code:0,onlineCount:12} {type:chat,from:小张,content:你好,ts:1710000000000} {type:chatErr,message:对方不在线}字段含义固定之后服务端消息入口只需要做一件事解析出 type然后分发到对应的处理方法。3.2 ChannelGroup 和用户会话怎么配合服务端要维护两个维度的状态连接维度和用户维度。连接维度用 Netty 自带的 ChannelGroup 就行。它内部监听每个 Channel 的关闭事件连接断开后会自动从组里移除省得你在每个业务分支里手动 remove。用户维度需要一个 Map把用户 ID 和 Channel 绑定起来public class UserSession { private static final MapChannel, String BIND_USER new ConcurrentHashMap(); private static final MapString, Channel USER_CHANNELS new ConcurrentHashMap(); public static void bind(String user, Channel channel) { BIND_USER.put(channel, user); USER_CHANNELS.put(user, channel); } public static String getUser(Channel channel) { return BIND_USER.get(channel); } public static Channel getChannel(String user) { return USER_CHANNELS.get(user); } public static void unbind(Channel channel) { String user BIND_USER.remove(channel); if (user ! null) { USER_CHANNELS.remove(user, channel); } } }这里注意为什么要用USER_CHANNELS.remove(user, channel)而不是remove(user)因为有重复登录踢下线场景用户 A 已经在连接 1 上登录又用连接 2 登录我们想把连接 1 里的旧记录踢掉。如果直接 remove(user)可能把新连接 2 的绑定也误删了。带第二个参数只有映射值还是旧 channel 时才删除能避免这个问题。3.3 消息分发与异常兜底业务核心 Handler 的骨架如下public class ChatMessageHandler extends ChannelInboundHandlerAdapter { private static final ChannelGroup ALL_CHANNELS new DefaultChannelGroup(GlobalEventExecutor.INSTANCE); private static final MapString, ChannelGroup ROOM_CHANNELS new ConcurrentHashMap(); Override public void channelActive(ChannelHandlerContext ctx) throws Exception { ALL_CHANNELS.add(ctx.channel()); System.out.println(新连接: ctx.channel().remoteAddress()); ctx.fireChannelActive(); } Override public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { if (msg instanceof TextWebSocketFrame) { String text ((TextWebSocketFrame) msg).text(); handleText(ctx, text); } else if (msg instanceof CloseWebSocketFrame) { // 客户端主动关闭时Netty 会走 close 流程这里做好清理 ctx.close(); } else { // 其他帧类型Ping/Pong 等引用计数释放掉 ReferenceCountUtil.release(msg); } } Override public void channelInactive(ChannelHandlerContext ctx) throws Exception { UserSession.unbind(ctx.channel()); ALL_CHANNELS.remove(ctx.channel()); System.out.println(连接关闭: ctx.channel().remoteAddress()); ctx.fireChannelInactive(); } }单聊路由的逻辑很简单private void handleChat(Channel sender, String to, String content) { String from UserSession.getUser(sender); if (from null) { sendError(sender, 请先登录再发言); return; } Channel target UserSession.getChannel(to); if (target null || !target.isActive()) { sendError(sender, 对方不在线); return; } sendText(target, {\type\:\chat\,\from\:\ from \,\content\:\ content \}); }群聊稍微多一层房间维度private void handleGroup(Channel sender, String room, String content) { ChannelGroup group ROOM_CHANNELS.computeIfAbsent( room, key - new DefaultChannelGroup(GlobalEventExecutor.INSTANCE)); group.add(sender); group.writeAndFlush(new TextWebSocketFrame( {\type\:\group\,\room\:\ room \,\content\:\ content \})); }使用 ChannelGroup 统一广播时Netty 内部会处理多个 Channel 对同一个消息对象的引用计数问题比自己写循环逐个 writeAndFlush 同一个 TextWebSocketFrame 要安全。消息处理的线程模型这里要提一嘴ChatMessageHandler 的 channelRead 是在 IO 线程也就是 EventLoop 线程上执行的。如果这里要查数据库、调第三方接口一定把这些耗时操作丢到业务线程池否则一个用户的慢查询会把整个 EventLoop 上的所有连接都拖住。4. 读写空闲检测IdleStateHandler 的触发规则和误用场景4.1 三个参数到底代表什么IdleStateHandler 构造方法的四个参数很多人没理解透就往上写new IdleStateHandler(readerIdleTimeSeconds, writerIdleTimeSeconds, allIdleTimeSeconds, TimeUnit.SECONDS)readerIdleTimeSeconds读空闲时间。超过这个时间没有从 Channel 读到任何数据触发读空闲事件。writerIdleTimeSeconds写空闲时间。超过这个时间没有往 Channel 写过数据触发写空闲事件。allIdleTimeSeconds读写都空闲时间。读也没有、写也没有同时超过该时间才触发。我经常用一句话类比读空闲相当于你一直没说话写空闲相当于我一直在听但根本没机会开口。注意任何一个事件触发都会以 IdleStateEvent 的形式沿着 pipeline 向后传播传播方向是入站。要接住这个事件最简单的方式是在后面的 Handler 里重写 userEventTriggeredOverride public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception { if (evt instanceof IdleStateEvent) { IdleStateEvent event (IdleStateEvent) evt; if (event.state() IdleState.READER_IDLE) { // 读空闲处理 } else if (event.state() IdleState.WRITER_IDLE) { // 写空闲处理 } } else { super.userEventTriggered(ctx, evt); } }不要想着用一个 ALL_IDLE 覆盖所有情况不同空闲状态表达的业务含义完全不一样。4.2 聊天场景为什么不能用 ALL_IDLE 踢人最常见的误配置就是把三个参数都设成 60 秒然后在 ALL_IDLE 里直接 ctx.close()。看起来好像逻辑没问题60 秒没人说话就断开呗。实际跑起来你会发现用户只是挂着不说话浏览器页面还开着过了 60 秒就被服务端踢下线了。因为在聊天场景里用户既不发言服务端又没有推送消息链路自然是读写都空闲的但这并不能说明客户端死了。这里必须区分链路空闲和客户端存活这两个概念。链路空闲只是说这个通道上没有数据流动客户端进程可能活得好好的。要判断客户端是否存活正确做法是让客户端主动发心跳让链路上定期有流量。所以聊天场景里正确姿势是把服务端空闲阈值设成比客户端心跳周期更长只针对读空闲做检测而不是读写都空闲。4.3 配合 Ping/Pong 的试探式检查如果不用业务心跳有一种折中方案是利用 WebSocket 协议层的 Ping/Pong 帧做试探。第一次触发读空闲时不急着断开而是发一个 PingWebSocketFrame 过去public class HeartbeatHandler extends ChannelInboundHandlerAdapter { private int readIdleTimes 0; Override public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception { if (evt instanceof IdleStateEvent) { IdleStateEvent event (IdleStateEvent) evt; if (event.state() IdleState.READER_IDLE) { readIdleTimes; if (readIdleTimes 2) { // 连续两个读空闲周期都没读到数据判定失联 System.out.println(连接失联关闭: ctx.channel().remoteAddress()); ctx.close(); } else { // 第一次空闲发 Ping 试探对端 ctx.writeAndFlush(new PingWebSocketFrame()); } } else if (event.state() IdleState.WRITER_IDLE) { // 写空闲只是服务端没推数据做日志或业务补偿不要断连 System.out.println(写空闲当前无数据下发); } } else { super.userEventTriggered(ctx, evt); } } Override public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { // 只要读到任何数据Pong、业务消息都算就认为链路活着重置计数 readIdleTimes 0; ctx.fireChannelRead(msg); } }这段代码踩过一个坑第一次读空闲触发后发了 Ping浏览器收到 Ping 会自动回 Pong但这个 Pong 要到达服务端才算是读到数据。如果你的业务 Handler 把 Pong 拦截掉或者没有正确转发readIdleTimes 就永远不会清零会导致正常客户端被误杀。所以 HeartbeatHandler 必须在业务 Handler 之前并且在 channelRead 里无条件把消息继续往下传。5. 心跳机制的三层配合从 TCP 到业务消息5.1 TCP KeepAlive 能干什么、不能干什么很多人以为开了 SO_KEEPALIVE 就万事大吉等线上出现大量连接还占着、消息发不出去时才知道它并不万能。TCP KeepAlive 是操作系统协议栈层面的探活机制默认探测时间非常长Linux 上一般以小时计。它只能告诉你这个 TCP 连接在内核眼里还活着不能感知应用层是否卡死、事件循环是否阻塞、业务逻辑是否正常。而且在一些网络环境里中间设备可能会主动丢弃 KeepAlive 探测包导致长期无响应的连接一直占着资源。WebSocket 的 Ping/Pong 帧是协议层保活手段比 TCP KeepAlive 轻量得多。浏览器 WebSocket 客户端收到 Ping 帧后会自动回 Pong不需要 JS 代码参与这个特性后面第 6 章还会具体展开。但 Ping/Pong 也解决不了业务逻辑异常但连接正常的问题。比如客户端进程出现死锁消息处理线程卡住TCP 连接和 WebSocket 帧仍然看起来都正常服务端却收不到任何业务消息。要感知这种状态必须在业务层有自己的心跳消息。5.2 一套实际可用的心跳参数组合我在做过几次线上故障之后总结出一套比较稳的参数组合配置项值说明客户端业务心跳周期30 秒客户端定时发送 {type:heartbeat}服务端读空闲阈值90 秒超过 90 秒未收到任何入站数据触发试探服务端 Ping 试探策略1 次第一次读空闲触发时发 Ping 帧服务端断开阈值连续 2 次触发第二次触发读空闲直接关闭连接这里的关键是服务端阈值必须比客户端心跳周期大留出网络抖动、GC 停顿、调度延迟的余量。90 秒基本上是 30 秒心跳的 3 倍比较安全。如果客户端心跳周期是 10 秒服务端你可以设 30 到 40 秒。比例太接近的话一次稍微严重的网络抖动就可能误杀大量连接。服务端收到心跳消息时直接忽略即可不需要回业务响应。如果你回了一条心跳响应客户端还得专门处理响应消息白白增加业务复杂度。只在日志里按需记录就行重点是服务端读空闲检测被心跳消息重置了。5.3 断线重连、重复登录与在线状态补偿心跳机制解决的是连接死了能被发现断线重连解决的是发现之后客户端能恢复。客户端断线重连我建议用指数退避别用固定间隔。固定 1 秒重连服务端如果正在抖动重连请求会像洪水一样打过来。指数退避的节奏是 1 秒、2 秒、4 秒、8 秒最多 30 秒封顶同时加一点随机抖动避免所有客户端同时重连造成雪崩效应。重连之后还有一个重复登录问题。客户端断线重连成功后通常会再发一次 login这时候服务端发现一个用户 ID 已经在线。我的处理策略是新连接踢旧连接把新连接绑定到用户 ID向旧连接发一条你在别处登录请重新连接的系统消息然后关闭旧连接。这样对用户来说断网重连是无感知的因为新连接立刻顶上来了。在线状态补偿也值得说。客户端重连成功后应该主动向服务端拉取一下自己所在的房间、好友在线状态、离线消息。不要指望服务端重连时自动把状态推回来服务端只管连接和路由状态同步交给客户端主动查询更可靠。6. 实测压测中的几个坑从浏览器 Pong 到连接数控制6.1 浏览器的 Ping/Pong 行为JS 拿不到但自动会回写 WebSocket 心跳时很多人第一反应是用浏览器 JS 发 Ping 帧。实际上 JS 的 WebSocket API 根本不暴露 Ping/Pong 帧方法你没办法在业务代码里主动发一个协议层 Ping。但这不代表服务端不能发 Ping。现代浏览器收到 Ping 帧后协议栈会自动回一个 Pong不需要 JS 参与。这个行为表面上看很省事实际上容易埋坑如果服务端通过 Ping/Pong 做存活检测Pong 是浏览器自动回的浏览器页面本身可能已经卡死或者 JS 线程已经阻塞了但协议层还会正常回 Pong。直观的验证方式是在 Chrome 开发者工具的 Network 面板里打开 WebSocket 连接切到 Frames 视图能看到 Pong 帧自动出现。我实测过一次客户端 JS 不做任何处理服务端发 Ping 之后Frames 面板里马上有条 Pong 帧而且 JS 的 onmessage 里完全收不到任何事件。所以做心跳保活一定要区分协议层保活和业务层保活。浏览器自动回 Pong 只能用于 TCP 链路保活不能用于感知页面是否卡死。要判断页面 JS 是否还活着必须让 JS 定时发业务心跳消息。6.2 忘记聚合 HTTP 导致握手失败这个坑非常隐蔽。默认情况下HttpServerCodec 会把 HTTP 请求解析成多个对象依次往后传先是 DefaultHttpRequest然后是 HttpContent最后是 LastHttpContent。如果 WebSocketServerProtocolHandler 拿到的不是聚合后的 FullHttpRequest握手可能直接抛异常或者静默失败。我排查过一次客户端连不上服务端的现场。客户端 setTimeout 里看着连接异常服务端日志没有业务输出只有一堆类型转换异常。后来打日志才发现问题pipeline 里只有 HttpServerCodec没有 HttpObjectAggregator握手请求被拆成碎片到达。WebSocketServerProtocolHandler 期望接收到完整的 FullHttpRequest结果拿到的是碎片对象当然处理不了。解决方式就是在 HttpServerCodec 后面补上pipeline.addLast(new HttpObjectAggregator(65536));这个顺序也不能乱。HttpServerCodec 必须在前因为它负责 HTTP 报文的编解码HttpObjectAggregator 在后因为它要把 Codec 产生的碎片拼接成整体WebSocketServerProtocolHandler 再往后因为它要拿完整的 HTTP 升级请求做握手。6.3 心跳消息被当作业务消息这是我接手过的一个线上事故心跳消息和聊天消息都走同一个入口业务代码里当时图省事直接对收到的所有 TextWebSocketFrame 做 JSON 解析然后当成业务消息分发。结果心跳消息被解析成未知 type虽然暂时没出大事但偶尔会出现心跳消息被错误转发、用户收到您发的消息无效之类提示的诡异现象。正确做法是在业务 Handler 的入口处先判断 type 等于 heartbeat 就直接 return不往任何业务逻辑分发。这里不需要回复只需要保证链路流量存在即可。6.4 连接数上限与内存释放长连接服务的资源管理比普通 HTTP 服务要求更严格。HTTP 请求用完即走连接是短命的WebSocket 连接如果不主动断开它会一直占着 Channel、ByteBuf、线程调度资源。我在真实项目里的处理方式包括但不限于用 AtomicInteger 维护全局连接数在 channelActive 里判断是否超限超限就回一条系统繁忙后关闭。压测时开启 Netty 的内存泄漏检测-Dio.netty.leakDetection.levelparanoid在日志里检查有没有 ByteBuf 泄漏。这个参数线上一般不用但压测阶段非常值得开。注意 WebSocketServerProtocolHandler 的 maxFrameSize。聊天场景不要设得太大否则有人故意发超大帧内存会被迅速打满。还有代理层的问题。线上服务很多要过一层反向代理而不少代理组件的默认空闲超时在 60 秒左右。如果客户端心跳周期大于这个值即使服务端和客户端都好好地活着代理也会把空闲连接静默回收。这也是很多线上 WebSocket半小时断一次的经典原因。解决方案是客户端心跳周期小于代理空闲超时或者把代理的空闲超时调大。这个坑不踩一次很难想到。说到最后分享一点我自己做这套连接管理后的实际体会。代码跑通只是第一步真正重要的是把这些参数当成一个整体来设计客户端心跳周期、服务端读空闲阈值、代理层空闲超时、断线重连间隔这四个值必须相互匹配不能单独调一个。我现在的习惯是先定客户端心跳 30 秒再反推服务端空闲 90 秒然后把代理超时配置成 180 秒以上。每次调完这些参数都做一次真实的拔网线测试直接断开网络而不是优雅关闭连接观察服务端多久能感知、客户端多久能重连成功。这套验证流程走一遍之后你才算真正把连接的可靠性握在了手里。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑