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

9 阅读1分钟

一个真实的线上事故

周五下午五点半,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 秒,超时直接切断

共同特征:连接已经不可用了,但双方都不知道

心跳保活:给连接装个"呼吸检测器"

心跳不是什么高深技术,本质就是——定时发一个包,确认对方还活着

就像你给朋友发微信"在吗?",对方回了"在",说明连接正常。连续问了三次都没人理,那大概率是被删了(或者网断了)。

基础实现

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 个客户端同时发起重连,服务刚恢复就被连接风暴打趴下——经典的"惊群效应"。

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))
  }
}

重连时的状态恢复

连接恢复了,但中间丢的消息怎么办?这取决于业务对消息丢失的容忍度:

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,作为同步游标
    // ... 业务处理
  }
}

这要求服务端有消息缓冲能力,在一定时间窗口内保留已发送的消息。

完整的连接管理器

把上面的能力组合起来:

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)吗?

取决于业务容忍度:

// 带 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 连接,服务端连接数直接翻倍。

// 用 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 连接管理本质上是 有限状态机 + 故障检测 + 自动恢复

DISCONNECTED → CONNECTING → CONNECTED ⇄ RECONNECTING
                                ↓
                          DISCONNECTED(主动关闭)

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

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

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

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