seq 空洞排查:Mongo 的 51 只是预留上界,Redis CURR 才是已消耗边界

我不会起名字322 · 后端 / 算法 / 数据库

📘 技术栈 | 🧩 力扣 Hot100 | 🐹 Go 项目 | 🟥 Redis | 🐬 MySQL

文章目录

seq 空洞排查:Mongo 的 51 只是预留上界,Redis CURR 才是已消耗边界

历史记录里 Mongo 的 max_seq 达到 51~54,但这只是号段的预留上界 ,不能直接当成正常拉取上界,也不能把与消息条数的差额认定为空洞。Redis 的 CURR 是已消耗边界,LAST 是预留上界;二者都不代表消息已经落库。

另一条需要关注的路径是:先分配 seq,再逐条写 Redis,写入中途失败时已分配序号不会在此处回滚,可能留下空洞。如果查询成功但结果为空,服务端会跳过该会话响应项,客户端的实际行为仍需端到端验证。

环境:基于 OpenIM Server v3.8.3 改造的 Go IM 后端,Redis 做消息缓存(RawSet 逐条写),MongoDB 做持久化,im-msgtransfer 消费 Kafka 落库。代码与行号来自静态核查的 im-server 工作区(分支 optimize/20261104,提交 ba63dac6516026b5f17870416df82e009ed9aab6);历史数据来自 loadtest 的 probe4 场景。补充测试运行的是 Docker 镜像 im-server:optimize-20261104,镜像内二进制的 Git 提交不可确认,不能断言与静态核查源码完全一致。

现象:Mongo 预留上界高于已记录的消息数

先看一组来自 probe4 场景的原始数据。同一次运行里,order.json 记录 3 个会话各发 10 条、seq 从 1 到 10 连续:

json 复制代码
// .backups/loadtest/probe4/order.json(节选)
{
  "conversationID": "si_ltprobe4_u0001_ltprobe4_u0002",
  "sent": 10, "rows": 10,
  "seqContinuous": true,
  "firstSeq": 1, "lastSeq": 10,
  "duplicates": 0
}

而约 7 分钟后的 verify.json 记录了部分重叠会话及其他会话在 Mongo 中的预留上界,两个文件不是同一时点的完整快照:

json 复制代码
// .backups/loadtest/probe4/verify.json(节选)
"seqIDs": [
  "si_ltprobe4_u0001_ltprobe4_u0002=52",
  "si_ltprobe4_u0001_ltprobe4_u0003=51",
  "si_ltprobe4_u0001_ltprobe4_u0004=51",
  "si_ltprobe4_u0001_ltprobe4_u0005=51",
  "si_ltprobe4_u0001_ltprobe4_u0006=51",
  "si_ltprobe4_u0003_ltprobe4_u0004=54",
  "si_ltprobe4_u0005_ltprobe4_u0006=53"
]

这些 seqIDs 来自 Mongo seq 集合的 max_seq(loadtest/main.go:89-102),表示预留上界。 order.json 只证明当时三个指定会话各查到 10 条连续消息,不能推出 verify.json 中所有会话当时的消息数,更不能把两者相减认定为已分配空号。

这里要区分两种机制:

第一种是号段预分配 。初始化或补号段时,单聊向 Mongo 额外申请 50 个号、群聊额外申请 100 个号;Redis 中 CURR 逐次推进,未消耗的余量在 LAST 以内仍可使用。

第二种是 写失败不回滚:seq 分配完后,逐条写 Redis 中途出错,已消耗的号不会在该路径中回滚,可能留下真实空洞;后续重试和落库情况仍需逐消息核对。

probe4 没有提供同一时点的 Redis CURR、LAST 与完整消息快照,也没有实测写失败。另一次 2026-11-08 的补充测试中,会话 si_ltaudit20261108a_u0002_ltaudit20261108a_u0003 的 Mongo 预留上界为 51,查询到 seq 1~30 连续的 30 条消息,随后 Redis 读取到 CURR=30、LAST=51;这支持区分两种边界,不能证明 probe4 的具体现场或排除其他条件下的空洞。缓存丢失或过期后从 Mongo 高水位重建时,未消耗号段也可能被跳过,需要另行验证。

