我不会起名字322 · 后端 / 算法 / 数据库
文章目录
- [会话缓存失效排查:区分 Redis 失效与节点广播](#会话缓存失效排查:区分 Redis 失效与节点广播)
-
- 现象:改成功的设置,过一会儿自己退回去
- [三层缓存,失效窗口差 144 倍](#三层缓存,失效窗口差 144 倍)
- 为什么发布失败只记一行日志
- 更隐蔽的一种:数据库改了,接口却报错
- 还有一个共享切片的坑
- 复现与判据:三条命令
- 怎么修?四种方案的代价对比
- 小结
会话缓存失效排查:区分 Redis 失效与节点广播
假设修改免打扰后重新进入 App 又看到旧设置,应该分别检查数据库、Redis 和进程内缓存,而不能仅凭现象确定根因。当前源码配置中 L1 标称 TTL 为 300 秒,Redis 标称 TTL 为 12 小时;历史日志没有复现发布失败,本文分析的是可能路径,并非已验证的现场因果。
接口成功也不能证明各节点 L1 都已失效:L2 标记失效报错会向上返回,而其成功后的 Pub/Sub 发布失败只记告警;即使发布命令成功,也不保证每个订阅者收到。
环境:基于 OpenIM Server v3.8.3 改造的 Go IM 后端,会话数据走「进程内 LRU → Redis(rockscache)→ MongoDB」三级读缓存,失效靠 Redis Pub/Sub 广播。代码与行号来自
im-server仓库,我逐条 grep 确认;配置来自im-server/config/local-cache.yml;日志统计来自本机local/logs/server/。
现象:改成功的设置,过一会儿自己退回去
以下写路径先更新 Mongo,再清缓存;缓存失效失败时不会回滚数据库更新。 这是出现新旧值不一致的一种可能。
以下展示 UpdateConversation 路径:它在 internal/rpc/conversation/conversation.go:731 把 recv_msg_opt 放进 map,再调 UpdateUsersConversationField。公开 /set_conversations 对应 SetConversations,不能仅凭下面片段证明该接口的完整调用链:
go
// internal/rpc/conversation/conversation.go:731(精简)
m["recv_msg_opt"] = req.RecvMsgOpt.Value
控制器层拿到这个 map 之后,先写 Mongo,再清缓存,pkg/common/storage/controller/conversation.go:182-197:
go
// pkg/common/storage/controller/conversation.go:182-197(精简,注释是我加的)
func (c *conversationDatabase) UpdateUsersConversationField(ctx context.Context, userIDs []string, conversationID string, args map[string]any) error {
_, err := c.conversationDB.UpdateByMap(ctx, userIDs, conversationID, args)
if err != nil {
return err
}
cache := c.cache.CloneConversationCache()
cache = cache.DelUsersConversation(conversationID, userIDs...).DelConversationVersionUserIDs(userIDs...)
if _, ok := args["recv_msg_opt"]; ok {
cache = cache.DelConversationNotReceiveMessageUserIDs(conversationID) // :189
cache = cache.DelConversationNotNotifyMessageUserIDs(userIDs...)
}
if _, ok := args["is_pinned"]; ok {
cache = cache.DelUserPinnedConversations(userIDs...) // :193
}
return cache.ChainExecDel(ctx) // :196
}
注意这里的顺序:Mongo 已经写成功了,缓存失效是后面单独一步。 如果 ChainExecDel 失败,UpdateByMap 的改动不会回滚;缓存是否保留旧值仍需核查。
三层缓存,失效窗口差 144 倍
这套读路径上叠了三层,每层的 TTL 和失效手段都不一样。把它们摆在一起,问题就很清楚了。
| 层 | 位置 | TTL | 失效方式 | 失效失败时 |
|---|---|---|---|---|
| L1 进程内 LRU | pkg/rpccache/conversation.go:31-38 |
成功 300 秒 / 失败 5 秒 | Redis Pub/Sub 消息 | 发布失败只告警,节点可能保留旧值至本地 TTL 到期 |
| L2 Redis | pkg/common/storage/cache/redis/conversation.go:21 |
标称 12 小时(随机缩到 9.6~12 小时) | TagAsDeletedBatch2 |
标记失败返回错误;旧值是否存在及剩余 TTL 需现场确认 |
| L3 MongoDB | --- | 无 | 就是数据源 | --- |
L1 的 TTL 在 config/local-cache.yml 里,五个模块全是同一组数:
yaml
# im-server/config/local-cache.yml:29-34
conversation:
topic: DELETE_CACHE_CONVERSATION
slotNum: 100
slotSize: 2000
successExpire: 300 # 5 分钟
failedExpire: 5
L2 的 12 小时在 pkg/common/storage/cache/redis/conversation.go:19-22:
go
// pkg/common/storage/cache/redis/conversation.go:19-22
const (
// conversationExpireTime 是会话相关缓存的 TTL,12 小时。
conversationExpireTime = time.Second * 60 * 60 * 12
)
300 秒和 12 小时,差 144 倍。 这个差不是设计失误,是两层缓存的目标不同:L1 是 RPC 响应缓存,短 TTL 换低延迟;L2 是跨进程共享的 Redis 缓存,长 TTL 换低回源率。
两层失效都由 execDel 协调,但操作和错误处理不同:先标记 L2,再发布 L1 失效通知。
为什么发布失败只记一行日志
核心在 pkg/common/storage/cache/redis/batch_handler.go:58-86,execDel:
go
// pkg/common/storage/cache/redis/batch_handler.go:58-86(精简,注释是我加的)
func (c *BatchDeleterRedis) execDel(ctx context.Context, keys []string) error {
if len(keys) > 0 {
err := ProcessKeysBySlot(ctx, c.redisClient, keys, func(ctx context.Context, slot int64, keys []string) error {
return c.rocksClient.TagAsDeletedBatch2(ctx, keys) // L2:置 10 秒后过期
})
if err != nil {
return err // L2 失败:向上返回
}
if len(c.redisPubTopics) > 0 && len(keys) > 0 {
keysByTopic := localcache.GetPublishKeysByTopic(c.redisPubTopics, keys)
for topic, keys := range keysByTopic {
if len(keys) > 0 {
data, err := json.Marshal(keys)
if err != nil {
log.ZWarn(ctx, "keys json marshal failed", err, "topic", topic, "keys", keys)
} else {
if err := c.redisClient.Publish(ctx, topic, string(data)).Err(); err != nil {
log.ZWarn(ctx, "redis publish cache delete error", err, "topic", topic, "keys", keys) // :78
}
}
}
}
}
}
return nil // :85 无论发布成没成功
}
看两个出口:L2 失败 return err(:66),L1 失效失败只 log.ZWarn 然后 return nil(:85)。
同一个函数里,两种失败被区别对待。L2 是共享缓存,L1 是各进程的副本;发布失败可能使已有 L1 旧值保留到该项 TTL 到期,不能据此推断 Redis 的旧值未被失效。
作者自己在 batch_handler.go:23-28 把这件事写明了:
// BatchDeleterRedis 是 cache.BatchDeleter 的 Redis 实现。
// ...
// 发布失败只记日志,不影响此处返回值;本方法没有补发或确认各节点已清理的机制。
「没有补发或确认机制」------这八个字是关键。 一条 Pub/Sub 消息发丢了(Redis 抖动、订阅端重连、网络闪断),没有任何机制发现它丢了,也没有任何机制重发。订阅端只能等自己的 300 秒 TTL 到点。
我查了本机日志,redis publish cache delete error 在这几天是 0 次:
bash
cd local/logs/server && for f in im-service-log.2026-11-05 im-service-log.2026-11-08; do
echo "$f publish_err=$(grep -c 'redis publish cache delete error' $f) exec=$(grep -c 'delete cache' $f)"; done
# im-service-log.2026-11-05 publish_err=0 exec=55
# im-service-log.2026-11-08 publish_err=0 exec=38
93 次缓存删除,0 次发布失败。这一轮压测没有复现到发布失败,所以下面是代码路径分析,不是实测现场。 这一点必须说清楚------发布失败需要 Redis 主从切换或网络分区,压测的读写都是正常路径,踩不到。
更隐蔽的一种:数据库改了,接口却报错
上面说的是发布失败只告警。另一条路径是 L2 失效返回错误,但数据库修改已经成功。
还是 UpdateUsersConversationField,:196 是 return cache.ChainExecDel(ctx)。如果 L2 的 TagAsDeletedBatch2 报错,execDel 在 :66 返回错误,ChainExecDel 把错误往上抛,RPC 层返回给客户端------但 Mongo 的 UpdateByMap 已经提交了。
客户端可能看到「修改失败」并重试;第二次是否成功取决于缓存服务及请求情况,不能由第一次错误推断。
如果客户端不重试,Mongo 可能已有新值;Redis 是否仍保留旧值、剩余 TTL 多长,要结合具体 key 和操作时序检查,不能仅凭错误确定。
这里有个容易被忽略的细节:TagAsDeletedBatch2 成功时的动作是 EXPIRE key 10,不是 DEL key。这是 rockscache 的延迟双删策略,10 秒窗口留给主从复制。10 秒这个数来自 rockscache.NewDefaultOptions() 的 Delay: 10 * time.Second,项目在 pkg/common/storage/cache/redis/batch.go:20-28 只覆盖了三个字段,没动 Delay:
go
// pkg/common/storage/cache/redis/batch.go:20-28(精简)
func GetRocksCacheOptions() *rockscache.Options {
opts := rockscache.NewDefaultOptions()
opts.LockExpire = rocksCacheTimeout // 11 秒
opts.WaitReplicasTimeout = rocksCacheTimeout
opts.StrongConsistency = true
opts.RandomExpireAdjustment = 0.2 // 12h → 9.6~12h 随机
return &opts
}
RandomExpireAdjustment = 0.2 将新设的 12 小时 TTL 随机缩短到约 9.6~12 小时;现场 key 的剩余寿命取决于设置时间,不能直接认为还有完整的这个区间。
还有一个共享切片的坑
Clone 的实现值得单独看一眼,pkg/common/storage/cache/redis/batch_handler.go:88-97:
go
// pkg/common/storage/cache/redis/batch_handler.go:88-97(精简)
// Clone 复制删除器;key 列表按值复制一份新的切片头,
// 但底层数组仍与原对象共享,AddKeys 扩容前请不要依赖两者完全独立。
func (c *BatchDeleterRedis) Clone() cache.BatchDeleter {
return &BatchDeleterRedis{
redisClient: c.redisClient,
keys: c.keys, // <- 只复制切片头,底层数组共享
rocksClient: c.rocksClient,
redisPubTopics: c.redisPubTopics,
}
}
keys: c.keys 复制的是切片头(指针 + len + cap),不是元素。 如果某个切片 len < cap,两个 BatchDeleterRedis 就会往同一块内存的同一个下标写。
这条链式调用会 Clone 再 AddKeys,但单条线性调用不足以证明发生覆盖。若保留并分别追加共享底层数组的两个分支,且剩余容量足够,后续追加可能覆盖彼此的数据;需要构造具体调用及并发条件验证。
这一段是读代码推断,我没有构造出复现。 说它是「坑」而不是「bug」,因为它需要一个特定的 append 扩容序列才能触发。但注释里那句「AddKeys 扩容前请不要依赖两者完全独立」说明作者知道这个约束,只是没有强制。
复现与判据:三条命令
下面三条都可以直接跑。
第一条,查看 Redis key 与剩余 TTL,作为排查线索;正常失效后重建也可能产生较长 TTL,不能单凭该值判定失败:
bash
# 会话缓存 key 的剩余 TTL(秒);长 TTL 也可能来自正常重建
redis-cli -p 16379 --scan --pattern "CONVERSATION:ltprobe4_u0001:*" | head -5 | while read k; do
echo "$k ttl=$(redis-cli -p 16379 ttl "$k")"
done
TTL 必须结合精确 key、失效前后时间和缓存重建情况解读;长 TTL 可能来自正常重建,不能单独作为失效失败的判据。
第二条,比对 Mongo 和 Redis 里的值是否一致:
bash
# Mongo 侧的真实值
mongosh --quiet "mongodb://127.0.0.1:37017/im" --eval '
db.conversation.find({owner_user_id:"ltprobe4_u0001", conversation_id:/ltprobe4/},
{recv_msg_opt:1, is_pinned:1}).limit(5).toArray()'
上一条只读取 key 和 TTL,没有读取 Redis 值。应先确认具体 key、类型与 rockscache 的编码格式,再读取并解码实际值,与 Mongo 同时段结果比对;还要结合失效和重建日志,排除并发写入。
第三条,确认有没有发布失败的历史记录:
bash
grep -c "redis publish cache delete error" local/logs/server/im-service-log.*
grep "redis publish cache delete error" local/logs/server/im-service-log.* | tail -3
这条日志说明发布调用报错;是否有节点持有旧 L1 值、何时过期,需要结合订阅状态和具体缓存项确认。
怎么修?四种方案的代价对比
先给结论:优先级最高的是给删除失败加补偿,不是缩短 TTL。
| 方案 | 做法 | 改哪里 | 代价 | 评价 |
|---|---|---|---|---|
| A. 缩短 Redis TTL | 调整缓存配置 | conversation.go:21 |
回源负载和一致性窗口需要实测 | 辅助措施 |
| B. 可靠补偿失效 | 持久记录待处理任务并重试 | 写入流程及后台 worker | 需设计可靠存储、幂等、堆积和失败恢复 | 值得评估 |
| C. Publish 失败返回错误 | 将发布错误向上传递 | batch_handler.go:78 |
可暴露发布命令失败,但不能确认订阅者收到 | 不能单独保证一致性 |
| D. 减少或去掉 L1 | 调整本地缓存策略 | 需验证配置及实现 | Redis 负载与延迟变化需压测 | 中期选项 |
方案 A 的 72 倍只是 TTL 数值之比,不是回源 QPS 的预测;实际负载取决于访问分布、失效和重建等因素。
方案 C 能让调用方知道发布命令失败,但 Pub/Sub 的成功返回并不证明所有订阅者收到。方案 B 需要可靠记录和重放,并考虑数据库更新与补偿记录之间的原子性;Redis 不可用时,依赖同一 Redis 的队列不能保证写入成功。方案 D 的负载与延迟影响需要实测。
小结
三点:
- 接口成功不保证各节点 L1 已失效。 L2 失败返回错误,但数据库更新可能已完成;发布失败只告警。
- 300 秒和 12 小时是两层缓存的标称 TTL。 广播丢失影响 L1,不能直接推断 L2 仍是旧值;剩余寿命也取决于具体写入时间。
- 可靠补偿、可观测性和 TTL 调整都需结合故障模型评估,不能单凭 TTL 比值预测负载或保证一致性。
排查时应关联请求、具体 key、失效及重建时序,读取并解码缓存值,再与 Mongo 和各节点 L1 状态比较;TTL 只是辅助线索。