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,有人加入时取消过期。这样既节省内存,又不会误删。

房间状态变更流程

我画了一张图,帮你理清整个流程:

房间状态同步流程 信令服务器 A 用户加入房间 信令服务器 B 用户离开房间 信令服务器 C 查询房间状态 SADD / HSET SREM / HDEL SMEMBERS / HGETALL Redis 集中存储 room:{id} / user:{id} 返回最新状态 所有服务器看到一致的数据

你看,所有操作都指向 Redis。服务器 A 写入的数据,服务器 C 立刻就能读到。这就是分布式状态同步的核心——去中心化存储,中心化数据

21.3 用户状态同步

用户状态比房间状态更复杂。为什么?因为用户会断线重连、会切换房间、会同时开多个页面

我曾经遇到过一个线上问题:用户刷新页面后,旧房间里的「幽灵用户」一直没被清理,导致其他用户看到房间里还有 5 个人,实际上只有 3 个在说话。排查了半天,发现是用户离开时没有正确删除 Redis 里的状态。

用户状态的生命周期

我总结了一套「三阶段管理法」:

  1. 连接阶段:用户建立 WebSocket 连接时,写入用户状态,设置 TTL 为 60 秒
  2. 心跳阶段:用户每 30 秒发送心跳,刷新 TTL
  3. 断开阶段:用户主动离开或心跳超时,清理用户状态
// 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