WebSocket实战:从断连重连到百万并发,一个项目踩遍所有坑

前言:我以为很简单

500″>

刚开始接实时通信需求时,我天真地想:WebSocket不就是个长连接吗?API一看就懂,Demo一把梭。结果上线第一周,用户反馈“消息收不到”“连接老断”“CPU飙升300%”——直接被打脸。

这篇文章,会从零搭建一个生产级WebSocket服务。我们不谈理论,直接上代码+排坑。这不是那种“用WebSocket发送Hello World”的教程,而是你在真实项目中一定会遇到的:怎么断线重连、怎么避免内存泄漏、怎么扛住高并发。

先看第一个图,这是我们最终要实现的效果——实时监控面板上,1万个客户端在线,消息延迟稳定在10ms以内:

WebSocket实时监控仪表盘示意图:显示在线连接数10000,平均延迟8ms,CPU使用率45%


一、最基础的:客户端实现与坑

1. 原生WebSocket写法

为什么还要写这个?因为很多框架(如Socket.IO)封装得太友好,导致出了问题你根本不知道底层发生了什么。先吃透原生再上层抽象,是技术人的生存法则。

javascript
// ws-client.js
// 这个类是生产级的基础,不是Demo
class WebSocketClient {
constructor(url, options = {}) {
this.url = url
this.options = {
reconnectInterval: 3000, // 重连间隔 3秒
maxReconnectAttempts: 10, // 最多重连10次
heartbeatInterval: 25000, // 心跳25秒一次(比服务端超时短5秒)
...options
}

this.ws = null
this.reconnectAttempts = 0
this.isDestroyed = false // 标记是否主动销毁
this.messageHandlers = new Map()

this.connect()
}

connect() {
if (this.isDestroyed) return

const ws = new WebSocket(this.url)

ws.onopen = () => {
console.log('连接成功,耗时: ', performance.now())
this.reconnectAttempts = 0
this.startHeartbeat()
this.emit('connected')
}

ws.onmessage = (event) => {
// 收到消息立刻解析,不要在这里做耗时操作
try {
const data = JSON.parse(event.data)
this.emit(data.type, data.payload)
} catch (e) {
console.warn('消息解析失败:', event.data)
}
}

ws.onclose = (event) => {
// 主动关闭(如页面关闭)不重连
if (!event.wasClean && !this.isDestroyed) {
this.reconnect()
}
this.stopHeartbeat()
}

ws.onerror = (error) => {
// 错误事件后一定会有close事件,所以不需要在这里处理重连
// 但可以记日志:'WS错误:', error.message
}

this.ws = ws
}

reconnect() {
if (this.reconnectAttempts >= this.options.maxReconnectAttempts) {
this.emit('maxReconnectFailed')
return
}

// 指数退避:每次重连间隔加50%,但不超过30秒
const delay = Math.min(
this.options.reconnectInterval * Math.pow(1.5, this.reconnectAttempts),
30000
)

console.log(第${this.reconnectAttempts + 1}次重连,等待${delay}ms)

setTimeout(() => {
if (!this.isDestroyed) {
this.reconnectAttempts++
this.connect()
}
}, delay)
}

// 心跳:防止NAT超时断开
startHeartbeat() {
this.heartbeatTimer = setInterval(() => {
if (this.ws.readyState === WebSocket.OPEN) {
// 发送一个空ping,负载越小越好
this.ws.send(JSON.stringify({ type: 'ping', ts: Date.now() }))
}
}, this.options.heartbeatInterval)
}

stopHeartbeat() {
clearInterval(this.heartbeatTimer)
}

destroy() {
this.isDestroyed = true
this.stopHeartbeat()
if (this.ws) {
this.ws.close(1000, '客户端主动关闭')
this.ws = null
}
}

send(type, payload) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type, payload, ts: Date.now() }))
} else {
// 这里有个大坑:不能直接存队列,否则内存会爆炸
console.warn('连接未就绪,消息丢弃:', type)
}
}

on(type, handler) {
this.messageHandlers.set(type, handler)
}

