从HTTP轮询到WebSocket:某百万DAU社区的技术选型复盘
几个月前我接手了一个日活 120 万+ 的社区项目,核心功能是用户动态实时通知(点赞、评论、私信)。接手时系统还在用 HTTP 长轮询(Long Polling),高峰期服务器 CPU 经常飙到 90% 以上,接口平均响应耗时 800ms,用户投诉消息延迟严重。
当时长轮询的实现逻辑是:客户端每隔 3 秒发一次 GET /api/notifications?after=xxx,服务端如果没有新消息就阻塞请求直到超时(30 秒),有消息立即返回。看似“实时”,实际问题很大:
- 重复握手开销:每次请求都带完整的 HTTP Header(平均 1.2KB),3 秒一次轮询,单用户每分钟产生 20 次 HTTP 握手,120 万 DAU 就是 2400 万次/分钟,Nginx 连接数直接打满。
- 无效请求浪费:90% 的轮询请求返回 304 无新消息,但服务端还是要走一遍鉴权、查库逻辑,QPS 虚高到 1.2 万,实际有效消息推送仅 800 次/秒。
- 延迟不可控:即使服务端有消息,最长也要等 3 秒的轮询间隔,用户发评论后对方要等 5-8 秒才能看到通知。
我拉了 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.1 和 proxy_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%。
这次事故让我记住两个教训:
- Nginx 转发 WebSocket 必须显式配
Upgrade 和 Connection 头,不然就是普通 HTTP 代理,会把长连接当短连接处理。
- 永远不要相信默认超时配置,
proxy_read_timeout 对 WebSocket 来说就是定时炸弹,要么设成 24 小时(由应用层心跳管),要么直接关掉。
后来我们做了压测:用 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万),需要实时更新订单状态。我做了个简单的压测对比:
- WebSocket:建立连接后,单条消息开销极小(2-10字节头部),但如果只是服务器推消息,客户端维持一个心跳(ping/pong)也是开销。
- SSE:基于 HTTP/1.1 或 HTTP/2,如果是 HTTP/2 多路复用,性能不错。但它是文本协议,传输二进制数据(比如我们要推的图片缩略图)很麻烦。
- WebTransport:在模拟 30% 丢包率的网络下,消息到达速度比 WebSocket 快了约 40%,但前端代码改动成本极高。
最终我决定:订单状态流用 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 库,单机部署,跑得挺顺。结果上线第三天,下午三点上课高峰期,服务直接假死。日志里全是 EADDRNOTAVED 和 EMFILE,连接数卡在 1024 就上不去了。
我当时以为是代码问题,折腾了半天的事件循环优化,后来才发现是 Linux 系统默认的文件描述符限制没改。改完 /etc/security/limits.conf 把 nofile 调大后,连接是上去了,但内存又爆了。我那时候没做心跳检测,很多客户端断网了也没发关闭帧,导致 TCP 连接半开,内存被这些僵尸连接吃光。
最后我是这么解决的:
* 调整了系统内核参数,支持更大的文件句柄。
* 在 ws 库里加了 心跳 Ping/Pong,超过 30 秒没反应的连接直接强制断开。
* 把 Nginx 的 proxy_read_timeout 调大,防止代理层误杀长连接。
优化完,单机扛住了 6 万并发,虽然没到百万那么夸张,但对于那个业务场景已经绰绰有余了。
我的真实看法
讲真,不是所有地方都需要 WebSocket。
如果你的需求只是“服务器推消息”,比如股票行情或者新闻推送,其实 SSE (Server-Sent Events) 更省心,它是基于 HTTP 的,穿透性好,代码也简单。
我踩过的坑就是:千万别为了“实时”而实时。有些后台管理系统,用个简单的短轮询(比如 5 秒一次)完全够用,引入 WebSocket 反而增加了连接管理和重连逻辑的复杂度,那是给自己找麻烦。
给读者的建议
学这个技术,别光看文档里的 onopen 和 onmessage。一定要亲手去抓包看看,看看握手那次 HTTP 请求长什么样,看看心跳包是怎么在 TCP 层跑的。只有当你亲手处理过一次“连接无故断开”的问题,你才算真正理解了长连接。