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 和业务消息,断线原因也在这里显示。
- 命令行 :用
wscat或websocat手动连,验证服务端行为:
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。四点都做了,连接就稳了。