emit(type, payload) {
const handler = this.messageHandlers.get(type)
if (handler) handler(payload)
}
}
de>`

这个设计反人类的地方:官方文档对onclosewasClean属性解释得跟谜语一样。简单说,wasClean: true表示主动关闭,比如调了close()或被服务端正常关闭;false表示网络断开或进程崩溃。我们只对非主动断开做重连。

2. 使用示例

`javascript
// 初始化
const client = new WebSocketClient('wss://api.example.com/ws', {
reconnectInterval: 2000,
maxReconnectAttempts: 20
})

// 监听消息类型
client.on('chat_message', (msg) => {
updateChatUI(msg)
})

client.on('maxReconnectFailed', () => {
// 提示用户“连接失败,请刷新页面”
showErrorToast('网络异常,请检查网络后重试')
})

// 发送消息(比如用户点击发送按钮)
sendBtn.onclick = () => {
client.send('chat_message', { text: input.value })
}
`

二、服务端实现:Node.js + ws模块

1. 为什么不用Socket.IO?

因为Socket.IO太重了,默认还带polling降级。我们的目标场景是:现代浏览器(覆盖95%用户)+稳定性要求高。直接用ws库,包体只有20KB(相比Socket.IO的200KB+)。

另一个坑:npm上ws包的文档示例会误导你——它把所有连接放在一个全局数组里,但这会导致内存泄漏。正确做法是用Map关联用户ID和连接。

`javascript
// ws-server.js
// 生产级服务端,支持鉴权、心跳检测、自动清理僵尸连接
const WebSocket = require('ws')
const http = require('http')
const server = http.createServer()

const wss = new WebSocket.Server({
server,
// 不设maxPayload?大错特错!攻击者发个100MB消息就崩了
maxPayload: 1024 * 10, // 最大10KB
clientTracking: false // 我们自己管理连接,省内存
})

// 用Map存储连接,key是userId
const clients = new Map()

wss.on('connection', async (ws, req) => {
// 第一步:鉴权
const token = req.url.split('?token=')[1] || ''
const userId = await verifyToken(token)

if (!userId) {
ws.close(4001, 'Unauthorized')
return
}

// 如果用户已有连接,关掉旧的(双端登录处理)
const existing = clients.get(userId)
if (existing) {
existing.close(4002, 'New login from another device')
}

// 绑定到Map
clients.set(userId, ws)
console.log(
用户 ${userId} 上线,当前连接数: ${clients.size})

// 设置心跳超时:30秒没收到ping就断开
let isAlive = true
ws.isAlive = true

const heartbeat = setInterval(() => {
if (!ws.isAlive) {
console.log(
用户 ${userId} 心跳超时,断开连接)
ws.terminate() // 直接终止,不触发close事件? 会触发
return
}
ws.isAlive = false
ws.ping() // 服务端主动ping
}, 30000)

ws.on('pong', () => {
ws.isAlive = true
})

ws.on('message', (data) => {
ws.isAlive = true // 收到任何消息都算在线

try {
const msg = JSON.parse(data.toString())

// 处理ping/pong(心跳)
if (msg.type === 'ping') {
ws.send(JSON.stringify({ type: 'pong', ts: Date.now() }))
return
}

// 其他消息处理
handleMessage(userId, msg)
} catch (e) {
// 不要直接断开,记录日志即可
console.warn(
用户 ${userId} 消息格式错误:, e.message)
}
})

ws.on('close', (code, reason) => {
clearInterval(heartbeat)
clients.delete(userId)
console.log(
用户 ${userId} 离线,当前连接数: ${clients.size})
})

// 发送上线消息
ws.send(JSON.stringify({ type: 'connected', userId }))
})

// 定时清理(防止内存泄漏第二道防线)
setInterval(() => {
wss.clients.forEach((ws) => {
if (!ws.isAlive) {
ws.terminate()
}
})
}, 35000) // 比心跳间隔长5秒

server.listen(8080, () => {
console.log('WS服务启动在 :8080')
})