为什么预留上界会比消息数大?

因为数据库预留号段、Redis 消耗序号和消息写入是不同动作,预留上界与已消耗边界、已落库消息数不能混为一谈。

先看分配逻辑,pkg/common/storage/cache/redis/seq_conversation.go:363-377:

go 复制代码
// pkg/common/storage/cache/redis/seq_conversation.go:363-377(精简,注释是我加的)
// getMallocSize 为本次需求额外预留余量:群会话多取 100,其他会话多取 50。
func (s *seqConversationCacheRedis) getMallocSize(conversationID string, size int64) int64 {
	if size == 0 {
		return 0
	}
	var basicSize int64
	if msgprocessor.IsGroupConversationID(conversationID) {
		basicSize = 100
	} else {
		basicSize = 50
	}
	basicSize += size
	return basicSize
}

初始化或补号段时,单聊请求 1 个号会向 MongoDB 预留 51 个。 Redis 保存当前消耗边界 CURR 和预留上界 LAST;号段尚有余量时,后续请求只推进 CURR,不必每次向 Mongo 申请。

真正往 Mongo 里加的是这个,pkg/common/storage/database/mgo/seq_conversation.go:58-76:

go 复制代码
// pkg/common/storage/database/mgo/seq_conversation.go:58-76(精简)
func (s *seqConversationMongo) Malloc(ctx context.Context, conversationID string, size int64) (int64, error) {
	// ...
	update := map[string]any{
		"$inc":         map[string]any{"max_seq": size},   // 原子自增
		"$setOnInsert": map[string]any{"min_seq": int64(0)},
	}
	opt := options.FindOneAndUpdate().SetUpsert(true).SetReturnDocument(options.After)
	lastSeq, err := mongoutil.FindOneAndUpdate[int64](ctx, s.coll, filter, update, opt)
	// ...
	return lastSeq - size, nil     // 返回分配前的边界
}

$inc 是原子的,返回的是自增之后 的 max_seq,再减掉 size 得到分配前的边界。

例如全新单聊首次申请 1 个号,Mongo max_seq 为 51,Redis 回填 CURR=1、LAST=51;编号 1 已分配,但消息是否写入和落库还要看后续结果。缓存正常时,GetMaxSeq 以 size=0 读取的是 CURR,不是 51。

这是号段分配的常见做法,不能把 Mongo 预留上界或 Redis 当前边界直接当成真实消息条数。缓存缺失重建、并发、删除和写入失败等情况也会影响二者与消息数的关系。

消息写失败的时候,洞是怎么留下的

这一段是本文的核心,位置在 pkg/common/storage/controller/msg_transfer.go:244-268。

go 复制代码
// pkg/common/storage/controller/msg_transfer.go:244-268(精简,注释是我加的)
sendTime := time.Now().UnixMilli()
currentMaxSeq, err := db.seqConversation.Malloc(ctx, conversationID, int64(len(msgs)))
if err != nil {
	log.ZError(ctx, "storage.seq.Malloc", err)
	return 0, false, nil, err
}
// ... 逐条赋 seq
for _, m := range msgs {
	currentMaxSeq++
	m.Seq = currentMaxSeq
	m.SendTime = sendTime
	userSeqMap[m.SendID] = m.Seq
	seqs = append(seqs, m.Seq)
}
// ... 写 Redis
if err := db.msgCache.SetMessageBySeqs(ctx, conversationID, datautil.Slice(msgs, msgToDB)); err != nil {
	return 0, false, nil, err      // <- 直接返回,seq 不回滚
}

SetMessageBySeqs 本身也是「逐条写、中途失败就返回」,pkg/common/storage/cache/redis/msg.go:87-103:

