首页
/ Socket.IO 集群实战:用 httpd 反向代理 + Redis 适配器实现多节点负载均衡聊天应用

Socket.IO 集群实战:用 httpd 反向代理 + Redis 适配器实现多节点负载均衡聊天应用

2026-09-03 21:52:59作者:曹令琨Iris

本文基于 Socket.IO 官方仓库中的 cluster-httpd 示例,完整讲解如何用 Apache httpd 的反向代理能力(负载均衡 + Cookie 粘滞会话)将 4 个 Socket.IO 节点组成集群,并借助 socket.io-redis 适配器实现跨节点广播。读完后,你将掌握一套可直接复制的 docker-compose 部署方式、可落地的 httpd.conf 负载均衡配置(含粘滞会话、WebSocket 路由、代理超时三大关键参数),以及节点故障后客户端自动重连的验证方法。

示例整体架构

该示例是一个运行在 httpd 与 Redis 之上的 Socket.IO 聊天 Demo,架构由三层组成:

  • 接入层httpd:2.4-alpine 容器作为反向代理,对 HTTP 长轮询和 WebSocket 两类流量分别做负载均衡,并通过 Cookie 实现粘滞会话(sticky sessions);
  • 应用层:4 个完全对等的 Socket.IO 节点(在 docker-compose.yml 中命名为 server-johnserver-paulserver-georgeserver-ringo,对应环境变量 NAME=John/Paul/George/Ringo),每个节点都是独立的 Express + Socket.IO 服务器;
  • 状态层:一个 redis:6 容器,作为所有节点共享的适配器后端,使任意节点上的事件都能广播到连接在其他节点上的所有客户端。

官方 README 对架构的描述是:httpd 代理负责把请求负载均衡到 4 个 Socket.IO 节点(借助 Cookie 实现粘滞会话);每个节点连接到 Redis 后端,从而保证无论客户端连接在哪个节点上,广播都能到达所有客户端。

之所以必须引入粘滞会话,其根源在于 Socket.IO 的心跳机制:连接状态由服务端和客户端两侧设置的定时器维持,超时参数(pingIntervalpingTimeout)在连接握手时共享,这两个定时器要求客户端后续的请求必须被路由到同一台服务器。官方文档在 Readme 中明确说明了这一约束。而在引擎层,engine.io 服务器 的默认值为 pingTimeout: 20000(20 秒)、pingInterval: 25000(25 秒)——这也是后文 httpd 代理超时配置的依据。

运行示例

前提:安装 Docker Compose(示例 README 原文引用的安装方式),然后执行:

$ docker-compose up -d

启动后将浏览器指向 http://localhost:3000。此时会拉起 5 个容器:1 个 httpd 代理 + 4 个 Socket.IO 节点 + 1 个 Redis。

示例还内置了故障演练方法——直接杀掉某个节点,观察客户端自动重连到其余节点:

# 你可以杀掉任意一个节点,客户端应当重连到其他节点
$ docker-compose stop server-george

由于粘滞会话 Cookie 的存在,当 server-george 停止后,httpd 的 balancer 会将其标记为不可用,客户端重连请求会被路由到其余存活节点;同时客户端会重新走完整的 Socket.IO 握手流程。这一点由客户端代码中的重连逻辑保证,见下文 server/public/main.js

核心配置剖析:httpd.conf

httpd.conf 是本示例的精髓,整个文件不到 60 行,但覆盖了集群化 Socket.IO 服务的三个关键技术点。

模块清单与双 balancer:区分长轮询与 WebSocket

配置首先加载负载均衡所需的模块:

LoadModule lbmethod_byrequests_module   modules/mod_lbmethod_byrequests.so
LoadModule proxy_module                 modules/mod_proxy.so
LoadModule proxy_balancer_module        modules/mod_proxy_balancer.so
LoadModule proxy_http_module            modules/mod_proxy_http.so
LoadModule proxy_wstunnel_module        modules/mod_proxy_wstunnel.so
LoadModule rewrite_module               modules/mod_rewrite.so

其中 mod_proxy_wstunnel 用于把 WebSocket 升级流量隧道转发到后端,mod_lbmethod_byrequests 指定按请求数做均衡。

Socket.IO 客户端默认先发起 HTTP 请求(长轮询/握手),之后可能升级为 WebSocket。两种流量走的是不同协议,因此配置中定义了两个独立的 balancerhttpd.conf):

<Proxy "balancer://nodes_polling">
    BalancerMember "http://server-john:3000"    route=john
    BalancerMember "http://server-paul:3000"    route=paul
    BalancerMember "http://server-george:3000"  route=george
    BalancerMember "http://server-ringo:3000"   route=ringo
    ProxySet stickysession=SERVERID
</Proxy>

<Proxy "balancer://nodes_ws">
    BalancerMember "ws://server-john:3000"    route=john
    BalancerMember "ws://server-paul:3000"    route=paul
    BalancerMember "ws://server-george:3000"  route=george
    BalancerMember "ws://server-ringo:3000"   route=ringo
    ProxySet stickysession=SERVERID
