多进程环境下使用 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 服务并发能力的同时,避免因进程隔离和连接调度造成消息丢失或连接异常。

相关推荐
IT_陈寒1 小时前
Redis的原子性竟然失效了?记一次分布式锁翻车实录
前端·人工智能·后端
时间的情敌3 小时前
《回顾 2018 前端:Vue2 成熟期,Webpack4 发布,PWA 大火》
前端
Java后端的Ai之路3 小时前
前端技术Vue-09-webpack-vuejs-实战
前端·vue.js·webpack
LMT流氓兔3 小时前
第五单元——第4课:给工具参数加校验
java·前端·算法·ai·langchain
涛涛ing3 小时前
React Compiler正式“Rust化”:你项目里的Babel插件,正在被编译进打包器
前端
迷迭绿3 小时前
Cesium 轨迹回放播放器
前端·javascript·cesium
HolySaga4 小时前
我用 UTS 写了一个三端同构的蓝牙插件(Android / iOS / 鸿蒙 NEXT),把踩过的坑都讲给你听
前端
程序员老赵4 小时前
Docker 部署 XWiki:轻松搭建企业知识库与协作 Wiki
前端·docker·开源
console.log('npc')4 小时前
第 10 章 更多业务模块扩展
前端·人工智能·大模型