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 拉缺口 + 渲染合帧防抖。把「假在线」和「重连刷屏」都压住,接入层才算完整闭环。
