21、使用 Redis 实现分布式房间管理
分布式房间管理,说白了就是解决一个问题:当你的信令服务器从一台变成多台时,房间数据怎么共享?
我最早做 WebRTC 项目时,就踩过这个坑。单机跑得好好的,一上负载均衡,用户 A 连到服务器 1 创建了房间,用户 B 连到服务器 2 却查不到这个房间。嗯,那时候我才意识到——房间状态必须集中管理。
Redis 就是干这个的。内存数据库、速度快、数据结构丰富,天然适合做实时通信的状态中心。今天我就带你看看,怎么用 Redis 把房间管理做成分布式的。
21.1 Redis 数据结构选择
选对数据结构,代码能少写一半。我个人习惯把房间信息拆成三层来存:
| 数据维度 | Redis 数据结构 | 用途 |
|---|---|---|
| 房间元信息 | Hash | 房间名、创建时间、房间类型、最大人数 |
| 房间成员列表 | Set | 存储用户 ID,去重、快速判断成员是否存在 |
| 用户状态 | Hash | 用户 ID → 连接状态、所在房间、最后活跃时间 |
为什么不用 String 一把存?我在项目中试过,后来发现要单独查某个字段时,得把整个 JSON 取出来反序列化,性能损耗不说,并发写还容易冲突。Hash 就优雅多了——字段级操作,原子性有保障。
房间元信息:Hash
// 创建房间
await redis.hSet(`room:${roomId}`, {
name: '技术分享',
type: 'video',
maxUsers: 8,
createdBy: 'user_001',
createdAt: Date.now()
});
// 读取房间名
const name = await redis.hGet(`room:${roomId}`, 'name');
// 更新房间类型
await redis.hSet(`room:${roomId}`, 'type', 'screen');
房间成员列表:Set
// 用户加入房间
await redis.sAdd(`room:${roomId}:members`, userId);
// 检查用户是否在房间
const isMember = await redis.sIsMember(`room:${roomId}:members`, userId);
// 获取房间所有成员
const members = await redis.sMembers(`room:${roomId}:members`);
// 用户离开房间
await redis.sRem(`room:${roomId}:members`, userId);
用户状态:Hash
// 设置用户状态
await redis.hSet(`user:${userId}`, {
currentRoom: roomId,
status: 'connected',
lastActive: Date.now(),
mediaType: 'video'
});
// 获取用户当前房间
const roomId = await redis.hGet(`user:${userId}`, 'currentRoom');
核心原则:房间数据用 room:{roomId} 前缀,用户数据用 user:{userId} 前缀。这样在 Redis 里按前缀扫描、删除都非常方便。
21.2 房间状态同步
分布式环境下,最怕的就是状态不一致。比如服务器 A 认为房间还有 3 个人,服务器 B 却显示 5 个人。为什么会这样?因为两台服务器各自缓存了房间数据,没有及时同步。
Redis 解决这个问题的方式很简单——所有服务器都读写同一个 Redis 实例。数据只有一份,不存在同步问题。
但这里有个细节:房间的「活跃状态」怎么管理?
我建议用 TTL(过期时间) 来做自动清理。比如房间创建时设置 30 分钟过期,每次有人加入或发言就刷新 TTL。这样即使某台服务器挂了,房间数据也不会永远留在 Redis 里。
// 创建房间时设置 TTL
await redis.hSet(`room:${roomId}`, roomData);
await redis.expire(`room:${roomId}`, 1800); // 30分钟
// 用户活跃时刷新 TTL
await redis.expire(`room:${roomId}`, 1800);
await redis.expire(`room:${roomId}:members`, 1800);
小技巧:可以用 Redis 的 EXPIRE 配合 PERSIST 做「软删除」。比如房间没人时设置短 TTL,有人加入时取消过期。这样既节省内存,又不会误删。
房间状态变更流程
我画了一张图,帮你理清整个流程:
你看,所有操作都指向 Redis。服务器 A 写入的数据,服务器 C 立刻就能读到。这就是分布式状态同步的核心——去中心化存储,中心化数据。
21.3 用户状态同步
用户状态比房间状态更复杂。为什么?因为用户会断线重连、会切换房间、会同时开多个页面。
我曾经遇到过一个线上问题:用户刷新页面后,旧房间里的「幽灵用户」一直没被清理,导致其他用户看到房间里还有 5 个人,实际上只有 3 个在说话。排查了半天,发现是用户离开时没有正确删除 Redis 里的状态。
用户状态的生命周期
我总结了一套「三阶段管理法」:
- 连接阶段:用户建立 WebSocket 连接时,写入用户状态,设置 TTL 为 60 秒
- 心跳阶段:用户每 30 秒发送心跳,刷新 TTL
- 断开阶段:用户主动离开或心跳超时,清理用户状态
// 1. 用户连接
async function onUserConnect(userId, roomId) {
await redis.hSet(`user:${userId}`, {
currentRoom: roomId,
status: 'connected',
lastActive: Date.now(),
serverId: this.serverId // 记录当前连接的服务器
});
await redis.expire(`user:${userId}`, 60);
}
// 2. 用户心跳
async function onUserHeartbeat(userId) {
await redis.hSet(`user:${userId}`, 'lastActive', Date.now());
await redis.expire(`user:${userId}`, 60);
}
// 3. 用户断开
async function onUserDisconnect(userId) {
const userData = await redis.hGetAll(`user:${userId}`);
if (userData && userData.currentRoom) {
// 从房间移除
await redis.sRem(`room:${userData.currentRoom}:members`, userId);
// 清理用户状态
await redis.del(`user:${userId}`);
}
}
注意:不要依赖 WebSocket 的 close 事件来做清理。网络闪断时,close 事件可能不会触发。一定要配合心跳超时机制,双重保障。
跨服务器通知
用户状态变了,怎么通知其他服务器?比如用户 A 在服务器 1 上加入了房间,服务器 2 上正在查看房间列表的用户需要实时看到变化。
我推荐用 Redis 的 Pub/Sub 来做跨服务器广播:
// 发布事件
await redis.publish('room:events', JSON.stringify({
type: 'user_joined',
roomId: 'room_001',
userId: 'user_001',
timestamp: Date.now()
}));
// 订阅事件(所有服务器都订阅)
const subscriber = redis.duplicate();
await subscriber.subscribe('room:events', (message) => {
const event = JSON.parse(message);
switch (event.type) {
case 'user_joined':
// 更新本地缓存的房间列表
break;
case 'user_left':
// 移除用户
break;
case 'room_destroyed':
// 清理本地状态
break;
}
});
避坑指南:我曾经把 Pub/Sub 的 channel 名字起得太随意,结果不同业务的事件混在一起。后来我统一用 {业务}:{领域}:{动作} 的命名规范,比如 webrtc:room:user_joined,清晰多了。
21.4 实战:完整的房间管理模块
最后,我把上面这些整合成一个完整的模块。你直接拿去用,改改配置就能跑:
class DistributedRoomManager {
constructor(redisClient) {
this.redis = redisClient;
this.sub = redisClient.duplicate();
this.initSubscriber();
}
async createRoom(roomId, roomData) {
await this.redis.hSet(`room:${roomId}`, {
...roomData,
createdAt: Date.now()
});
await this.redis.expire(`room:${roomId}`, 1800);
await this.publishEvent('room_created', { roomId });
}
async joinRoom(roomId, userId) {
// 检查房间是否存在
const exists = await this.redis.exists(`room:${roomId}`);
if (!exists) throw new Error('房间不存在');
// 检查房间是否已满
const memberCount = await this.redis.sCard(`room:${roomId}:members`);
const maxUsers = await this.redis.hGet(`room:${roomId}`, 'maxUsers');
if (memberCount >= parseInt(maxUsers)) throw new Error('房间已满');
// 加入房间
await this.redis.sAdd(`room:${roomId}:members`, userId);
await this.redis.hSet(`user:${userId}`, {
currentRoom: roomId,
status: 'connected',
lastActive: Date.now()
});
await this.redis.expire(`user:${userId}`, 60);
// 刷新房间 TTL
await this.redis.expire(`room:${roomId}`, 1800);
await this.redis.expire(`room:${roomId}:members`, 1800);
await this.publishEvent('user_joined', { roomId, userId });
}
async leaveRoom(roomId, userId) {
await this.redis.sRem(`room:${roomId}:members`, userId);
await this.redis.del(`user:${userId}`);
// 如果房间没人了,设置短 TTL 准备清理
const count = await this.redis.sCard(`room:${roomId}:members`);
if (count === 0) {
await this.redis.expire(`room:${roomId}`, 300);
await this.redis.expire(`room:${roomId}:members`, 300);
}
await this.publishEvent('user_left', { roomId, userId });
}
async getRoomInfo(roomId) {
const roomData = await this.redis.hGetAll(`room:${roomId}`);
const members = await this.redis.sMembers(`room:${roomId}:members`);
return { ...roomData, members };
}
async publishEvent(type, data) {
await this.redis.publish('webrtc:room:events', JSON.stringify({
type,
...data,
timestamp: Date.now()
}));
}
initSubscriber() {
this.sub.subscribe('webrtc:room:events', (message) => {
const event = JSON.parse(message);
// 这里可以触发本地回调
console.log('收到房间事件:', event.type);
});
}
}
这个模块我用了好几个项目,稳定运行没出过问题。核心思路就一句话:所有状态读写走 Redis,服务器只做逻辑转发。
嗯,分布式房间管理其实没那么玄乎。选对数据结构、管好 TTL、用 Pub/Sub 做通知,三件事做好,你的信令服务器就能水平扩展了。
总结一下今天的关键点:
- Hash 存房间元信息,Set 存成员列表,Hash 存用户状态
- TTL 做自动清理,心跳保活
- Pub/Sub 做跨服务器通知
- 所有服务器读写同一个 Redis,天然一致
下一章我会讲怎么用 Redis Cluster 做高可用部署,到时候再聊。
公众号:蓝海资料掘金营,微信deep3321