跳到主要内容

WebSocket与长连接

1. 概念与原理

WebSocket(RFC 6455)是 HTTP 之上的全双工长连接协议。和 HTTP 的根本区别:

HTTPWebSocket
模式请求-响应全双工
谁主动只能客户端双向
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异常关闭(最常见,浏览器自己生成,不在协议传输中)
1007UTF-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_hashsticky 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. 延伸阅读