群聊设计(一):群模型、成员与权限
群聊把会话(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 Set | O(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 为真相、缓存只删不写并广播版本事件,踢人按事件时间判定。下一章解决群消息到底「写给谁」。