前言:我以为很简单
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>`
这个设计反人类的地方:官方文档对onclose的wasClean属性解释得跟谜语一样。简单说,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${(mem.rss / 1024 / 1024).toFixed(2)} MB
setInterval(() => {
// 打印进程内存和连接数
const mem = process.memoryUsage()
console.log({
rss: ,${(mem.heapUsed / 1024 / 1024).toFixed(2)} MB
heapUsed: ,`
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简单得多,而且是单向的(服务端推送给客户端),对于通知推送场景足够。
六、总结:你可以立刻用的三个点
和心跳超时:不设就是给DDoS留后门,心跳间隔比超时时间短5秒省30% CPU,clientTracking: false省200MB内存> 最后说一句:WebSocket看起来很美好,但上线前一定要做压测。ws库官方没有提供压力测试工具,推荐用artillery`或自己写脚本模拟。别等到用户投诉了才发现代码有坑。