WebSocket与长连接
1. 概念与原理
WebSocket(RFC 6455)是 HTTP 之上的全双工长连接协议。和 HTTP 的根本区别:
| HTTP | WebSocket | |
|---|---|---|
| 模式 | 请求-响应 | 全双工 |
| 谁主动 | 只能客户端 | 双向 |
| Header 开销 | 每请求都重传 | 一次握手后只有 2-14 字节帧头 |
| 协议标识 | http:// | ws:// / wss:// |
前端场景:实时聊天、协作文档、直播弹幕、股票行情、IM 推送、订单状态推送、AI Agent 流式输出。
2. 握手机制
WebSocket 复用 HTTP 端口(80/443)建立连接,靠 HTTP Upgrade 切换协议。
2.1 客户端请求
GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Sec-WebSocket-Protocol: chat, json (可选,子协议)
Origin: https://app.example.com
2.2 服务端响应
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chat
Sec-WebSocket-Accept = SHA1(Sec-WebSocket-Key + 固定 GUID) 的 base64。防误连。
握手成功后这条 TCP 连接不再走 HTTP 语义,开始用 WebSocket 帧(frame)格式通信。
2.3 帧结构
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/64) |
|N|V|V|V| |S| | (if payload len==126/127) |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
opcode:
- 0x0 continuation
- 0x1 文本(UTF-8)
- 0x2 二进制
- 0x8 close
- 0x9 ping
- 0xA pong
MASK:客户端发服务端必须掩码(防代理缓存投毒),服务端发客户端不掩码。
3. 服务端实现
3.1 Node 用 ws
const { WebSocketServer } = require('ws')
const wss = new WebSocketServer({ port: 8080 })
wss.on('connection', (ws, req) => {
const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress
console.log('client connected:', ip)
ws.on('message', (data, isBinary) => {
// 广播
wss.clients.forEach((c) => {
if (c.readyState === c.OPEN) c.send(data, { binary: isBinary })
})
})
ws.on('close', (code, reason) => {
console.log('closed:', code, reason.toString())
})
ws.on('error', (err) => {
console.error('ws error:', err)
})
// 心跳
ws.isAlive = true
ws.on('pong', () => { ws.isAlive = true })
})
// 心跳轮询
const heartbeat = setInterval(() => {
wss.clients.forEach((ws) => {
if (!ws.isAlive) return ws.terminate()
ws.isAlive = false
ws.ping()
})
}, 30000)
wss.on('close', () => clearInterval(heartbeat))
3.2 集成现有 HTTP server
const http = require('http')
const { WebSocketServer } = require('ws')
const server = http.createServer((req, res) => {
res.writeHead(200); res.end('OK')
})
const wss = new WebSocketServer({ noServer: true })
server.on('upgrade', (req, socket, head) => {
// 鉴权
if (!validate(req.headers.cookie)) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n')
socket.destroy()
return
}
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit('connection', ws, req)
})
})
server.listen(3000)
4. 客户端实现
const ws = new WebSocket('wss://example.com/chat')
ws.binaryType = 'arraybuffer' // 或 'blob'
ws.addEventListener('open', () => {
ws.send('hello')
ws.send(JSON.stringify({ type: 'subscribe', topic: 'order' }))
})
ws.addEventListener('message', (e) => {
if (typeof e.data === 'string') {
const msg = JSON.parse(e.data)
// ...
} else {
// 二进制
}
})
ws.addEventListener('close', (e) => {
console.log('closed:', e.code, e.reason, e.wasClean)
})
ws.addEventListener('error', () => {
// error 不会给详细信息,看 close 事件的 code
})
5. 心跳与断线重连
5.1 为什么必须心跳
- 中间设备(云 LB、NAT、运营商)超时断连,TCP 层不通知应用
- 客户端断网,TCP 不会立刻知道(FIN 没收到)
- 服务端进程崩溃,OS 发 RST 但客户端可能没及时收到
5.2 心跳策略
应用层 ping/pong(推荐,跨代理兼容):
// 客户端
setInterval(() => {
if (ws.readyState === ws.OPEN) ws.send(JSON.stringify({ type: 'ping' }))
}, 25000)
ws.addEventListener('message', (e) => {
const msg = JSON.parse(e.data)
if (msg.type === 'pong') lastPong = Date.now()
})
// 超过 60s 没 pong 就重连
setInterval(() => {
if (Date.now() - lastPong > 60000) reconnect()
}, 5000)
WebSocket 协议自带 ping/pong 帧(opcode 0x9/0xA),但浏览器 API 不暴露,只能在服务端发起。客户端到服务端的心跳必须应用层实现。
5.3 重连策略
指数退避 + 抖动,防雪崩:
class ReconnectingWebSocket {
constructor(url) {
this.url = url
this.attempts = 0
this.connect()
}
connect() {
this.ws = new WebSocket(this.url)
this.ws.onopen = () => { this.attempts = 0 }
this.ws.onclose = (e) => {
if (e.code === 1000) return // 正常关闭不重连
this.attempts++
const base = Math.min(30000, 1000 * 2 ** this.attempts)
const jitter = Math.random() * 1000
setTimeout(() => this.connect(), base + jitter)
}
}
}
更成熟用 reconnecting-websocket 或 socket.io(自带)。
6. close code 与排查
标准 close code(前端必知):
| code | 含义 |
|---|---|
| 1000 | 正常关闭 |
| 1001 | 端点离开(页面切走) |
| 1002 | 协议错 |
| 1003 | 数据类型不支持 |
| 1006 | 异常关闭(最常见,浏览器自己生成,不在协议传输中) |
| 1007 | UTF-8 错 |
| 1008 | 策略违反 |
| 1009 | 消息太大 |
| 1011 | 服务端遇到错误 |
| 1012 | 服务重启 |
| 1013 | 稍后再试 |
1006 是排障重灾区:握手没完成或 TCP 异常断开,没有任何细节。常见原因:
- Nginx 没配 upgrade(504 / 转回 HTTP)
- 防火墙拦截 ws 帧
- 服务端崩溃
- 网络中断
7. Nginx 反代 WebSocket
location /ws {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 长连接超时(默认 60s,WebSocket 必须调大)
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
Upgrade 头是关键。少了任何一个 WebSocket 都连不上。
8. WebSocket vs SSE vs Long Polling
| 方案 | 方向 | 协议 | 复杂度 | 浏览器兼容 |
|---|---|---|---|---|
| 长轮询 | 服 → 客 | HTTP | 低 | 全 |
| SSE | 服 → 客 | HTTP(text/event-stream) | 低 | 不支持 IE |
| WebSocket | 双向 | 自有 | 中 | 全 |
选型:
- 单向推送:SSE(自动重连、HTTP/2 多路复用、基础设施友好)
- 双向高频:WebSocket
- 极低频 + 兼容老:长轮询
LLM 流式输出(ChatGPT 风格)多用 SSE。
9. 性能与扩展
9.1 单机连接数极限
理论上 fd 是上限(百万级 ok),实际瓶颈在内存(每连接几十 KB),50 万连接 = 50GB 内存。Node 单进程做 IM 网关常见 10-20 万。
调优:
# 系统级
ulimit -n 1048576
sysctl -w net.ipv4.tcp_max_syn_backlog=65535
sysctl -w net.core.somaxconn=65535
# Node 启动
node --max-old-space-size=4096 server.js
9.2 水平扩展
WebSocket 是有状态的(连接绑定特定服务器实例),跨实例广播必须用消息队列:
Client A → LB → Node 实例 1
Client B → LB → Node 实例 2
↓ subscribe channel
Redis Pub/Sub
↓ publish
广播到所有实例
或用 Redis adapter(socket.io 内置)。
9.3 sticky session
LB 必须 sticky(同一客户端始终路由到同一实例),否则 WebSocket 升级会失败(不同实例不认这个连接)。
- Nginx:
ip_hash或sticky cookie - 云 LB:开 cookie 粘性
9.4 消息压缩 permessage-deflate
握手时协商,每条消息 deflate 压缩。适合文本(JSON)场景,二进制(已压缩)开了反而慢。
10. 安全考量
- 必须 wss://(TLS):明文 ws 在公网被劫持/窃听
- Origin 校验:服务端验证 Origin 头防 CSWSH(跨站 WebSocket 劫持)
- 认证:握手时通过 cookie 或 token query 参数。query token 会进日志,慎用
- 限速:每连接消息率、单 IP 连接数
- 消息大小限制:避免内存炸(ws 库
maxPayload选项) - DoS:心跳超时坚决断开,不留僵尸连接
11. 故障排查
# 看是否连接成功(浏览器 DevTools → Network → WS → 单击)
# Messages 标签看收发帧、Frames 看原始帧
# 命令行测试
npx wscat -c wss://example.com/ws
> hello
# 抓包看握手
sudo tcpdump -i any -nn -A 'port 443' -c 50
# 找 Upgrade: websocket
常见问题:
- 频繁 1006 重连:Nginx 没配 upgrade、LB 超时太短、心跳间隔大于服务端超时
- 服务端收不到消息:客户端没掩码 / 消息格式错
- CPU 100%:消息广播 N²,要用 Redis pub/sub 或拆 channel
- 内存涨:连接没清理 / 监听器泄漏
12. 常见反模式
- 不开心跳:连接被中间设备静默断开,业务感知不到
- 重连不加抖动:服务端重启后所有客户端同一秒涌入,雪崩
- 明文 ws://:移动端运营商劫持
- 不限消息大小:内存被打爆
- 不限连接数 / IP:放大攻击载体
- LB 不开 sticky:连接随机失败
- 没有断开补偿:用户网络抖动后丢消息,业务态丢失
- WebSocket 当请求-响应用:失去全双工意义,应该用 HTTP
13. 延伸阅读
- RFC 6455 The WebSocket Protocol
- MDN: WebSocket API
- ws 库源码 — Node 主流实现
- socket.io 文档 — 含 fallback、room、adapter
- 《WebSocket 实战》
- Cloudflare: WebSocket scaling — 海量连接架构