// 广播消息给指定用户
function sendToUser(userId, msg) {
const ws = clients.get(userId)
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(msg))
}
}
`

上面这个设计有个大坑:setInterval里的定时器跑得太频繁,会导致CPU空转。我们后面讲优化。


三、断线重连:那些你没考虑到的场景

1. 指数退避 vs 固定间隔

首先说结论:一定要用指数退避。为什么?假设100个用户同时断线,如果固定3秒重连,服务器在3秒时收到100个连接请求,网络拥堵会加重。指数退避把请求分散到不同时间点。

上面的客户端代码里我们已经实现了:

`javascript
// 每次重连间隔 = 上次间隔 * 1.5,但不超过30秒
const delay = Math.min(
this.options.reconnectInterval * Math.pow(1.5, this.reconnectAttempts),
30000
)
`

第一次:3s → 第二次:4.5s → 第三次:6.75s → ... → 最多30s

2. 幂等性问题

这是最容易被忽视的。用户断线重连后,服务端怎么保证不重复处理消息?

解决方案:客户端生成消息ID(使用crypto.randomUUID()),服务端维护一个已处理ID的LRU缓存(最多存1000条,5分钟过期)。收到消息时先查缓存,存在就丢弃。

重连时序图:展示客户端断线、消息重发、服务端去重流程


四、高并发优化:单机10万连接怎么扛

先看这个图,这是压测后的数据:

压测结果图表:横轴为并发连接数(10000-100000),纵轴为CPU使用率和延迟,显示10万连接时CPU 65%,延迟12ms

1. 服务端调优

`javascript
// 隐藏的优化点
const wss = new WebSocket.Server({
server,
maxPayload: 1024 * 10,
clientTracking: false, // 节省内存,每连接约2KB
backlog: 10240, // 加大待处理连接队列
perMessageDeflate: false // 关闭压缩,省CPU
})
`

  • 关闭clientTracking:每个连接节省约2KB内存,10万连接就是200MB
  • 关闭perMessageDeflate:压缩在WebSocket场景下收益不大,但CPU消耗明显(约30%)

2. 避免全局广播时卡死

假设你有一个公告功能,需要给所有在线用户发消息。最蠢的做法:

`javascript
// ❌ 错误:同步遍历,10万个用户时,用户1000会等用户1发完才收到
clients.forEach((ws) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(data)
}
})
`

正确做法:把写操作放在微任务或setTimeout中批量处理

`javascript
// ✅ 正确:分批发送,每批次50个,用setTimeout让出事件循环
function broadcast(data, batchSize = 50) {
const entries = Array.from(clients.entries())
let index = 0

function sendBatch() {
const batch = entries.slice(index, index + batchSize)
batch.forEach(([userId, ws]) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(data)
}
})
index += batchSize
if (index < entries.length) {
setTimeout(sendBatch, 0) // 让出主线程
}
}

sendBatch()
}
`

3. 内存泄漏检测

怎么知道WebSocket有没有泄漏?写个定时打日志:

`javascript
setInterval(() => {
// 打印进程内存和连接数
const mem = process.memoryUsage()
console.log({
rss:
${(mem.rss / 1024 / 1024).toFixed(2)} MB,
heapUsed:
${(mem.heapUsed / 1024 / 1024).toFixed(2)} MB,
connections: clients.size,
heartbeats: heartbeatsCount
})
}, 60000)
`

如果rss内存持续增长,而连接数稳定,很可能是有内存泄漏。


五、生产环境踩坑汇总

1. Nginx反代WebSocket

很多人的服务跑在Nginx后面,但Nginx默认不支持WS升级。配置如下:

`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_set_header X-Real-IP $remote_addr;

# 超时设置
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
`

坑点:proxy_read_timeout默认60秒,超过60秒没数据就断开。如果你心跳间隔是25秒,理论上够用,但网络抖动时容易被误杀。设成300秒比较安全。

2. 混合部署策略

不是所有场景都用WebSocket。举例:

  • 实时聊天 -> WebSocket
  • 后台通知 -> 用SSE(Server-Sent Events)更轻量
  • 大量数据批量下载 -> HTTP长连接

SSE比WebSocket简单得多,而且是单向的(服务端推送给客户端),对于通知推送场景足够。


六、总结:你可以立刻用的三个点

  • 断线重连必加指数退避和去重:客户端重连时间乘以1.5倍到30秒封顶,消息ID去重防重复消费
  • 服务端一定要设maxPayload和心跳超时:不设就是给DDoS留后门,心跳间隔比超时时间短5秒
  • 高并发用批量发送和关闭压缩perMessageDeflate: false省30% CPU,clientTracking: false省200MB内存
  • > 最后说一句:WebSocket看起来很美好,但上线前一定要做压测。ws库官方没有提供压力测试工具,推荐用artillery`或自己写脚本模拟。别等到用户投诉了才发现代码有坑。

    滚动至顶部