从HTTP轮询到WebSocket:某百万DAU社区的技术选型复盘

几个月前我接手了一个日活 120 万+ 的社区项目,核心功能是用户动态实时通知(点赞、评论、私信)。接手时系统还在用 HTTP 长轮询(Long Polling),高峰期服务器 CPU 经常飙到 90% 以上,接口平均响应耗时 800ms,用户投诉消息延迟严重。

当时长轮询的实现逻辑是:客户端每隔 3 秒发一次 GET /api/notifications?after=xxx,服务端如果没有新消息就阻塞请求直到超时(30 秒),有消息立即返回。看似“实时”,实际问题很大:

我拉了 3 天监控数据对比:长轮询下,单台 4 核 8G 的 Node.js 服务器(v18.19.0)最多扛 800 个并发连接,内存占用 1.2GB;而换成 WebSocket 后,同样配置能稳定维持 1.2 万个连接,内存仅 450MB。

为什么选 WebSocket 而不是 SSE?我们场景需要双向通信(用户发消息、服务端推通知),SSE 只能服务端单向推,客户端还得单独发 HTTP 请求写评论,多维护一套连接反而复杂。至于 WebTransport,2024 年初我们调研时 Chrome 才刚支持 HTTP/3 下的 WebTransport,Safari 17 还没跟进,生产环境不敢用。

最终方案是:客户端用原生 WebSocket API(Chrome 120+、Firefox 115+ 覆盖率 98%),服务端用 ws 库 v8.16.0(当时最新稳定版,RFC 6455 标准实现),通过 HTTP Upgrade 头从 /api/ws 升级连接。上线后消息延迟从 800ms 降到 120ms,服务器 CPU 峰值降到 45%,单连接内存开销从 1.5MB 降到 80KB。

这里有个细节:我们没直接用 Socket.IO。它的 v4.7.4 虽然兼容性好,但默认会降级到长轮询,而且包体积比原生 ws 大 3 倍,我们场景不需要兼容 IE11,原生实现更轻量。

手把手搭建高可用服务:Node.js ws库与心跳保活实战

我现在的 WebSocket 服务基于 Node.js 18.19.0 + ws v8.16.0 搭建,直接上生产可用的核心代码。先贴服务端实现:

const { WebSocketServer } = require('ws'); const http = require('http'); const jwt = require('jsonwebtoken'); // 先创建 HTTP 服务,后续通过 Upgrade 头升级为 WebSocket const server = http.createServer((req, res) => { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ status: 'ok' })); }); const wss = new WebSocketServer({ noServer: true }); // 鉴权逻辑:从 Upgrade 请求的 Query 里取 Token,避免连接后单独发鉴权消息 server.on('upgrade', (req, socket, head) => { const url = new URL(req.url, `http://${req.headers.host}`); const token = url.searchParams.get('token'); if (!token) { socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); socket.destroy(); return; } try { const user = jwt.verify(token, process.env.JWT_SECRET); // 把用户信息挂到 socket 上,后续推送用 wss.handleUpgrade(req, socket, head, (ws) => { ws.userId = user.id; ws.isAlive = true; // 心跳标记 wss.emit('connection', ws, req); }); } catch (err) { socket.write('HTTP/1.1 403 Forbidden\r\n\r\n'); socket.destroy(); } }); // 连接建立后的逻辑 wss.on('connection', (ws) => { console.log(`用户 ${ws.userId} 连接,当前总连接数:${wss.clients.size}`); // 心跳:服务端发 ping,客户端自动回 pong(RFC 6455 规定浏览器原生支持 pong) ws.on('pong', () => { ws.isAlive = true; }); // 接收客户端消息(比如用户发评论) ws.on('message', (data) => { const msg = JSON.parse(data.toString()); if (msg.type === 'comment') { // 推送给被评论的用户,假设我们知道对方的 userId const targetWs = Array.from(wss.clients).find(c => c.userId === msg.targetId && c.readyState === WebSocket.OPEN); if (targetWs) { targetWs.send(JSON.stringify({ type: 'notification', content: `${ws.userId} 评论了你的动态`, timestamp: Date.now() })); } } }); // 连接关闭时清理 ws.on('close', () => { console.log(`用户 ${ws.userId} 断开,剩余连接数:${wss.clients.size}`); }); }); // 全局心跳检测:每 30 秒检查一次,超时未响应直接断开 const heartbeatInterval = setInterval(() => { wss.clients.forEach((ws) => { if (ws.isAlive === false) { console.log(`用户 ${ws.userId} 心跳超时,断开连接`); return ws.terminate(); } ws.isAlive = false; ws.ping(); // 触发客户端的 pong 响应 }); }, 30000); server.listen(3001, () => { console.log('WebSocket 服务启动在 3001 端口'); });