</Proxy>

两个 balancer 的成员一一对应,且每个 BalancerMember 都带有唯一的 route 标识(john/paul/george/ringo)。这个 route 值正是粘滞会话 Cookie 的取值来源:一旦某个客户端被分到 route=george 的成员,后续请求都会带上 SERVERID=sticky.george,httpd 便据此持续将该客户端路由到 server-george,保证握手、心跳与业务事件落在同一节点。

粘滞会话:SERVERID Cookie 的注入

Cookie 并非由应用自己设置,而是由 httpd 在路由切换发生的那一刻通过响应头自动注入(httpd.conf):

Header add Set-Cookie "SERVERID=sticky.%{BALANCER_WORKER_ROUTE}e; path=/" env=BALANCER_ROUTE_CHANGED

这里利用了 mod_proxy_balancer 的环境变量机制:%{BALANCER_WORKER_ROUTE}e 取当前被选中成员的 route 值,env=BALANCER_ROUTE_CHANGED 条件则确保仅在路由发生变化时才追加该头。path=/ 保证 Cookie 对该站点所有路径生效——这一点很关键,因为 Socket.IO 的握手、心跳轮询和 WebSocket 升级都发生在同一前缀下,粘滞 Cookie 必须对它们全部可见。

路由规则:按 Upgrade 头分发到不同 balancer

流量分发由 mod_rewrite 根据请求头完成(httpd.conf):

RewriteEngine On
RewriteCond %{HTTP:Upgrade} =websocket [NC]
RewriteRule /(.*) balancer://nodes_ws/$1 [P,L]
RewriteCond %{HTTP:Upgrade} !=websocket [NC]
RewriteRule /(.*) balancer://nodes_polling/$1 [P,L]

逻辑非常直白:带 Upgrade: websocket(大小写不敏感)的请求进入 nodes_ws balancer,走 ws:// 隧道转发;其余所有请求(包括 /socket.io/?EIO=4&transport=polling 的握手与心跳轮询、静态页面)进入 nodes_polling balancer。[P,L] 标志分别表示以代理方式转发和终止规则匹配。

代理超时:必须覆盖心跳窗口

配置最后一行(httpd.conf):

# must be bigger than pingInterval (25s by default) + pingTimeout (20s by default)
ProxyTimeout 60

ProxyTimeout 控制 httpd 与后端之间空闲连接的保持时长。若它小于 Socket.IO 的心跳间隔,代理会在心跳到来之前主动切断与后端的空闲连接,导致长轮询与 WebSocket 频繁中断。因此配置值 60 秒必须大于 pingInterval(默认 25s)加 pingTimeout(默认 20s),即 45 秒——这与 engine.io 服务端默认值 中的 pingInterval: 25000pingTimeout: 20000 完全对应。如果你在服务端自定义了心跳参数,这一行的取值也需要同步调整。

服务编排剖析:docker-compose.yml

docker-compose.yml 定义了 5 个服务,要点如下:

services:
  httpd:
    image: httpd:2.4-alpine
    volumes:
      - ./httpd.conf:/usr/local/apache2/conf/httpd.conf:ro
    links:
      - server-john
      - server-paul
      - server-george
      - server-ringo
    ports:
      - "3000:80"

  server-john:
    build: ./server
    links:
      - redis
    expose:
      - "3000"
    environment:
      - NAME=John
  # server-paul / server-george / server-ringo 结构相同

  redis:
    image: redis:6
    expose:
      - "6379"

几个设计细节值得注意:

  • 端口映射只在代理层暴露:4 个节点服务都用 expose: ["3000"] 而非 ports:,即它们只存在于容器网络内部,外部流量唯一入口是 httpd 的 3000:80。这强制所有连接必须经过负载均衡与粘滞会话机制,避免了绕过代理直连节点带来的会话不一致问题。
  • links 提供服务名解析:httpd 通过 server-john 等服务名访问后端(与 httpd.conf 中 BalancerMember 的 URL 完全一致),节点则通过服务名 redis 访问 Redis(见下文 server/index.js 中的 host: 'redis')。
  • NAME 环境变量:为每个节点注入唯一身份(John/Paul/George/Ringo),用于在聊天界面展示客户端当前实际连接在哪个节点上,便于直观验证负载均衡的分布效果。
  • 构建方式:节点镜像由 server/Dockerfile 构建——基于 node:14-alpine,先 npm install --prod 安装 package.json 声明的依赖(expresssocket.io ^4.0.0socket.io-redis ^6.0.1),再拷贝源码,EXPOSE 3000 后以 npm start(即 node index.js)启动。

Socket.IO 节点实现:Redis 适配器与节点身份

一行代码接入 Redis 适配器

每个节点的服务端逻辑在 server/index.js 中,集群相关的核心只有一行(server/index.js):

var io = require('socket.io')(server);
var redis = require('socket.io-redis');

io.adapter(redis({ host: 'redis', port: 6379 }));

