WebSocket 实战:心跳、断线重连、鉴权,一次讲清

WebSocket 实战:心跳、断线重连、鉴权,一次讲清

你以为 WebSocket 连上就完事了?真正难的不是建立连接,而是让它在弱网、重启、token 过期时还能活下来。

做实时聊天、在线协作、行情推送、小游戏对战,最后都会落到 WebSocket 上。但新手最容易踩的坑是:本地连上 demo 跑通,一上生产就各种断线、收不到消息、连接数爆掉。这篇把心跳、断线重连、鉴权这三个最常被忽略、却最要命的点一次讲清,并给你能直接抄的客户端封装和服务端最小示例。

一、什么时候该用 WebSocket

先别急着上 WebSocket。很多"实时"需求,其实轮询或 SSE 更省事。三者怎么选:

方案 通信方向 适用场景 不适用
轮询(Polling) 客户端定时拉 数据更新慢、几秒一次就够(如待办数量) 高频、低延迟要求
SSE(Server-Sent Events) 服务端单向推 服务端主动推、客户端不回(如日志流、通知) 客户端要向服务端频繁发消息
WebSocket 全双工双向 聊天、协作编辑、行情、对战(双向高频) 简单只读展示,杀鸡用牛刀

一句话判断:服务端要主动、频繁、双向地跟客户端说话,才上 WebSocket。 如果只是"服务端偶尔通知一下",SSE 更简单,还自带断线重连。

二、握手与生命周期

WebSocket 不是凭空出现的,它从一次 HTTP 请求"升级"而来。客户端发一个带特殊头的请求,服务端同意后就从 HTTP 切到 WebSocket 协议:

http 复制代码
# 客户端发起的握手请求
GET /ws HTTP/1.1
Host: example.com
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

连接建立后,浏览器里用 WebSocket 对象管理,它有四个状态:

readyState 含义 说明
0 CONNECTING 正在连接 还没 ready
1 OPEN 已连接 可以收发
2 CLOSING 关闭中 正在挥手
3 CLOSED 已关闭 需要重连逻辑

为什么必须用 wss(WebSocket over TLS) :第一,明文 ws 在公网会被中间人窃听和注入;第二,现在浏览器在 HTTPS 页面里禁止连接非加密的 ws ,会直接报安全错误。生产一律用 wss,和 HTTPS 同理。

三、鉴权两种方案对比

连接上来之后,服务端得知道"你是谁"。常见两种做法:

方案 做法
URL query 带 token wss://host/ws?token=xxx token 会进 Nginx/网关的访问日志,明文落盘,泄露风险高
连接后发认证消息 连上先发 {type:'auth', token:'xxx'} 需约定认证超时,未认证前不能收业务消息

我的建议:别把 token 放 URL。 URL 里的 token 极易进日志、进监控、进浏览器历史。正确姿势是连接后第一帧发认证消息,服务端在收到认证前只收不发:

javascript 复制代码
const ws = new WebSocket('wss://example.com/ws')

// 连上后第一步:发认证,不要在 URL 带 token
ws.addEventListener('open', () => {
  ws.send(JSON.stringify({ type: 'auth', token: getToken() }))
})

// token 过期怎么办?
// 服务端认证失败时发一个特定类型消息,客户端收到就去刷新并重连
ws.addEventListener('message', (e) => {
  const msg = JSON.parse(e.data)
  if (msg.type === 'auth_fail' && msg.code === 401) {
    refreshToken().then(() => reconnect()) // 刷新后重建连接
  }
})

关键细节:认证不要无限等待。服务端应设一个超时(比如连上 5 秒内没收到合法认证帧就主动断开),否则会堆一堆"已连接但未认证"的僵尸连接,吃光文件描述符。

四、心跳与保活

这是生产环境第一大坑。WebSocket 连接"看起来还在",其实中间链路早就断了,你发的消息石沉大海。

为什么必须心跳:

  • Nginx 默认 60 秒 断开空闲连接(proxy_read_timeout 默认 60s),你半天没发消息,连接被悄悄掐了。
  • NAT / 运营商对空闲 TCP 连接有几分钟不等的回收策略,尤其手机网络。

解决:客户端定时发 ping,服务端回 pong,并监控"多久没收到 pong"来判定死亡。