go 复制代码
// pkg/common/storage/cache/redis/msg.go:87-103(精简,注释是我加的)
// SetMessageBySeqs 逐条 JSON 编码并直接写消息缓存,跳过空消息和非正序号。
// 中途出错即返回,此前已写入的消息不会回滚。
func (c *msgCache) SetMessageBySeqs(ctx context.Context, conversationID string, msgs []*model.MsgInfoModel) error {
	for _, msg := range msgs {
		// ...
		if err := c.rcClient.GetClient().RawSet(ctx, cachekey.GetMsgCacheKey(conversationID, msg.Msg.Seq), string(data), msgCacheTimeout); err != nil {
			return err                    // <- 第 3 条失败,前 2 条已经在 Redis 里了
		}
	}
	return nil
}

例如全新单聊首次分配 10 条,Mongo 预留上界和 Redis LAST 为 60,CURR 为 10。假设前两条写入成功、第 3 次写入返回错误,那么前两次写入不会在此处回滚,第 4~10 次不会执行;第 3 次服务端是否已写入还要看错误类型。缓存正常且没有其他并发分配时,下一次从 11 开始,11~60 仍是可消耗的预留号段。3~10 是否最终形成持久空洞,还要结合重试、其他写入和落库结果判断;这条失败路径尚未故障注入实测。

作者自己在 msg_transfer.go:233 写了这句话:

复制代码
// 如果 SetMessageBySeqs 失败,已分配的 seq 不会回滚,会在会话 seq 中留下空洞;
// 调用方 MsgTransfer 对 redis.Nil 做了特殊处理。

错误处理:只有 redis.Nil 被当成可以继续

上面那个错误返回之后,调用方会怎么处理?internal/msgtransfer/online_history_msg_handler.go:325-328:

go 复制代码
// internal/msgtransfer/online_history_msg_handler.go:325-328(精简)
if err != nil && !errors.Is(errs.Unwrap(err), redis.Nil) {
	log.ZWarn(ctx, "batch data insert to redis err", err, "storageMsgList", storageMessageList)
	return
}

如果错误是 redis.Nil,条件不成立,流程继续往下走 :接着推已读 seq、接着建会话、接着 MsgToMongoMQ、接着 toPushTopic。反过来,非 redis.Nil 错误会直接 return,本批存储消息不会执行后面的已读推进、建会话、MsgToMongoMQ 和存储消息的 toPushTopic;已经写进 Redis 的部分消息会留在缓存,但不会由这次处理转发落库。无需存储的消息在更早的分支里已经单独推送,不受这里影响。

同一个文件 :309 的注释把这个约定写明了:

复制代码
// BatchInsertChat2Cache 返回的 redis.Nil 被视为可继续(消息未写进 Redis,但流程不中断)。

redis.Nil 的语义是「key 不存在」,不是「写失败」。 SetMessageBySeqs 的 RawSet 路径是否可能返回它,取决于底层缓存客户端的错误包装;仅凭源码无法确认这个分支在什么现场被触发。代码注释明确把 redis.Nil 视为可继续,其他错误则直接返回;无论哪种情况,已消耗的 seq 都不会因此回滚。消息是否还能由重试、其他批次或落库流程补齐,需要逐消息核对,不能仅凭这一分支断言永久空洞。

更直接的一处是 :330-334:

go 复制代码
// internal/msgtransfer/online_history_msg_handler.go:330-334(精简)
err = och.msgTransferDatabase.SetHasReadSeqs(ctx, conversationID, userSeqMap)
if err != nil {
	log.ZWarn(ctx, "SetHasReadSeqs error", err, "userSeqMap", userSeqMap, "conversationID", conversationID)
	prommetrics.SeqSetFailedCounter.Inc()      // 只加指标,不重试、不回滚
}

已读 seq 写失败只记一个指标,不在此路径重试或回滚。 已读推进可能没写成功,但用户侧表现取决于读取路径,本次没有客户端实测;这属于另一个可靠性话题,不能单独证明用户端一定会退回旧已读数。

客户端会看到什么

