2.4 断线重连:状态补齐与防抖

长连接一定会断——切后台、断网、网关超时、进程重启。断线不可怕,可怕的是「断了没人管、重连后画面错乱」。本篇讲断线重连的三件事:怎么触发、怎么补齐断线期间的增量、怎么防抖不刷屏。

重连要解决的四件事

问题 做法
何时重连 判死超时 + 回前台事件
补什么 断线期间的增量事件
怎么补 按 seq 拉缺口
别刷屏 防抖、合并、限速

怎么发现断线并触发重连

触发源 说明
心跳超时 连续 N 个周期无 pong 判死
visibilitychange 页面回前台立即重连
网络事件 online / offline 切换
服务端踢人 收到 close 帧主动重连

触发后要去抖:多个事件同一时刻触发重连,要合并成一次,且用指数退避,别一千房间齐刷刷重连把服务打爆。

断线期间怎么补齐

白板是「只增不改」的事件流,补齐就是按 seq 拉缺口: | 步骤 | 动作 | |---|---| | 1 | 本地记住最后处理到的 seq | | 2 | 重连后带上 lastSeq 申请 | | 3 | 服务端回发 seq > lastSeq 的增量 | | 4 | 客户端按序重放,跳过已见 seq |

这就是为什么事件协议要「幂等 + 全局 seq」——补齐就是拉一段没见过的 seq 重放,重复的自动忽略。

防抖与限速

重连瞬间会有「补数据 + 新事件」两股流同时涌来,不加防抖画面会抖、CPU 会飙: | 措施 | 作用 | |---|---| | 增量按 seq 排序 | 保证重放顺序 | | 渲染合帧(rAF 批量) | 一帧内合并多次事件 | | 补拉限速 | 大缺口分批拉,别一次灌满 | | 去重(幂等) | 同 seq 只生效一次 |

踩坑提示

最坑的是补齐用全量快照:断线 10 秒拉整板几万笔,比增量还慢还抖。正确做法是「小缺口拉增量、大缺口拉快照 + 少量增量」,按缺口大小自动切策略。再一个坑:重连时「把旧连接关了再开新连接」之间有一个真空期,这段真空期来的事件要么丢、要么要纳入补齐范围——把 lastSeq 的口径定清楚,真空期才不会漏事件。

重连状态机

把重连写成「状态机」而不是一坨条件分支,后期维护会轻松很多。四状态循环:连接中 → 已连接 → 退避等待 → 彻底判死。

状态 进入条件 退出动作
connecting 首次 / 重连发起 启动握手
connected welcome 到达 清退避计数、启心跳
backoff 判死超时 指数退避等待
dead 退避到次数上限 提示网络异常

退避的实现里必须带随机抖动,否则退避节奏整齐划一,重连依旧是雪崩:

// 指数退避:带抖动的重连调度
let attempt = 0;
function scheduleReconnect() {
  const base = Math.min(1000 * Math.pow(2, attempt), 30000);
  const jitter = base * 0.2 * Math.random();   // 加抖动,防同步
  setTimeout(connect, base + jitter);
  attempt += 1;
  if (attempt > 8) showOffline();
}

抖动为什么是必选项?想象一栋楼断电又恢复,成千上万的客户端如果同时重连,即使退避公式一样,节奏完全一致,依旧会在同一秒打爆服务。抖动把重连打散到几百毫秒的窗口里,服务端就来得及消化。

补齐策略的分流

缺口大小不同,补法完全不同,硬编码「永远拉增量」在长断线下反而是灾难:

断线时长 缺口规模 推荐策略
5 秒以内 几十条事件 直接拉增量
5 秒到 1 分钟 上百条 分批拉增量
1 分钟以上 千条以上 拉快照 + 少量增量

分界的实战参数是「缺口总量估算 = 房间平均每秒事件数 × 断线秒数」。这个均速可以从服务端 welcome 里一并下发,客户端据此自己选策略,不用每次回服务端问。快照与日志分离存储的设计,可以参考 历史回放:操作日志与撤销 一节,那里讲了日志压缩与快照切换的边界。

防抖的三个层次

重连回来那几秒是体验最脆弱的时刻,防抖要分三层做,缺一层都会被用户看到:

层次 手段 效果
传输层 增量去重(幂等) 同 seq 只画一遍
调度层 补拉限速 + 分批 不被大量数据灌爆
渲染层 rAF 合帧 一帧只重绘一次

渲染层的合帧最容易被忽略:补齐的几百条事件若逐条同步渲染,主线程会卡死几百毫秒,用户看到的是「界面冻住然后突然跳变」。把事件推进队列、每帧只消费到时间预算耗尽,界面就是平滑追赶而不是跳变。服务端的批量与节流实践,可以参考 性能优化:批量与节流 里的限流设计,客户端的合帧思路其实与之同源。

lastSeq 的口径定义

真空期丢事件的根因,绝大多数是 lastSeq 记早了。规范定义是:lastSeq 等于客户端「已经渲染完成」的最大 seq,而不是「已经收到」的最大 seq。收到的可能还压在队列里没画,用它申请缺口,队列里那些就会被当成旧数据跳过——其实根本没画过。

口径 重连后现象
已收到的最大 seq 队列内事件永久丢失
已渲染的最大 seq 无丢失,可能重复补少量
渲染完成且带确认 最精确,实现最重

重复补的少量事件靠幂等协议自动消化,成本远低于丢事件。所以工程上统一选「已渲染的最大 seq」即可,确认式口径留给金融级场景。

服务端踢人与安全重连

有一类断线不是故障,而是服务端主动踢的:异地登录顶号、房间被锁定、违规内容清理。这类场景的重连必须区别对待——默认的重连逻辑会把客户端无限拉回来,形成「踢了又连、连了又踢」的死循环。

踢人原因 close code 客户端动作
异地登录 4002 停止重连,提示被顶下线
房间已锁定 4003 停止重连,提示无权限
服务端维护 4004 读 Retry-After,延迟重连
内容被清理 4005 停止重连,弹审查提示

规则很简单:4000 段的自定义码一律先看是否允许重连,只有明确允许的才进入退避队列。另外重连前要重新走握手鉴权,token 可能已经过期,拿过期 token 重连只会被服务端再次踢下线,白白浪费一轮退避。

重连在弱网下的体验设计

弱网易碎(地铁、电梯)场景下,重连的「体感」比成功率的绝对值更影响用户评价。三个体验要点:本地先画、状态可见、渐进同步。本地先画是指断线期间用户画的每一笔都缓存在本地队列里,重连成功后按序提交,用户不会感到「画不上去」;状态可见是给一个明确的重连提示徽标,告诉用户当前是离线编辑模式;渐进同步则是补齐时用合帧追赶,而不是一次性跳变。

体验要素 弱网下的做法 反面例子
离线编辑 本地队列,重连后提交 直接禁笔,用户干等
状态提示 徽标写「重连中…」 无任何提示
补齐表现 合帧平滑追赶 界面冻住后跳变

本地队列要设上限(比如 2000 条),超过就提示用户网络过差,避免内存被离线笔触吃光。这套离线优先的思路在 离线编辑与队列实践 里有更完整的展开,值得和本篇的补齐策略对照着读,把弱网体验从「能用」打磨到「无感」。失去连接时先把退避计数清零、本地队列打开,别等补齐数据回来再手忙脚乱。

小结

断线重连 = 多源触发 + 指数退避 + 按 seq 拉缺口 + 渲染合帧防抖。把「假在线」和「重连刷屏」都压住,接入层才算完整闭环。

延伸阅读