:一条消息只占 1/100 个文档,100 是改不了的硬编码)
你好我是利威尔xu。这是**「Go IM 源码实战」系列第 6 篇**。上一篇讲号怎么发这一篇讲消息存哪、怎么存。先看一个数字聊天记录一条消息占的不是一行而是 1/100 个文档。constsingleGocMsgNum100// pkg/common/storage/model/msg.go:11一个 MongoDB 文档里塞 100 条消息用数组装第 101 条消息进下一个文档。这个做法叫分桶Bucket Pattern。为什么不用 MySQL 一行一条为什么不是一条消息一个 Mongo 文档为什么偏偏是 100 而不是 1000这一篇把这三个问题讲透顺便讲清读时改写这个让所有新手困惑的设计——同一条群消息为什么对 500 个人显示 500 种样子却只存一份。源码位置全系列同一份GitHubhttps://github.com/CYQ111999/IM-Server基线OpenIM Server v3.8 瘦身版main 07f9ff6本篇涉及pkg/common/storage/model/msg.go、pkg/common/storage/database/mgo/msg.go、pkg/common/storage/controller/msg.go前 4 篇1 全局地图、2 WS 网关、3 HTTP API 层、4 发送链路。第 5 篇起全部在同名专栏里见作者文章列表系列导航在《新粉必看技术博客学习路线》。文章目录一、先把为什么不用 MySQL这个问题答干净二、如果一条消息一个 Mongo 文档会死在哪三、三层嵌套一个文档里到底装了什么四、位置寻址四个纯函数决定一切五、写入先 update 后 insert 的幂等状态机① tryUpdate 的语义是乐观假设已存在② 为什么插入必须整文档一次写完③ 循环里的 i-- 是最容易写错的地方六、三种写动作覆盖所有场景七、读取三层缓存 → 文档 → 下标八、三种删除语义完全不同九、读时改写一份数据N 种视角代价必须讲清楚十、空洞占位机制写好了但没人调用十一、三个真问题都和看起来没问题有关① IsFull() 会 panic② IsRead 字段出现了两次③ 一个没人调的常量 一处素材纠错十二、踩坑记录三个坑一共 14 小时十三、面试会怎么问十四、小结三个数字、三张标签、一条契约一、先把为什么不用 MySQL这个问题答干净很多人第一反应是聊天记录不就是一张表吗id / from / to / content / time跟订单表没区别。区别在于访问模式。把这五个维度列出来答案自己就出来了维度订单表聊天记录写读比读写均衡写多读少群里 500 人发可能只有 50 人在看更新状态反复变更待付→已付→已发只追加几乎不改唯一例外是撤回/已读结构强 schema字段固定弱 schema文本/图片/语音/自定义/引用字段差异大查询多维条件 聚合基本都是某会话的一段 seq增长可按用户归档无上限一个 500 人群一天能几十万条最关键的是第四行聊天记录的查询几乎只有一个维度——conversationID seq 区间。而 MySQL 的强项是多维度灵活查询 事务这里一条都用不上反而要为写入吞吐和无上限增长付出代价。MongoDB 的优势恰好对得上文档模型装弱 schema、按doc_id主键查询极快、水平分片容易、写入吞吐高。所以选型不是哪个数据库更好而是你的访问模式匹配哪个模型。这句话在面试里比背Mongo 是文档数据库值钱得多。面试追问那为什么不用 Redis 当主存储Redis 是内存 可能淘汰 主从切换可能丢最后一段数据。它可以当加速层不能当真相层。这个项目里 Redis 存的是最近消息的副本TTL 24 小时Mongo 才是唯一真相。二、如果一条消息一个 Mongo 文档会死在哪自然想法是一条消息一个文档_id用conversationID:seq。看起来干净但三个地方立刻出问题问题具体后果文档数爆炸100 万条消息 100 万个文档 100 万个索引条目一个活跃群一天几十万文档读取放大拉 20 条历史 20 次查询或一次$in查 20 个文档但每个文档都要单独读页索引膨胀每个文档都有conversation_idseq索引项索引大小 ≈ 数据大小第三点最容易被忽略。MongoDB 的索引是 B 树100 万个小文档的索引比 1 万个每桶 100 条的文档索引大得多——而索引是常驻内存的。分桶就是把这些小文档合并成大文档用一次 IO 换 100 条来摊薄开销。这和 InnoDB 的页16KB 一页装多行是同一个思路存储系统的性能永远取决于一次 IO 能拿回多少有用的数据。三、三层嵌套一个文档里到底装了什么// model/msg.go:20-23 一个 MongoDB 文档typeMsgDocModelstruct{DocIDstringbson:doc_idMsg[]*MsgInfoModelbson:msgs// 长度固定 100空洞是 nil}// model/msg.go:65-70 数组里的一个元素typeMsgInfoModelstruct{Msg*MsgDataModelbson:msg// 消息本体Revoke*RevokeModelbson:revoke// 撤回信息DelList[]stringbson:del_list// 谁删了这条IsReadboolbson:is_read// 单聊已读}// model/msg.go:40-63 消息本体对应 protobuf 的 MsgDatatypeMsgDataModelstruct{SendIDstringbson:send_idRecvIDstringbson:recv_idClientMsgIDstringbson:client_msg_idServerMsgIDstringbson:server_msg_idContentTypeint32bson:content_typeContentstringbson:contentSeqint64bson:seqSendTimeint64bson:send_time...}为什么是三层而不是把revoke/del_list塞进消息本体因为它们的写入者不同字段谁写什么时候写msg发送方消息落库时一次写完不再变revoke撤回者事后del_list每个删消息的人事后可能被 500 个人写is_read接收方事后消息本体是不可变的状态标签是可变的。分开之后改状态只需要$set msgs.85.revoke不需要把整条消息读出来改再写回——这在500 人群里 200 个人先后删同一条消息的场景下差别巨大。生活里的类比消息本体像发出去的快递单谁也改不了del_list像收件人自己的便签这单我拒收。便签可以贴很多张快递单只有一张。四、位置寻址四个纯函数决定一切分桶之后消息在哪个文档、哪个下标必须能算出来不能查。四个函数解决// model/msg.go:94-127删去无关行func(m*MsgDocModel)GetDocIndex(seqint64)int64{return(seq-1)/singleGocMsgNum}func(m*MsgDocModel)GetDocID(conversationIDstring,seqint64)string{returnm.indexGen(conversationID,(seq-1)/singleGocMsgNum)}func(m*MsgDocModel)GetMsgIndex(seqint64)int64{return(seq-1)%singleGocMsgNum}func(m*MsgDocModel)indexGen(conversationIDstring,seqSuffixint64)string{returnconversationID:strconv.FormatInt(seqSuffix,10)}拿纸笔验算一遍这张表建议收藏写分桶存储时直接照抄seqdocIndex (seq-1)/100docID数组下标 (seq-1)%10010si_A_B:001000si_A_B:0991011si_A_B:1010086100si_A_B:10085⚠️(seq-1)这个减一不能省。如果写成seq/100seq100会算成 docIndex 1、arrayIndex 0和seq101撞同一个位置seq0不存在会算成 docIndex 0把空洞当有效消息。一个小细节值得单独立一条 review 规则凡是位置计算都要拿边界值1、100、101、0验算一遍。五、写入先 update 后 insert 的幂等状态机这是整个存储层最烧脑的一段controller/msg.go:120附近。要解决的问题是Kafka 是 at-least-once同一批消息可能被消费两次。所以写入消息必须幂等写两次和写一次结果一样。为什么不建唯一索引因为消息在数组里MongoDB 没法给数组元素的某个字段建唯一索引来防重复插入同一个位置。所以用了一个状态机式的写法伪代码保留了关键分支tryUpdate:true// 乐观假设目标文档已存在fori:0;ilen(fields);i{seq:firstSeqint64(i)iftryUpdate{matched:UpdateMsg(docID,index,msg,field)// $set msgs.index.msgifmatched{continue}// 位置已有数据 → 幂等命中}// 位置是空的 → 构造整个文档把这一段全部塞进去一次插入doc:MsgDocModel{DocID:...,Msg:make([]*MsgInfoModel,100)}insert:0forj:i;jlen(fields);j{ifGetDocID(conversationID,firstSeqint64(j))!doc.DocID{break}doc.Msg[GetMsgIndex(firstSeqint64(j))]MsgInfoModel{Msg:fields[j]}insert}fillNil(doc)// 空洞补 MsgInfoModel{}别留 nulliferr:Create(ctx,doc);err!nil{ifmongo.IsDuplicateKeyError(err){// 别人抢先插了i--// 退回这个位置tryUpdatetrue// 后面全改用 updatecontinue}returnerr}tryUpdatefalse// 已经插过一块后面继续 insertiinsert-1// 跳过刚插进去的这一段}三个设计点①tryUpdate的语义是乐观假设已存在第一轮先假设文档在重投递的常见情况$set命中了就直接continue—— 一条消息都不多写这就是幂等。一旦成功插入了一块说明后面的块也是新的tryUpdate false省掉后面的 update 往返。② 为什么插入必须整文档一次写完因为MongoDB 的数组是位置敏感的。你要写msgs[85]但数组当前只有 1 个元素$set msgs.85.msg会直接报错或者建出奇怪的结构。唯一稳的做法是把 0~99 全部一次性填好空洞填空对象再插入。这也解释了为什么代码里有一段补 nil的逻辑不补齐数组长度就不是 100后面IsFull()、GetMsgIndex()的假设全部失效。③ 循环里的i--是最容易写错的地方插入撞了 duplicate key说明别的消费者抢先创建了同一个文档。此时不能跳过这条消息——它可能还没写进去。所以i--退回去下一轮改用 update 重试这一条。 这类回退一步重试的写法是乐观并发 幂等写入的经典模式。面试里聊幂等能说到用位置做天然唯一键 失败回退重试比说加锁高级。六、三种写动作覆盖所有场景动作实现用在关键点CreateInsertMany整文档新消息首次落库数组必须一次填满 100UpdateMsg$set msgs.i.field消息本体、撤回MatchedCount是幂等判据PushUnique$addToSet msgs.i.del_list删除消息去重同一个人删两次不会重复$addToSet这个选择很讲究用$push的话同一个人点两次删除就会在del_list里出现两次虽然业务上不影响判断在不在数组里但数组会无限膨胀。$addToSet用一点点性能换掉了一个长期的垃圾增长问题。七、读取三层缓存 → 文档 → 下标拉 20 条历史seq 10001~10020会走三层第一层按 seq 单条缓存RedisTTL 24 小时GetMessageBySeqs把一个 seq 列表拆成命中和未命中未命中的聚合成一次回源。命中的直接返回。为什么按单条缓存而不是按文档缓存因为撤回、删除、已读都只影响一条消息。按文档缓存的话撤回一条就要失效 100 条消息的缓存。第二层按 docID 分组一次读一个文档func(m*MsgDocModel)GetDocIDSeqsMap(conversationIDstring,seqs[]int64)map[string][]int64{t:make(map[string][]int64)for_,seq:rangeseqs{docID:m.GetDocID(conversationID,seq)t[docID]append(t[docID],seq)}returnt}20 条连续消息通常只落在一个 docID 里 → 1 次 Mongo 查询。这就是分桶最直接的收益读放大从 20 次变成 1 次。第三层文档内精准取下标用聚合管道的$map$arrayElemAt只把需要的那几个下标取出来bson.D{{Key:$project,Value:bson.D{{Key:msgs,Value:bson.D{{Key:$map,Value:bson.D{{Key:input,Value:indexes},// [85, 86, 87]{Key:as,Value:index},{Key:in,Value:bson.D{{Key:$arrayElemAt,Value:bson.A{$msgs,$$index}}}},}},}}}}}如果直接$match把整个文档取回来一个装满图片消息的文档可能几十 KB网络和反序列化都浪费。用$project只取需要的下标是分桶能真正省 IO 的关键一步——否则你只是把 100 条消息一次性搬回来。八、三种删除语义完全不同这一节是 IM 新手最容易写错的地方。产品嘴里的删除至少有三种类型触发存储动作影响范围可恢复软删除按人长按删除单条$addToSet del_list加 userID仅自己理论可区间清除按人清空聊天记录抬高该用户的userMinSeq水位仅自己整个会话否物理删除全局管理员/合规删文档或删数组元素所有人否重点看第二种「清空聊天记录」一条数据都不删。它只是把这个用户的minSeq水位往上抬之后查seq 水位的消息直接返回空。为什么必须这样一个 10 万条消息的会话逐条标记要 10 万次写抬高水位只要 1 次写。这就是用区间表达批量操作的威力。而且它反过来解释了上一篇的一条铁律seq 必须稠密否则区间表达不了。如果 seq 是一堆跳跃的大整数“水位这个概念就失效了——你没法说小于这个数的都清掉”。面试追问用户清了聊天记录管理员还能查到吗能。数据一条没删只是这个用户的读视图变了。这正是合规和用户可见删除的正确关系用户删的是自己的视图不是别人的数据。真想删数据只能走物理删除路径。九、读时改写一份数据N 种视角现在到了最关键的设计。同一条群消息四个人看到的样子完全不同视角发送者 A已读的 B删了这条的 C撤回后所有人内容原文原文看不到撤回提示已读标记无意义已读——如果每个视角存一份一条群消息 × 500 人 500 份 —— 这就是第 1 篇里1500 次写入的来源之一直接出局。所以只能读时改写read-time projection存一份事实 极少量谁对它做了什么的标签读的时候用「事实 标签 当前用户」现场算出视图。标签只有三类标签字段语义谁删了del_list []string在数组里的人看不到内容被撤回了revoke {role,user_id,time}对所有人变成撤回提示谁读过is_read bool单聊专用读的时候三步拿原文 → 判断userID ∈ del_list→ 判断revoke ! nil该清空的清空、该换文案的换文案。代价必须讲清楚代价具体表现读放大每次读都要判断 可能重写 JSON逻辑重复缓存命中/未命中两条读路径改写逻辑要写两遍项目里确实重复了状态无界增长del_list只增不减500 人群里所有人都删就是 500 个字符串挂在一条消息上第三条是真实隐患而且这个项目没有清理机制。生产上如果遇到热门消息被大量删除这个字段会持续变大直到超过单个文档 16MB 上限——那时候写入会直接失败。 面试里这么说是最专业的“读时改写的代价是标签无界增长。del_list只增不减在极端场景下会让文档膨胀到超过 Mongo 单文档上限。修法是超过 N 条时降级成位图或者单独的删除表。”十、空洞占位机制写好了但没人调用上一篇讲过号段模式必然产生空洞seq 跳号。空洞在读这一侧会变成麻烦客户端说我要 10001~10020服务端只查到 18 条缺的那 2 个 seq 是不存在还是还没落库如果客户端分不清就会一直重试或者报错。代码里确实准备了应对手段// model/msg.go:133-140func(*MsgDocModel)GenExceptionMessageBySeqs(seqs[]int64)(exceptionMsg[]*sdkws.MsgData){for_,v:rangeseqs{msgModel:new(sdkws.MsgData)msgModel.Seqv exceptionMsgappend(exceptionMsg,msgModel)}returnexceptionMsg}思路很标准给缺失的 seq 造一个占位消息只有 seq、没有内容让客户端知道这个位置确实没有消息别等了。这就是 IM 里常说的墓碑消息。⚠️ 但我在全仓库搜了一遍GenExceptionMessageBySeqs只有定义没有任何调用方。也就是说这套占位机制设计过、写好了、但没接上主流程。现在的行为是缺就缺了返回 18 条客户端只能靠返回数量少于请求数量来推断有空洞。现状客户端需要怎么做风险不做占位当前把缺失当成可以跳过更新本地 maxSeq 时用返回列表里的最大值如果最大 seq 就是缺的那个本地 maxSeq 会停在旧值下次还会重拉同一段做占位设计意图收到占位消息就把本地 maxSeq 推进到该 seq需要多传一批空对象流量略增 这是一个非常好的面试素材因为它能证明你不是读了个大概“这个项目里墓碑消息的函数写好了但没有任何调用方说明功能设计过但没启用。现在的同步逻辑依赖客户端自己跳过空洞——这在小空洞下没问题但如果连续几十个 seq 都缺失比如服务崩在落库前客户端可能反复重拉同一段。”面试追问为什么不直接把缺失的 seq 跳过因为你不知道它是永远没有还是还没写进来。前者可以跳过后者跳过就丢消息。所以要么服务端明确告诉你这里永远没有墓碑要么客户端保守地重试一次。区别在于谁承担不确定性——这个项目把它交给了客户端。十一、三个真问题都和看起来没问题有关①IsFull()会 panicfunc(m*MsgDocModel)IsFull()bool{returnm.Msg[len(m.Msg)-1].Msg!nil}len(m.Msg)为 0 时m.Msg[-1]直接 panic。这个函数在读到一个空文档/构造中的文档时就会被调用。防御式写法应该是先判len(m.Msg) 0。②IsRead字段出现了两次MsgInfoModel.IsRead外层和MsgDataModel.IsRead内层同时存在读出来时要把外层搬到内层。这是历史遗留的冗余看到重复字段别急着删——先查清哪条路径读哪个否则会静默丢状态。③ 一个没人调的常量 一处素材纠错const(singleGocMsgNum100singleGocMsgNum50005000// ← 改桶大小时留下的备选值)我查了全仓库它只被GetSingleGocMsgNum5000()这一个 getter 引用而这个 getter 没有任何调用方grep -rn GetSingleGocMsgNum5000只有定义。⚠️素材纠错项目学习手册里写的是定义了但全文未使用。更准确的说法是被一个没人调的 getter 包了一层——两者在 review 里的含义不同前者是纯粹的常量冗余后者是曾经计划过改成 5000留了个后门但没接上。这类半截设计在快速迭代的项目里非常常见也是读源码时最该记录的东西。十二、踩坑记录三个坑一共 14 小时坑一$set msgs.85报错卡了 6 小时现象自己写迁移脚本想直接往文档里补第 86 条消息报Cannot create field msg in element ...。定位文档里数组长度只有 1msgs.85超出边界。MongoDB 不会帮你自动扩容到 86 个元素。修复要么先把数组补齐到 100我们选的要么用$push逐条追加。教训位置寻址写之前必须保证数组长度正确。分桶模型的隐含契约是数组永远 100 个槽破坏它后面所有GetMsgIndex都会错位。坑二del_list出现重复 userID3 小时现象同一个人反复删除/恢复同一条消息del_list里出现 5 个相同 id。定位用了$push。修复改$addToSet。教训幂等类操作优先选$addToSet而不是$push。差一个字符一个字段的语义就完全不同。坑三清空聊天记录被实现成逐条标记5 小时现象产品提清空聊天记录同事实现成查出所有消息逐条塞 del_list10 万条的会话直接超时。定位把区间操作实现了成逐条操作。修复改成抬高minSeq水位。教训看到全部/批量这种需求第一反应应该是有没有一个数能代表这批。这在时序数据、消息、日志里都成立。十三、面试会怎么问追问怎么答一句话版聊天记录为什么不用 MySQL查询只有 conversationIDseq 一个维度还要高写入吞吐和无上限增长Mongo 文档模型更贴为什么要分桶100 条一个文档摊薄文档数和索引一次 IO 拿 100 条读放大从 20 次降到 1 次为什么是 100 不是 1000100 条约几 KB~30KB接近一次 IO 的理想大小1000 条会让单文档过大、更新成本高seq 怎么定位到文档和下标(seq-1)/100和(seq-1)%100减一不能省消息写入怎么保证幂等位置即唯一键先$set试命中未命中插入整文档撞 duplicate key 就回退重试为什么插入要整文档Mongo 数组位置敏感没法跳着写msgs.85一条群消息为什么只存一份读时改写存事实 三个标签del_list/revoke/is_read读时按用户算视图清空聊天记录会删数据吗不会只抬高 minSeq 水位1 次写替代 10 万次这套设计有什么风险del_list无界增长可能撑爆单文档 16MBIsFull()空数组 panic改写逻辑两处重复十四、小结三个数字、三张标签、一条契约100一个文档 100 条(seq-1)/100定文档、(seq-1)%100定下标。3 个标签del_list/revoke/is_read撑起所有每个人看到不一样的需求。1 条契约数组永远 100 个槽破坏它寻址全错。下面这张表建议收藏下次你要存高写入 按区间查 有个人状态的数据消息、通知、日志、动态流都适用照着对一遍检查项该问什么本项目的答案主键设计用自增 ID 还是位置表达式位置表达式conversationID:docIndex分桶大小单文档多大合适100 条约 30KB寻址怎么从业务 key 算出物理位置两次整除 取余注意减一幂等重复消费怎么不重复写先 update 再 insert撞键回退重试部分读只取需要的那几条$project$map$arrayElemAt个人状态每人一份还是共享一份共享事实 标签读时改写批量删除逐条标记还是水位水位minSeq1 次写缓存粒度按条还是按桶按条变更只影响一条隐患哪个字段会无限增长del_list需要上限或降级方案扩容桶大小能中途改吗不能改了历史数据的寻址全错空洞读不到时怎么告诉客户端目前靠少返回占位函数没接上还有一个容易被忽略的迁移问题分桶大小写进了寻址公式等于写进了数据布局。一旦线上已经有数据singleGocMsgNum就再也改不了了——举例桶是 100 时seq150落在文档 1 的第 49 槽桶改成 200 后同一个 seq 变成文档 0 的第 149 槽——文档和槽位同时变了历史数据全部错位。要改只能写迁移脚本把所有历史数据重排一遍或者引入版本字段做双读。这就是为什么那里躺着一个没人用的singleGocMsgNum5000——想法有过路走不通。留个问题评论区一句话能答如果singleGocMsgNum从 100 改成 200已经存进 Mongo 的历史数据会发生什么如果这篇帮你把聊天记录怎么存彻底想明白了点个赞、收个藏——这个系列后面还有 10 篇下一篇讲同步与已读四个 seq 怎么配合才能做到断线三天重连也不丢一条。下一篇《Go IM 源码实战7四个 seq 一台戏断线三天重连为什么不丢一条》——maxSeq/hasReadSeq/minSeq/userMinSeq分别管什么。作者利威尔xu | CSDN 专栏《Go IM 源码实战》