一个真实的线上事故
周五下午五点半,IM 系统告警群炸了。运营反馈:大量用户消息延迟,部分用户"在线"状态显示正常,但实际收不到任何推送。
排查了两个小时,结论让人窒息------WebSocket 连接早就断了,但客户端根本不知道。
TCP 层面的连接还"活着"(准确说是处于半开状态),浏览器没有触发 onclose,服务端也没有感知到断开。消息往一个"死连接"里塞,塞了个寂寞。
这就是为什么做 WebSocket,连接管理比业务逻辑难十倍。
为什么 WebSocket 连接会"假死"?
很多人以为 new WebSocket(url) 一行搞定,剩下的交给浏览器就完事了。
现实是:WebSocket 底层跑在 TCP 上,而 TCP 有个经典问题------它无法主动感知对端的沉默断开。
几种典型的"假死"场景:
- NAT 网关超时回收:运营商或公司网关会清理长时间无数据的连接映射,通常 5~10 分钟
- 移动端网络切换:WiFi 切 4G,底层 socket 已经失效,但上层 API 毫无反应
- 服务端进程崩溃:进程被 OOM kill,来不及发 close frame
- 中间代理超时 :Nginx 的
proxy_read_timeout默认 60 秒,超时直接切断
共同特征:连接已经不可用了,但双方都不知道。
心跳保活:给连接装个"呼吸检测器"
心跳不是什么高深技术,本质就是------定时发一个包,确认对方还活着。
就像你给朋友发微信"在吗?",对方回了"在",说明连接正常。连续问了三次都没人理,那大概率是被删了(或者网断了)。
基础实现
ts
class WebSocketClient {
private ws: WebSocket | null = null
private heartbeatTimer: number | null = null
private pongTimer: number | null = null
private HEARTBEAT_INTERVAL = 30_000 // 心跳间隔 30 秒,够保持 NAT 映射不被回收
private PONG_TIMEOUT = 10_000 // 等待 pong 超时 10 秒,超过说明连接大概率已死
connect(url: string) {
this.ws = new WebSocket(url)
this.ws.onopen = () => {
this.startHeartbeat()
}
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data)
if (data.type === 'pong') {
this.clearPongTimer() // 收到 pong → 连接还活着,清除超时计时器
return
}
this.handleBusinessMessage(data)
}
this.ws.onclose = () => {
this.stopHeartbeat()
this.reconnect() // 不是主动关的?那就重连
}
}
private startHeartbeat() {
this.heartbeatTimer = window.setInterval(() => {
if (this.ws?.readyState !== WebSocket.OPEN) return
this.ws.send(JSON.stringify({ type: 'ping', ts: Date.now() }))
// 开始倒计时,PONG_TIMEOUT 内没收到 pong → 连接已死
// 关键设计:超时后先 close(),再走 onclose 里统一的重连逻辑
// 把重连入口收敛到一个地方,避免多处触发导致连接混乱
this.pongTimer = window.setTimeout(() => {
console.warn('心跳超时,主动断开重连')
this.ws?.close()
}, this.PONG_TIMEOUT)
}, this.HEARTBEAT_INTERVAL)
}
}
为什么不用 WebSocket 协议层的 Ping/Pong?
WebSocket 协议本身定义了 Ping/Pong 控制帧(opcode 0x9 / 0xA),但浏览器端的 WebSocket API 不暴露发送 Ping 帧的能力 ,onping 事件也不存在。
所以浏览器环境下只能用应用层心跳------发个普通 JSON 消息模拟。服务端(Node.js、Go 等)倒是可以发协议层 Ping,但客户端感知不到,拿来做服务端单方面的存活检测还行,双向确认还是得靠应用层。
断线重连:不是无脑 retry 就完了
指数退避 + 随机抖动
最天真的重连策略是断了就立刻连。问题是:服务端宕机后 1000 个客户端同时发起重连,服务刚恢复就被连接风暴打趴下------经典的"惊群效应"。
ts
class ReconnectManager {
private retryCount = 0
private maxRetry = 8
private baseDelay = 1000 // 起步 1 秒
private maxDelay = 60_000 // 封顶 60 秒
private isReconnecting = false
getNextDelay(): number {
// 指数退避:1s → 2s → 4s → 8s → 16s → 32s → 60s → 60s
const exponential = this.baseDelay * Math.pow(2, this.retryCount)
const capped = Math.min(exponential, this.maxDelay)
// 随机抖动(jitter):避免所有客户端同一时刻重连
// 没有 jitter,每一轮退避的请求依然集中在同一个时间点
// 加上 jitter,请求被打散到一个时间窗口内,服务端压力平滑很多
const jitter = capped * (0.5 + Math.random() * 0.5)
return Math.floor(jitter)
}
async reconnect(connectFn: () => Promise<void>) {
if (this.isReconnecting) return // 防止重复触发
this.isReconnecting = true
while (this.retryCount < this.maxRetry) {
const delay = this.getNextDelay()
console.log(`第 ${this.retryCount + 1} 次重连,${delay}ms 后尝试`)
await this.sleep(delay)
try {
await connectFn()
this.retryCount = 0 // 连接成功,重置计数器
this.isReconnecting = false
return
} catch {
this.retryCount++
}
}
this.isReconnecting = false
this.onMaxRetryExceeded() // 超过最大重试次数,通知上层该降级了
}
private sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms))
}
}
重连时的状态恢复
连接恢复了,但中间丢的消息怎么办?这取决于业务对消息丢失的容忍度:
ts
class WebSocketClient {
private lastMessageId: string | null = null
private onReconnected() {
if (this.lastMessageId) {
// 重连后告诉服务端:我最后收到的是这条,后面的重发给我
// 本质上是基于游标的增量同步协议,和数据库 binlog 复制思路一样
this.ws?.send(JSON.stringify({
type: 'sync',
lastMessageId: this.lastMessageId,
}))
}
}
private handleBusinessMessage(data: any) {
this.lastMessageId = data.id // 每条消息都记录 ID,作为同步游标
// ... 业务处理
}
}
这要求服务端有消息缓冲能力,在一定时间窗口内保留已发送的消息。
完整的连接管理器
把上面的能力组合起来:
ts
enum ConnectionState {
DISCONNECTED = 'disconnected',
CONNECTING = 'connecting',
CONNECTED = 'connected',
RECONNECTING = 'reconnecting',
}
class ProductionWebSocket {
private ws: WebSocket | null = null
private state: ConnectionState = ConnectionState.DISCONNECTED
private url: string
private intentionalClose = false // 区分"用户主动退出"和"网络断开",没有这个标记用户点退出登录系统还在拼命重连
// 心跳相关
private heartbeatTimer: number | null = null
private pongTimer: number | null = null
// 重连相关
private retryCount = 0
private reconnectTimer: number | null = null
// 离线消息队列:断连期间的消息先缓存,恢复后批量发送,调用方无感知
private offlineQueue: Array<{ data: string; resolve: () => void }> = []
// 状态变更回调,上层 UI 可据此显示连接状态
onStateChange?: (state: ConnectionState) => void
constructor(url: string) {
this.url = url
// 监听网络状态变化(移动端 WiFi 切 4G 场景很关键)
window.addEventListener('online', () => {
if (this.state === ConnectionState.RECONNECTING) {
this.retryCount = 0 // 网络恢复,重置退避计数
this.doReconnect()
}
})
// 页面可见性变化:移动端切后台时系统可能冻结 Timer 甚至杀掉 WebSocket
// 切回来时心跳已经停了很久,需要主动检查连接状态
document.addEventListener('visibilitychange', () => {
if (!document.hidden && this.ws?.readyState !== WebSocket.OPEN) {
this.doReconnect()
}
})
}
send(data: string): Promise<void> {
return new Promise((resolve, reject) => {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(data)
resolve()
} else {
this.offlineQueue.push({ data, resolve }) // 连接不可用 → 放入离线队列
}
})
}
private flushOfflineQueue() {
while (this.offlineQueue.length > 0) {
const item = this.offlineQueue.shift()!
this.ws?.send(item.data)
item.resolve()
}
}
close() {
this.intentionalClose = true // 标记:这次是我自己要关的,别重连
this.cleanup()
this.ws?.close()
}
}
几个绕不开的设计决策
心跳间隔选多少?
| 间隔 | 优点 | 缺点 |
|---|---|---|
| 10 秒 | 故障发现快 | 带宽开销大,移动端耗电 |
| 30 秒 | 平衡之选 | 最多 30 秒感知延迟 |
| 60 秒 | 省资源 | 可能被 NAT 网关清理 |
大多数生产环境选 25~30 秒。运营商 NAT 超时通常 5 分钟,Nginx 默认 60 秒,取一个安全余量就是 30 秒左右。
要不要用 Socket.IO?
Socket.IO 内置了心跳、重连、房间管理、降级轮询,但:
- 它不是标准 WebSocket 协议,有自己的握手和帧格式,后端必须也用 Socket.IO
- 包体积不小(gzip 后约 20KB),如果你只需要基础的 WebSocket + 重连,有点杀鸡用牛刀
- 自动降级到 HTTP 轮询在 2026 年已经不太必要了,现代浏览器对 WebSocket 支持很完善
如果团队没有现成的 WebSocket 基础设施,Socket.IO 确实能少踩很多坑。但如果你对连接管理有定制需求(自定义重连策略、精细的状态机控制),自己封装反而更可控。
需要消息确认机制(ACK)吗?
取决于业务容忍度:
ts
// 带 ACK 的发送:服务端收到后回复确认,否则重发
// 聊天应用、交易系统 → 必须要,消息不能丢
// 实时数据大盘、股票行情 → 不需要,丢一帧下一帧就覆盖了
async function sendWithAck(ws: WebSocket, message: any, timeout = 5000) {
const msgId = crypto.randomUUID()
return new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`消息 ${msgId} ACK 超时`))
}, timeout)
// 注册一次性监听器,等待服务端确认
const handler = (event: MessageEvent) => {
const data = JSON.parse(event.data)
if (data.type === 'ack' && data.msgId === msgId) {
clearTimeout(timer)
ws.removeEventListener('message', handler)
resolve()
}
}
ws.addEventListener('message', handler)
ws.send(JSON.stringify({ ...message, msgId }))
})
}
踩坑记录
多 Tab 页连接爆炸
用户开了 8 个 Tab,就是 8 个 WebSocket 连接,服务端连接数直接翻倍。
ts
// 用 BroadcastChannel 让多 Tab 共享一个连接
const channel = new BroadcastChannel('ws-shared')
if (isMainTab()) {
// 主 Tab 负责维持连接
const ws = new WebSocket(url)
ws.onmessage = (e) => {
channel.postMessage(e.data) // 转发给其他 Tab
}
}
// 其他 Tab 通过 BroadcastChannel 收消息
channel.onmessage = (e) => {
handleMessage(e.data)
}
Token 过期导致重连死循环
WebSocket URL 里带了认证 Token,Token 过期后重连永远 401,退避到 60 秒后依然 401,无限循环。
重连前先刷新 Token。Token 刷新也失败了?停止重连,引导用户重新登录。
移动端后台冻结
iOS Safari 切后台后,所有 Timer 停止,WebSocket 可能被系统杀掉。切回前台时:
visibilitychange事件触发- 检查
ws.readyState------大概率已经不是OPEN了 - 立即重连,不走退避(这不是网络问题,是系统行为)
这套思路不只适用于 WebSocket
回过头看,WebSocket 连接管理本质上是 有限状态机 + 故障检测 + 自动恢复。
markdown
DISCONNECTED → CONNECTING → CONNECTED ⇄ RECONNECTING
↓
DISCONNECTED(主动关闭)
只要涉及"长连接"的场景------数据库连接池、gRPC 流、MQTT、SSE------都是同一套东西:
- 存活检测:心跳 / Ping / 空闲超时
- 故障恢复:指数退避 + 抖动 + 最大重试
- 状态同步:断点续传 / 游标同步 / 增量拉取
- 资源管理:连接复用 / 上限控制 / 优雅关闭
下次遇到类似问题,不管底层协议是什么,先把状态机画出来,把故障检测和恢复策略定好,剩下的就是填代码。
回想那个周五的事故------如果当时有心跳检测,最多 30 秒就能发现连接异常并自动重连,而不是等运营打电话来骂。