Socket.IO 集群实战:用 httpd 反向代理 + Redis 适配器实现多节点负载均衡聊天应用
本文基于 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-john、server-paul、server-george、server-ringo,对应环境变量NAME=John/Paul/George/Ringo),每个节点都是独立的 Express + Socket.IO 服务器; - 状态层:一个
redis:6容器,作为所有节点共享的适配器后端,使任意节点上的事件都能广播到连接在其他节点上的所有客户端。
官方 README 对架构的描述是:httpd 代理负责把请求负载均衡到 4 个 Socket.IO 节点(借助 Cookie 实现粘滞会话);每个节点连接到 Redis 后端,从而保证无论客户端连接在哪个节点上,广播都能到达所有客户端。
之所以必须引入粘滞会话,其根源在于 Socket.IO 的心跳机制:连接状态由服务端和客户端两侧设置的定时器维持,超时参数(pingInterval 和 pingTimeout)在连接握手时共享,这两个定时器要求客户端后续的请求必须被路由到同一台服务器。官方文档在 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。两种流量走的是不同协议,因此配置中定义了两个独立的 balancer(httpd.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: 25000、pingTimeout: 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 声明的依赖(express、socket.io ^4.0.0、socket.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 |
login、user joined |
记录用户名,广播加入通知 |
new message |
new message |
聊天消息,socket.broadcast.emit 广播给其他客户端 |
typing / stop typing |
typing / stop typing |
"正在输入"提示 |
disconnect |
user left |
断开时广播离开通知 |
其中 login 事件会携带当前节点本地的 numUsers 计数,这也是理解本示例的一个边界:numUsers 是各节点进程内的独立变量,不同节点上的计数并不相等;真正跨节点一致的语义(如"某人加入了聊天室")则依赖 Redis 适配器完成广播。
客户端:重连与节点身份展示
浏览器端由 server/public/index.html 与 server/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 列出的功能特性为:
- 多个用户可在页面加载时各自输入唯一用户名加入聊天室;
- 用户可向聊天室发送消息;
- 有用户加入或离开聊天室时,会向所有用户发送通知。
对照仓库文件,一套完整的验证流程是:
docker-compose up -d启动全部容器,确认 httpd 与 4 个节点均处于运行状态(docker-compose ps);- 浏览器打开
http://localhost:3000,输入用户名进入聊天室,观察日志中的节点名(John/Paul/George/Ringo 之一); - 用多个浏览器窗口/隐身窗口分别以不同用户名加入,验证
user joined/user left通知与消息广播在所有窗口一致到达——这一步验证 Redis 适配器跨节点广播; - 执行
docker-compose stop server-george,观察已连在 George 节点上的窗口打印you have been disconnected→you have been reconnected,并重新显示新的host is now ...; 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.conf 的 BalancerMember 中;socket.io-redis 为独立于主仓库的第三方适配器包,其依赖版本以 server/package.json 中声明的 ^6.0.1 为准。对于生产环境,可以基于同一套"代理粘滞会话 + 共享广播后端"的思路,参考仓库中 cluster-nginx、cluster-haproxy、cluster-traefik 等同类示例替换接入层,或用仓库内维护的 socket.io-cluster-adapter、socket.io-cluster-engine 等包替换广播层。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00