我不会起名字322 · 后端 / 算法 / 数据库
文章目录
- [seq 空洞排查:Mongo 的 51 只是预留上界,Redis CURR 才是已消耗边界](#seq 空洞排查:Mongo 的 51 只是预留上界,Redis CURR 才是已消耗边界)
-
- [现象:Mongo 预留上界高于已记录的消息数](#现象:Mongo 预留上界高于已记录的消息数)
- 为什么预留上界会比消息数大?
- 消息写失败的时候,洞是怎么留下的
- [错误处理:只有 `redis.Nil` 被当成可以继续](#错误处理:只有
redis.Nil被当成可以继续) - 客户端会看到什么
- 复现与判据:三条命令
- 怎么修?四种方案的代价对比
- 小结
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 两处都在读。
小结
三点:
- Mongo
max_seq是预留上界,RedisCURR是已消耗边界,LAST是预留上界。 三者都不等于消息落库数;差额主要来自未消耗号段,不能直接认定为空洞。把 Mongo 预留上界或 Redis 边界当成消息条数都会算错;正常拉取使用的是CURR再按用户可见边界和num裁剪后的结果。 - 写失败不回滚是当前实现的可见行为。 分配后逐条写缓存,失败时此前写入和已消耗序号不会在
BatchInsertChat2Cache内回滚;是否形成持久空洞,取决于错误类型、重试、后续写入和落库结果,尚未做故障注入实测。 - 空查询结果会让会话响应项被跳过。
len(msgs) == 0时直接continue,isEnd没有写入响应;真实客户端是否会因此重拉或丢会话,需要端到端验证。
判据一句话:先分别读取 Mongo max_seq、Redis CURR/LAST,再对照 1..CURR 内实际存在的消息 seq,并排除删除/撤回、缓存过期等解释;只有对照已消耗边界和逐 seq 结果,才能判断是否存在写失败留下的空洞,单看 Mongo 预留上界与消息条数的差不够。