会话缓存失效排查:区分 Redis 失效与节点广播

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

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

文章目录

会话缓存失效排查:区分 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 的负载与延迟影响需要实测。

小结

三点:

  1. 接口成功不保证各节点 L1 已失效。 L2 失败返回错误,但数据库更新可能已完成;发布失败只告警。
  2. 300 秒和 12 小时是两层缓存的标称 TTL。 广播丢失影响 L1,不能直接推断 L2 仍是旧值;剩余寿命也取决于具体写入时间。
  3. 可靠补偿、可观测性和 TTL 调整都需结合故障模型评估,不能单凭 TTL 比值预测负载或保证一致性。

排查时应关联请求、具体 key、失效及重建时序,读取并解码缓存值,再与 Mongo 和各节点 L1 状态比较;TTL 只是辅助线索。

相关推荐
深入云栈2 小时前
Netty 4.2.x 源码深度解析 (二十三):ByteBuf 核心设计 —— 索引、类型与零拷贝
java·后端·架构
JackJiang2 小时前
IM踩坑:聊天消息ID怎么设计?一个UUID搞不定,我用双ID解决
后端·架构
开心猴爷2 小时前
iOS 上架 App 流程
后端·ios
code_Bo2 小时前
不只命令行:我把跨 Agent 会话同步做成了桌面应用
前端·javascript·后端
用户8356290780513 小时前
使用 Python 控制 Excel 工作表的分页位置
后端·python
用户8356290780513 小时前
Python 如何在 Word 中添加和管理文档变量
后端·python
量化分析码农4 小时前
股票历史数据的复权参数对照:多级别返回的字段与数值差异
java·javascript·后端
程序员cxuan5 小时前
讲真,智能路由这事儿,还得看基元律动
人工智能·后端·程序员
vaskilab6 小时前
Schema 全绿,Agent 却把参数编造了
后端