群聊设计(一):群模型、成员与权限

群聊把会话(conversation)的参与者从 2 个变成 N 个,随之出现三个硬约束:成员关系是持久且频繁变更的一等公民;发言权限分层且带时效(禁言到期、临时管理员);群规模跨度极大(3 人到 10 万人)无法用一个方案通吃。本章给出群与成员的表结构、成员集合的缓存与分页方案、权限判定流程,以及成员变更时的一致性取舍。

三个决定模型的约束

  • 关系持久:单聊的关系隐含在会话 ID 里,群聊的成员关系必须单独存储、单独索引、单独缓存。
  • 规模跨度大:3 人群与 10 万人群若共用「全量成员加载进内存」,大群会先把服务打崩。
  • 权限带时效:禁言到某时刻、临时管理员、群主转让,都是「权限 × 时间」的组合,静态角色字段表达不了。

表结构:群与成员分表

群资料读多写少,成员关系写多读多且量级大两个数量级以上,两张表必须分开;成员表按 group_id 分片,让同一群成员落进同一分片,因为「列举本群成员」是主路径:

CREATE TABLE im_group (
  group_id     BIGINT       NOT NULL COMMENT '群 ID,雪花生成',
  group_type   TINYINT      NOT NULL DEFAULT 2 COMMENT '1 单聊 2 群聊 3 超大群',
  name         VARCHAR(64)  NOT NULL DEFAULT '',
  owner_id     BIGINT       NOT NULL COMMENT '群主',
  member_count INT          NOT NULL DEFAULT 0 COMMENT '冗余计数,异步校准',
  max_member   INT          NOT NULL DEFAULT 500,
  mute_all     TINYINT      NOT NULL DEFAULT 0 COMMENT '1 表示全员禁言',
  join_mode    TINYINT      NOT NULL DEFAULT 1 COMMENT '1 邀请 2 审批 3 自由加入',
  status       TINYINT      NOT NULL DEFAULT 0 COMMENT '0 正常 1 解散 2 封禁',
  version      BIGINT       NOT NULL DEFAULT 0 COMMENT '资料版本,用于缓存失效',
  updated_at   DATETIME(3)  NOT NULL,
  PRIMARY KEY (group_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE im_group_member (
  group_id   BIGINT      NOT NULL,
  user_id    BIGINT      NOT NULL,
  role       TINYINT     NOT NULL DEFAULT 0 COMMENT '0 普通 1 管理员 2 群主',
  alias      VARCHAR(32) NOT NULL DEFAULT '' COMMENT '群内昵称',
  mute_until DATETIME(3) NULL COMMENT '个人禁言到期,NULL 为未禁言',
  join_seq   BIGINT      NOT NULL DEFAULT 0 COMMENT '入群时的群内 seq,判定可见范围',
  join_time  DATETIME(3) NOT NULL,
  status     TINYINT     NOT NULL DEFAULT 0 COMMENT '0 正常 1 已退群 2 被踢',
  PRIMARY KEY (group_id, user_id),
  KEY idx_user (user_id, status)  -- 「我加入了哪些群」的查询路径
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

退群用 status 软删而不是 DELETE:扩散层与已读位点还会短暂引用这条关系,物理删除会留下无法解释的孤儿数据。(group_id, user_id) 主键让「被重复邀请同一用户」天然幂等(INSERT ... ON DUPLICATE KEY UPDATE)。join_seq 是新人不可见历史消息的判定依据,比用时间戳更可靠。

成员集合的缓存:小群 Set、大群 ZSet 分桶

每次发言都查 MySQL 判成员,等于把全部 QPS 压在成员表上。成员集合进 Redis,并按规模换结构:

小群(< 2000 人):
  SADD      group:members:{gid} {uid}      # 成员集合
  SISMEMBER group:members:{gid} {uid}      # O(1) 判定是否成员
  SCARD     group:members:{gid}            # O(1) 成员数

大群(>= 2000 人):单 key 会变成大 key,按 user_id 取模分 16 桶
  ZADD   group:members:{gid}:{uid%16} {join_time} {uid}   # score 用入群时间
  ZRANGE group:members:{gid}:{uid%16} 0 199               # 稳定分页
  ZSCORE group:members:{gid}:{uid%16} {uid}               # 判定 + 取入群时间
  SCARD  各桶求和                                          # 成员总数

用 ZSet 而不是 Set 的关键在分页稳定:SSCAN(增量遍历)在 rehash 后返回顺序会变,客户端翻页可能重复或漏人;改用入群时间做 score 后 ZRANGE 按位次分页,顺序恒定。分桶则保证单个 key 的元素数不过万,避免大 key 阻塞主线程。

成员存储方案成员判定全量列举计数适用规模
MySQL 直查有索引可用,但吃连接与 IO分页容易COUNT 需扫描小群、低频操作
Redis SetO(1)大 key 风险O(1)中群、需随机取成员
Redis ZSet 分桶O(1)稳定分页各桶求和大群、需成员列表
位图(bitmap)O(1)、内存极省不支持列举精确10 万人群只判成员身份

缓存失效统一用「先写 DB 再删缓存」,群资料带 version,本地缓存(local cache)只放 1~3 秒短 TTL 兜底,变更时靠 Redis 发布订阅广播失效。

权限:角色、权限位与时效

角色(群主/管理员/成员)便于产品表达,权限位(permission bitmask)便于代码判定,两者映射:

权限位值群主管理员普通成员
SEND_MSG 发言1是是未被禁言即可
RECALL_OTHER 撤回他人2是是否
KICK 踢人4是是否
INVITE 拉人8是是视 join_mode
MUTE 禁言16是是否
SET_ADMIN 设管理员32是否否
EDIT_INFO 改群资料64是是否
AT_ALL 全体提醒128是是否
DISMISS 解散群1024是否否

判定顺序固定为:群存在且未解散 → 是否成员 → 全局禁言/个人禁言是否生效 → 权限位是否覆盖本次操作。禁言要区分两种语义:group.mute_all=1 时仅群主与管理员可发言;member.mute_until > now 时该成员一律禁言(即使他是管理员)。

发言请求 → 接入层 → 逻辑层
  1. 读群信息(本地缓存 → Redis → DB),校验 status 正常
  2. 判成员:SISMEMBER / ZSCORE,未命中回查 DB 并回填缓存
  3. 判禁言:mute_all 且 role<1 → 拒绝;mute_until > now → 拒绝
  4. 校验权限位:(rolePerm[role] | memberExtraPerm) & NEED_BIT != 0
  5. 通过 → 分配群内 seq → 进入扩散与投递(见下一章)

成员变更与一致性取舍

加人/踢人/退群要同时改三处:成员表、成员缓存、扩散层依赖的成员快照(snapshot)。做法是 DB 为唯一真相、缓存只删不写、扩散层按快照版本对齐:

踢人事务:UPDATE im_group_member SET status=2 WHERE group_id=? AND user_id=?
        → DEL 缓存中该群成员 key(或 ZREM 对应元素)
        → 发一条 member_changed 事件(含 group_id、member_version)
        → 扩散层收到事件后重建热名单,允许百毫秒级延迟

三个必须提前定的边界:

  • 踢人瞬间的消息:按「发送时刻的成员关系」判定,而不是投递时刻重查成员表,否则消息会在投递途中被踢人操作凭空吞掉。
  • 全员禁言下的紧急通道:为群主与管理员保留 SEND_MSG,否则禁言后没人能解除禁言。
  • 超大群不返回全量成员列表:10 万人群的成员列表对客户端没有意义,只提供搜索与前 N 个活跃成员,否则一次响应就能压垮接入层。

小结:群用 im_group 与 im_group_member 分表,成员表按群分片并带 join_seq 划定可见范围;成员集合进 Redis,小群用 Set、大群用 ZSet 按 user_id 分桶以支持稳定分页与单 key 大小受控;权限用角色映射权限位,判定顺序固定为群状态→成员→禁言→权限位;成员变更以 DB 为真相、缓存只删不写并广播版本事件,踢人按事件时间判定。下一章解决群消息到底「写给谁」。

笔记加载中…