服务端拉消息的入口是 internal/rpc/msg/sync_msg.go:28 的 PullMessageBySeqs。它对每个 SeqRange 这么处理(:42-59):

go 复制代码
// internal/rpc/msg/sync_msg.go:42-59(精简)
minSeq, maxSeq, msgs, err := m.MsgDatabase.GetMsgBySeqsRange(ctx, req.UserID, seq.ConversationID,
	seq.Begin, seq.End, seq.Num, conversation.MaxSeq)
if err != nil {
	log.ZWarn(ctx, "GetMsgBySeqsRange error", err, "conversationID", seq.ConversationID, "seq", seq)
	continue
}
var isEnd bool
switch req.Order {
case sdkws.PullOrder_PullOrderAsc:
	isEnd = maxSeq <= seq.End
case sdkws.PullOrder_PullOrderDesc:
	isEnd = seq.Begin <= minSeq
}
if len(msgs) == 0 {
	log.ZWarn(ctx, "not have msgs", nil, "conversationID", seq.ConversationID, "seq", seq)
	continue          // <- 整个会话跳过,isEnd 没有机会被写进响应
}
resp.Msgs[seq.ConversationID] = &sdkws.PullMsgs{Msgs: msgs, IsEnd: isEnd}

看最后那个 continue。isEnd 已经算出来了,但因为 len(msgs) == 0 直接跳过,这个会话在响应里连一个 key 都不会出现。客户端拿到的是「响应成功、但我要的那个会话不在里面」。

客户端会不会因此重拉或跳过会话,取决于客户端实现;本次没有抓取真实客户端请求和重试序列,不能把服务端这一跳直接等同于"同步卡死"或"丢会话"。

而 GetMsgBySeqsRange 内部的 maxSeq 从哪里来?pkg/common/storage/controller/msg.go:424-476。它先取会话与用户的最小可见序号,再调用 GetMaxSeq,并用调用方传入的 userMaxSeq(这里是 conversation.MaxSeq)进一步压低上界:

go 复制代码
// pkg/common/storage/controller/msg.go:441-457(精简)
maxSeq, err := db.seqConversation.GetMaxSeq(ctx, conversationID)
if err != nil {
	return 0, 0, nil, err
}
if userMaxSeq != 0 {
	if userMaxSeq < maxSeq {
		maxSeq = userMaxSeq
	}
}
if begin < minSeq {
	begin = minSeq
}
if end > maxSeq {
	end = maxSeq
}

缓存存在且命中时,这里的 GetMaxSeq 走 Redis 号段缓存,以 size=0 读取 CURR,不是 Mongo 文档里的预留上界。 如果 CURR=10,请求 begin=11,end=52 会被夹成 end=10,随后 :459-460 因 end < begin 直接返回参数错误;sync_msg.go 记的是 GetMsgBySeqsRange error 并 continue,不会走到 not have msgs。只有区间在可见边界内、展开后确实查不到消息时,才会打 not have msgs。缓存缺失时则会触发号段初始化,可能直接以 Mongo 预留上界作为当前边界,这是另一个会跳过未消耗号段的场景。

区间展开同时受 num 限制,pkg/common/storage/controller/msg.go:462-471:

go 复制代码
// pkg/common/storage/controller/msg.go:462-471(精简)
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)
	}
}

如果上界来自 CURR 且范围内存在已分配但未写入的编号,查询结果可能少于请求区间;若只有部分命中,响应仍会返回已命中的消息,客户端不能把返回条数当作区间完整的证明。若整个区间都为空,会话响应项会被跳过。服务器端这一行为由代码可见,但客户端是否会因此卡住或丢失会话,尚未通过真实客户端验证。

复现与判据:三条命令

下面每一条都可以在你自己的环境跑,但读出的数各有口径,不能混用。

第一条,对比 Mongo 预留上界、Redis 已消耗边界和实际消息条数(Mongo 的 msg 按文档块存放,必须展开 msgs 数组,不能按 doc_id 数文档):