javascript 复制代码
class Heartbeat {
  constructor(ws, { interval = 15000, timeout = 10000 } = {}) {
    this.ws = ws
    this.interval = interval   // 每 15 秒发一次 ping
    this.timeout = timeout     // 超过 10 秒没收到 pong 判死
    this.timer = null
    this.pongTimer = null
  }
  start() {
    // 定时发 ping
    this.timer = setInterval(() => {
      if (this.ws.readyState !== WebSocket.OPEN) return
      this.ws.send(JSON.stringify({ type: 'ping' }))
      // 启动"等 pong"倒计时
      this.pongTimer = setTimeout(() => {
        console.warn('心跳超时,主动断开重连')
        this.ws.close() // 触发下面的重连逻辑
      }, this.timeout)
    }, this.interval)
    // 收到 pong 就取消倒计时
    this.ws.addEventListener('message', (e) => {
      const m = JSON.parse(e.data)
      if (m.type === 'pong') clearTimeout(this.pongTimer)
    })
  }
  stop() {
    clearInterval(this.timer)
    clearTimeout(this.pongTimer)
  }
}

服务端也要回 pong:收到 ping 就回 {type:'pong'}。注意浏览器原生 WebSocket 会帮你发/收协议层的 ping 帧,上面用的是应用层 JSON 心跳,跨语言通用、好调试,推荐新手用这种。

五、断线重连

网络抖动、服务重启、切后台,连接一定会断。没有重连,用户体验就是"聊天突然不动了"。

javascript 复制代码
class Reconnector {
  constructor(url, { maxDelay = 30000, maxRetry = 10 } = {}) {
    this.url = url
    this.attempt = 0
    this.maxRetry = maxRetry
    this.maxDelay = maxDelay
    this.ws = null
  }
  connect() {
    this.ws = new WebSocket(this.url)
    this.ws.addEventListener('open', () => {
      this.attempt = 0 // 连上就重置计数
      // 重连后要补偿:重新订阅房间 / 拉取漏掉的消息
      this.ws.send(JSON.stringify({ type: 'resume', lastMsgId: getLastMsgId() }))
    })
    this.ws.addEventListener('close', () => {
      if (this.attempt >= this.maxRetry) {
        console.error('重连次数耗尽,停止重试')
        return
      }
      // 指数退避:1s, 2s, 4s, 8s ... 封顶 30s
      const delay = Math.min(1000 * 2 ** this.attempt, this.maxDelay)
      this.attempt++
      console.log(`第 ${this.attempt} 次重连,${delay}ms 后`)
      setTimeout(() => this.connect(), delay)
    })
  }
}

两个容易被漏的点:

  • 页面可见性变化重连 :手机锁屏或切后台,浏览器会挂起 WebSocket,回来时已经断了。监听 visibilitychange,页面重新可见时若 readyState === CLOSED 就立刻重连,比傻等下一次 close 事件更快。
  • 重连后补偿:重连成功不能只发个 auth 就完事。要重新订阅你之前加入的房间,并告诉服务端"我最后收到的消息 ID 是 X",让服务端把 X 之后的消息补发给你,否则你会漏消息。

六、服务端最小示例(Node.js ws)

给一个能跑的服务端,包含订阅房间、广播、连接数统计:

javascript 复制代码
const { WebSocketServer } = require('ws')
const wss = new WebSocketServer({ port: 8080 })

// 房间:roomId -> Set<ws>
const rooms = new Map()
let authed = new Set() // 已认证连接

wss.on('connection', (ws) => {
  ws.isAuthed = false
  ws.rooms = new Set()

  ws.on('message', (raw) => {
    const msg = JSON.parse(raw.toString())
    // 1) 认证
    if (msg.type === 'auth') {
      if (validToken(msg.token)) { // 你自己的 token 校验
        ws.isAuthed = true
        authed.add(ws)
        ws.send(JSON.stringify({ type: 'auth_ok' }))
      } else {
        ws.send(JSON.stringify({ type: 'auth_fail', code: 401 }))
        ws.close()
      }
      return
    }
    if (!ws.isAuthed) return // 未认证不处理业务消息

    // 2) 加入房间
    if (msg.type === 'subscribe') {
      if (!rooms.has(msg.roomId)) rooms.set(msg.roomId, new Set())
      rooms.get(msg.roomId).add(ws)
      ws.rooms.add(msg.roomId)
    }
    // 3) 广播到房间
    if (msg.type === 'chat') {
      const set = rooms.get(msg.roomId)
      set?.forEach((client) => {
        if (client.readyState === 1) {
          client.send(JSON.stringify({ type: 'chat', from: msg.from, text: msg.text }))
        }
      })
    }
  })

  ws.on('close', () => {
    authed.delete(ws)
    ws.rooms.forEach((r) => rooms.get(r)?.delete(ws))
  })
})