客户端的实现也很简单,我加了指数退避重连,避免雪崩:

class WSClient { constructor(url) { this.url = url; this.ws = null; this.reconnectAttempts = 0; this.maxReconnectDelay = 30000; // 最大重连间隔 30 秒 this.connect(); } connect() { this.ws = new WebSocket(this.url); this.ws.onopen = () => { console.log('WebSocket 连接成功'); this.reconnectAttempts = 0; // 连接成功重置重连次数 }; this.ws.onmessage = (event) => { const msg = JSON.parse(event.data); if (msg.type === 'notification') { // 实际项目里这里会更新通知红点 console.log('收到通知:', msg.content); } }; this.ws.onclose = () => { console.log('连接断开,准备重连'); this.reconnect(); }; this.ws.onerror = (err) => { console.error('WebSocket 错误:', err); }; } reconnect() { this.reconnectAttempts++; // 指数退避:2^attempts * 1000,最多不超过 maxReconnectDelay const delay = Math.min(Math.pow(2, this.reconnectAttempts) * 1000, this.maxReconnectDelay); console.log(`第 ${this.reconnectAttempts} 次重连,等待 ${delay}ms`); setTimeout(() => this.connect(), delay); } send(data) { if (this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify(data)); } } } // 初始化,Token 从 localStorage 取,实际项目里要处理 Token 过期刷新 const token = localStorage.getItem('token'); const client = new WSClient(`ws://localhost:3001?token=${token}`);

这里有个我踩过的坑:之前没做心跳检测,用户切换 WiFi 到 5G 时,TCP 连接不会立即断开,但服务端还在往死连接发消息,导致内存泄漏。加了 ping/pong 后,30 秒就能清理掉死连接,单服务器 1.2 万连接时内存稳定在 450MB 左右。另外鉴权一定要在 Upgrade 阶段做,不要等连接建立后再发 auth 消息,不然会有几秒的空窗期,恶意用户能钻空子发垃圾消息。

线上事故复盘:一次由于Nginx配置导致的WS连接雪崩

今年 3 月大促,我们的 WebSocket 服务突然在 10 分钟内掉了 80% 的连接,用户消息全部延迟。我盯着监控看:Nginx 的活跃连接数从 2 万飙到 8 万,然后瞬间掉到 0,Node.js 服务本身 CPU、内存都正常,问题肯定在 Nginx 层。

翻 Nginx 配置(当时用的是 1.21.6 版本),发现之前的运维同学配了 proxy_read_timeout 60s,意思是如果 60 秒内服务端没发消息,Nginx 就主动断开连接。但我们的心跳是 30 秒一次,按理说不会触发?不对,我查了 ws 库的 ping 逻辑:服务端的 ws.ping() 是向客户端发控制帧,Nginx 默认不会转发 WebSocket 的控制帧!也就是说 Nginx 根本没收到心跳,60 秒一到就断连接。

更糟的是,8 万连接同时断开,客户端全部触发重连,瞬间打过来 8 万个 Upgrade 请求,Nginx 的 worker_connections 配的是 10240,直接被打满,新连接全被拒绝,形成雪崩。

当时的临时修复方案:先把 proxy_read_timeout 改成 300s,然后加 proxy_http_version 1.1proxy_set_header Upgrade $http_upgrade,让 Nginx 正确转发 WebSocket 帧。完整修复后的 Nginx 配置如下:

http { # 全局配置:单个 worker 最大连接数,我们 4 核服务器配了 4 个 worker,总连接数 4*65535 worker_connections 65535; # 开启连接复用,减少握手开销 keepalive_timeout 65; server { listen 80; server_name ws.example.com; # WebSocket 升级路径 location /ws/ { proxy_pass http://127.0.0.1:3001; # 必须配这两行,不然 Nginx 不认识 Upgrade 请求 proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; # 关闭超时断开,由应用层心跳控制连接生命周期 proxy_read_timeout 86400s; proxy_send_timeout 86400s; # 转发真实客户端 IP proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # 限制单个 IP 最多 10 个连接,防恶意刷连接 limit_conn per_ip 10; } } # 连接数限制配置 limit_conn_zone $binary_remote_addr zone=per_ip:10m; }

改完配置 reload Nginx 后,连接数立刻稳定下来。但还有个问题:之前的客户端重连逻辑是固定 3 秒间隔,8 万连接同时重连还是会打满带宽。我后来给客户端加了指数退避(就是上一章的代码),重连间隔从 2 秒开始,最多到 30 秒,错峰重连后峰值请求量降了 90%。

这次事故让我记住两个教训:

后来我们做了压测:用 ws 库的 WebSocket 客户端模拟 10 万连接,Nginx 1.25.3 + 上述配置,单台 8 核 16G 服务器能稳定扛住,连接建立成功率 99.99%,心跳延迟稳定在 30ms 以内。现在这个配置跑了 6 个月,没再出过连接雪崩的问题。

4. 深度对比:WebSocket vs SSE vs WebTransport选型策略

我在去年接手一个跨境电商后台重构时,团队在实时通知模块上吵了两天。产品经理想要实时库存变动提醒,前端组长坚持用 SSE,后端同学说 WebSocket 更通用,而我当时正在研究 QUIC 协议,心里还惦记着 WebTransport。最后我们没选任何一个,而是根据三个不同的业务场景拆成了两套方案。

先拿生活场景打个比方。你开了一家咖啡店,需要给顾客通知取餐。WebSocket 就像你给顾客发了一部对讲机,你们可以随时互相喊话,他告诉你加糖,你告诉他快好了。SSE 更像店里的广播喇叭,你只能从店里往外播报“拿铁好了”,顾客没法通过喇叭回复你。WebTransport 则是新出的智能取餐柜,不仅能推送消息,还能一次性塞进大堆餐盒,速度极快,但只有最新款的手机(浏览器)才支持。

回到技术本身,我们得看协议本质。WebSocket 遵循的是 RFC 6455(2011年12月发布),这个标准非常成熟,Chrome 120+、Firefox 115+、Safari 17+ 以及 Node.js 的 ws 库(我用的是 v8.16.0)都完美支持。它的核心优势是全双工,适合聊天室或者协同编辑。

SSE(Server-Sent Events)则是基于 HTTP 协议,它最大的特点是单向自动重连。我在做那个库存通知时,发现库存变动只需要服务器推送给前端,前端不需要回复“我知道了”,这时候用 WebSocket 就显得重了。SSE 的数据格式是纯文本流,实现起来极其简单。

WebTransport 是目前的新贵,基于 HTTP/3 (QUIC)。我在测试环境试过,在弱网环境下,它的队头阻塞问题比 WebSocket 好太多。但现实很骨感,截至我写这篇文章,它的浏览器兼容性还是个坎,而且 Node.js 的生态库还没完全跟上。

真实场景下的选型决策

我们当时的订单系统在大促时(QPS 峰值 1.2万),需要实时更新订单状态。我做了个简单的压测对比:

最终我决定:订单状态流用 SSE,客服聊天用 WebSocket

为什么这么分?因为订单状态是单向数据流,SSE 自带的重连机制能帮我省去很多处理断线的代码。而客服聊天需要双向交互,WebSocket 是刚需。

代码示例:SSE 与 WebSocket 的共存

这是我们在 NestJS 里实现的一个简化版通知网关,展示了如何根据路径区分两种协议。注意看鉴权部分,这是我在一次安全审计中补上的。

import { Controller, Get, Query, Res, Sse } from '@nestjs/common'; import { Response } from 'express'; import { Observable, interval } from 'rxjs'; import { map } from 'rxjs/operators'; import { WebSocketGateway, WebSocketServer, OnGatewayConnection, OnGatewayDisconnect } from '@nestjs/websockets'; import { Server, Socket } from 'socket.io'; import * as jwt from 'jsonwebtoken'; // SSE 控制器:用于订单状态更新 @Controller('events') export class SseController { // 这是一个订单状态的 SSE 端点 @Get('order-status') sseOrderStatus(@Query('token') token: string, @Res() res: Response) { // 鉴权:我在这里吃过亏,以前没鉴权,导致任何人都能订阅订单状态 try { const payload = jwt.verify(token, process.env.JWT_SECRET); // 设置 SSE 头 res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); // 模拟每3秒推送一次库存变化 const intervalMs = 3000; const intervalId = setInterval(() => { // 实际项目中这里会监听 Redis Pub/Sub 或者数据库变更 const data = JSON.stringify({ orderId: 123, status: 'SHIPPED', time: new Date().toISOString() }); res.write(`data: ${data}\n\n`); }, intervalMs); // 客户端断开时清理 res.on('close', () => { clearInterval(intervalId); res.end(); }); } catch (err) { res.status(401).json({ message: 'Unauthorized' }); } } } // WebSocket 网关:用于客服聊天 @WebSocketGateway({ path: '/chat', cors: { origin: '*' } // 生产环境需配置具体域名 }) export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect { @WebSocketServer() server: Server; handleConnection(client: Socket) { // 连接时的鉴权,Socket.IO v4.7.4 支持在握手时传 token const token = client.handshake.auth.token; try { const user = jwt.verify(token, process.env.JWT_SECRET); console.log(`Client connected: ${user.id}`); // 绑定用户ID,方便后续定向推送 client.data.userId = user.id; } catch (e) { client.disconnect(); } } handleDisconnect(client: Socket) { console.log(`Client disconnected: ${client.id}`); } // 示例:广播消息 broadcastMessage(msg: string) { this.server.emit('new_message', { content: msg, timestamp: Date.now() }); } }

为什么不都用 WebSocket? 因为 SSE 在 Nginx 反向代理下的配置比 WebSocket 简单得多,而且对于只需要“服务器推”的场景,SSE 的 HTTP 兼容性更好,不容易被某些企业防火墙误杀。

5. 进阶优化:单机百万连接与AI流式输出场景实践

8年开发经验里,最让我头疼的不是写代码,而是那次线上接口突然变慢。当时我们的推送服务用了 ws v8.16.0,在单机 8核16G 的云主机上,连接数刚过 12万,CPU 就飙到了 90%,内存倒是没爆,但 GC(垃圾回收)频繁到让人怀疑人生。那时候我才知道,所谓的“百万连接”不是光靠 Node.js 默认配置就能跑起来的。

后来我接手了一个 AI 实时对话的项目,需要把 LLM 生成的文字像打字机一样推送给前端。这又是一个新挑战:WebSocket 连接数可能没那么大,但每条连接的数据吞吐频率极高,而且必须保证顺序。

单机百万连接的优化实战

我排查下来发现,瓶颈主要在两个方面:一是 Linux 系统的文件描述符限制,二是 Node.js 的 ws 库默认配置没有针对大连接数优化。

第一步:系统调优

这是硬指标。Linux 默认的文件描述符(File Descriptor)限制是 1024,连 1万 连接都撑不住。我直接修改了 /etc/security/limits.conf

* soft nofile 1000000 * hard nofile 1000000

同时调整内核参数 sysctl.conf,增加 TCP 缓冲区:

net.ipv4.tcp_mem = 786432 2097152 3145728 net.ipv4.tcp_rmem = 4096 4096 16777216 net.ipv4.tcp_wmem = 4096 4096 16777216

不这么做的话,系统会在连接数达到 3万 左右时报 EMFILE 错误。

第二步:Node.js 内存与心跳优化

在代码层面,我关闭了 ws 库自带的自动 ping/pong,改成了应用层的心跳,因为默认的心跳逻辑在大量连接下会产生很多小对象,加重 GC 负担。

const WebSocket = require('ws'); const http = require('http'); const fs = require('fs'); // 关键配置:我在生产环境验证过的参数 const server = http.createServer(); const wss = new WebSocket.Server({ server, // 1. 关闭自带的 ping,自己控制心跳逻辑 clientTracking: false, // 2. 增加缓冲区大小,应对突发流量 maxPayload: 1024 * 1024, // 1MB // 3. 调整背压控制,防止慢客户端拖垮服务器 perMessageDeflate: { zlibDeflateOptions: { chunkSize: 1024, memLevel: 7, level: 3 }, zlibInflateOptions: { chunkSize: 10 * 1024 }, clientNoContextTakeover: true, serverNoContextTakeover: true, serverMaxWindowBits: 10, concurrencyLimit: 10, } }); // 存储连接,使用 Set 而不是数组,查找更快 const clients = new Set(); wss.on('connection', (ws) => { clients.add(ws); // 自定义心跳:每 30 秒发一次,超时 10 秒断开 ws.isAlive = true; ws.on('pong', () => { ws.isAlive = true; }); ws.on('close', () => { clients.delete(ws); }); }); // 全局心跳检测,避免为每个连接单独设置定时器 const interval = setInterval(() => { // 这里我用了 for...of 而不是 forEach,因为 forEach 无法处理删除逻辑 for (const ws of clients) { if (ws.isAlive === false) { clients.delete(ws); ws.terminate(); continue; } ws.isAlive = false; ws.ping(); } }, 30000); server.listen(8080, () => { console.log('Server started on port 8080'); console.log(`Current FD limit: ${process.stdout.fd}`); });

经过这轮优化,那台 16G 内存的机器,连接数稳定在 45万 左右时,内存占用约 12G,CPU 空闲率保持在 20% 以上。如果再往上,就需要考虑边缘计算节点(如 Cloudflare Workers)来分担压力了。

AI 流式输出场景的特殊处理

在做 AI 对话功能时,我遇到了另一个问题。LLM 返回的数据是流式的,如果直接把每一个 token 都通过 WebSocket 发出去,会产生大量的网络帧。

问题现象:前端收到的字是跳动的,而且网络请求数暴增。

原因分析:WebSocket 虽然头部小,但每一个 send() 调用都会产生一次系统调用。如果 LLM 每秒生成 20 个 token,那就是每秒 20 次 send()

解决方案:我在服务端做了一个简单的缓冲队列

// 模拟 AI 流式输出处理 function streamAIResponse(ws, aiStream) { let buffer = ''; let timer = null; const FLUSH_INTERVAL = 100; // 100ms 刷新一次,而不是每个字都发 const MAX_BUFFER_SIZE = 50; // 或者攒够 50 个字就发 // 清除之前的定时器 if (timer) clearInterval(timer); timer = setInterval(() => { if (buffer.length > 0) { // 发送缓冲的数据 if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'text', data: buffer })); } buffer = ''; } }, FLUSH_INTERVAL); // 假设 aiStream 是一个 Readable Stream aiStream.on('data', (chunk) => { const text = chunk.toString(); buffer += text; // 如果缓冲区满了,强制发送 if (buffer.length >= MAX_BUFFER_SIZE) { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'text', data: buffer })); } buffer = ''; } }); aiStream.on('end', () => { clearInterval(timer); // 发送剩余数据 if (buffer.length > 0 && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'text', data: buffer })); } ws.send(JSON.stringify({ type: 'end' })); }); }

这个策略把网络交互从“逐字传输”变成了“批量传输”。实测下来,原本每秒 20 次的 send 降到了每秒 2-3 次,前端渲染的卡顿感也消失了。

现在的趋势是结合 WebTransport 来处理这种高吞吐的流式数据,但在目前(2024年),WebSocket 配合这种缓冲策略依然是兼容性最好、成本最低的方案。如果你正在做类似 ChatGPT 那种逐字输出的效果,一定要考虑服务端的发送频率控制,否则你的服务器带宽会被这些小包耗尽。

站长实战手记

一个真实的线上事故

去年我接手了一个在线教育平台的重构,业务很简单:老师上课,学生实时看到白板和聊天。当时我脑子一热,觉得 WebSocket 这么成熟,直接全上不就完了?

我选了 Node.js + ws 库,单机部署,跑得挺顺。结果上线第三天,下午三点上课高峰期,服务直接假死。日志里全是 EADDRNOTAVEDEMFILE,连接数卡在 1024 就上不去了。

我当时以为是代码问题,折腾了半天的事件循环优化,后来才发现是 Linux 系统默认的文件描述符限制没改。改完 /etc/security/limits.confnofile 调大后,连接是上去了,但内存又爆了。我那时候没做心跳检测,很多客户端断网了也没发关闭帧,导致 TCP 连接半开,内存被这些僵尸连接吃光。

最后我是这么解决的:

* 调整了系统内核参数,支持更大的文件句柄。

* 在 ws 库里加了 心跳 Ping/Pong,超过 30 秒没反应的连接直接强制断开。

* 把 Nginx 的 proxy_read_timeout 调大,防止代理层误杀长连接。

优化完,单机扛住了 6 万并发,虽然没到百万那么夸张,但对于那个业务场景已经绰绰有余了。

我的真实看法

讲真,不是所有地方都需要 WebSocket。

如果你的需求只是“服务器推消息”,比如股票行情或者新闻推送,其实 SSE (Server-Sent Events) 更省心,它是基于 HTTP 的,穿透性好,代码也简单。

我踩过的坑就是:千万别为了“实时”而实时。有些后台管理系统,用个简单的短轮询(比如 5 秒一次)完全够用,引入 WebSocket 反而增加了连接管理和重连逻辑的复杂度,那是给自己找麻烦。

给读者的建议

学这个技术,别光看文档里的 onopenonmessage一定要亲手去抓包看看,看看握手那次 HTTP 请求长什么样,看看心跳包是怎么在 TCP 层跑的。只有当你亲手处理过一次“连接无故断开”的问题,你才算真正理解了长连接。