你好,我是利威尔xu。
这是**「Go IM 源码实战」系列第 13 篇**。前面讲完消息怎么存、怎么拉,这一篇讲用户第一眼看到的东西:会话列表。
问题是这样的:一个用户有 30 个会话,其中 28 个都有新消息,但他只看到 2 个红点。红点少了 26 个。
排查下来发现三个不同的原因:有的会话是免打扰(不算红点),有的群被设成"消息免打扰",还有一个是因为消息被归到了 n_ 通知会话里(第 9 篇讲的系统消息)。同一个"红点",背后是三条不同的链路。
这篇讲三件事:会话 ID 是怎么拼的(一行 sort 决定系统能不能工作)、未读数为什么能直接用减法算、以及我在这段排序代码里发现的一个真 bug。
源码位置(全系列同一份):
- GitHub:https://github.com/CYQ111999/IM-Server
- 基线:OpenIM Server v3.8 瘦身版,main @
07f9ff6 - 本篇涉及:
pkg/msgprocessor/conversation.go、internal/rpc/conversation/conversation.go、pkg/common/storage/model/conversation.go
前 4 篇:1 全局地图、2 WS 网关、3 HTTP API 层、4 发送链路。第 5 篇起全部在同名专栏里,见作者文章列表;系列导航在《新粉必看:技术博客学习路线》。
文章目录
-
- 一、会话到底是什么
- [二、会话 ID:一行 `sort` 决定系统能不能工作](#二、会话 ID:一行
sort决定系统能不能工作) -
- [`sort.Strings` 是精髓](#
sort.Strings是精髓) - [`sg_` 里的 s 不是 super](#
sg_里的 s 不是 super) - [通知会话:`n_` 前缀](#通知会话:
n_前缀)
- [`sort.Strings` 是精髓](#
- 三、未读数:为什么可以直接减
- [四、会话排序:一个用时间戳当 map key 的真 bug](#四、会话排序:一个用时间戳当 map key 的真 bug)
-
- 问题在哪
- [为什么这个 bug 特别阴险](#为什么这个 bug 特别阴险)
- 正确写法
- 为什么排序必须在服务端做
- 排序用的是接收时间,不是发送时间
- 五、免打扰:为什么是三态不是布尔
- 六、会话是"懒创建"的
-
- [单聊建两条,群聊建 N 条](#单聊建两条,群聊建 N 条)
- 创建失败只记日志,不返回错误
- [群聊建完还要把水位设成 0](#群聊建完还要把水位设成 0)
- 七、删除会话:两个互斥参数,一个好设计
- [八、阅后即焚:不删消息,抬 `min_seq`](#八、阅后即焚:不删消息,抬
min_seq) - [九、会话表的 13 个字段,全是冗余](#九、会话表的 13 个字段,全是冗余)
- [十、踩坑记录:两个坑,一共 5 小时](#十、踩坑记录:两个坑,一共 5 小时)
- 十一、面试会怎么问
- 十二、小结:一个排序、一个减法、一个水位
一、会话到底是什么
一句话:会话是"一个用户对一段对话的视角"。
同一个群,500 个人有 500 条会话记录,每条记录的 last_msg、unread_count、is_pinned、is_muted 都是每个人独立的。而群消息只存一份(第 6 篇)。
所以:
| 数据 | 存几份 | 谁的视角 |
|---|---|---|
| 群 / 群成员 | 1 份 / 每人一条 | 群 |
| 消息 | 1 份(读扩散) | 群 |
| 会话 | 每人一条 | 用户 |
| 已读水位 | 每人每条会话一个 | 用户 |
| 未读数 | 不算,减法得出 | 用户 |
能不能不建会话表,全靠算?
技术上可以:会话列表 = "我参与的群 + 我跟谁聊过",最后一条消息从消息表查,未读数用减法,置顶/免打扰从另一张表查。
但这样每次打开 App 要算的东西太多 :几十个群 + 几百个单聊,每个都要查最后一条消息、算未读、查设置,然后排序。这个列表是全 App 最高频的查询(每次切前后台都要刷)。
所以会话表本质是一张为用户视角预计算好的物化视图(materialized view)。它的字段几乎全是"算出来的冗余",冗余的目的是把 O(会话数 × 查询) 降成一次分页查询。
📌 判断一张表该不该存在,就看它是不是"高频查询的物化视图"。 是的话,冗余完全值得;代价是每次源头变更都要同步更新它------这就是会话这块最复杂的地方。
二、会话 ID:一行 sort 决定系统能不能工作
pkg/msgprocessor/conversation.go:85-101:
go
func GetConversationIDBySessionType(sessionType int, ids ...string) string {
sort.Strings(ids) // ← 关键:先排序
if len(ids) > 2 || len(ids) < 1 {
return ""
}
switch sessionType {
case constant.SingleChatType:
return "si_" + strings.Join(ids, "_")
case constant.WriteGroupChatType:
return "g_" + ids[0]
case constant.ReadGroupChatType:
return "sg_" + ids[0]
case constant.NotificationChatType:
return "sn_" + strings.Join(ids, "_")
}
return ""
}
四种前缀:
| 前缀 | 用途 | 例子 |
|---|---|---|
si_ |
单聊(single) | si_userA_userB |
g_ |
写扩散群 | g_123456 |
sg_ |
读扩散群 | sg_123456 |
sn_ |
服务端通知 | sn_userA_userB |
sort.Strings 是精髓
A 发给 B 和 B 发给 A,拼出来的会话 ID 必须相同:
go
sort.Strings([]string{"userB", "userA"}) // → ["userA", "userB"]
// "si_userA_userB"
如果忘了排序 :A 看到的是 si_userA_userB,B 看到的是 si_userB_userA------两个人在两个不同的会话里聊天,消息互相看不见。
📌 这是"一行代码决定系统能否工作"的典型例子。而且它极难测出来:如果测试时只从一个方向发消息,一切正常;上线后用户互相发,才发现"我发的他能收到,他发的我收不到"(或者两边都收不到,取决于谁建了会话)。
这类问题的排查成本极高,因为现象不像"会话 ID 算错",而像"消息丢了"。
sg_ 里的 s 不是 super
WriteGroupChatType → g_(写扩散群),ReadGroupChatType → sg_(读扩散群)。
而本项目实际用的是 ReadGroupChatType(group.go:431 生成 sg_ 前缀),也就是读扩散。g_ 前缀是历史遗留或未启用的模式。
⚠️ 命名陷阱 :sg_ 里的 s 更可能是"session + group",不是"super group";而写扩散的 g_ 反而没有 s。这套命名是历史积累的产物,不要从字面猜语义。
这是我读这个项目最大的体会之一:看到"缩写 + 下划线"的 ID 格式,一定要找到生成它的唯一函数,别从名字猜。 猜错的代价是把
g_当成主路径去分析,然后发现整条链路对不上。
通知会话:n_ 前缀
GetConversationIDByMsg(conversation.go:54-83):
go
func GetConversationIDByMsg(msg *sdkws.MsgData) string {
options := Options(msg.Options)
switch msg.SessionType {
case constant.SingleChatType:
l := []string{msg.SendID, msg.RecvID}
sort.Strings(l)
if !options.IsNotNotification() {
return "n_" + strings.Join(l, "_") // ← 通知消息 → n_ 会话
}
return "si_" + strings.Join(l, "_")
// ...
}
}
同一个单聊,有两种会话 ID:
| 消息类型 | 会话 ID | 例子 |
|---|---|---|
| 普通消息 | si_ |
si_userA_userB |
| 通知消息 | n_ |
n_userA_userB |
为什么要分开?四个理由,每一个都很实际:
| 理由 | 如果不分开会怎样 |
|---|---|
| 通知不该算未读 | "B 同意了你的好友申请"混在未读消息里 |
| 通知不该改排序 | 会话列表会因为系统通知频繁跳动 |
| 通知不需要进正常消息表 | 系统消息占了聊天记录的位置 |
| 客户端要能单独决定显示 | 无法把通知收到"系统消息"里 |
IsNotification 就一句:
go
func IsNotification(conversationID string) bool {
return strings.HasPrefix(conversationID, "n_")
}
三处调用,第三处最关键:
msggateway/client.go:250:推送时判断,决定是否发送;push/push_handler.go:314:离线推送时判断;rpc/msg/sync_msg.go:25:拉消息时跳过通知会话。
最后这条意味着:拉历史消息时,n_ 会话的消息不会被拉下来 (除非客户端单独请求 n_ 会话)。
📌 这就是"系统消息"和"聊天消息"在客户端分开的根源。 你在 App 里看到"某某加入了群聊"显示在聊天窗口里(第 9 篇讲的克隆一份进消息会话),同时"好友申请"在系统消息里------两件事的分界线就是前缀。
⚠️ 顺带一个观察:GetConversationIDBySessionType 里还定义了 sn_ 前缀,但 GetConversationIDByMsg 只有"通知会话 + 非通知消息"时才返回 sn_------这个组合几乎不会出现。
所以实际运行中会话 ID 只有 si_、sg_、n_ 三种,sn_ 是个未完成的设想。 这种"定义了但没启用"的枚举值,读代码时要认得出来,否则会浪费时间分析一条不存在的路径。
三、未读数:为什么可以直接减
朴素做法是数出来:
sql
SELECT COUNT(*) FROM message
WHERE conversation_id = 'xxx' AND seq > 我的已读位置 AND 不是我发的
问题是:200 个会话要 count 200 次,而消息是分桶存的(100 条一个文档),count 要遍历多个文档。
本项目的做法是减法(conversation.go:166-170):
go
for conversationID, maxSeq := range maxSeqs {
unreadCount := maxSeq - hasReadSeqs[conversationID]
conversation_unreadCount[conversationID] = unreadCount
unreadTotal += unreadCount
}
未读数 = 会话最大 seq - 我的已读 seq。
这个公式成立,需要四个前提同时满足:
| 前提 | 谁保证 |
|---|---|
| seq 在会话内严格连续 | 第 5 篇的号段分配(空洞用占位填) |
| 每条消息恰好占一个 seq | 撤回不删消息,只改状态 |
自己发的消息也占 seq ,且发送时把自己的 hasReadSeq 推到最新 |
第 8 篇的 SetHasReadSeqs |
hasReadSeq 单调不减 |
只允许前进,不允许回退 |
第三个最容易被忽略:你发了 10 条消息、对方没回,你的未读数应该是 0。 如果发送时不推自己的水位,你自己发的 10 条会被算成 10 条未读。
📌 所以这个减法公式的完整表述应该是:未读 = maxSeq - hasReadSeq,前提是 hasReadSeq 包含"自己发过的所有消息"。
边界:减出来是负数怎么办
如果 hasReadSeq > maxSeq(已读位置超过最新消息),减出来是负数。
代码里没有 max(0, ...) 的保护。正常不会发生,但并发或数据损坏时可能出现,客户端直接显示负数会很难看。
📌 这是一个典型的"该不该防御"的判断 :加一行 if unread < 0 { unread = 0 } 成本几乎为零,而线上出现一次负红点的成本是用户投诉 + 排查半天。这类"廉价防御"我倾向于都加上------前提是加上之后要能记录一次异常指标,否则它会掩盖真正的数据问题。
四、会话排序:一个用时间戳当 map key 的真 bug
排序规则本身很标准:
1. 置顶的排前面
2. 置顶的之间按"最后消息时间"倒序
3. 非置顶的之间按"最后消息时间"倒序
实现(conversation.go:172-204):
go
conversation_isPinTime := make(map[int64]string)
conversation_notPinTime := make(map[int64]string)
for _, v := range conversations {
conversationID := v.ConversationID
var time int64
if _, ok := conversationMsg[conversationID]; ok {
time = conversationMsg[conversationID].MsgInfo.LatestMsgRecvTime // 最后消息时间
} else {
conversationMsg[conversationID] = &pbconversation.ConversationElem{...}
time = v.CreateTime.UnixMilli() // 没消息就用创建时间
}
if v.IsPinned {
conversation_isPinTime[time] = conversationID // ← ⚠️ 用时间戳当 map key
continue
}
conversation_notPinTime[time] = conversationID
}
问题在哪
map[int64]string 的 key 是毫秒时间戳,value 是会话 ID。
如果两个会话在同一个毫秒收到消息,后者会覆盖前者------会话列表里少一个会话。
这在活跃用户身上很容易发生:群 A 和群 B 在同一毫秒各来一条消息(批量推送、多端同步、或者只是巧合),列表就少一个。
为什么这个 bug 特别阴险
| 特征 | 后果 |
|---|---|
| 概率低但非零 | 单人测试几乎不会碰到,压测并发时才会 |
| 现象是"少一个会话" | 用户以为是 App 的问题,不会报具体 bug |
| 下一次刷新可能就好了 | 因为时间戳变了,覆盖关系消失 → 完全不可复现 |
"不可复现 + 不报错 + 随机丢数据",是 bug 里最难查的一类。
正确写法
两种都可以:
go
// 方案一:时间戳 → 会话 ID 列表,同时间的按 ID 再排
map[int64][]string
// 方案二:切片 + sort.Slice(推荐,更直观)
type convWithTime struct{ id string; t int64 }
list := make([]convWithTime, 0, len(conversations))
sort.Slice(list, func(i, j int) bool {
if list[i].t == list[j].t { return list[i].id < list[j].id } // 同时间用 ID 兜底,保证稳定
return list[i].t > list[j].t
})
方案二还顺手解决了另一个问题:排序结果稳定 。用 map 的话,同 key 覆盖后剩下的那个是谁,取决于遍历顺序------每次刷新可能不一样,用户会看到会话顺序莫名跳动。
📌 这是本篇我认为最有价值的一处发现。 面试时讲"我发现会话排序用时间戳当 map key,同一毫秒会覆盖导致丢会话",比背十张架构图有说服力------因为它需要你同时看懂数据结构选择、并发时序和线上现象。
为什么排序必须在服务端做
因为客户端数据可能不全(分页拉取)。客户端只拉了前 20 条,无法正确排序全部 200 条。
所以服务端排完全部,再分页(:206):
go
resp.ConversationElems = datautil.Paginate(resp.ConversationElems, int(req.Pagination.GetPageNumber()), int(req.Pagination.GetShowNumber()))
代价:每次拉列表都要排序全部会话。 200 个会话是微秒级,可接受;2000 个会话(重度用户)就开始有压力了。
优化方向:把排序结果缓存,或者按"置顶/非置顶"维护两个有序结构(比如 Redis ZSET,score 用时间戳)。本项目没做。
📌 这里有个很实用的判断 :"每次请求都全量排序"在数据量 < 1000 时几乎无感,> 5000 时必成瓶颈。 所以早期不用优化,但要在设计时留好"换成有序结构"的口子(比如把排序逻辑抽成一个函数,而不是散在 handler 里)。
排序用的是接收时间,不是发送时间
注意 LatestMsgRecvTime(接收时间)而不是 LatestMsgSendTime(发送时间)。
因为多端同步时,发送时间由发送方客户端决定,不可信(客户端时钟可能错几分钟)。而接收时间是服务端在处理消息时打的,可信。
📌 一条通用原则:凡是用来做排序、分页、过期的"时间",都必须用服务端时间。 客户端时间只适合做展示(比如"3 分钟前")。
五、免打扰:为什么是三态不是布尔
model/conversation.go:17 的 RecvMsgOpt 是 int32,不是 bool:
| 值 | 含义 | 收消息 | 算未读 | 推送 |
|---|---|---|---|---|
| 0 | 正常接收 | ✅ | ✅ | ✅ |
| 1 | 接收但不通知 | ✅ | ✅ | ❌ |
| 2 | 不接收 | ❌(或收但不展示) | ❌ | ❌ |
为什么三个状态? 因为"免打扰"和"不接收"是两件完全不同的事:
- 免打扰:消息还是要来的,未读数要涨,只是不弹通知不响铃(比如工作群在下班后);
- 不接收:连未读数都不涨(比如退订的营销群、被折叠的群)。
用 bool 只能表达"打扰/不打扰",无法表达"完全不收"。
📌 这解释了文章开头"30 个会话只有 2 个红点"的第一个原因:28 个里有 26 个是 opt=2(不接收)。它们的消息根本不进这个用户的会话。
免打扰在哪一层过滤
在线/离线推送前,push 服务会先问会话服务(conversation.go:502-524):
go
func (c *conversationServer) GetConversationOfflinePushUserIDs(ctx, req) (...) {
userIDs, err := c.conversationDatabase.GetConversationNotReceiveMessageUserIDs(ctx, req.ConversationID)
if len(userIDs) == 0 {
return &pbconversation.GetConversationOfflinePushUserIDsResp{UserIDs: req.UserIDs}, nil
}
// 从候选里剔掉
userIDSet := make(map[string]struct{})
for _, userID := range req.UserIDs { userIDSet[userID] = struct{}{} }
for _, userID := range userIDs { delete(userIDSet, userID) }
return &pbconversation.GetConversationOfflinePushUserIDsResp{UserIDs: datautil.Keys(userIDSet)}, nil
}
⚠️ 注意函数名:GetConversationNotReceiveMessageUserIDs 只查 opt=2(不接收),不查 opt=1(免打扰)。
也就是说:免打扰的会话,服务端仍然会推离线推送。 要么是客户端收到后自己静默处理,要么是这里漏了。
📌 如果确实是客户端处理,代价是:推送已经发出去了,流量和电量已经消耗了,只是没弹出来。 对"免打扰"这种高频状态(很多人所有群都设免打扰),这个成本不小。
这是我认为这个项目里值得查证并改进的一处 :服务端过滤 opt=1,能省掉大量无效推送。但要注意------有些产品希望在"通知中心"里还能看到免打扰的消息 ,那就不能纯服务端过滤,得区分"推送到通知栏"和"推送到消息列表"。这已经不是一个技术问题,是产品定义了。
六、会话是"懒创建"的
会话什么时候创建?
不是"用户注册时给每个可能的关系建一个"------1 万个用户理论上 5000 万个单向关系,组合爆炸。
而是 "第一条消息发出时创建"。CreateSingleChatConversations 的注释写得很直白:
go
// 为消息 Redis 转存创建会话,不发送通知。
func (c *conversationServer) CreateSingleChatConversations(ctx context.Context, req *...) ...
"为消息 Redis 转存创建会话" ------它是在 msgtransfer 把消息写进 Redis 时调用的(第 8 篇那个 isNewConversation 分支),不是用户主动创建。
单聊建两条,群聊建 N 条
go
// 发送方视角
conversation.OwnerUserID = req.SendID
conversation.UserID = req.RecvID
c.conversationDatabase.CreateConversation(ctx, []*dbModel.Conversation{&conversation})
// 接收方视角
conversation2 := conversation // ← 值拷贝
conversation2.OwnerUserID = req.RecvID
conversation2.UserID = req.SendID
c.conversationDatabase.CreateConversation(ctx, []*dbModel.Conversation{&conversation2})
一个单聊要建两条会话记录(两个人各自的视角),一个群聊要建 N 条(每个成员一条)。
📌 这里的 conversation2 := conversation 是值拷贝 。安全的前提是 Conversation 结构体里全是值类型(string/int32/bool/time.Time)。如果哪天有人加了一个 []string 或 *Foo 字段,这个拷贝就会共享底层数据,改一个影响另一个。
Go 里做"复制一份再改",一定要确认结构体里没有引用类型字段。 这是一条很容易被后续改动破坏的隐式约束。
创建失败只记日志,不返回错误
go
err := c.conversationDatabase.CreateConversation(ctx, []*dbModel.Conversation{&conversation})
if err != nil {
log.ZWarn(ctx, "create conversation failed", err, "conversation", conversation)
}
这是有意的:会话创建失败不该阻塞消息投递。消息已经写进 Redis 了,没有会话只是"列表里看不到",用户还能收到推送。
但它留下一处不一致 :消息存在,会话不存在。修复靠下次发消息时再试(CreateConversation 是 upsert)。
📌 这个取舍我认为是对的------"主流程不能被附属流程拖死" 。但代价是必须保证那个"下次会重试"的路径真的存在。如果没有重试路径,这就不是"降级",而是"静默丢数据"。 两者的区别只在于"会不会有人来补"。
群聊建完还要把水位设成 0
go
if err := c.msgClient.SetUserConversationMaxSeq(ctx, conversation.ConversationID, req.UserIDs, 0); err != nil {
return nil, err
}
这就是"新入群看不到历史消息"的实现 :把每个新成员的 userMaxSeq 设成 0。
等等,设成 0 不是"什么都能看到"吗?
不是。第 7 篇讲的边界是下界取 max :max(会话minSeq, userMinSeq)。新成员的 userMinSeq 由入群时单独设置(setMemberJoinSeq),这才是挡住历史的那个值。userMaxSeq = 0 影响的是上界(min(会话maxSeq, userMaxSeq))------如果真是 0,那这个用户什么都拉不到。
📌 所以这里有一个约定 :userMaxSeq = 0 表示"未初始化",读的时候要当作"不限制"处理,而不是"上限为 0"。这种"用 0 表示未设置"的约定,在代码里必须查一次读路径才能确认------否则你会得出"新入群的人拉不到任何消息"的错误结论。
我在这里踩过一次:一开始按字面理解
MaxSeq = 0,觉得是个 bug;跟到读路径才发现 0 被特判了。读源码时看到"把某个值设成 0/空/负数",一定要去找读它的地方,而不是相信字面意思。
七、删除会话:两个互斥参数,一个好设计
DeleteConversations(conversation.go:768+):
go
if req.NeedDeleteTime == 0 && len(req.ConversationIDs) == 0 {
return nil, errs.ErrArgs.WrapMsg("need_delete_time or conversationIDs need be set")
}
if req.NeedDeleteTime != 0 && len(req.ConversationIDs) != 0 {
return nil, errs.ErrArgs.WrapMsg("need_delete_time and conversationIDs cannot both be set")
}
两种删除模式:
| 参数 | 语义 |
|---|---|
ConversationIDs |
删指定的几个 |
NeedDeleteTime |
删"N 天没有消息"的会话(time.Now().AddDate(0, 0, -N)) |
为什么禁止同时传? 因为语义冲突------到底是"删这几个"还是"删 N 天没动的"?
📌 用"互斥参数"表达两种模式,比开两个接口好:客户端只学一个接口,服务端只有一条代码路径。缺点是约束藏在代码里,调用方不看注释不知道。
更好的做法 :用 oneof(protobuf 原生支持)表达互斥,让约束出现在生成的类型里,编译期就能发现。
八、阅后即焚:不删消息,抬 min_seq
这是整个会话模块里最有意思的一个设计。
直觉上"阅后即焚"应该是"时间到了把消息删掉"。但本项目的做法是(conversation.go:720-748):
go
for i, conversation := range conversations {
if !conversation.IsMsgDestruct || conversation.MsgDestructTime == 0 {
continue // 没开启阅后即焚
}
seq, err := c.msgClient.GetLastMessageSeqByTime(ctx, conversation.ConversationID,
req.Timestamp-(conversation.MsgDestructTime*1000)) // 当前时间 - 存活时长
if seq <= 0 {
c.setConversationMinSeqAndLatestMsgDestructTime(ctx, ..., -1, latestMsgDestructTime)
continue
}
seq++
c.setConversationMinSeqAndLatestMsgDestructTime(ctx, ..., seq, latestMsgDestructTime)
}
核心:抬高 min_seq,一条消息都不删。
MsgDestructTime = 60 秒(消息活 60 秒)
当前时间 T → T-60 之前的消息都该"焚毁"
GetLastMessageSeqByTime(conv, T-60) → 找到那条的 seq=100
min_seq = 101 → 用户只能看到 seq >= 101 的消息
为什么不真删
| 原因 | 说明 |
|---|---|
| 群聊消息是共享的 | A 的阅后即焚不该影响 B 的可见性 |
| 不可逆 | 真删是物理操作,删错就没了;抬 min_seq 只改一个数字 |
| 成本 | 删 100 条要动 N 个分桶文档(第 6 篇),抬水位是 1 次更新 |
seq++ 的原因 :GetLastMessageSeqByTime 返回"时间点之前的最后一条"(= 最后一条该焚的),而 min_seq 是"第一条可见的",所以 +1。
FindRandConversation(timestamp, limit) 是"随机取一批"------因为要处理全站所有会话,一次处理不完,分批轮询。latestMsgDestructTime 记录"上次处理到哪个时间点",用于断点续传和监控延迟。
📌 "逻辑删除优于物理删除"是一条很有力的通用原则。 凡是"按时间/条件让数据不可见"的需求(阅后即焚、清空记录、撤回、被踢后不能看新消息、付费墙),都优先考虑"改一个水位/状态",而不是"删数据":
- 可逆(删错了能恢复);
- O(1)(不用扫描和删除);
- 不影响其他视角(群聊共享数据的关键)。
这条原则的边界 :涉及合规删除 (GDPR"被遗忘权")时不能只做逻辑删除,必须有真正的物理删除路径------否则数据还在库里。所以生产系统通常是"逻辑删除 + 定期物理清理"两层。
九、会话表的 13 个字段,全是冗余
go
type Conversation struct {
OwnerUserID string `bson:"owner_user_id"` // 1. 谁的会话
ConversationID string `bson:"conversation_id"` // 2. 哪个对话
ConversationType int32 `bson:"conversation_type"` // 3. 单聊/群聊/通知
UserID string `bson:"user_id"` // 4. 单聊对方
GroupID string `bson:"group_id"` // 5. 群聊的群
RecvMsgOpt int32 `bson:"recv_msg_opt"` // 6. 免打扰三态
IsPinned bool `bson:"is_pinned"` // 7. 是否置顶
IsPrivateChat bool `bson:"is_private_chat"` // 8. 是否私密聊天
BurnDuration int32 `bson:"burn_duration"` // 9. 阅后即焚时长
GroupAtType int32 `bson:"group_at_type"` // 10. @ 我的类型
AttachedInfo string `bson:"attached_info"` // 11. 扩展(草稿等)
Ex string `bson:"ex"` // 12. 业务扩展
MaxSeq int64 `bson:"max_seq"` // 13. 最新消息 seq
}
这 13 个字段里,只有 6、7、8、9、11 是"用户自己设的",其余全是"从别处算出来/同步过来的"。
MaxSeq来自消息写入(每来一条消息就要更新);ConversationType/UserID/GroupID来自会话 ID 和群信息;GroupAtType来自 @ 消息(有人 @ 我时置位)。
📌 这就是"物化视图"的代价:13 个字段,5 个源,每次源头变更都要同步。 会话这块所有的复杂度都来自这一句话------第 12 篇的"解散群删 8 类缓存"也一样。
判断一个"物化视图"设计得好不好,看两件事:
- 同步是推送还是拉取?(推送快但会丢,拉取准但慢)
- 不同步的后果是"看不到"还是"看到错的"?(后者严重得多)
比如 MaxSeq 没同步 → 未读数偏小(少一个红点,用户可能漏消息);RecvMsgOpt 没同步 → 免打扰失效(半夜被吵醒)。后者对体验的伤害大得多,所以用户设置的同步优先级应该高于计数类字段。
十、踩坑记录:两个坑,一共 5 小时
坑一:会话列表少一个(3 小时)
现象:用户反馈"我有一个群不见了",刷新几次又出现了。
定位:第四节那个 map[int64]string 用毫秒时间戳当 key 的 bug。两个群在同一毫秒收到消息,后者覆盖前者。
为什么难查:因为刷新就好了(时间戳变了),日志里也没报错。最后是压测时用脚本统计"返回条数 vs 库里条数",才稳定复现。
改法:改成 sort.Slice + 时间相同按 ID 兜底。
坑二:免打扰的群半夜还在响(2 小时)
现象:用户把某个群设成"免打扰",白天正常,半夜还是弹通知。
定位:第五节那个 GetConversationNotReceiveMessageUserIDs 只过滤 opt=2(不接收),不过滤 opt=1(免打扰)。
改法:在 push 服务里补了一层 opt=1 的过滤(读会话缓存判断)。代价是每次推送多一次缓存查询。
教训 :"免打扰"这个词在服务端和客户端是两种实现。 必须查清楚到底是"不推"还是"推了不显示"------前者省流量省电,后者省开发量。做需求时就要定清楚,不然上线后才发现"免打扰"其实一直在耗流量。
十一、面试会怎么问
| 追问 | 怎么答(一句话版) |
|---|---|
| 会话表有必要吗?不都是算出来的? | 它是用户视角的物化视图,把 N 次查询降成一次分页;代价是源头变更要同步 |
| 会话 ID 怎么拼? | 四个前缀 + sort.Strings 保证 A→B 和 B→A 是同一个 ID |
为什么有 n_ 通知会话? |
通知不该算未读、不该改排序、不该进正常消息表 |
| 未读数为什么能减? | maxSeq - hasReadSeq,前提是 seq 连续、每条占一个、自己发的也推水位、水位只增 |
| 会话排序有什么坑? | 用毫秒时间戳当 map key,同一毫秒会覆盖丢会话;应改 sort.Slice |
| 排序用发送时间还是接收时间? | 接收时间,客户端时间不可信 |
| 免打扰为什么是三态? | 免打扰(收但不清醒)和不接收(收都不收)是两件事,bool 表达不了 |
| 阅后即焚怎么实现? | 不删消息,抬 min_seq;因为群聊消息共享,且逻辑删除可逆、O(1) |
| 这个项目会话模块有什么问题? | 排序用时间戳当 map key、未读数没有负数防御、免打扰只在客户端过滤、sn_ 前缀是死枚举 |
十二、小结:一个排序、一个减法、一个水位
- 一个排序 :
sort.Strings决定 A→B 和 B→A 是同一个会话;排序时用接收时间、别用 map key 存时间戳。 - 一个减法 :未读 =
maxSeq - hasReadSeq,四个前提缺一不可。 - 一个水位 :
min_seq抬起来就能实现阅后即焚/清空/被踢------逻辑删除优先于物理删除。
这张表建议收藏,做任何"列表 + 红点"的功能都能套:
| 检查项 | 该问什么 | 本项目的答案 |
|---|---|---|
| 列表数据 | 存的还是算的? | 物化视图(每人一条会话) |
| 唯一 ID | 双方视角是同一个吗? | 排序后拼接 |
| 未读数 | 数出来还是减出来? | 减法(依赖 seq 连续 + 水位单调) |
| 排序 | 谁的时间?分页在排序前还是后? | 接收时间,先全量排序再分页 |
| 免打扰 | 几态?在哪层过滤? | 三态;只过滤了"不接收" ⚠️ |
| 可见性 | 怎么让"旧消息不可见"? | 抬 min_seq,不删数据 |
留个问题,评论区一句话能答: 如果把会话排序的 key 从"最后消息接收时间"换成"最后消息 seq",能解决那个同一毫秒覆盖的问题吗?会引入什么新问题?
下一篇:《Go IM 源码实战(14):Redis 三层缓存,为什么本地缓存反而更容易出错》------从 rockscache、本地缓存到缓存一致性。
作者:利威尔xu | CSDN 专栏《Go IM 源码实战》