// 连接数统计(排查连接泄漏很好用)
setInterval(() => {
  console.log('当前连接数:', wss.clients.size, '已认证:', authed.size)
}, 5000)

七、生产部署坑

本地跑通只是开始,上生产这几个坑必踩:

  • 坑 1:Nginx 不配 Upgrade 头,WebSocket 直接连不上。反向代理必须显式转发升级头,否则握手失败:
nginx 复制代码
location /ws {
    proxy_pass http://127.0.0.1:8080;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;       # 关键
    proxy_set_header Connection "upgrade";         # 关键
    proxy_set_header Host $host;
    proxy_read_timeout 3600s;   # 调大,否则空闲被掐
}
  • 坑 2:多实例要用 Redis pub/sub 广播 。单机 rooms 在内存里,你部署 2 台以上,A 实例的用户发消息,B 实例的用户收不到。解决:所有实例订阅同一个 Redis 频道,发消息时 publish,各实例收到后转发给自己连的客户端。
  • 坑 3:连接数与文件描述符 。每个 WS 连接占一个 fd,默认系统 ulimit 可能只有 1024,几千连接就爆。生产要调大 fs.file-max 和进程的 ulimit -n
  • 坑 4:消息积压打满内存。客户端掉线但服务端还在拼命发,消息在 socket 缓冲区堆积。要监控发送队列长度,积压超阈值就主动断开慢客户端。

八、调试方法

  • 浏览器:打开 DevTools → Network → 筛选 WS,点开连接能看到 Frames 面板,逐帧看 ping/pong 和业务消息,断线原因也在这里显示。
  • 命令行 :用 wscatwebsocat 手动连,验证服务端行为:
bash 复制代码
# 安装
npm i -g wscat

# 连上发一条消息
wscat -c wss://example.com/ws
> {"type":"auth","token":"xxxx"}
> {"type":"ping"}

连不上时,先看握手响应状态码:101 是成功,40x 多为鉴权/路径错,52x 多为 Nginx 没配 Upgrade 头。

九、结语

能把 WebSocket 跑通的开发者很多,能让它在弱网、重启、token 过期里活下来的不多。把上面这套封装沉淀成一个可复用的客户端模块,你之后做聊天、协作、推送都不用再踩一遍。

下次写实时功能前,先把这份清单过一遍:鉴权用连接后发消息而不是 URL 带 token、心跳 15s 发一次且监控 pong 超时、重连用指数退避并补发漏消息、Nginx 配好 Upgrade 头和 read_timeout。四点都做了,连接就稳了。

相关推荐
默_笙2 小时前
🚋 从流水线到地铁网:为什么复杂 AI 都要拆成多 Agent(上)——LangGraph 基础入门
前端·javascript
秋秋小事2 小时前
node postgreSQL的select与include
node.js
科技苑2 小时前
前后端分离与微服务架构如何协同?
前端·后端·前端框架
神秘的猪头3 小时前
TypeScript 高级用法全解析:从泛型到 infer,把类型系统真正用起来
前端·typescript
moMo3 小时前
React Hooks 与闭包
前端·react.js
ID34610744203 小时前
【课程设计】基于Spring Boot+Vue的游戏账号租赁系统的设计与实现-计算机毕设 附源码50345
javascript·vue.js·spring boot·python·node.js·php·课程设计
柚yuzumi3 小时前
彻底搞懂 JavaScript 类型转换:显式转换、隐式转换与 ToPrimitive
前端·javascript·node.js
向北丶3 小时前
vscode 导入语句排序和删除未使用的导入
前端·visual studio code
PHP实战开发录3 小时前
PHP接口偶发变慢怎么查
前端·php·开发