<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>WebSocket on 飞天造物手记</title><link>https://roper.cool/tags/websocket/</link><description>Recent content in WebSocket on 飞天造物手记</description><generator>Hugo -- gohugo.io</generator><language>zh-cn</language><lastBuildDate>Fri, 08 May 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://roper.cool/tags/websocket/index.xml" rel="self" type="application/rss+xml"/><item><title>WebSocket 重连策略实践：把“看起来在线”变成“真正可恢复”</title><link>https://roper.cool/posts/websocket-reconnect/</link><pubDate>Fri, 08 May 2026 00:00:00 +0000</pubDate><guid>https://roper.cool/posts/websocket-reconnect/</guid><description>&lt;p&gt;最近在做一个用于展示实时构建日志的小工具。最开始我图省事，浏览器端只要 &lt;code&gt;socket.onclose = () =&amp;gt; reconnect()&lt;/code&gt; 就算完成。结果上线到测试环境后，问题很快出现了：办公室网络偶发抖动、电脑休眠后恢复、切换 VPN、浏览器标签页长时间后台挂起，都会把长连接带到一个半死不活的状态。页面上还能看到“已连接”，但日志已经不再滚动。&lt;/p&gt;
&lt;p&gt;这次复盘让我意识到，WebSocket 的困难从来不在“连上”，而在“如何知道它已经坏了，以及坏了之后怎样恢复得足够稳”。&lt;/p&gt;
&lt;h2 id="1-先区分几类失败"&gt;&lt;a href="#1-%e5%85%88%e5%8c%ba%e5%88%86%e5%87%a0%e7%b1%bb%e5%a4%b1%e8%b4%a5" class="header-anchor"&gt;&lt;/a&gt;1. 先区分几类失败
&lt;/h2&gt;&lt;p&gt;我先把现场抓到的失败类型做了分类：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;服务端主动关闭。通常会有 close code，可以明确判断是鉴权失败、版本不兼容，还是服务端发布导致断开。&lt;/li&gt;
&lt;li&gt;网络瞬断。客户端有时能收到 &lt;code&gt;close&lt;/code&gt;，有时什么事件都没有，只是消息不再到达。&lt;/li&gt;
&lt;li&gt;浏览器休眠或后台节流。定时器和心跳都可能被延后，导致误判。&lt;/li&gt;
&lt;li&gt;连接恢复但状态丢失。比如服务端增量推送的游标没有同步，重连后会漏消息。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;把这些情况分开看之后，重连策略就清晰很多：不是所有断开都该“立刻重连”，不是所有重连成功都代表业务恢复。&lt;/p&gt;
&lt;h2 id="2-最小可用方案心跳--指数退避"&gt;&lt;a href="#2-%e6%9c%80%e5%b0%8f%e5%8f%af%e7%94%a8%e6%96%b9%e6%a1%88%e5%bf%83%e8%b7%b3--%e6%8c%87%e6%95%b0%e9%80%80%e9%81%bf" class="header-anchor"&gt;&lt;/a&gt;2. 最小可用方案：心跳 + 指数退避
&lt;/h2&gt;&lt;p&gt;我给客户端补了两层最基础的保护。&lt;/p&gt;
&lt;p&gt;第一层是应用级心跳。浏览器原生 WebSocket API 并不直接暴露 ping/pong，所以我在业务协议里加了一个 &lt;code&gt;type = heartbeat&lt;/code&gt; 的消息，每 20 秒发送一次。服务端原样返回即可。&lt;/p&gt;
&lt;p&gt;第二层是指数退避。以前我每次断开就 1 秒后重试，结果网络稍差时会疯狂重连，把日志刷满。改成 1s、2s、4s、8s、16s，上限 30s 后，客户端和服务端都平稳很多。为了避免所有客户端同时重连，我又加了一个 0 到 800ms 的随机抖动。&lt;/p&gt;
&lt;p&gt;这里有个细节：心跳超时不能只看“多久没收到任何消息”，还要看当前页面是否处于后台。如果标签页被浏览器严重节流，原本 20 秒的心跳定时器可能推迟到一分钟以后才执行。我的做法是，在 &lt;code&gt;visibilitychange&lt;/code&gt; 里记录页面状态，后台时只维持弱监测，回到前台后立即做一次快速探活。&lt;/p&gt;
&lt;h2 id="3-真正麻烦的是假活着"&gt;&lt;a href="#3-%e7%9c%9f%e6%ad%a3%e9%ba%bb%e7%83%a6%e7%9a%84%e6%98%af%e5%81%87%e6%b4%bb%e7%9d%80" class="header-anchor"&gt;&lt;/a&gt;3. 真正麻烦的是“假活着”
&lt;/h2&gt;&lt;p&gt;最难排查的不是彻底断开，而是 TCP 连接还没完全释放、前端实例也没报错，但实际上消息链路已经断了。这种情况下，&lt;code&gt;readyState&lt;/code&gt; 仍然可能是 &lt;code&gt;OPEN&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;我最后采用的判断标准是：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;最近一次收到任意服务端消息的时间。&lt;/li&gt;
&lt;li&gt;最近一次收到心跳回包的时间。&lt;/li&gt;
&lt;li&gt;当前是否存在连续多次发送失败或解析异常。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;当这三项组合起来超过阈值时，我会主动 &lt;code&gt;close()&lt;/code&gt; 当前连接，并进入受控重连，而不是继续相信这个“看似在线”的 socket。&lt;/p&gt;
&lt;p&gt;这个策略的好处是主动。与其等待浏览器底层某个时刻终于意识到连接坏了，不如业务层自己做出“此连接不可信”的判定。&lt;/p&gt;
&lt;h2 id="4-重连不等于恢复"&gt;&lt;a href="#4-%e9%87%8d%e8%bf%9e%e4%b8%8d%e7%ad%89%e4%ba%8e%e6%81%a2%e5%a4%8d" class="header-anchor"&gt;&lt;/a&gt;4. 重连不等于恢复
&lt;/h2&gt;&lt;p&gt;我之前另一个误区是，只要重新连上就把 UI 改成绿色“已连接”。后来发现这会掩盖更深层的问题：实时系统真正要恢复的是“数据连续性”。&lt;/p&gt;
&lt;p&gt;所以我把连接状态拆成了三层：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Transport Connected：底层 WebSocket 已建立。&lt;/li&gt;
&lt;li&gt;Session Ready：鉴权、订阅、房间加入等初始化动作完成。&lt;/li&gt;
&lt;li&gt;Stream Healthy：消息序列号连续，业务流恢复正常。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;只有第三层成立，我才让页面展示“实时同步中”。如果只是第一层成功，页面会显示“连接已恢复，正在补齐数据”。&lt;/p&gt;
&lt;p&gt;这套状态分层非常值钱，因为它把技术状态翻译成了用户能理解的提示，也方便我在日志里精确定位问题卡在哪一层。&lt;/p&gt;
&lt;h2 id="5-消息补偿是最后一块拼图"&gt;&lt;a href="#5-%e6%b6%88%e6%81%af%e8%a1%a5%e5%81%bf%e6%98%af%e6%9c%80%e5%90%8e%e4%b8%80%e5%9d%97%e6%8b%bc%e5%9b%be" class="header-anchor"&gt;&lt;/a&gt;5. 消息补偿是最后一块拼图
&lt;/h2&gt;&lt;p&gt;为了避免重连期间丢消息，我给每条服务端消息增加了递增序列号，同时客户端保存最近一次成功消费的序号。重连后，客户端会把这个序号带给服务端，请求补发缺失区间。&lt;/p&gt;
&lt;p&gt;如果服务端无法补全，比如缓存窗口已经过期，我会触发一次全量刷新。虽然体验比增量恢复差，但至少不会让界面默默停在错误数据上。&lt;/p&gt;
&lt;p&gt;这个思路和消息队列里的 offset 很像。实时系统常常看起来是“推”的，真正稳定以后，背后一定要有某种“拉回来校验”的能力。&lt;/p&gt;
&lt;h2 id="6-我最后沉淀下来的工程原则"&gt;&lt;a href="#6-%e6%88%91%e6%9c%80%e5%90%8e%e6%b2%89%e6%b7%80%e4%b8%8b%e6%9d%a5%e7%9a%84%e5%b7%a5%e7%a8%8b%e5%8e%9f%e5%88%99" class="header-anchor"&gt;&lt;/a&gt;6. 我最后沉淀下来的工程原则
&lt;/h2&gt;&lt;p&gt;这次改完之后，我给自己记了几条很朴素但实用的原则：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;不要把 &lt;code&gt;onopen&lt;/code&gt; 当成连接成功的终点，它只是开始。&lt;/li&gt;
&lt;li&gt;不要把 &lt;code&gt;readyState === OPEN&lt;/code&gt; 当作健康状态的充分条件。&lt;/li&gt;
&lt;li&gt;重连必须带退避和抖动，否则故障期间会放大系统压力。&lt;/li&gt;
&lt;li&gt;心跳只能证明“最近还能说话”，不能证明“数据一定完整”。&lt;/li&gt;
&lt;li&gt;真正重要的是恢复路径是否可验证，而不是表面上连没连上。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;现在回头看，这个功能并不复杂，难的是一开始没有从“坏掉以后怎么办”的角度去设计。很多实时系统的可靠性，其实就是把这些边缘情况一条条补成默认能力。&lt;/p&gt;</description></item></channel></rss>