下面是工作过程中,不断的优化 Socket.IO 的集群大小时的一些总结,本部分是关注的 Redis Adapter 的配置和跨服务器通信的优化。
1. Socket.IO 分布式架构概述
在多服务器部署中,Socket.IO 需要一个适配器来同步不同服务器实例之间的消息和状态。Redis Adapter 是最常用的解决方案,它使用 Redis 作为中间件来协调多个 Socket.IO 服务器实例。
基本架构图
客户端 A -----> 服务器实例 1 ----+
|
客户端 B -----> 服务器实例 2 ----+--> Redis <--+
| |
客户端 C -----> 服务器实例 3 ----+ |
|
v
跨服务器通信和状态同步
消息发送方式及本地/远程处理实现
这分析的就是 Socket.IO Redis Adapter 怎么样在多个服务器实例之间同步消息的适配器。以下是对不同消息发送方式的分析:
1. io.to(roomId).emit 消息发送机制
当使用 io.to(roomId).emit 发送消息时:
- 本地处理 :
- 首先检查本地是否有该 roomId 对应的连接
- 如果本地有该连接,会直接发送消息给本地客户端
- 远程处理 :
- 同时,消息会通过 Redis 发布到其他服务器实例
- 在
index.js中的 broadcast 方法负责这一过程
broadcast(packet, opts) {
packet.nsp = this.nsp.name;
const onlyLocal = opts && opts.flags && opts.flags.local;
if (!onlyLocal) {
const rawOpts = {
rooms: [...opts.rooms],
except: [...new Set(opts.except)],
flags: opts.flags,
};
const msg = this.parser.encode([this.uid, packet, rawOpts]);
let channel = this.channel;
if (opts.rooms && opts.rooms.size === 1) {
channel += opts.rooms.keys().next().value + "#";
}
debug("publishing message to channel %s", channel);
this.pubClient.publish(channel, msg);
}
super.broadcast(packet, opts);
}
2. socket.emit 消息发送机制 当使用 socket.emit 发送消息时:
- 这是一个直接发送给特定客户端的消息
- 不会通过 Redis 广播到其他服务器
- 因为 socket.emit 只发送给当前连接的客户端,所以不需要跨服务器通信
3. 房间和 Socket ID 的处理
关于 roomId 和房间的处理:
- 一个 Socket ID 通常只会在一台服务器上存在
- 在
sharded-adapter.js中,通过 looksLikeASocketId 函数判断房间名是否为 Socket ID:
function looksLikeASocketId(room) {
return typeof room === "string" && room.length === 20;
}
- 当消息发送到一个 Socket ID 对应的房间时,如果本地有该连接,理论上不需要广播到其他服务器
- 但是 Redis Adapter 默认会广播所有消息,除非设置了 local 标志
2. Redis Adapter 的关键配置选项
2.1 publishOnSpecificResponseChannel
这是一个重要的性能优化选项,特别是在大规模部署中。
作用 :当设置为 true 时,跨服务器请求的响应只会发送给发起请求的服务器实例,而不是所有服务器实例。
默认值 : false (响应会发送给所有服务器实例)
配置示例 :
import { createAdapter } from "@socket.io/redis-adapter";
import { pubClient, subClient } from "../config/redis.js";
// 创建 Socket.IO 服务器
const io = new Server({
cors: {
origin: '*',
},
});
// 配置 Redis Adapter 并启用优化选项
io.adapter(createAdapter(pubClient, subClient, {
publishOnSpecificResponseChannel: true
}));
2.2 默认行为与优化行为对比
默认行为(不启用 publishOnSpecificResponseChannel) :
- 服务器 A 执行 fetchSockets() 查询设备状态
- 服务器 B 和 C 收到请求,检查自己是否有匹配的 socket
- 服务器 B 找到了匹配的 socket,将结果发布到 Redis
- 服务器 A、B 和 C 都会收到这个响应
- 服务器 A 处理响应,B 和 C 接收到后会忽略它.
虽然这样也能正常工作,但流量比较大,然后 B 和 C 接收会还是不要这些信息。
在源代码中,默认通道是在构造函数中定义的:
const prefix = opts.key || "socket.io";
this.channel = prefix + "#" + nsp.name + "#";
this.requestChannel = prefix + "-request#" + this.nsp.name + "#";
this.responseChannel = prefix + "-response#" + this.nsp.name + "#";
this.specificResponseChannel = this.responseChannel + this.uid + "#";
默认情况下:
- 基本前缀是 "socket.io" (可通过 opts.key 自定义)
- 主通道格式为 socket.io#{namespace}#
- 请求通道格式为 socket.io-request#{namespace}#
- 响应通道格式为 socket.io-response#{namespace}#
启用 publishOnSpecificResponseChannel 情况下:
- 特定响应通道格式为 socket.io-response#{namespace}#{uid}#
优化行为(启用 publishOnSpecificResponseChannel) :
- 服务器 A 执行 fetchSockets() ,并在请求中包含自己的唯一标识符
- 服务器 B 和 C 收到请求,检查自己是否有匹配的 socket
- 服务器 B 找到了匹配的 socket, 只将结果发送回服务器 A
- 服务器 C 不会收到这个响应
两种模式的区别
不启用 publishOnSpecificResponseChannel(默认)
- 发布方式 :
- 所有响应消息都发布到同一个通用响应通道 ( this.responseChannel )
- 所有节点都会收到所有响应消息
- 过滤机制 :
- 每个节点接收到响应后,需要检查 requestId 来确定是否是发给自己的响应
- 在 onresponse 方法中有这样的过滤逻辑:
if (!requestId || !(this.requests.has(requestId) || this.ackRequests.has(requestId))) { debug("ignoring unknown request"); return; }
启用 publishOnSpecificResponseChannel
-
发布方式 :
- 响应消息发布到特定于请求节点的通道 ( {request.uid}# )
- 只有发出请求的节点会接收到响应
-
过滤机制 :
- 通过 Redis 通道机制进行过滤,而不是在应用层
- 节点只会收到发给自己的响应消息
3. 跨服务器请求优化
3.1 fetchSockets 方法的优化参数
fetchSockets() 方法可以接受参数来优化请求行为:
参数说明 :
- timeout : 设置请求超时时间(毫秒)。如果在指定时间内没有收到足够的响应,请求会自动完成。
- expectResponses : 期望收到的响应数量。一旦收到这么多响应,请求就会立即完成,不再等待其他响应。
4. 响应机制详解
4.1 响应消息格式
当服务器实例响应跨服务器请求时,它会发布一个类似这样的消息到 Redis:
{
"requestId": "req123456",
"uid": "server-A-uid",
"responseChannel": "socket.io#/#request#response",
"response": [
{
"id": "socket-xyz",
"handshake": { ... },
"rooms": [ ... ],
"data": { ... }
}
]
}
4.2 响应处理流程
- 每个服务器实例都有一个唯一的 uid
- 发起请求时,服务器会生成一个唯一的 requestId
- 响应中包含 requestId 和发起请求的服务器 uid
- 服务器收到响应后会检查:
- 这是否是自己发起的请求(通过 uid 判断)
- 这个请求是否仍在等待响应(通过 requestId 判断)
- 如果不是自己的请求,服务器会直接忽略这个响应
5. 性能影响与优化建议
5.1 不启用 publishOnSpecificResponseChannel 的性能影响
- 网络流量增加 :所有服务器都接收所有响应
- 处理开销 :每个服务器都需要解析响应并判断是否需要处理
- 内存使用 :处理不必要的消息会占用内存
5.2 优化建议
-
启用 publishOnSpecificResponseChannel :
io.adapter(createAdapter(pubClient, subClient, { publishOnSpecificResponseChannel: true })) -
使用 fetchSockets 的优化参数 :
const sockets = await this.io.in(roomName).fetchSockets({ flags: { timeout: 1000, // 合理的超时时间 expectResponses: 1 // 只需要最少数量的响应 } }); -
避免不必要的跨服务器请求 :先检查本地,再查询远程
-
使用 Redis 存储共享状态 :减少跨服务器查询的需求
6. 常见问题与解决方案
6.1 为什么 Socket.IO 不默认启用这些优化?
Socket.IO 的默认配置倾向于简单性和可靠性,而不是最大性能。这是为了:
- 向后兼容性 :保持 API 的稳定性
- 简单性优先 :使初学者更容易上手
- 通用性 :适应各种使用场景
- 渐进式优化 :让开发者根据需求逐步优化
6.2 如何判断是否需要这些优化?
如果您的应用符合以下条件,应该考虑启用这些优化:
- 使用多个 Socket.IO 服务器实例
- 有大量的客户端连接
- 频繁使用跨服务器操作(如 fetchSockets() 、 serverSideEmit() )
- 对实时性和性能有较高要求
7. 实际案例分析
7.1 设备登录场景
在我们的系统中,当设备登录时,需要确保同一设备ID只有一个活跃连接。这需要跨服务器检查和操作:
// 1. 先检查本地服务器
const localMembers = await this.io.sockets.adapter.rooms.get(roomId);
if (localMembers && localMembers.size > 0) {
// 处理本地重复连接...
} else {
// 2. 再检查其他服务器
try {
const remoteSockets = await this.io.in(roomId).fetchSockets({
flags: { timeout: 1500, expectResponses: 1 }
});
if (remoteSockets.length > 0) {
// 处理远程重复连接...
}
} catch (error) {
logger.error(`跨服务器查询失败: ${error.message}`);
}
}
8. 总结
在 Socket.IO 分布式系统中,正确配置 Redis Adapter 和优化跨服务器请求可以显著提高系统性能和可扩展性。关键优化点包括:
- 启用 publishOnSpecificResponseChannel 选项
- 使用 fetchSockets() 的优化参数
- 先检查本地再查询远程
- 使用 Redis 存储共享状态 这些优化对于大规模部署尤为重要,可以减少网络流量、降低处理开销,并提高系统响应速度。