多进程环境下使用 PM2 部署 Socket.IO:需要注意什么

在 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,关键不是单纯增加实例数,而是处理好连接分发、跨进程广播、状态共享和进程重启。

上线前至少确认以下事项:

  1. 了解 PM2 的 cluster 模式是多进程,而不是共享内存的多线程。
  2. 多进程房间和广播已配置合适的 Socket.IO Adapter。
  3. 使用 HTTP 长轮询时,已验证连接粘性配置。
  4. 重要在线状态和业务数据没有只保存在单个进程内存中。
  5. 代理支持 WebSocket,并配置了合适的超时时间。
  6. 客户端具备断线重连、重新鉴权和恢复订阅的能力。
  7. 已在多进程和生产代理链路下完成压测与故障测试。

做到这些,PM2 才能在提升 Socket.IO 服务并发能力的同时,避免因进程隔离和连接调度造成消息丢失或连接异常。

相关推荐
卸任7 小时前
Vibe coding工具使用心得
前端·ai编程
brien97 小时前
为什么模型标称 1M,Google Antigravity 聊到 256K 却突然失忆?我们给它做了个原生行内监控圆环
前端
修炼的dance8 小时前
录屏关键帧间隔多少合适?六档实测,2秒到10秒小了75.6%
前端
Lank_M8 小时前
关键帧从1秒拉到10秒,录屏小87.3%,摄像头小10.8%
前端
补码缩写8 小时前
用 ffprobe 拆录屏,八成字节在 29 个关键帧上
前端
百思不得小李8 小时前
一个复杂的动态表单是如何实现的
前端·设计模式
陈随易8 小时前
Cmder 停更 17 个月,2.0 支持 Windows Terminal
前端·后端·程序员
风骏时光牛马8 小时前
前沿视界
前端
kyriewen8 小时前
我扫了 814 个依赖,97 个还敢要 Node 10
前端·javascript·程序员
陈随易9 小时前
3000 万人在用的翻译插件,重写一遍就是一门生意
前端·后端·程序员