Go IM 消息存储拆解:100 条一桶的 Mongo 分桶、幂等写入与三个线上 bug
项目:CYQ111999/IM-Server,基线是 OpenIM Server v3.8 的瘦身版,main @07f9ff6。
const singleGocMsgNum = 100 // pkg/common/storage/model/msg.go:11
一个 MongoDB 文档塞 100 条消息,第 101 条进下一个文档。这是 Bucket Pattern,整套存储逻辑——寻址、幂等写入、读取、删除、迁移——都是围绕这个 100 展开的。
一、为什么不用 MySQL
聊天记录和订单表不是一类数据,逐项对一下:
- 写读比:订单读写基本均衡;聊天写多读少,500 人的群可能只有 50 人在看。
- 更新:订单状态反复变更;聊天几乎只追加,例外只有撤回和已读。
- 结构:订单强 schema;聊天弱 schema,文本、图片、语音、自定义、引用消息的字段差异很大。
- 查询:订单是多维条件加聚合;聊天几乎只有
conversationID + seq区间这一个维度。 - 增长:订单可以按用户归档;聊天没有上限,一个 500 人群一天几十万条。
MySQL 的强项是多维灵活查询和事务,这两样在这里一条都用不上,反而要为写入吞吐和无上限增长付代价。MongoDB 对得上:文档模型装弱 schema,按 doc_id 主键查询快,水平分片容易,写入吞吐高。
Redis 不能当真相层。它是内存、可能被淘汰、主从切换可能丢最后一段。本项目 Redis 只存最近消息的副本,TTL 24 小时,Mongo 是唯一真相。
二、一条消息一个文档的三个问题
如果 _id = conversationID:seq,一条消息一个文档:
- 文档数爆炸:100 万条消息就是 100 万个文档加 100 万个索引项,一个活跃群一天能产生几十万个文档。
- 读取放大:拉 20 条历史要么 20 次查询,要么
$in查 20 个文档,但每个文档仍然要单独读页。 - 索引膨胀:每个文档都有
conversation_id + seq索引项,索引大小接近数据大小;Mongo 索引是 B 树,而且常驻内存。
分桶的本质是合并小文档,用“一次 IO 换 100 条”把固定开销摊薄,和 InnoDB 用 16KB 页装多行是同一个思路。
三、三层嵌套:本体不可变,标签可变
type MsgDocModel struct {
DocID string `bson:"doc_id"`
Msg []*MsgInfoModel `bson:"msgs"` // 长度固定 100,空洞是 nil
}
type MsgInfoModel struct {
Msg *MsgDataModel `bson:"msg"`
Revoke *RevokeModel `bson:"revoke"`
DelList []string `bson:"del_list"`
IsRead bool `bson:"is_read"`
}
MsgDataModel 含 SendID / RecvID / ClientMsgID / ServerMsgID / ContentType / Content / Seq / SendTime。
为什么拆三层:写入者不同。msg 由发送方在落库时一次写完,之后不再变;revoke 由撤回者事后写;del_list 由每个删消息的人事后写,群里可能有 500 个人;is_read 由接收方写。本体不可变、标签可变,分开之后改状态只需要 $set msgs.85.revoke,不必把整条消息读出来改完再写回。
四、位置寻址的四个纯函数
GetDocIndex = (seq-1) / singleGocMsgNumGetDocID = indexGen(conversationID, (seq-1)/100),即conversationID + ":" + strconv.FormatInt(suffix, 10)GetMsgIndex = (seq-1) % 100indexGen:拼 docID 的底层函数
验算一下:
| seq | docIndex | docID | 数组下标 |
|---|---|---|---|
| 1 | 0 | si_A_B:0 | 0 |
| 100 | 0 | si_A_B:0 | 99 |
| 101 | 1 | si_A_B:1 | 0 |
| 10086 | 100 | si_A_B:100 | 85 |
减一不能省。写成 seq/100,seq=100 会算成 doc 1、下标 0,和 seq=101 撞位;seq=0 会算成 doc 0,把空洞当成有效消息。
五、写入幂等状态机
Kafka 是 at-least-once,重复消费必然发生,写入必须幂等。这里没有建唯一索引,原因是消息在数组里,Mongo 没法给数组元素的某个字段建唯一索引来防止同一位置重复插入。
controller/msg.go:120 附近的做法:
tryUpdate := true // 乐观假设文档已存在
for i := 0; i ` | 改本体、撤回,`MatchedCount` 是幂等判据 |
| `PushUnique` | `$addToSet msgs..del_list` | 删除消息,去重 |
用 `$addToSet` 而不是 `$push`:同一个人点两次删除,`del_list` 里会出现两次同一个 userID。业务上判断“在不在”不受影响,但数组会无限膨胀。
## 七、读取三层
**第一层:按单条 seq 缓存**(Redis,TTL 24h)。`GetMessageBySeqs` 把 seq 列表拆成命中/未命中,未命中聚合成一次回源。按单条而非按文档缓存,因为撤回、删除、已读都只影响一条;按文档缓存的话,改一条要失效 100 条。
**第二层:按 docID 分组**(`GetDocIDSeqsMap`),一次读一个文档。20 条连续消息通常只落在一个 docID 上,读放大从 20 次查询变成 1 次。
**第三层:聚合只取需要的下标**:
```go
$project + $map + $arrayElemAt
// input: indexes 如 [85, 86, 87],as: index
// in: { $arrayElemAt: ["$msgs", "$$index"] }
不这么做就得把整个文档取回来,装满图片消息的文档可能几十 KB,而实际只需要其中三条。
八、三种删除语义
- 软删除(按人):长按删单条,
$addToSet del_list加 userID,只影响自己,理论可恢复。 - 区间清除(按人):清空聊天记录,抬高该用户的
userMinSeq水位,只影响自己整个会话,不可恢复。 - 物理删除(全局):管理员或合规操作,删文档或数组元素,影响所有人。
重点在第二种:清空聊天记录一条数据都不删,只抬高 minSeq 水位,之后 seq <= 水位 直接返回空。10 万条消息逐条标记要 10 万次写,抬水位只要 1 次。
这也解释了 seq 必须稠密:如果 seq 跳跃,“水位”这个概念就失效了。用户清空后管理员仍然查得到——数据没删,只是该用户的读视图变了;真想删走物理删除。
九、读时改写
同一条群消息对不同人显示不同:发送者看原文、已读者看已读、删除者看不到、撤回后所有人看撤回提示。如果每个视角存一份,一条群消息 × 500 人 = 500 份,写入直接爆炸。
所以只存一份事实加极少量标签,读的时候用“事实 + 标签 + 当前用户”算视图。三类标签:
del_list []string:在数组里的人看不到内容revoke {role, user_id, time}:对所有人变成撤回提示is_read bool:单聊专用
读时三步:拿原文 → 判断 userID ∈ del_list → 判断 revoke != nil。
代价有三个:
- 读放大:每次读都要做判断,可能还要重写 JSON。
- 逻辑重复:缓存命中和未命中是两条读路径,改写逻辑写了两遍,项目里确实重复。
- 状态无界增长:
del_list只增不减。500 人群全删就是 500 个字符串挂在一条消息上,项目没有清理机制,极端情况下会撞到 Mongo 单文档 16MB 上限导致写入失败。修法是超过 N 条降级成位图,或改存单独的删除表。
十、空洞与墓碑
号段模式必然跳号。客户端要 10001~10020,只查到 18 条,缺的 2 个是“不存在”还是“还没落库”,分不清,客户端就会重试或报错。
代码里有 GenExceptionMessageBySeqs(pkg/common/storage/model/msg.go:133-140),给缺失的 seq 造一个只有 seq、没有内容的占位消息(墓碑消息)。但全仓库搜索下来只有定义,没有任何调用方。
现状是“缺就缺了,返回 18 条”。客户端只能靠返回数量少于请求数量来推断空洞,用返回列表的最大值更新本地 maxSeq——如果最大 seq 恰好就是缺的那个,本地 maxSeq 停在旧值,下次重拉同一段。
为什么不直接跳过缺失 seq:因为不知道是“永远没有”还是“还没写进来”。前者可以跳过,后者跳过就丢消息。
十一、三个真 bug
IsFull()空数组 panic:len(m.Msg) == 0时m.Msg[-1]直接越界。应该在读到空或构造中文档时先判零长。IsRead出现两次:MsgInfoModel外层一个、MsgDataModel内层一个,读出时要把外层搬到内层。历史遗留冗余,看到重复字段别急着删。singleGocMsgNum5000 = 5000没人用:只被GetSingleGocMsgNum5000()这个 getter 引用,而 getter 本身没有调用方。说明曾经计划改桶大小,但没接上。
十二、三个坑,一共 14 小时
坑一(6 小时):迁移脚本直接补第 86 条消息,报 Cannot create field 'msg' in element ...。数组长度只有 1,msgs.85 越界,Mongo 不会自动扩容。修法是先把数组补齐到 100,或者 $push 逐条追加。教训是位置寻址前必须保证数组长度正确——分桶模型的隐含契约就是数组永远 100 个槽。
坑二(3 小时):同一个人反复删除/恢复,del_list 里出现 5 个相同 id。原因是用了 $push,改成 $addToSet。
坑三(5 小时):“清空聊天记录”被实现成查出所有消息逐条塞 del_list,10 万条的会话直接超时。改成抬高 minSeq 水位。
十三、桶大小写进寻址公式,线上就改不了
分桶大小一旦写进 (seq-1)/100,就等于写进了数据布局。
- 桶 100:
seq=150→ doc 1,下标 49 - 桶 200:
seq=150→ doc 0,下标 149
同一个 seq,文档和槽位同时变,历史数据全部错位。要改只能写迁移脚本重排全部历史数据,或者引入版本字段做双读。singleGocMsgNum5000 躺在那里没人用,原因就在这里。
最后几个数字
100:(seq-1)/100定文档,(seq-1)%100定下标。3:三个标签del_list / revoke / is_read。1:一条契约——数组永远 100 个槽。- 100 条约几 KB 到 30KB,接近一次 IO 的理想大小;1000 条会让单文档过大、更新成本变高。