bash 复制代码
# Mongo seq.max_seq(预留上界)与该会话实际消息条数;doc_id 形如 "<conversationID>:<分块序号>"
mongosh --quiet "mongodb://127.0.0.1:37017/im" --eval '
db.seq.find({}, {conversation_id: 1, max_seq: 1}).forEach(function (s) {
  var n = db.msg.aggregate([
    {$match: {doc_id: {$regex: "^" + s.conversation_id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + ":"}}},
    {$unwind: "$msgs"},
    {$match: {"msgs.msg.seq": {$gt: 0}}},
    {$count: "n"}
  ]).toArray();
  var count = n.length ? n[0].n : 0;
  print(s.conversation_id, "reservedLast=" + s.max_seq, "msgs=" + count);
})' | head -20

Redis 侧读取同一会话的号段哈希:

bash 复制代码
redis-cli -p 16379 -a "$REDIS_PASSWORD" HMGET "MALLOC_SEQ:$CID" CURR LAST

正常回填成功时,Mongo max_seq 与 Redis 的 LAST 指向同一个预留上界,但不是 CURR(已消耗边界),也不等于消息落库数。 预留上界与消息数的差主要来自尚未消耗的号段;只有对照 CURR 与 1..CURR 内实际存在的 seq,才能进一步检查未写入的编号。缺失的 seq 也可能来自删除/撤回、缓存过期或读取失败,需要结合业务记录和落库结果区分,不能只看编号跳过就认定为写失败。这个差也不必是 50 或 100 的整数倍:多次补号段、并发分配、缓存重建、消息删除等都会改变差值。

第二条,看服务端有没有在回 not have msgs:

bash 复制代码
grep -c "not have msgs" logs/im-service-log.* 
grep "not have msgs" logs/im-service-log.* | head -3

这条日志只在普通会话的整个区间一条都没查到 时才打(sync_msg.go:56;通知会话对应 not have notificationMsgs,在 :79)。它出现意味着有客户端在拉一个没有消息的区间,但还要结合请求区间和 CURR 解释。

第三条,对着接口验一个区间(当前注册的路由是 /msg/pull_msg_by_seq,见 internal/api/router.go:285,不是带 s 的路径):

bash 复制代码
curl -s -X POST http://127.0.0.1:10002/msg/pull_msg_by_seq \
  -H "token: $TOKEN" -H "Content-Type: application/json" \
  -d '{"userID":"'$UID'","seqRanges":[{"conversationID":"'$CID'","begin":11,"end":51,"num":20}]}' \
  | jq '.data.msgs'

结果取决于 CURR 和用户可见范围。若 CURR=10、可见下界不高于 51,GetMsgBySeqsRange 会把 end=51 夹到不高于 10,因 begin=11 > end 返回参数错误;该会话没有结果,日志是 GetMsgBySeqsRange error,不是 not have msgs。若 CURR≥51、用户上界为 0 或至少 51、可见下界不高于 11,因为 num=20,区间会展开成最靠近 end 的 20 个号,即 32~51;这段查询成功但没有返回消息时才会打 not have msgs。其他权限、边界或查询错误也可能导致会话缺席,所以要同时检查完整响应和日志。

怎么修?四种方案的代价对比

方案 做法 改哪里 代价 评价
A. 不再预分配,按需精确分配 getMallocSize 直接返回 size seq_conversation.go:363-377 每次申请号段都打一次 Mongo,首批及补号段延迟可能上升 需同条件压测,不能只凭直觉判断
B. 写失败时回滚已分配号段 回退 Redis CURR,并同步处理 Mongo 预留 msg_transfer.go:266 分支 与并发批次冲突,需要比较并交换 不推荐
C. 让拉取上界反映已写入边界 单独维护"已成功写入消息缓存的最大 seq",供 GetMsgBySeqsRange 使用 seq_conversation 读写路径 多一个字段和一致性维护 需先厘清 CURR/LAST 语义和所有读取方
D. 空区间也返回结束标记 空响应也写 IsEnd sync_msg.go:55-57 在 continue 之前写一次 resp.Msgs[cid] 改动小,但客户端效果需联调验证

