你好,我是利威尔xu。
这是**「Go IM 源码实战」系列第 7 篇**。前 6 篇讲完了"发"和"存",这一篇讲**"拉"**------用户断线三天后重新登录,怎么把这三天的消息一条不多、一条不少地补回来。
这个问题看起来简单(select * where seq > 300),但真实系统里它有四个边界要同时满足:
| 边界 | 谁定的 |
|---|---|
| 上界 | 会话最大 seq 和用户最大 seq,取小的那个 |
| 下界 | 会话最小 seq 和用户最小 seq,取大的那个 |
| 数量 | 单次最多 N 条,超了截断 |
| 方向 | 升序拉新消息 / 降序翻历史,isEnd 判定完全反过来 |
只要少夹一层边界,就会出现两种线上事故:要么把用户"已经清空"的旧消息又拉回来,要么让客户端无限翻页。
源码位置(全系列同一份):
- GitHub:https://github.com/CYQ111999/IM-Server
- 基线:OpenIM Server v3.8 瘦身版,main @
07f9ff6 - 本篇涉及:
internal/rpc/msg/sync_msg.go、internal/rpc/msg/as_read.go、pkg/common/storage/controller/msg.go、pkg/common/storage/cache/redis/seq_user.go
前 4 篇:1 全局地图、2 WS 网关、3 HTTP API 层、4 发送链路。第 5 篇起全部在同名专栏里,见作者文章列表;系列导航在《新粉必看:技术博客学习路线》。
文章目录
-
- 一、先把"同步"拆成三种
- 二、为什么时间戳不能当"上次的状态"
- 三、水位模型与四道边界
- [四、范围收敛:本篇最核心的 30 行](#四、范围收敛:本篇最核心的 30 行)
-
- [为什么截断要保留"最靠近 end 的那一段"](#为什么截断要保留"最靠近 end 的那一段")
- [返回 `minSeq` 和 `maxSeq` 干什么](#返回
minSeq和maxSeq干什么)
- 五、两个用户级水位,服务两个相反的需求
- [六、`IsEnd` 的两个方向是镜像的](#六、
IsEnd的两个方向是镜像的) - [七、未读数 = `maxSeq - hasReadSeq`,凭什么能成立](#七、未读数 =
maxSeq - hasReadSeq,凭什么能成立) - 八、已读:四个接口,三种语义
-
- [`MarkConversationAsRead` 为什么还要"补齐区间"](#
MarkConversationAsRead为什么还要"补齐区间") - [单聊写 Mongo,群聊不写](#单聊写 Mongo,群聊不写)
- [一次完整的「进会话 → 读消息 → 上报已读 → 再同步」闭环](#一次完整的「进会话 → 读消息 → 上报已读 → 再同步」闭环)
- [`MarkConversationAsRead` 为什么还要"补齐区间"](#
- [九、已读水位存 Redis 为主、Mongo 为备,和一个没接上的开关](#九、已读水位存 Redis 为主、Mongo 为备,和一个没接上的开关)
- [十、踩坑记录:三个坑,一共 13 小时](#十、踩坑记录:三个坑,一共 13 小时)
-
- [对照:主流 IM 的"同步"都长什么样](#对照:主流 IM 的"同步"都长什么样)
- 十一、面试会怎么问
- 十二、小结:四个数、四道边界、一个方向问题
一、先把"同步"拆成三种
新手容易把"同步"当成一件事,实际上至少要拆成三种,它们的机制完全不同:
| 类型 | 同步什么 | 锚点 | 频率 |
|---|---|---|---|
| 会话列表同步 | 有哪些会话、每个会话最后一条、未读数 | 会话的"版本号"(incrversion) | 每次登录/下拉刷新 |
| 消息同步 | 某个会话里 seq > 本地水位 的消息 |
每会话一个 seq 水位 | 进会话 / 收到推送后补拉 |
| 已读同步 | 我读到哪、对方读到哪 | 每会话每用户一个已读水位 | 点开会话 / 滚动 |
三种同步各有各的水位,混用一定会出错。比如把"会话列表的 maxSeq"当成"我的消息水位",就会一直重复拉同一批消息。
这一篇主要讲后两种(消息 + 已读),会话列表的版本号在系列后面单独讲。
面试追问:客户端本地要存几个数?
最小集合是"每会话一个
localMaxSeq(我已经拿到哪)+ 一个localHasReadSeq(我读到哪)"。会话列表还要一个version。这三个数就是客户端的全部同步状态,丢了任意一个,都要全量重拉。
二、为什么时间戳不能当"上次的状态"
最直觉的做法是记住"我上次拉到的时间",然后 send_time > lastTime。三个致命问题:
| 问题 | 后果 |
|---|---|
| 同毫秒多条 | 边界上的消息会重复拉或漏拉 |
| 客户端时间不可信 | 用户改系统时间 → 永远拉不到新消息 |
| 服务端时间也不严格单调 | 多机时钟漂移,后写的消息时间可能更早 |
所以必须用一个服务端生成、单调递增、按会话聚合 的数------就是 seq。这也是第 5 篇那个"两段式 Lua"存在的全部理由:seq 的唯一使命是给同步提供一个可靠的锚点。
三、水位模型与四道边界
水位(watermark) 这个词很形象:像水库的水位线,你只需要知道"到哪了",不需要记住"都有哪些"。
客户端的请求长这样:我要 conversationID=A 的 seq 10001~10100,最多 50 条,升序。服务端要做四件事,一件都不能省:
| # | 边界 | 规则 | 漏了会怎样 |
|---|---|---|---|
| 1 | 下界取最大 | minSeq = max(会话minSeq, 用户minSeq) |
把用户"清空过"的旧消息又拉回来 |
| 2 | 上界取最小 | maxSeq = min(会话maxSeq, 用户maxSeq) |
把用户"还没资格看"的消息泄露出去 |
| 3 | 数量截断 | 超过 num 只保留最靠近 end 的一段 | 一次返回十万条,直接把客户端撑爆 |
| 4 | 越界返回空 | minSeq > end 直接返回空 |
构造出一个巨大的无效区间 |
第 1 条最容易理解:用户"清空聊天记录"就是把自己的 minSeq 抬高了(第 6 篇讲过,1 次写代替 10 万次)。如果服务端不夹这一层,用户清空之后一刷新,历史消息全回来了------用户会认为"删除没生效",这是最典型的投诉。
第 2 条是很多人想不到的:用户也有"上界"。被踢出群的人,userMaxSeq 设成他被踢前的最大 seq,他就只能看到入群到被踢之间的消息。同一个机制,既做"清空"又做"权限",这就是水位设计的漂亮之处。
四、范围收敛:本篇最核心的 30 行
pkg/common/storage/controller/msg.go:370,GetMsgBySeqsRange:
go
func (db *commonMsgDatabase) GetMsgBySeqsRange(ctx context.Context, userID string, conversationID string,
begin, end, num, userMaxSeq int64) (int64, int64, []*sdkws.MsgData, error) {
// ① 下界:用户水位与会话水位取大
userMinSeq, err := db.seqUser.GetUserMinSeq(ctx, conversationID, userID) // 我清空到哪了
minSeq, err := db.seqConversation.GetMinSeq(ctx, conversationID) // 会话被删到哪了
if userMinSeq > minSeq {
minSeq = userMinSeq
}
if minSeq > end { // 请求区间整体在可见范围以下
return 0, 0, nil, nil
}
// ② 上界:会话 maxSeq 与用户 maxSeq 取小
maxSeq, err := db.seqConversation.GetMaxSeq(ctx, conversationID)
if userMaxSeq != 0 && userMaxSeq < maxSeq {
maxSeq = userMaxSeq
}
// ③ 把区间夹进 [minSeq, maxSeq]
if begin < minSeq { begin = minSeq }
if end > maxSeq { end = maxSeq }
if end < begin { return 0, 0, nil, errs.ErrArgs.WrapMsg("seq end < begin") }
// ④ 按 num 截断:只保留最靠近 end 的一段
var seqs []int64
if end-begin+1 <= num {
for i := begin; i <= end; i++ { seqs = append(seqs, i) }
} else {
for i := end - num + 1; i <= end; i++ { seqs = append(seqs, i) }
}
msgs, err := db.GetMessageBySeqs(ctx, conversationID, userID, seqs)
return minSeq, maxSeq, msgs, err
}
为什么截断要保留"最靠近 end 的那一段"
假设请求是 begin=1, end=100000, num=50。你有两种截断方式:
| 方式 | 返回 | 用户感受 |
|---|---|---|
保留 [1, 50] |
最早 50 条 | 打开聊天窗看到三天前的消息,"我的新消息呢?" |
保留 [99951, 100000] |
最新 50 条 | 打开就是最近的消息,符合直觉 |
所以是 end-num+1 ~ end。这个细节在面试里被问到时,能答出"保留靠近 end 的一段"就说明你真读过代码,因为大部分人只会说"限制返回条数"。
返回 minSeq 和 maxSeq 干什么
函数不只是返回消息,还把服务端真实的边界返回给调用方。客户端拿到之后可以:
- 修正自己的
localMaxSeq(原来以为到 10100,实际只到 10098); - 给"向上翻到头了"提供依据(
isEnd)。
"把边界一起返回"是分布式接口的通用好习惯:因为客户端的认知永远可能过期,服务端顺手告诉它真相,能省掉一轮重试。
五、两个用户级水位,服务两个相反的需求
函数注释里写得很清楚(msg.go:355 附近),值得原样抄下来:
userMaxSeq:用户在群内可拉取的消息序号上限。未设置(默认 0)时,与会话的maxSeq相同。
userMinSeq:用户在群内拉取消息的起始序号。未设置(默认 0)时,与会话的minSeq相同。典型场景:
- 被踢出群的用户,
userMaxSeq可设为其被踢出前的最大序号,限制其只能拉到某个时间点之前的消息。- 新入群用户如果不需要历史消息,
userMinSeq可设为入群那一刻会话的maxSeq。
这张表建议收藏,因为它把"水位"从"计数器"升级成了"可见性窗口":
| 业务需求 | 配置 | 效果 |
|---|---|---|
| 用户清空聊天记录 | 抬高 userMinSeq |
旧消息"消失",数据一条没删 |
| 被踢出群的用户 | 冻结 userMaxSeq |
只能看到被踢前的消息 |
| 新入群不看历史 | 抬高 userMinSeq 到当前 max |
群里 10 万条历史,他只看入群之后 |
| 付费解锁历史 | 调低 userMinSeq |
能看到更早的消息(水位可回退是特性不是 bug) |
同一套字段,四个业务需求。 这就是"好的抽象不是把所有情况都编码进去,而是找出那个能被多种业务复用的维度"。
面试追问:为什么不用权限表?
权限表是"能不能",水位是"能看到哪一段"。IM 的绝大部分限制都是"区间"而不是"是/否",用区间表达的好处是:判定只做两次比较,不需要查表。这就是为什么 seq 必须稠密------不稠密的序号没法比较区间。
六、IsEnd 的两个方向是镜像的
go
switch req.Order {
case sdkws.PullOrder_PullOrderAsc:
isEnd = maxSeq <= seq.End // 升序拉新:我要的终点已经超过服务端最大值
case sdkws.PullOrder_PullOrderDesc:
isEnd = seq.Begin <= minSeq // 降序翻历史:我要的起点已经低于服务端最小值
}
| 方向 | 场景 | 判"到底了"看哪个界 |
|---|---|---|
Asc(升序) |
登录后补新消息 | 看上界 :maxSeq <= end |
Desc(降序) |
往上翻历史 | 看下界 :begin <= minSeq |
这两个条件写反,客户端会无限翻页:它永远以为"还有更多",一直请求同一段,直到把服务端打出一个 QPS 尖峰。这类 bug 特别隐蔽------功能看起来正常(消息确实能拉到),只是流量翻倍。
📌 排查技巧:"客户端无限翻页"类问题,第一件事是抓包看 isEnd 是不是永远 false,再对一遍上下界方向。
七、未读数 = maxSeq - hasReadSeq,凭什么能成立
第 1 篇里我给过这个公式,这一篇补上它成立的前提条件:
go
currentMaxSeq, err := db.seqConversation.Malloc(ctx, conversationID, int64(len(msgs)))
for _, m := range msgs {
currentMaxSeq++
m.Seq = currentMaxSeq
userSeqMap[m.SendID] = m.Seq // ← 发送者自己的已读水位推到这条消息
...
}
调用方随后把 userSeqMap 写进 hasReadSeq。"自己发的消息不算未读"不是靠减法时排除,而是靠写入时把自己的水位推到最新。
这个设计的价值在于:计算未读数的路径完全不需要知道"谁是发送者",只要两个数字相减。 如果改成"减法之后再排除自己发的 N 条",你就要遍历区间、过滤发送者------成本从 O(1) 变成 O(N)。
⚠️ 但它有一个必然的副作用:如果 A 手上有 500 条未读,他发一条消息,未读会直接清零 (因为水位被推到了自己这条)。微信不是这么做的(发消息不清未读)。这是减法模型的固有取舍,不是说它错,而是要知道它的边界在哪。
面试追问:那怎么才能做到"发消息不清未读"?
需要把"未读计数"从"减法"改成"独立计数"(比如 Redis 里维护每个会话的 unread 计数器,收到消息 +1,读了 -N)。代价是多一份状态要维护、要能重建。减法是"用 seq 免费得到未读数",独立计数是"用额外状态换精确语义"。 这是一个典型的设计权衡题。
八、已读:四个接口,三种语义
internal/rpc/msg/as_read.go 里有四个接口,别看名字都差不多,语义完全不同:
| 接口 | 语义 | 谁调 | 会不会写 Mongo |
|---|---|---|---|
MarkConversationAsRead |
"这会话我读到 seq N 了",会补齐区间 | 点进会话 | 单聊会(批量打 is_read) |
MarkMsgsAsRead |
"这 3 条我读了",精确列表 | 滚动到某几条 | 单聊会 |
SetConversationHasReadSeq |
"直接设成 N",不校验、不补 | 管理端/特殊场景 | 不会 |
GetConversationsHasReadAndMaxSeq |
批量读水位 + maxSeq | 登录/下拉刷新 | 只读 |
MarkConversationAsRead 为什么还要"补齐区间"
go
if conversation.ConversationType == constant.SingleChatType {
// 上次水位 +1 一直补到本次水位
for i := hasReadSeq + 1; i <= req.HasReadSeq; i++ {
seqs = append(seqs, i)
}
// 再并上客户端显式报的 seqs(去重)
...
m.MsgDatabase.MarkSingleChatMsgsAsRead(ctx, req.UserID, req.ConversationID, seqs)
}
原因很实际:单聊要在 Mongo 里给每条消息打 is_read(对方看到的"已读"标记)。客户端可能只报"我读到 100 了",但中间有些消息上次请求失败没打上标记。服务端按水位差补齐,保证不漏。
⚠️ 但这段代码有个明显的性能炸弹:
go
for i := hasReadSeq + 1; i <= req.HasReadSeq; i++ { ... }
如果 hasReadSeq = 0、客户端上报 HasReadSeq = 100000,就会构造一个 10 万元素的切片,再对 1000 个文档做 BulkWrite。一个恶意(或 buggy)客户端就能让这台机器内存飙起来。
📌 正确的做法是限制单次补齐的数量 (比如超过 1000 就只补最近 1000 条 + 直接设水位),或者改成"按区间 $set"而不是逐条枚举。这是这个项目里我认为最值得提的一个改进点。
单聊写 Mongo,群聊不写
go
filter := bson.M{
"doc_id": docID,
fmt.Sprintf("msgs.%d.msg.send_id", index): bson.M{"$ne": userID}, // 不标记自己发的
}
update := bson.M{"$set": bson.M{fmt.Sprintf("msgs.%d.is_read", index): true}}
三个细节:
send_id != userID:单聊里的"已读"语义是"对方读了",自己的消息不该被自己标记成已读;BulkWrite:一次往返提交一批,不是 N 次 update;- 函数名带
SingleChat:这个限制是刻意的。
群聊为什么不写? 500 人的群,每人读一次要给 100 条消息各写一次"谁读了"------一个 bool 字段根本存不下 500 个人的状态。就算用数组,写放大也是 500×。
所以群聊只更新水位,不落库任何消息级已读。这就是"群已读回执"这个功能在存储层被放弃的证据。(产品上通常只显示"3 人已读",靠水位 + 人数近似算。)
一次完整的「进会话 → 读消息 → 上报已读 → 再同步」闭环
把前面所有零件串起来,客户端进一个会话时的真实顺序是这样的(每一步都能在代码里找到对应函数):
| 步 | 客户端动作 | 打到哪个接口 | 服务端干了什么 | 水位怎么变 |
|---|---|---|---|---|
| 1 | 点开会话 | PullMessageBySeqs(begin=userMaxSeq+1) |
读水位四取边界 → 定位文档 → 投影改写 | 不变 |
| 2 | 渲染完列表 | MarkConversationAsRead(hasReadSeq=最新seq) |
Redis 写已读水位(只前进)+ 单聊批量打 is_read |
hasReadSeq 前进 |
| 3 | 下拉刷新 | GetConversationsHasReadAndMaxSeq |
批量读 hasReadSeq + 会话 maxSeq |
不变 |
| 4 | 列表算红点 | 不需要请求 | maxSeq - hasReadSeq 客户端本地算 |
不变 |
| 5 | 退到后台 | 无 | WebSocket 关闭,userMaxSeq 已在第 1 步写回 |
不变 |
| 6 | 隔天再进 | 同第 1 步 | 从 userMaxSeq+1 继续增量 |
不变 |
⚠️ 第 2 步和第 3 步之间有个容易忽略的竞态 :客户端上报已读是异步的,如果第 3 步的请求先到,拿到的还是旧水位,红点会"闪一下又消失"。所以第 2 步必须等上报成功再刷新列表,或者本地先乐观更新。
⚠️ 第 5 步是另一个坑:userMaxSeq 是在拉取时 写入的,不是"消息到达时"。如果用户一直在线收推送、但从不打开这个会话,userMaxSeq 会一直是旧值------下次进会话会把推送期间的消息再拉一遍。不丢消息,但会重复。这也解释了为什么"离线消息"在客户端要做 seq 去重,而不能只靠服务端保证。
九、已读水位存 Redis 为主、Mongo 为备,和一个没接上的开关
pkg/common/storage/cache/redis/seq_user.go:
go
func NewSeqUserCacheRedis(rdb redis.UniversalClient, mgo database.SeqUser) cache.SeqUser {
return &seqUserCacheRedis{
mgo: mgo,
readSeqWriteRatio: 100, // ← 声明了,但没人用
expireTime: time.Hour * 24 * 7,
readExpireTime: time.Hour * 24 * 30, // 已读水位缓存 30 天
rocks: newRocksCacheClient(rdb),
}
}
写水位的逻辑:
go
func (s *seqUserCacheRedis) SetUserReadSeq(ctx context.Context, conversationID, userID string, seq int64) error {
if s.rocks.GetRedis() == nil {
return s.SetUserReadSeqToDB(ctx, conversationID, userID, seq) // 没 Redis → 直接落库
}
dbSeq, err := s.GetUserReadSeq(ctx, conversationID, userID)
if err != nil { return err }
if dbSeq < seq { // ← 只允许前进,不允许回退
s.rocks.GetClient().RawSet(ctx, s.getSeqUserReadSeqKey(...), strconv.Itoa(int(seq)), s.readExpireTime)
}
return nil
}
三个设计意图:
| 设计 | 原因 |
|---|---|
| 只写 Redis | 已读水位是高频写(每读几条就更新一次),丢一点没关系 |
只允许前进 dbSeq < seq |
防止乱序请求把水位打回去(比如并发上报 100 和 90) |
| 30 天过期 | 冷会话不常驻内存 |
⚠️ 但 readSeqWriteRatio: 100 这个字段在整个仓库里没有任何地方被读取 (grep readSeqWriteRatio 只有"声明 + 赋值"两处)。从命名推断,原设计是"每 100 次写落一次 Mongo"(write-behind 批量落库),但这个机制没实现。
后果推演:
- 已读水位只在 Redis 里,Mongo 里是"很久以前的值";
- 如果这个会话 30 天没动,Redis key 过期;
- 用户再打开时
GetUserReadSeq回源 Mongo,拿到旧值; - 未读数突然变成几百条 (
maxSeq - 旧hasReadSeq)。
📌 这不是"必现的线上故障",而是一个设计意图没有落地的证据 。面试里讲这种发现,比画十个架构图都有说服力:"代码里声明了 readSeqWriteRatio: 100,但全仓库没有一处读取它,说明 write-behind 批量落库没实现。已读水位只活在 Redis 里,30 天过期后回源 Mongo 会拿到旧值,未读数会突然变大。"
十、踩坑记录:三个坑,一共 13 小时
坑一:清空记录后历史消息又回来了(6 小时)
现象:用户点"清空聊天记录",退出重进,消息全回来了。
定位:拉取接口只夹了会话级 minSeq,没夹用户级 userMinSeq。清空写的是用户水位,但读取时没读它。
修复:minSeq = max(会话minSeq, 用户minSeq),并且取大(不是取小!)。
教训:"多个水位取最严格的那个"------下界取 max,上界取 min。 记不住就想一想:既要满足会话可见,又要满足用户可见,两个条件同时成立,就是交集。
坑二:isEnd 写反,客户端无限翻页(4 小时)
现象:用户往上翻历史,翻到最早之后一直转圈,抓包发现客户端在重复请求同一个区间。
定位:Asc 和 Desc 的判定条件写成了一个方向(都看 maxSeq)。升序拉新时永远以为"还有更多"。
修复:改成镜像判定,并加了一个断言:如果返回条数为 0 且 isEnd=false,记为异常指标。
教训:方向类逻辑一定要拿两个方向各跑一遍。只测一个方向,另一半永远是盲区。
坑三:群聊误调 MarkMsgsAsRead,把已读广播到 500 人群(3 小时)
现象:某个版本上线后,群成员的客户端收到大量"已读"通知,网络流量和推送量翻了几倍。
定位:客户端对群会话调了 MarkMsgsAsRead(这个接口不区分单聊群聊,直接用 conversation.ConversationType),于是已读回执真的广播给了群成员。
修复:客户端群会话只调 MarkConversationAsRead(那个分支强制用 SingleChatType 只发给自己),并在服务端对群会话的 MarkMsgsAsRead 加限制。
教训:同一件事有两套接口时,必须明确"谁在什么场景下调哪个"。 不然客户端一换人写,就会用错入口。
对照:主流 IM 的"同步"都长什么样
如果你只做过这一个项目,面试时容易把 OpenIM 的做法当成唯一答案。实际上主流方案分三派,各自的取舍很清楚:
| 方案 | 代表 | 锚点 | 优点 | 代价 |
|---|---|---|---|---|
| seq 水位(本项目) | OpenIM / 早期微信 | 每会话一个单调递增整数 | 天然有序、可做区间、幂等、能算未读 | 要一个全局分配器;空洞要处理 |
| 游标(cursor) | Slack / 部分开放平台 | 不透明字符串 + 快照 | 服务端可换存储不动接口 | 游标没法比较大小,算不了未读 |
| 时间戳 + 去重 | 不少自研小系统 | last_ts |
实现最简单 | 同毫秒重复、时钟漂移、分页会漏 |
三派的核心分歧只有一个:锚点能不能比较大小。
- 能比较(seq)→ 可以"从 A 到 B"取区间、可以算
max - hasRead、可以做> 锚点的增量; - 不能比较(cursor)→ 只能"从这往下取 N 条",未读数必须独立维护计数器;
- 时间戳 → 介于两者之间,能比较但不唯一。
📌 这条判断在面试里非常好用。当被问"你们为什么用 seq",别只说"有序",要说:"因为我要在同一个模型里同时支持区间拉取、未读数计算和幂等重放,这三件事都要求锚点可比较、唯一、且由服务端单点分配。cursor 方案做不到减法,就必须再维护一套未读计数,一致性成本反而更高。"
顺带说一句:本项目 maxSeq - hasReadSeq 能成立,靠的正是"写入时把发送者自己的水位推到最新"这个隐含前提(第七节)。换成 cursor 方案,这个前提根本不存在,未读数只能走单独的计数器 + 定期对账。
十一、面试会怎么问
| 追问 | 怎么答(一句话版) |
|---|---|
| 同步为什么用 seq 不用时间戳? | 同毫秒重复 + 客户端时钟不可信 + 服务端时钟漂移 |
| 拉取要夹几层边界? | 下界取 max(会话 min、用户 min)、上界取 min(会话 max、用户 max)、num 截断、越界返回空 |
| 截断为什么保留靠近 end 的? | 用户要看最新,不是最早 |
userMaxSeq / userMinSeq 干什么? |
可见性窗口:清空记录、踢出群、新入群不看历史、付费解锁 |
isEnd 两个方向为什么反过来? |
升序看下界还是上界?------升序看上界 maxSeq<=end,降序看下界 begin<=minSeq |
| 未读数为什么能直接相减? | 写入时把发送者自己的水位推到最新,减法路径不需要知道发送者 |
| 群聊为什么不存消息级已读? | 500 人 × 100 条写放大,bool 存不下"谁读了",只留水位 |
| 已读水位丢了会怎样? | Redis 30 天过期回源 Mongo 拿旧值,未读数突然变大;readSeqWriteRatio 声明了没实现 |
十二、小结:四个数、四道边界、一个方向问题
- 四个数 :会话
minSeq、会话maxSeq、用户userMinSeq、用户userMaxSeq。 - 四道边界:下界取 max、上界取 min、num 截断(保留靠近 end)、越界返回空。
- 一个方向问题 :
isEnd在两个方向上是镜像判定,写反就是无限翻页。
下面这张表建议收藏,下次你做"增量同步"(消息、通知、日志、动态流都适用),照着过一遍:
| 检查项 | 该问什么 | 本项目的答案 |
|---|---|---|
| 锚点 | 用什么做"上次拉到哪"? | 每会话一个 seq 水位 |
| 上界 | 是否有"用户级上限"? | userMaxSeq,取 min |
| 下界 | 是否有"用户级下限"? | userMinSeq,取 max |
| 截断 | 超量怎么截? | 保留最靠近 end 的 num 条 |
| 越界 | 请求整段不可见怎么办? | 直接返回空,不报错 |
| 边界回传 | 要不要告诉客户端真实边界? | 返回 minSeq/maxSeq/endSeq |
| 翻页 | isEnd 判定方向对吗? |
升序看上界,降序看下界 |
| 未读 | 减法还是独立计数? | 减法,靠写入时推自己水位 |
| 已读存储 | 存哪、丢了怎么办? | Redis 为主(30 天),Mongo 为备(批量落库未实现) |
| 群已读 | 存消息级还是只存水位? | 只存水位 |
留个问题,评论区一句话能答: 如果同时把会话级 minSeq 和用户级 userMinSeq 都设成 100,客户端请求 begin=1, end=50,服务端应该返回什么?为什么不能报错?
如果这篇帮你把"同步"这块捋顺了,点个赞、收个藏------这个系列后面还有 9 篇。下一篇讲推送与在线状态:Kafka 三个 topic 怎么分工,为什么推送要单独一条流水线。
下一篇:《Go IM 源码实战(8):Kafka 四个 topic 一条流水线,消息为什么先进 Redis 再落库》------从 toRedis / toMongo / toPush 的分工讲到消费位点与重试。
作者:利威尔xu | CSDN 专栏《Go IM 源码实战》