你好,我是利威尔xu。
这是**「Go IM 源码实战」系列第 12 篇**。前面讲的是"一对一"的链路,这一篇开始讲**“一对多”**:群组。
先说一个建群时的真实开销:建一个 500 人的群,不是"插一条群记录",而是"插 1 条群记录 + 500 条成员记录 + 500 条会话记录 + 1 条通知消息,还要推给 500 个人"。 而这些操作跨越了 group、conversation、msg、push 四个服务。
所以群这块的难点从来不是"建群"这个功能,而是三个数:5000 人的群改个群名要写多少条?一个人退群要不要动别人的数据?群主退群会怎样?
这篇讲四件事:群成员这张中间表怎么设计(为什么不能塞数组)、三级角色的权限矩阵(为什么不做成可配置)、禁言为什么必须两层、以及"群主不能退群"这条不变量背后的真实原因。
源码位置(全系列同一份):
- GitHub:https://github.com/CYQ111999/IM-Server
- 基线:OpenIM Server v3.8 瘦身版,main @ 07f9ff6
- 本篇涉及:pkg/common/storage/model/group_member.go、internal/rpc/group/group.go、internal/rpc/group/notification.go、pkg/common/storage/controller/group.go
前 4 篇:1 全局地图、2 WS 网关、3 HTTP API 层、4 发送链路。第 5 篇起全部在同名专栏里,见作者文章列表;系列导航在《新粉必看:技术博客学习路线》。
文章目录
-
- 一、群成员存哪:为什么不能塞进群文档的数组
-
- 方案一:群文档里存成员数组
- 方案二:独立成员表(本项目的选择)
- 三个字段值得单独说
- 二、三级角色:一个刻意的"不配置化"
-
- 权限矩阵(读源码时最该做的事)
- 三、禁言:为什么必须是两层
- 四、建群一次要写多少东西
- 五、"这个群有多少人":三种算法和各自的天花板
- 六、成员变更通知:三层开关和扇出成本
- 七、群主转让:一个必须原子完成的两步
- 八、解散群:两种模式,和那个"要删 8 类缓存"的教训
-
- 两个模式写的版本日志不一样,为什么
- 九、群 ID 生成:为什么允许调用方指定
- 十、PopulateGroupMember:N+1 查询的标准解法
- 十一、踩坑记录:三个坑,一共 11 小时
- 十二、面试会怎么问
- 十三、小结:一张中间表、两个不变量、三种算法
一、群成员存哪:为什么不能塞进群文档的数组
一个群有 N 个成员,一个人有 M 个群。这是典型的多对多关系,两种存法。
方案一:群文档里存成员数组
{
"group_id": "123",
"group_name": "技术群",
"members": [
{"user_id": "a", "role": 1},
{"user_id": "b", "role": 3}
// … 500 人
]
}
查"群的所有成员"确实是一次查询,很快。但三个缺点是致命的:
| 文档大小 | Mongo 单文档上限 16MB,每条成员 200 字节 → 最多 8 万人(看着够,但读群信息会连带拉回整个数组) |
| 反向查询 | "我加入了哪些群"要遍历所有群文档扫数组 —— O(群总数) |
| 并发冲突 | 两个人同时改不同成员,都重写整个数组,后写的覆盖前写的 |
第三条是最难缠的。 它不会报错,只会"丢更新"——A 改了成员 X 的禁言时间,B 同时改了成员 Y 的群昵称,最后只有一个生效。
方案二:独立成员表(本项目的选择)
group_member 集合:
{ group_id: "123", user_id: "a", role_level: 1, join_time: …, mute_end_time: … }
{ group_id: "123", user_id: "b", role_level: 3, … }
{ group_id: "456", user_id: "a", role_level: 1, … }
一条记录 = 一个"用户在某群里的身份",配三个索引:
| (group_id, user_id) 唯一 | 查单个成员、防重复入群 |
| group_id | 查群的所有成员 |
| user_id | 查某人加入的所有群 |
📌 M:N 关系用中间表,不要塞数组。 数组只适合"数量有上限、总是整体读写"的场景(比如一条消息的 @ 列表)。
三个字段值得单独说
type GroupMember struct {
GroupID string `bson:"group_id"`
UserID string `bson:"user_id"`
Nickname string `bson:"nickname"` // 群昵称
FaceURL string `bson:"face_url"`
RoleLevel int32 `bson:"role_level"` // 1=owner 2=admin 3=ordinary
JoinTime time.Time `bson:"join_time"`
JoinSource int32 `bson:"join_source"`
InviterUserID string `bson:"inviter_user_id"`
OperatorUserID string `bson:"operator_user_id"`
MuteEndTime time.Time `bson:"mute_end_time"`
Ex string `bson:"ex"`
}
① Nickname 是冗余的。 它和 user.Nickname 可能不同(群昵称是群内独立的)。为什么存一份?因为"群成员列表"是高频查询,如果每个人昵称都去用户表查,500 人的群就是 501 次查询。
代价是:用户改了昵称,要同步更新所有群的成员记录——这就是 PopulateGroupMember(发现空了就补)和 NotificationUserInfoUpdate(资料变更通知所有群)存在的原因。
② MuteEndTime 存的是"结束时间"而不是"剩余秒数"。 因为剩余秒数会随时间变化,存下来立刻就过期了。存结束时间,判断禁言就是 MuteEndTime.After(time.Now())。
📌 "存绝对时间,不存相对时间"是一条通用原则。 缓存过期、任务延迟、限流窗口、token 过期……凡是"还剩多久"的东西,都应该存"到什么时候"。
③ JoinSource 记录了入群方式(邀请/扫码/搜索/导入)。用于统计和风控——一个"扫码进来又立刻退群"的账号,和"被好友邀请"的账号,风控评级完全不同。
二、三级角色:一个刻意的"不配置化"
RBAC 完整版有四张表(角色、权限、角色-权限、用户-角色)。本项目用了一个极简版:角色写死三级,权限写死在代码的 switch 里。
RoleLevel: 1 = GroupOwner 群主
2 = GroupAdmin 管理员
3 = GroupOrdinaryUsers 普通成员
switch opMember.RoleLevel {
case constant.GroupOwner:
// 什么都能干
case constant.GroupAdmin:
// 不能动群主和其他管理员
case constant.GroupOrdinaryUsers:
return nil, errs.ErrNoPermission.WrapMsg("opUserID no permission")
}
为什么不设计成可配置的权限表?
因为 IM 的群权限是行业标准化的——几乎所有产品都是"群主 > 管理员 > 成员",边界也大同小异。做成配置会带来三个成本:
代价是灵活性差:想加"只读成员""审计员"这类角色,得改代码。
📌 这里有个很值得记住的判断:不是所有东西都值得做成可配置。稳定的领域模型应该硬编码,变化频繁的部分才配置化。 群角色属于前者(十年不变),入群验证策略属于后者(每个群都可能不同)。
权限矩阵(读源码时最该做的事)
我把散在十几个 if 里的判断汇总成一张表:
| 修改群信息 | ✅ | ✅ | ❌ | ✅ |
| 修改群公告 | ✅ | ✅ | ❌ | ✅ |
| 邀请成员 | ✅ | ✅ | ✅(视验证策略) | ✅ |
| 踢人 | ✅ | ✅(不能踢群主/管理员) | ❌ | ✅ |
| 设置管理员 | ✅ | ❌ | ❌ | ✅ |
| 禁言成员 | ✅ | ✅(不能禁言群主/管理员) | ❌ | ✅ |
| 群禁言(全员) | ✅ | ✅ | ❌ | ✅ |
| 转让群主 | ✅(只能转给自己名下的) | ❌ | ❌ | ✅ |
| 解散群 | ✅ | ❌ | ❌ | ✅ |
| 退群 | ❌ | ✅ | ✅ | ✅ |
“群主不能退群” 是这张表里唯一一个反直觉的:
if member.RoleLevel == constant.GroupOwner {
return nil, errs.ErrNoPermission.WrapMsg("group owner can't quit")
}
因为群主一退,群就没有主了——没人能转让、没人能解散、没人能设管理员。这是一个业务不变量:要么先转让,要么解散。
📌 好的代码会把不变量写成显式校验,而不是"靠流程约束"。 如果这里不拦,运营侧就一定会出现"群主已离职,群无人管理"的脏数据。
三、禁言:为什么必须是两层
"禁言"在 IM 里有两个完全不同的粒度:
| 范围 | 整个群,所有人不能发言 | 某个成员 |
| 存在哪 | group.status = GroupStatusMuted | group_member.mute_end_time |
| 谁能设 | 群主/管理员 | 群主/管理员(有层级限制) |
| 解除 | 手动 CancelMuteGroup | 到期自动解除,或手动 |
为什么不能合成一层? 因为它们的语义完全不同:
- 群禁言是群级状态,一次设置影响所有人,改一个字段就够;
- 成员禁言是关系级状态,只影响一个人,存在成员记录上。
如果合成一层(比如"给每个人都设一个禁言时间"),群禁言就要写 N 条记录——5000 人的群,一次"全员禁言"写 5000 条,还要在有人入群时补写。用群级字段表达群级语义,是省 5000 倍写放大的关键。
📌 这个思路很通用:判断一个状态该存在哪一层,就问"它描述的是谁"。 描述群的存群表,描述"人和群关系"的存关系表,描述人的存用户表。放错层的代价通常是写放大或者查询放大。
四、建群一次要写多少东西
CreateGroup 的真实开销,按服务拆:
| 1 | 群记录 | 1 条 |
| 2 | 群成员记录 | 500 条(批量) |
| 3 | 会话记录 | 500 条(每人一条,跨 conversation 服务) |
| 4 | 建群通知消息 | 1 条(进消息流水线) |
| 5 | 推送 | 500 个推送任务 |
注意第 3 步是跨服务的。 群服务建完群,要调 conversation 服务给每个成员建会话——否则用户打开 App 看不到这个群。
而且这 500 条会话记录,每条都要带 minSeq/maxSeq 水位(第 7 篇讲的),初始都是 0。这个设计带来一个后果:新入群的人看不到入群前的历史消息(因为他的水位从 0 开始,但会话 minSeq 已经很大了,下界取 max 就把它挡住了)。
📌 这就是第 7 篇"用户级水位"的存在意义,而这里正是它的第二个应用场景(第一个是清空聊天记录)。同一个机制,多种用途——这是抽象做得好的标志。
五、“这个群有多少人”:三种算法和各自的天花板
看着是最简单的一个需求,实际有三种做法,各有各的坑。
方案一:实时 count
db.group_member.countDocuments({group_id: "123"})
问题是它高频。每个群会话列表项都要显示人数,用户有 30 个群,打开 App 就是 30 次 count,每次扫几百条。
方案二:群文档里存 member_count 字段,变更时 $inc
update := bson.M{"$inc": bson.M{"member_count": 1}}
问题是容易算错:
- 批量踢 100 人,循环 100 次 $inc -1,中间失败一次数就错了;
- 两个人同时 $inc,虽然 Mongo 是原子的,但业务上"踢 100 人"这个操作本身可能部分成功;
- 一旦错了,没有任何机制能自动纠正——它是个纯增量,没有基线。
方案三:缓存 + Cache-Aside(本项目的做法)
读:缓存有 → 直接返回;没有 → 聚合 count → 回填缓存
写:成员变更 → 删缓存(下次读时重新 count)
核心思想:不维护精确计数,而是缓存查询结果。
区别很关键:
| 错了会怎样 | 永久错(没有基线) | 下次读就纠回来 |
| 代价 | 无 | 变更后第一次读会慢(回源 count) |
📌 这是一个非常值得记住的取舍:凡是"可以被重新计算出来"的数字,都不要维护增量,直接缓存计算结果。 因为增量一定会错,而结果可以被重算。
反过来,凡是"算不出来"的(比如"这个用户累计发过多少条消息"这种跨表聚合、成本高到不能实时算的),才需要维护增量 + 定期对账。
六、成员变更通知:三层开关和扇出成本
"A 加入了群"这件事,要让全群所有人看到一条系统消息——一次 1 对 N 的扇出。
按第 9 篇讲的,扇出发生在推送层,存储层只写一份。
但这里有个更隐蔽的成本:每天 20 个人进出的 500 人群,一天就有 20 条系统消息,用户翻聊天记录全是"xxx 加入了群聊"。
本项目给了三层控制:
| 1 | 调用方传 sendMessage: false | 这次不发 |
| 2 | config/notification.yml 的 memberEnter.enable: false | 这类通知全局不发 |
| 3 | 客户端渲染折叠 | 连续多条"xx 加入"合并显示 |
📌 "默认行为可关闭"是个好设计原则:基础设施不该强制业务接受某种用户体验。
但我要指出一个容易被忽略的点:即使 sendMessage: false,推送仍然会发生(因为通知消息和推送是两条链)。想彻底不发,得同时把 Options 里的 IsOfflinePush / IsUnreadCount 关掉——这是第 4 篇讲的 11 个开关的又一个用途。
七、群主转让:一个必须原子完成的两步
转让群主要改两条成员记录:
- 旧群主:role_level 1 → 3(或 2)
- 新群主:role_level 3(或 2)→ 1
如果第二步失败,就会得到一个"没有群主"的群——旧群主已经降级,新群主没升上去。而这个群的所有管理操作(设管理员、踢人、解散)都需要群主权限,等于这个群从此不可管理。
所以必须在事务里做:
if err := g.db.TransferGroupOwner(ctx, req.GroupID, req.OldOwnerUserID, req.NewOwnerUserID, newOwner.RoleLevel); err != nil {
注意最后那个参数 newOwner.RoleLevel——新群主原来的角色。它的用途是决定旧群主降级成什么:
| 管理员 | 管理员 | 保持管理员数量不变 |
| 普通成员 | 普通成员 | 不凭空多一个管理员 |
📌 这是一个很细腻的设计:转让不该改变群的管理力量对比。 大部分系统会简单地把旧群主降成普通成员,结果"转让一次少一个管理员"——运营侧会发现管理员越转越少。
这类"副作用要守恒"的思考方式很有价值:任何变更操作,先问一句"它会不会意外减少某个东西"(管理员数、未读数、在线人数、配额)。
八、解散群:两种模式,和那个"要删 8 类缓存"的教训
DismissGroup(ctx, req.GroupID, req.DeleteMember),deleteMember 决定两种模式:
| false(仅解散) | 群状态改 Dismissed,成员记录保留 | 软删,可恢复;或留成员关系做分析 |
| true(解散并删成员) | 状态改 Dismissed + 删所有成员记录 + 给每人写一条版本日志 | 彻底删,释放空间 |
模式二的实现(controller/group.go:273-310)值得逐行看,因为它暴露了"群模块到底有多少缓存":
func (g *groupDatabase) DismissGroup(ctx context.Context, groupID string, deleteMember bool) error {
return g.ctxTx.Transaction(ctx, func(ctx context.Context) error {
c := g.cache.CloneGroupCache()
if err := g.groupDB.UpdateStatus(ctx, groupID, constant.GroupStatusDismissed); err != nil { return err }
if deleteMember {
userIDs, err := g.cache.GetGroupMemberIDs(ctx, groupID)
if err := g.groupMemberDB.Delete(ctx, groupID, nil); err != nil { return err } // nil = 删全部
c = c.DelJoinedGroupID(userIDs…). // 每个人的"我加入的群"
DelGroupMemberIDs(groupID). // 群的成员 ID 列表
DelGroupsMemberNum(groupID). // 群成员数
DelGroupMembersHash(groupID). // 成员列表 hash(增量同步用)
DelGroupAllRoleLevel(groupID). // 全部角色映射
DelGroupMembersInfo(groupID, userIDs…). // 每个成员详情
DelMaxGroupMemberVersion(groupID). // 群成员版本号
DelMaxJoinGroupVersion(userIDs…) // 每个人的"加入群"版本号
for _, userID := range userIDs {
g.groupMemberDB.JoinGroupIncrVersion(ctx, userID, []string{groupID}, model.VersionStateDelete)
}
}
return c.DelGroupsInfo(groupID).ChainExecDel(ctx)
})
}
要删 8 类缓存。 这张列表本身就是理解群模块最好的材料:
| JoinedGroupID | userID | 我加入了哪些群 |
| GroupMemberIDs | groupID | 群有哪些成员 |
| GroupsMemberNum | groupID | 群有多少人 |
| GroupMembersHash | groupID | 成员列表 hash(增量同步) |
| GroupAllRoleLevel | groupID | 全部角色 |
| GroupMembersInfo | groupID + userID | 成员详情 |
| MaxGroupMemberVersion | groupID | 群成员版本 |
| MaxJoinGroupVersion | userID | 我的加入群版本 |
为什么要这么多维度? 因为查询模式多:按用户查、按群查、按群+用户查、按群查数量。每个查询模式一个 key 维度,这是多维度缓存的必然结果,代价就是失效要删一大堆。
⚠️ 这里有个真实的易错点:漏删任何一个,就会出现"群解散了但我列表里还有"或者"成员数显示 500 实际 0 人"。ChainExecDel 用 pipeline 把删除攒到最后一次执行(减少往返),但不保证原子性——执行到一半失败,缓存就处于"部分失效"的中间态。
📌 多维度缓存的通用解法:给缓存加"版本号"或"命名空间代际"(比如 key 前缀带 gen,失效时把 gen +1),这样一次失效就是 O(1),不用逐个删。代价是旧代的 key 要靠 TTL 自然淘汰。缓存维度超过 3 个,就该考虑这个方案了。
两个模式写的版本日志不一样,为什么
模式二给每个成员写 VersionStateDelete(“你退出了这个群”)。
模式一写的是:
g.groupMemberDB.MemberGroupIncrVersion(ctx, groupID, []string{""}, model.VersionStateUpdate)
注意 []string{""}——一个空字符串。
这是哨兵值,表达"群本身变了(状态改为解散),但没有具体哪个成员变了"。客户端收到这个变更后重新拉群信息,发现 status = dismissed。
📌 用特殊 ID 表达"非成员维度的变更",是个很实用的技巧(好友关系链里也有类似做法)。它的前提是 ID 空间里保留了一个不可能出现的值——所以 userID 的字符校验(第 11 篇讲的"不能含冒号")在这里同样重要:如果允许空字符串当 userID,这个哨兵就会被误解。
九、群 ID 生成:为什么允许调用方指定
group.go:165-192:
func (g *groupServer) GenGroupID(ctx context.Context, groupID *string) error {
if *groupID != "" {
// 调用方指定了 ID:检查是否已存在
_, err := g.db.TakeGroup(ctx, *groupID)
if err == nil {
return servererrs.ErrGroupIDExisted.WrapMsg("group id existed " + *groupID)
}
…
两种模式:
| 服务端生成 | IM 分配(雪花 ID 或类似) | 客户端直接建群 |
| 调用方指定 | 业务后端给 ID | 业务系统要"群 ID 和业务主键一致" |
第二种和第 11 篇"userID 必须与业务主键一致"是同一个道理:如果群里对应一个业务对象(比如一个项目、一个订单、一个班级),群 ID 就应该等于那个对象的 ID。
“指定 ID"这条路径必须做唯一性检查,而且要用唯一索引兜底——只靠"先查再插"在并发下一定会有重复(两个请求同时查到"不存在”,然后都插成功)。
📌 "先查再写"永远不是并发安全的。 判断唯一性只有一种可靠方式:数据库唯一索引 + 处理冲突错误。这条在分布式系统里是铁律,不管查的是 Mongo、MySQL 还是 Redis。
十、PopulateGroupMember:N+1 查询的标准解法
拉群成员列表时有个绕不开的问题:成员表里只有 user_id 和冗余的 nickname,但要展示头像、真实昵称、在线状态——这些在用户表里。
最笨的写法:
for _, m := range members {
user := getUser(m.UserID) // 500 次 RPC
}
500 人的群 = 501 次查询,P99 直接爆炸。
PopulateGroupMember 的做法是:
注意第 3 步:它不只是"补这次的结果",还顺手修数据。这是**读时修复(read repair)**的思路——读到脏数据/缺数据时顺手修,比跑批处理任务简单得多。
📌 读时修复的适用条件:修复成本低(一次批量写)、漏修不致命(下次读到还会再修)、修的数据量小(只有缺失的那部分)。三个条件都满足时,读时修复比定时任务好得多——它不需要调度、不会漏、没有额外的扫描成本。
十一、踩坑记录:三个坑,一共 11 小时
坑一:解散群后,成员列表还在(3 小时)
现象:调 dismiss_group 之后,get_group_members 还能查到 20 个人。
定位:调用时 deleteMember 传了 false(默认值)。第八节讲的两种模式——false 只改群状态,成员记录是故意保留的。
改法:前端/调用方要显式传 true。
教训:"删除"类接口的布尔参数,一定要在 API 文档里写清默认值和后果。 这个坑不是代码 bug,是接口设计让人误判——deleteMember 这个名字听起来像"要不要删群",实际是"要不要删成员记录"。
坑二:群转让后管理员变少了(4 小时)
现象:把群主转给一个管理员,转让后群里有 0 个管理员(原来 2 个)。
定位:早期版本转让时,旧群主一律降成普通成员(3),新群主升成群主(1)。于是:
- 旧群主:1 → 3(少了一个"高层")
- 新群主:2 → 1(又少了一个管理员)
两个管理员位同时被消耗掉。
改法:就是第七节那个 newOwner.RoleLevel 参数——按新群主原来的角色决定旧群主降成什么。
教训:任何"角色变更"都要算一遍"变更前后的角色分布",别只看两个人。
坑三:成员数显示 501,实际 500 人(4 小时)
现象:群里显示 501 人,实际成员列表 500 条。
定位:早期用 $inc 维护 member_count,有一次"邀请 50 人"批量操作部分失败,$inc 加了 50 但实际只成功 49 个,从此永久错 1。
改法:改成第五节方案三——不维护计数,缓存 count 结果,成员变更就删缓存。
教训:增量计数一旦错了就没有基线。 能算出来的数就别维护增量。
十二、面试会怎么问
| 群成员关系怎么存? | 独立中间表 + 三个索引(群+用户唯一、群、用户),不能塞进群文档数组 |
| 为什么不塞数组? | 16MB 上限 + 反向查询要全表扫 + 并发改不同成员会互相覆盖 |
| 群成员表为什么冗余昵称? | 避免 N+1,500 人群 501 次查询;代价是改昵称要同步所有群 |
| 禁言为什么两层? | 群级状态描述群,成员级状态描述关系;合成一层会有 5000 倍写放大 |
| 群人数怎么算? | Cache-Aside 缓存 count 结果,不维护 $inc 增量(错了没基线) |
| 群主转让为什么要事务? | 两步改角色,中间失败会得到"没有群主"的不可管理群 |
| 解散群要清多少缓存? | 8 类(多维度缓存的代价),建议上代际版本号做 O(1) 失效 |
| 这个项目群模块有什么问题? | 8 类缓存逐个删且不原子、deleteMember 参数命名易误判、版本日志用空字符串当哨兵 |
十三、小结:一张中间表、两个不变量、三种算法
- 一张中间表:group_member,一条记录 = 一个"用户在某群的身份"。M:N 关系永远用中间表。
- 两个不变量:群主不能退群(否则群无人可管)、转让不减少管理员(副作用守恒)。
- 三种算法:实时 count(慢)、$inc 增量(错了没基线)、Cache-Aside(推荐)。
这张表建议收藏,做任何"组织/成员"类功能(群、团队、项目成员、权限组)都能套:
| 关系存储 | 中间表还是数组? | 中间表 + 三索引 |
| 角色 | 硬编码还是配置化? | 硬编码三级 + 代码 switch |
| 计数 | 增量还是缓存结果? | 缓存结果(可重算的不维护增量) |
| 通知 | 扇出多少、能不能关? | 推送层扇出,三层开关 |
| 原子性 | 哪些操作必须事务? | 转让群主、解散群 |
| 缓存 | 几个维度、怎么失效? | 8 类,逐个删(建议代际版本号) |
| ID | 谁生成、要不要业务对齐? | 可指定,业务系统要求对齐 |
留个问题,评论区一句话能答: 如果一个 5000 人的群"全员禁言"用的是"给每个成员设置禁言时间"(而不是群级字段),那么大群和小群在写放大上差多少倍?为什么小群里看不出问题?
下一篇:《Go IM 源码实战(13):会话列表 30 个群,为什么只有 2 个有红点》——会话与未读计数的完整链路。
作者:利威尔xu | CSDN 专栏《Go IM 源码实战》
网硕互联帮助中心






评论前必须登录!
注册