方案 A 不能只因为出现预留上界差就上。 号段预分配减少了每条消息都向 Mongo 申请的次数,问题不在"预分配"本身,而在把预留上界误当成消息边界。

如果改成精确分配,延迟影响需要实测;发送 ACK 与后续 seq 分配是否在同一条同步链路,也要分开确认,不能直接断言 ackP99 必然上升。

方案 B 会踩并发:两个批次同时失败时,回滚到哪个值、如何避免退掉其他批次正在使用的号段,都需要比较并交换。作者在 msg_transfer.go:233 只注释了"不会回滚",没有实现回滚;这是现状说明,不是对并发成本的证明。

D 的改动小 :在 sync_msg.go:55-57 的空结果分支里,先写入 resp.Msgs[seq.ConversationID] = &sdkws.PullMsgs{Msgs: nil, IsEnd: isEnd} 再 continue。它不修号段,也不修空洞,只是让空区间在响应中带上边界。客户端是否会因此停止重拉、是否原本就存在卡死,本次没有真实客户端验证。

方案 C 针对的是边界语义,不是"根治空洞";动存储层前要先确认除 PullMessageBySeqs 外还有谁在读 max_seq,至少 GetMsgBySeqsRange 和 GetMsgBySeqs 两处都在读。

小结

三点:

  1. Mongo max_seq 是预留上界,Redis CURR 是已消耗边界,LAST 是预留上界。 三者都不等于消息落库数;差额主要来自未消耗号段,不能直接认定为空洞。把 Mongo 预留上界或 Redis 边界当成消息条数都会算错;正常拉取使用的是 CURR 再按用户可见边界和 num 裁剪后的结果。
  2. 写失败不回滚是当前实现的可见行为。 分配后逐条写缓存,失败时此前写入和已消耗序号不会在 BatchInsertChat2Cache 内回滚;是否形成持久空洞,取决于错误类型、重试、后续写入和落库结果,尚未做故障注入实测。
  3. 空查询结果会让会话响应项被跳过。 len(msgs) == 0 时直接 continue,isEnd 没有写入响应;真实客户端是否会因此重拉或丢会话,需要端到端验证。

判据一句话:先分别读取 Mongo max_seq、Redis CURR/LAST,再对照 1..CURR 内实际存在的消息 seq,并排除删除/撤回、缓存过期等解释;只有对照已消耗边界和逐 seq 结果,才能判断是否存在写失败留下的空洞,单看 Mongo 预留上界与消息条数的差不够。

相关推荐
特立独行的猫A6 分钟前
少 3 个环节、少 1 次 ABI、少 3500 行代码:cj-tauri 与 Ionic/Capacitor 的鸿蒙桥接机制对比
java·后端·python
学长毕设21 分钟前
基于SpringBoot的高校后勤报修管理系统-附源码
java·spring boot·后端
特立独行的猫A30 分钟前
cj-tauri vs Rust/Tauri 同款播放器实测:编译 21 秒 vs 11 分钟,磁盘 12 MB vs 1.2 GB
java·后端·python
乐橙开放平台1 小时前
海康和大华摄像头能统一管理吗?可以通过 GB28181 或视频融合平台接入
网络·后端·物联网·音视频
时间的拾荒人4 小时前
Redis 集合类型详解:Set 与 Zset
redis·缓存·面试
一条Go4 小时前
OnCall 时段路由:跨午夜、左闭右开与「Local 不是时区」
后端·架构·github
Moment4 小时前
不会后期的我,发现了一个开源胶片神器😍😍😍
前端·后端·开源
数据库小学妹4 小时前
pgbouncer 事务池:prepared statement、临时表与会话状态
数据库·后端·postgresql
三小河5 小时前
SWC 凭什么比 Babel 快 ?项目优化迁移踩坑记录
前端·后端