Go IM 源码实战(13):30 个会话只有 2 个红点,未读数错在三条路径

你好,我是利威尔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 篇起全部在同名专栏里,见作者文章列表;系列导航在《新粉必看:技术博客学习路线》。

文章目录

一、会话到底是什么

一句话:会话是"一个用户对一段对话的视角"。

同一个群,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 类缓存"也一样。

判断一个"物化视图"设计得好不好,看两件事:

  1. 同步是推送还是拉取?(推送快但会丢,拉取准但慢)
  2. 不同步的后果是"看不到"还是"看到错的"?(后者严重得多)

比如 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 源码实战》

相关推荐
薛定谔的猫-菜鸟程序员1 小时前
软件架构设计师开发资料大全(从入门到精通)
后端·python·系统架构·flask·架构设计师
FungLeo2 小时前
成为全栈·Go 后端篇·从 Node SQLite 到 PostgreSQL:把迁移做成独立交付物
postgresql·golang·sqlite·go·数据库迁移·成为全栈
will_net2 小时前
Redis集群部署+Redis 链接地址放入Aollo +c#项目接入Redis
redis·c#·bootstrap
IT_陈寒3 小时前
SpringBoot多环境配置把我坑惨了,这个踩坑经验你最好看看
前端·人工智能·后端
码事漫谈3 小时前
DeepSeek 搞了个 "Super-agent",但不是超级智能
后端
吴凡wudianmei4 小时前
从 0 搭一个 AI Agent(四):SSE 跑通之后,三处“契约错配”与它们的修复边界
前端·后端·aigc
小兔子4 小时前
defer 的参数在写下那一刻就求值了,循环里这样写会踩坑
后端
苍何4 小时前
腾讯 Buddy One 来了,办公生态一键打通 WorkBuddy(附教程)
后端