在 Node.js 项目中,Socket.IO 常用于实现实时通信,例如聊天室、消息推送、在线状态和协同操作。当服务部署到多核机器上时,通常会使用 PM2 的 cluster 模式启动多个进程,以提高 CPU 利用率和并发处理能力。
但 Socket.IO 与普通 HTTP 接口不同:它需要维护长连接,并且连接、房间和客户端状态通常保存在进程内存中。因此,单进程可用的实现,在 PM2 多进程环境中未必仍然正确。
本文介绍多进程部署 Socket.IO 时的关键注意事项。
一、先区分"多线程"和"多进程"
Node.js 主线程运行 JavaScript 代码时,通常依赖事件循环处理请求。PM2 的 cluster 模式并不是让一个 Node.js 实例变成多个 JavaScript 线程,而是启动多个独立的 Node.js 进程:
markdown
PM2
├── Node.js 进程 1
├── Node.js 进程 2
└── Node.js 进程 3
每个进程都有自己的内存、Socket.IO 实例和连接状态。因此:
- 进程 A 中的
io.emit(),默认不会自动通知进程 B 上的客户端。 - 进程 A 中创建的房间,进程 B 默认并不知道。
- 进程 A 中的在线用户列表,不等于全局在线用户列表。
- 客户端的连接可能被分配到不同进程。
理解这一点,是正确部署 Socket.IO 的基础。
二、PM2 开启 cluster 模式后,连接可能分散到多个进程
例如使用 PM2 启动多个实例:
arduino
pm2 start app.js -i max
或者配置 ecosystem.config.js:
css
module.exports = {
apps: [
{
name: "socket-server",
script: "./app.js",
exec_mode: "cluster",
instances: "max"
}
]
};
PM2 会启动多个应用进程。不同客户端的连接可能进入不同进程:
css
客户端 A → 进程 1
客户端 B → 进程 2
客户端 C → 进程 1
如果不处理进程间通信,就可能出现以下问题:
- A、B 在同一个聊天室,但彼此收不到消息。
- 某个进程认为用户在线,另一个进程却认为用户不在线。
- 一个进程触发广播,只有连接在该进程上的客户端收到消息。
三、使用 Socket.IO Adapter 实现跨进程广播
Socket.IO 的 Adapter 用于管理连接、房间以及广播。多进程部署时,通常需要使用共享 Adapter,让各个进程通过外部存储或消息系统交换事件。
常见选择:
- Redis Adapter:常见方案,适合大多数部署场景。
- Redis Streams Adapter:适合需要更强消息持久性等能力的场景,具体取决于业务和版本要求。
- 其他支持 Socket.IO 的 Adapter:按基础设施选型。
以 Redis Adapter 为例,安装依赖:
bash
npm install @socket.io/redis-adapter redis
示例代码:
javascript
const http = require("http");
const { Server } = require("socket.io");
const { createClient } = require("redis");
const { createAdapter } = require("@socket.io/redis-adapter");
const server = http.createServer();
const io = new Server(server, {
cors: {
origin: ["https://example.com"],
credentials: true
}
});
async function start() {
const pubClient = createClient({
url: process.env.REDIS_URL
});
const subClient = pubClient.duplicate();
await Promise.all([
pubClient.connect(),
subClient.connect()
]);
io.adapter(createAdapter(pubClient, subClient));
io.on("connection", (socket) => {
socket.on("join-room", (roomId) => {
socket.join(roomId);
});
socket.on("send-message", ({ roomId, message }) => {
io.to(roomId).emit("new-message", message);
});
});
server.listen(process.env.PORT || 3000);
}
start().catch((err) => {
console.error("Socket.IO 启动失败:", err);
process.exit(1);
});
使用共享 Adapter 后,一个进程发出的房间广播可以被其他进程上的相关客户端收到。
Redis Adapter 解决的是进程间广播和房间协同问题,不等同于连接粘性,也不自动解决所有业务状态的一致性问题。
四、注意连接粘性:尤其是启用了 HTTP 长轮询时
Socket.IO 通常会先使用 HTTP 长轮询建立连接,再尝试升级到 WebSocket。如果同一个客户端的轮询请求被负载均衡到不同进程,服务端可能无法识别原来的会话,从而导致连接失败或出现类似"Session ID unknown"的错误。
因此:
- 启用 HTTP 长轮询时,通常需要确保同一客户端的请求始终到达同一个进程。
- Redis Adapter 不能替代连接粘性。
- 只启用 WebSocket 传输时,不再存在同一轮询会话的跨请求分发问题,但要确认客户端和代理都支持 WebSocket,并接受无法回退到长轮询的影响。
例如,强制只使用 WebSocket:
vbscript
const io = new Server(server, {
transports: ["websocket"]
});
这可能简化负载均衡配置,但降低了客户端处于受限网络时的兼容性。是否这么做,应结合实际网络环境测试。
若要保留长轮询,需要根据部署方式配置会话粘性,例如:
- 在负载均衡器或反向代理上配置粘性会话。
- 使用经过验证的 Socket.IO 集群方案。
- 确认 PM2、Node.js 版本及所采用的负载均衡方式确实满足粘性要求。
**不要默认认为 PM2 的 cluster 模式会自动为 Socket.IO 提供可靠的会话粘性。**应在实际部署链路中验证。
五、不要把业务状态只放在进程内存中
下面这种写法只在当前进程内有效:
ini
const onlineUsers = new Map();
如果用户连接到了进程 1,而查询请求落在进程 2,那么进程 2 看不到进程 1 的 onlineUsers。
对于需要跨进程共享的数据,应考虑使用:
- Redis、数据库或其他共享存储;
- 带过期时间的在线状态;
- 心跳与断线清理机制;
- 可重复执行的状态更新逻辑。
还要注意:Socket.IO 断线事件可能因网络中断、进程重启或代理超时而触发。在线状态不宜只依赖单次 disconnect 事件来维护,可结合心跳、租约或过期时间设计。
六、PM2 重启和发布时要处理好连接断开
PM2 重启进程时,已有的 WebSocket 连接会断开。客户端需要具备自动重连和重连后的恢复逻辑,例如重新鉴权、重新加入房间、恢复订阅状态。
服务端应监听连接断开,并正确释放资源:
javascript
io.on("connection", (socket) => {
socket.on("disconnect", (reason) => {
console.log("客户端断开:", socket.id, reason);
});
});
如果应用需要优雅退出,可在进程收到退出信号时停止接收新连接、关闭 Socket.IO 和 HTTP 服务,并设置合理的退出时限。具体实现要与 PM2 的重启策略、负载均衡健康检查和部署流程配合,避免进程迟迟无法退出。
需要重点验证:
- 重启期间客户端是否能自动重连;
- 重连后是否能重新加入房间;
- 是否会产生重复订阅或重复处理;
- 发布期间是否允许短暂断线;
- 进程退出超时后,PM2 是否会强制结束进程。
七、代理和防火墙也必须支持 WebSocket
如果 Socket.IO 前面还有 Nginx、云负载均衡或 API 网关,需要确认代理正确处理 WebSocket 升级请求,并设置合理的空闲超时时间。
Nginx 配置示例:
ini
location /socket.io/ {
proxy_pass http://socket_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;
proxy_read_timeout 75s;
proxy_send_timeout 75s;
}
具体超时时间应与 Socket.IO 心跳配置、负载均衡器超时和业务连接时长相匹配。超时过短可能导致连接被代理提前关闭。
若启用了长轮询,还要同时确认代理支持相应的 HTTP 请求行为,并正确配置会话粘性。
八、鉴权、房间和广播需要统一设计
1. 连接鉴权
不要只依赖前端传来的用户 ID。服务端应校验可信凭证,例如 Token:
ini
io.use((socket, next) => {
const token = socket.handshake.auth?.token;
if (!token) {
return next(new Error("未授权"));
}
// 此处应实际验证 token 的签名、有效期和用户状态
socket.user = { id: "verified-user-id" };
next();
});
生产环境中,必须使用真正的 Token 验证逻辑,不能仅检查 Token 是否存在。
2. 房间名称和权限
加入房间前应验证用户是否有权限访问该房间。不能让客户端任意指定房间 ID 并直接加入。
3. 广播语义
明确区分:
kotlin
socket.emit("event", data); // 发送给当前 socket
socket.broadcast.emit("event"); // 发送给其他 socket
io.emit("event", data); // 发送给当前 Socket.IO 服务管理的所有连接
io.to("room").emit("event", data); // 发送给指定房间
接入跨进程 Adapter 后,广播范围通常可以跨进程,但仍要根据所用 Adapter 的能力和部署版本进行验证。
九、容量规划与监控不能忽略
每条长连接都会占用一定的内存、文件描述符和网络资源。PM2 实例数量不应简单理解为"越多越好"。
上线前建议压测并监控:
- 当前连接数及连接增长趋势;
- 每个进程的内存、CPU 和文件描述符;
- WebSocket 建立失败率、断线率和重连率;
- Redis 连接状态、延迟和错误;
- 消息吞吐量、广播耗时;
- PM2 重启次数和异常退出日志。
还应检查操作系统的文件描述符限制、负载均衡器连接上限和 Redis 容量。多进程能提升服务能力,但也会增加共享依赖、网络通信和运维复杂度。
十、常见误区
误区一:开了 PM2 cluster 就能自动共享房间
不行。进程内存互相隔离,需要使用 Socket.IO Adapter 等方案进行跨进程协调。
误区二:用了 Redis Adapter,就不需要粘性会话
不一定。Redis Adapter 负责跨进程协同,不保证 HTTP 长轮询请求始终进入同一进程。
误区三:在线用户存在内存里就够了
多进程、重启和扩容时,内存状态无法自然保持全局一致。重要状态应放在共享存储中,并设计过期与清理机制。
误区四:只在本地测试连接成功就可以上线
本地通常只有一个进程,也没有完整的代理和负载均衡链路。必须在多进程、真实代理和生产网络条件下测试。
结论
使用 PM2 多进程部署 Socket.IO,关键不是单纯增加实例数,而是处理好连接分发、跨进程广播、状态共享和进程重启。
上线前至少确认以下事项:
- 了解 PM2 的
cluster模式是多进程,而不是共享内存的多线程。 - 多进程房间和广播已配置合适的 Socket.IO Adapter。
- 使用 HTTP 长轮询时,已验证连接粘性配置。
- 重要在线状态和业务数据没有只保存在单个进程内存中。
- 代理支持 WebSocket,并配置了合适的超时时间。
- 客户端具备断线重连、重新鉴权和恢复订阅的能力。
- 已在多进程和生产代理链路下完成压测与故障测试。
做到这些,PM2 才能在提升 Socket.IO 服务并发能力的同时,避免因进程隔离和连接调度造成消息丢失或连接异常。