案例 Go IM 消息存储拆解:100 条一桶的 Mongo 分桶、幂等写入与三个线上 bug

2026-11-06 20:02:57

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,一条消息一个文档:

  1. 文档数爆炸:100 万条消息就是 100 万个文档加 100 万个索引项,一个活跃群一天能产生几十万个文档。
  2. 读取放大:拉 20 条历史要么 20 次查询,要么 $in 查 20 个文档,但每个文档仍然要单独读页。
  3. 索引膨胀:每个文档都有 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) / singleGocMsgNum
  • GetDocID = indexGen(conversationID, (seq-1)/100),即 conversationID + ":" + strconv.FormatInt(suffix, 10)
  • GetMsgIndex = (seq-1) % 100
  • indexGen:拼 docID 的底层函数

验算一下:

seqdocIndexdocID数组下标
10si_A_B:00
1000si_A_B:099
1011si_A_B:10
10086100si_A_B:10085

减一不能省。写成 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

  1. IsFull() 空数组 panic:len(m.Msg) == 0 时 m.Msg[-1] 直接越界。应该在读到空或构造中文档时先判零长。
  2. IsRead 出现两次:MsgInfoModel 外层一个、MsgDataModel 内层一个,读出时要把外层搬到内层。历史遗留冗余,看到重复字段别急着删。
  3. 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 条会让单文档过大、更新成本变高。
复制全文 生成海报 Go MongoDB 存储设计 IM

推荐文章

程序员茄子在线接单