WebSocket 长连接方案设计:从心跳保活到断线重连的生产级实践

一个真实的线上事故

周五下午五点半,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 可能被系统杀掉。切回前台时:

  1. visibilitychange 事件触发
  2. 检查 ws.readyState------大概率已经不是 OPEN
  3. 立即重连,不走退避(这不是网络问题,是系统行为)

这套思路不只适用于 WebSocket

回过头看,WebSocket 连接管理本质上是 有限状态机 + 故障检测 + 自动恢复

markdown 复制代码
DISCONNECTED → CONNECTING → CONNECTED ⇄ RECONNECTING
                                ↓
                          DISCONNECTED(主动关闭)

只要涉及"长连接"的场景------数据库连接池、gRPC 流、MQTT、SSE------都是同一套东西:

  1. 存活检测:心跳 / Ping / 空闲超时
  2. 故障恢复:指数退避 + 抖动 + 最大重试
  3. 状态同步:断点续传 / 游标同步 / 增量拉取
  4. 资源管理:连接复用 / 上限控制 / 优雅关闭

下次遇到类似问题,不管底层协议是什么,先把状态机画出来,把故障检测和恢复策略定好,剩下的就是填代码。

回想那个周五的事故------如果当时有心跳检测,最多 30 秒就能发现连接异常并自动重连,而不是等运营打电话来骂。

相关推荐
子琦啊3 分钟前
华为 OD 2026年5月笔试题解析
javascript·华为
无风听海11 分钟前
Promise 与 Async Await 深度解析
前端·javascript
橘子味的冰淇淋~1 小时前
优化前端性能之从“全局引入”改为“按需引入”
前端·javascript·vue.js
Vennn1 小时前
Android自动化:使用 Web 方式实现某音未读消息检查与采集
前端·javascript·vue.js
Smilezyl1 小时前
为了搞懂 AI Agent,我用 6000 行 JS 代码手搓了一个零依赖的 Coding Agent
前端·javascript·github
掰头战士1 小时前
搞定JavaScript类型判断,一文就够了
javascript
周凡1232 小时前
AI 时代的 Web JavaScript 逆向分析实践与思考
前端·javascript·人工智能
zhoumeina992 小时前
分段创建产品,tab 页切换又要保留缓存
前端·javascript
The Sheep 20232 小时前
EFcore 查询数据
java·javascript
怕浪猫2 小时前
Electron 开发实战(七):网络通信与 API 集成全解
前端·javascript·electron