Day 58 · 心跳与重连 — 半开连接的死刑判决书与指数退避的艺术
昨天埋下的最大隐患今天爆雷:拔掉网线,你的页面 10 分钟内都显示"连接正常"。这不是 bug,是 TCP 的天性(半开连接)——物理链路断了,但没有人在上面说话,谁都不知道对方已经没了。今天的两个主题环环相扣:心跳负责判决连接死刑,重连负责安排转世。学完今天,你的
ReconnectingWebSocket才算生产级。
目录
- 一、半开连接:最阴险的故障形态
- 二、心跳:应用层的死亡判决
- 三、重连:指数退避与抖动
- 四、协议层心跳 vs 应用层心跳
- 五、生产级 ReconnectingWebSocket
- 六、验证实验:亲手制造故障
- 七、常见坑点
- 八、自测挑战
- 九、总结
一、半开连接:最阴险的故障形态
1.1 复现实验(必做)
1. 连上昨天的本地 WS 服务器
2. 拔网线(或 DevTools → Network → Offline)
3. 观察:onclose 没触发、readyState 还是 OPEN、页面毫无感知
4. 重新插上网线:连接其实早就死了,发消息才报错
1.2 为什么 TCP 不告诉你
TCP 的设计哲学:沉默 ≠ 死亡。只有当它发送数据并等不到 ACK 时(重传超时,通常要几分钟),才会宣布连接失效。而你的 WS 连接大部分时间在安静收数据——链路断了,内核还抱着一条"僵尸连接"。
1.3 工业场景的代价
工厂监控大屏挂着一条死连接:值班员看着"温度正常"的假画面 10 分钟,实际炉温早已超限——半开连接不是体验问题,是安全事故。所以工业级实时链路的心跳间隔通常压到 5~15 秒,比互联网场景(30~60 秒)激进得多。
二、心跳:应用层的死亡判决
2.1 原理
客户端每 N 秒发一个 ping → 服务器回一个 pong
│
├── R 秒内收到 pong → 连接还活着,继续
└── R 秒没收到 pong → 判决死亡 → ws.close() → 触发重连流程
2.2 实现要点
/**
* 心跳检测器
* 设计要点:
* 1. ping/pong 用应用层消息(协议层的见第四节)
* 2. 任何收到的消息都算"活着"的证据(不必非等 pong——数据帧也能证明链路通)
* 3. 超时判决要 close 而不是傻等
*/
export class Heartbeat {
private pingTimer = 0;
private deathTimer = 0;
private seq = 0;
constructor(
private send: (msg: string) => void,
private onDead: () => void,
private intervalMs = 10_000, // 每 10 秒一次 ping
private timeoutMs = 5_000 // ping 后 5 秒无任何消息判死
) {}
/** 连接建立后启动 */
start(): void {
this.stop();
this.pingTimer = window.setInterval(() => {
this.send(JSON.stringify({ type: "ping", seq: this.seq++ }));
// 每次 ping 后布置死亡判决计时器
clearTimeout(this.deathTimer);
this.deathTimer = window.setTimeout(() => this.onDead(), this.timeoutMs);
}, this.intervalMs);
}
/** 收到任何消息时调用:撤销死刑判决 */
feed(): void {
clearTimeout(this.deathTimer);
}
/** 断开/重连前清理 */
stop(): void {
clearInterval(this.pingTimer);
clearTimeout(this.deathTimer);
}
}
2.3 参数选择
| 参数 | 工业监控 | 互联网应用 | 权衡 |
|---|---|---|---|
| intervalMs | 10s | 30s | 越小发现越快,流量/功耗越大 |
| timeoutMs | interval 的 1/2 | interval 的 1/2 | 必须显著小于 interval,否则两次 ping 叠加 |
三、重连:指数退避与抖动
3.1 三种重连策略对比
// ❌ 立即重连:服务器宕机时 1000 个客户端同时每秒打 1000 次 → 重启瞬间被打挂
const delay = 0;
// ❌ 固定间隔:比立即重连好,但"同时断、同时试"的共振仍在
const delay = 3000;
// ✅ 指数退避 + 随机抖动:行业最佳实践(TCP 重传、AWS SDK、socket.io 都这么干)
// 每次 failures+1,延迟翻倍;上限封顶;抖动打散同时重试的客户端
const delay = Math.min(1000 * 2 ** failures, 30_000) * (0.5 + Math.random() * 0.5);
3.2 时间线示意
失败次数 退避基数 随机抖动后
1 1s 0.5 ~ 1.0s
2 2s 1.0 ~ 2.0s
3 4s 2.0 ~ 4.0s
4 8s 4.0 ~ 8.0s
5 16s 8.0 ~ 16.0s
≥6 30s 15 ~ 30s(封顶)
──▶ 服务器恢复后最多 30s 内全部客户端回来,且不会共振
3.3 重连成功后必须做的事(明天展开,今天先列清单)
- 重新 subscribe(昨天的第四节的"有状态"代价)
- 拉取断线期间的数据缺口(Day 61 补传)
- 通知 UI 层从"重连中"恢复"正常"
四、协议层心跳 vs 应用层心跳
4.1 WebSocket 协议自带 ping/pong(opcode 0x9/0xA)
特点:
- 帧只有 2 字节头,零 payload 开销
- 但浏览器 JavaScript API 不能发协议层 ping,也收不到 pong
(浏览器内核自动回 pong,但你的代码无感知)
- 只有服务器端(Node ws 库)能主动发协议层 ping
4.2 选型结论
| 场景 | 方案 |
|---|---|
| 纯浏览器客户端 | 只能应用层心跳(JSON 消息 ping/pong) |
| Node 服务器探测客户端 | 协议层 ping(ws.ping() + on('pong')) |
| 生产组合 | 双向各测各的:服务器协议层 ping 测下行,客户端应用层 ping 测上行 |
💡 为什么浏览器代码摸不到协议层心跳?安全模型决定:把 ping/pong 暴露给 JS 会给恶意页面提供流量指纹工具。基础设施的每个"不能用"背后都有设计理由——这周反复体会这一点。
五、生产级 ReconnectingWebSocket
把昨天 WsConnection 升级:集成心跳 + 指数退避重连。
import { Heartbeat } from "./heartbeat";
/** 连接状态(UI 层据此显示状态灯) */
export type ConnState = "connecting" | "open" | "reconnecting" | "closed";
/**
* 生产级可重连 WebSocket:
* - 心跳判死半开连接
* - 指数退避 + 抖动重连
* - 主动 close(code 1000) 不触发重连(用户意图优先)
*/
export class ReconnectingWebSocket {
private ws: WebSocket | null = null;
private state: ConnState = "closed";
private failures = 0; // 连续失败计数(成功后清零)
private retryTimer = 0;
private manualClose = false; // 区分"用户主动关"与"意外断"
private heartbeat: Heartbeat;
private msgListeners = new Set<(data: string) => void>();
private stateListeners = new Set<(s: ConnState) => void>();
constructor(
private url: string,
private opts = {
heartbeatInterval: 10_000,
heartbeatTimeout: 5_000,
maxRetryDelay: 30_000,
}
) {
// 心跳:发不出去(send 返回 false)或超时无响应都判死
this.heartbeat = new Heartbeat(
() => this.send("ping"),
() => {
console.warn("心跳超时,判决连接死亡");
this.ws?.close(); // 触发 onclose → 走统一重连流程
},
opts.heartbeatInterval,
opts.heartbeatTimeout
);
}
/** 建立连接(对外主入口,幂等) */
connect(): void {
this.manualClose = false;
this.setState("connecting");
this.openSocket();
}
/** 主动关闭:不重连(组件卸载时调用) */
close(): void {
this.manualClose = true;
this.heartbeat.stop();
clearTimeout(this.retryTimer);
this.ws?.close(1000, "client-shutdown");
this.setState("closed");
}
/** 发送文本(仅 OPEN 状态) */
send(data: string): boolean {
if (this.ws?.readyState !== WebSocket.OPEN) return false;
this.ws.send(data);
return true;
}
onMessage(listener: (data: string) => void): () => void {
this.msgListeners.add(listener);
return () => this.msgListeners.delete(listener);
}
onState(listener: (s: ConnState) => void): () => void {
this.stateListeners.add(listener);
return () => this.stateListeners.delete(listener);
}
// ===== 内部实现 =====
private openSocket(): void {
const ws = new WebSocket(this.url);
ws.binaryType = "arraybuffer";
this.ws = ws;
ws.onopen = () => {
this.failures = 0; // 成功:退避序列归零
this.setState("open");
this.heartbeat.start();
// 重连成功后的会话恢复钩子(Day 61 补传逻辑挂这里)
this.onSessionRestored?.();
};
ws.onmessage = (ev) => {
this.heartbeat.feed(); // 任何消息都撤销死刑判决
if (typeof ev.data === "string") {
this.msgListeners.forEach((l) => l(ev.data as string));
}
};
ws.onclose = (ev) => {
this.heartbeat.stop();
this.ws = null;
if (this.manualClose || ev.code === 1000) return; // 用户意图:不重连
this.scheduleReconnect();
};
// onerror 统一交给 onclose 处理(Day 57 坑 6)
}
/** 指数退避 + 抖动重连 */
private scheduleReconnect(): void {
const base = Math.min(1000 * 2 ** this.failures, this.opts.maxRetryDelay);
const delay = base * (0.5 + Math.random() * 0.5);
this.failures++;
this.setState("reconnecting");
console.log(`将在 ${Math.round(delay)}ms 后第 ${this.failures} 次重连`);
clearTimeout(this.retryTimer);
this.retryTimer = window.setTimeout(() => this.openSocket(), delay);
}
/** 会话恢复钩子(子类/外部注入重连后的恢复逻辑) */
onSessionRestored: (() => void) | null = null;
private setState(s: ConnState): void {
this.state = s;
this.stateListeners.forEach((l) => l(s));
}
get connectionState(): ConnState {
return this.state;
}
}
类职责一览(本周持续迭代这个类)
| 版本 | 增加能力 |
|---|---|
| Day 57 版 | 类型化封装、环境 URL |
| Day 58 版(今天) | 心跳判死、指数退避重连、状态广播 |
| Day 61 版 | 发送缓冲、补传请求、会话恢复 |
六、验证实验:亲手制造故障
生产代码的容错必须亲手验证。今天设计 4 个故障实验(全部要做,截图进博客):
实验 1:半开连接(心跳判死)
1. 连接后立刻 DevTools → Network → Offline
2. 观察控制台:10s 后 ping 发出 → 15s 时判决死亡 → 进入 reconnecting
3. 恢复网络:退避后自动重连成功 → open
实验 2:服务器宕机(terminate)
服务器代码里加 setTimeout(() => ws.terminate(), 10_000),观察客户端:close code 1006 → 重连序列(1s→2s→4s…)→ 重启服务器后恢复。
实验 3:服务器拒绝连接(端口没人听)
连一个不存在的端口:立即 close(code 1006,wasClean false)→ 退避重连持续失败但节奏正确(封顶 30s)。
实验 4:共振测试(抖动的价值)
同时开 10 个 Tab 连接,杀服务器再启动:观察 Messages 面板,10 个客户端的重连时刻分散在不同时间点(抖动生效),而不是齐刷刷重试。
七、常见坑点
坑 1:心跳计时器没清理导致"僵尸心跳"
断线后 Heartbeat.stop() 没调用,旧连接的死刑计时器还在跑,新连接刚建立就被旧计时器误杀。修复:openSocket 前必 stop,close 里必 stop(见 5 的实现顺序)。
坑 2:重连后 listeners 丢失
每次 openSocket 新建 WebSocket 对象——如果 onmessage 绑在新 ws 上但忘了把 listener 转接(或反之绑在旧 ws 上),重连后收不到消息。修复:listener 集合放在外层类(5 的结构保证这点)。
坑 3:退避无上限
服务器长期宕机时延迟涨到分钟级,恢复后回来太慢。修复:Math.min(..., 30_000) 封顶。
坑 4:手动 close 触发了重连
用户切页面主动 close(1000),结果 2 秒后自动重连(页面已卸载,报错+泄漏)。修复:manualClose 标志 + close code 1000 双重判断(见 5)。
坑 5:心跳消息打崩统计
ping 消息混进业务消息分发,图表画出锯齿。修复:onmessage 里先过滤 type === 'ping'/'pong',或心跳用独立的 message type 前缀。
坑 6:用 setTimeout 模拟 setInterval 做心跳
setInterval 的回调可能因主线程阻塞而堆积。心跳用 setInterval + 每次判死刑的 clearTimeout(2.2 实现)已经规避了最坏情况;更严格的生产实现会用时间戳比对(“上次收到消息距今多久”)代替计时器计数。
八、自测挑战
T1 · 完成 ReconnectingWebSocket(45 分钟)
把第五节代码敲进项目(含 Heartbeat 类),服务器加 terminate 测试钩子。今天核心作业。
T2 · 四故障实验(30 分钟)
跑第六节全部 4 个实验,控制台输出 + WS Messages 面板截图整理成"故障响应报告"。
T3 · 状态灯 UI(20 分钟)
页面右上角做连接状态灯:open 绿 / reconnecting 黄闪烁 / closed 红,订阅 onState 驱动。这个小组件明天直接进 BOSS 战面板。
T4 · 心跳 RTT 监控(进阶,25 分钟)
ping 消息带 t: Date.now(),服务器 pong 原样回显,客户端算 RTT 并画 60 点迷你折线(复用 Day 51 的 LineChart)。RTT 突增往往是断线前兆——这是"网络健康度"监控的雏形。
九、总结
| 问题 | 武器 | 关键参数 |
|---|---|---|
| 半开连接 | 应用层心跳 | 10s 间隔 / 5s 超时(工业级) |
| 重连风暴 | 指数退避 + 抖动 | min(1s × 2^n, 30s) × (0.5~1.0) |
| 用户主动断 | manualClose + code 1000 | 不重连 |
| 计时器泄漏 | stop 时机纪律 | openSocket 前 / close 里 |
今天解决了"发现死亡 + 回到人间",但断线期间错过的数据永远消失了——明天的二进制协议和后天的补传机制负责把丢的数据找回来。先从"数据怎么高效传"开始:1000 点/秒的传感器流,JSON 和二进制的差距是 5 倍。