io.adapter() 将默认的内存适配器替换为 socket.io-redis 适配器,所有 emit/broadcast 不再只在本进程内分发,而是经由 Redis 转发到其余节点,由对应节点再下发给本地连接的客户端。这正是 README 所说"Each node connects to the redis backend, which will enable to broadcast to every client, no matter which node it is currently connected to"的实现基础。host: 'redis' 是 compose 服务名,port: 6379 为 Redis 默认端口,两者在 docker-compose.yml 中均有对应定义。

让客户端知道自己连在哪个节点

节点在连接建立时立即向该客户端回发自身身份(server/index.js):

io.on('connection', function (socket) {
  socket.emit('my-name-is', serverName);
  ...

配合 NAME 环境变量(var serverName = process.env.NAME || 'Unknown'),客户端即可在界面上打印"当前连接的节点",这是验证负载均衡分布的最直接手段。

聊天事件模型

节点处理 5 类事件(server/index.js):

客户端 → 服务端 服务端 → 客户端 说明
add user loginuser joined 记录用户名,广播加入通知
new message new message 聊天消息,socket.broadcast.emit 广播给其他客户端
typing / stop typing typing / stop typing "正在输入"提示
disconnect user left 断开时广播离开通知

其中 login 事件会携带当前节点本地的 numUsers 计数,这也是理解本示例的一个边界:numUsers 是各节点进程内的独立变量,不同节点上的计数并不相等;真正跨节点一致的语义(如"某人加入了聊天室")则依赖 Redis 适配器完成广播。

客户端:重连与节点身份展示

浏览器端由 server/public/index.htmlserver/public/main.js 组成,直接加载 Express 静态目录下由 Socket.IO 自动挂载的 /socket.io/socket.io.js。两个与集群强相关的逻辑:

重连后重新注册用户名main.js):

socket.on('reconnect', function () {
  log('you have been reconnected');
  if (username) {
    socket.emit('add user', username);
  }
});

当节点被停掉或网络中断时,Socket.IO 客户端会自动重连。由于重连很可能落在另一个节点上(新节点上没有该用户的会话状态),客户端必须在 reconnect 时重新发出 add user,恢复自己的用户名与在线状态。

节点身份提示main.js):

socket.on('my-name-is', function (serverName) {
  log('host is now ' + serverName);
})

对应服务端的 my-name-is 事件,在聊天窗口打印 host is now George 之类的日志。执行 docker-compose stop server-george 后,你会看到受影响的用户打印重连日志,并切换到新的节点名——这就是 README 所述故障演练的完整闭环。

功能特性与验证步骤

README 列出的功能特性为:

  • 多个用户可在页面加载时各自输入唯一用户名加入聊天室;
  • 用户可向聊天室发送消息;
  • 有用户加入或离开聊天室时,会向所有用户发送通知。

对照仓库文件,一套完整的验证流程是:

  1. docker-compose up -d 启动全部容器,确认 httpd 与 4 个节点均处于运行状态(docker-compose ps);
  2. 浏览器打开 http://localhost:3000,输入用户名进入聊天室,观察日志中的节点名(John/Paul/George/Ringo 之一);
  3. 用多个浏览器窗口/隐身窗口分别以不同用户名加入,验证 user joined/user left 通知与消息广播在所有窗口一致到达——这一步验证 Redis 适配器跨节点广播;
  4. 执行 docker-compose stop server-george,观察已连在 George 节点上的窗口打印 you have been disconnectedyou have been reconnected,并重新显示新的 host is now ...
  5. docker-compose start server-george 将其恢复,观察 balancer 重新纳入该节点。

关键文件索引

文件 作用
examples/cluster-httpd/README.md 示例说明、启动命令与故障演练方法
examples/cluster-httpd/docker-compose.yml 5 容器编排:httpd 代理 + 4 节点 + Redis
examples/cluster-httpd/httpd.conf 双 balancer、SERVERID 粘滞 Cookie、WebSocket 路由、ProxyTimeout
examples/cluster-httpd/server/index.js 节点服务端:socket.io-redis 适配器、聊天事件
examples/cluster-httpd/server/Dockerfile 节点镜像构建(node:14-alpine)
examples/cluster-httpd/server/package.json 依赖清单:express、socket.io ^4.0.0、socket.io-redis ^6.0.1
examples/cluster-httpd/server/public/main.js 客户端:重连重注册、节点身份展示
packages/socket.io/Readme.md 官方对 sticky-session 约束的说明
packages/engine.io/lib/server.ts pingInterval/pingTimeout 默认值出处

需要说明的适用前提:本示例是教学性质的最小集群方案,节点数为 4 且写死在 httpd.confBalancerMember 中;socket.io-redis 为独立于主仓库的第三方适配器包,其依赖版本以 server/package.json 中声明的 ^6.0.1 为准。对于生产环境,可以基于同一套"代理粘滞会话 + 共享广播后端"的思路,参考仓库中 cluster-nginxcluster-haproxycluster-traefik 等同类示例替换接入层,或用仓库内维护的 socket.io-cluster-adaptersocket.io-cluster-engine 等包替换广播层。

登录后查看全文
热门项目推荐
相关项目推荐