拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

缓存刷新不生效?一文拆解多级缓存与数据一致性的坑

缓存刷新不生效?一文拆解多级缓存与数据一致性的坑

1. 刷新缓存失败的现场,比想象中隐蔽

先交代一下背景,这个标题看着很朴素,但它背后其实是一个很磨人的问题。"刷新缓存"这四个字,在研发日常里通常被描述得很轻巧——"你把缓存刷一下不就好了吗?"说这话的人大概率没被坑过。真正干过的人会知道,刷新缓存从来不是执行一条命令、调一次接口那么简单,它牵涉到键的设计、多级缓存的联动、刷新时机的选取,甚至还有数据一致性的边界问题。

我第一次被这事缠住,是一个很平常的工作日下午。线上有个数据统计类接口,数据被缓存了十分钟,运营那边改完配置之后,希望立刻看到最新值。我当时就是简单粗暴地进了 Redis 执行了一条 DEL,然后满怀信心地跟对方说"刷好了"。结果对方刷新页面之后发现,数值还是旧的。我当时的第一反应是"是不是键名写错了?"——这个直觉有一部分是对的,但远不是全部。真正的问题比键名写错要复杂得多,后面排查了将近三个小时才彻底弄明白,其中牵扯到序列化格式、缓存前缀、本地缓存覆盖、延迟异步刷新等等一系列东西。从那次之后,我就决定把这部分工作经验系统沉淀下来,这也是这篇笔记的来由。

如果你也负责接口开发、数据维护或运营配置系统,这篇笔记应该能帮你省下不少查日志的时间。我会从最常见的刷新缓存失败场景入手,一层层拆解背后的原因,然后给出我实测下来比较稳妥的解决和验证方式。不光是告诉你命令怎么写,更关键的是告诉你——刷新之后怎么确认它真的"刷新到位"了。

通常来说,刷新缓存失败会有这几种表象:数据还是旧的,但缓存里已经不见 key 了;部分用户看到的是新的,部分用户看到的还是旧的;刷新之后立刻是新的,过几分钟又回退成旧的;还有一种是接口报错,直接拿不到数据。这些表象看着差别很大,但它们指向的根因,翻来覆去也就是那几类,下面逐一拆开说。

2. 根因拆解:为什么"刷新了"却没有"生效"

2.1 你删的 key,和业务读的 key 可能根本不是同一个

这是新手最容易踩的坑,也是排查时必须先排除掉的一环。你在 Redis 里删了一个"看起来"正确的 key,但应用代码里实际读的 key 可能经过了一层拼接。举个例子,很多团队习惯用统一前缀加业务维度组成 key,比如user:123:profile,但是写 key 的时候用的是String.format("user:%s:profile", userId),而查 key 的时候用的是CacheKeyUtil.buildUserProfileKey(userId),如果这个工具方法里拼接顺序不一样,或者中间的连接符从冒号换成了下划线,那么你手动删的 key 和代码在读的 key 就完全对不上——你删了等于没删,旧数据还是稳稳躺在原 key 里,还会继续被读到。

这块的隐蔽性在于,Redis 命令本身不会报错,DEL 返回的也是 1,看起来一切正常,但实际什么都没刷掉。

所以我在出问题时,第一个动作不是去删,而是去查。先看看业务代码里到底封装了什么样的 key 生成逻辑,再拿真实的入参去模拟生成一次,得到确切的 key 字符串,这才去执行删除操作。这个习惯看着多花了一分钟,实际上能省掉后面一小时甚至更久的折腾。

另外还有一个容易被忽略的相似场景:键名里带了环境或版本号。比如预发环境和生产环境用同一个 Redis 实例的团队,很多人会在 key 里拼 env 标识,删的时候只记得删config:data,忘了还有config:data:prod和config:data:staging两套,结果只刷掉了其中一套,线上当然还是旧的。

2.2 刷新后重新写入的,可能是一份过期数据

这个问题比 2.1 要隐蔽得多,因为它涉及的已经不是"缓存层"本身,而是缓存和数据源之间的一致性。我当时遇到的那个案例,重启之后就出现这个症状:删除缓存后,应用自动回源重新加载数据到缓存里,但加载出来的还是旧值。一开始百思不得其解,后来查了代码才发现,回源加载的数据来自一个中间表,而这个中间表本身是由一个异步 Job 从另一个库里同步过来的,同步周期是五分钟。也就是说,运营在两分钟前改了源数据,但中间表里存的本质上还是五分钟前的快照,我刷新缓存只是在"刷新"一个注定会拿到旧数据的流程。

这个场景在实际工作中非常常见,尤其是涉及多系统联动、数据仓库同步、消息异步通知更新缓存这一类链路的时候。缓存只是数据链条的最后一公里,前面任何一环有延迟,光刷缓存都没用。

排查思路是:别只盯着缓存层问"为什么还是旧的",要把数据链条往前推一层,看看数据在到达缓存之前经过了多少个环节,每个环节的时效性分别是什么。可以用一个比较笨但很管用的方法——把当前读到的值和数据库中的值、中间表中的值放在同一时刻做对比,看它到底卡在哪个环节里。一般来说会得到三种结果:数据库里本身就是旧的,那问题不在缓存;数据库是新的但中间表是旧的,那问题在同步链路;中间表是新的但缓存是旧的,那才真正轮到缓存刷新出马。

2.3 你只刷了一层缓存,但上面还有一层本地缓存

这是分布式系统里特别经典的一个坑。很多服务为了追求性能,会在应用内部用 Caffeine 或 Guava Cache 做一层本地缓存,再在 Redis 里做一层分布式缓存。你在 Redis 里删 key 删得再干净,应用进程内的本地缓存可能还存着旧值,甚至本地缓存的过期时间设得比 Redis 还长,这种情况下你刷 Redis 一百遍,效果也是零。

这种问题的排查难点在于它"时好时坏"。同一个 key,在这台机器上刷新后立即生效,因为你恰好连到了没命中本地缓存的那台节点;在另一台机器上却还是旧值,因为它进程内的本地缓存还活着。如果服务后面接了多实例负载均衡,用户请求随机打到不同节点上,就会出现那种最让人头疼的表现——一批用户看到新数据,另一批用户看到旧数据,过了很久才慢慢统一过来。

处理办法也很明确:如果你确认服务里有本地缓存这一层,那就要同时处理两件事——删 Redis key,以及触发本地缓存的主动失效。主动失效有很多种方式,比较通用的做法是引入一个消息通知机制,比如通过 Redis 的 Pub/Sub 广播一个"某某 key 已失效"的事件,所有服务节点收到后清掉自己的本地缓存。注意,这里要用广播而不能用点对点队列,因为每台机器上的本地缓存都是独立副本,必须都通知到。

我在实际的系统里是这么处理的:提供一个统一的缓存刷新接口,这个接口做三件事,先删远程缓存,再往广播频道发失效消息,最后记录一个操作日志,方便事后追溯。效果比单纯执行一条 DEL 命令要可靠得多。

2.4 并发下"删早"导致旧值回填

这个场景我说它是"刷新缓存界的钓鱼执法"。你以为刷成功了,验证的时候也确实看到缓存被删掉了,反复刷新几次页面也都是新的,正当你准备收工,用户的反馈又来了——又变回旧的了。这里面的根因是并发回填:在缓存被删掉的那一瞬间,仍然有大量请求正在执行旧数据的查询和回填逻辑,它们在你删除操作之前就已经开启了回源流程,持有的还是旧数据,只不过回填动作晚了一拍,导致你删完之后它们又堂而皇之地把旧值写回了缓存。

用大白话讲就是:你删的速度,赶不上别人写的速度。

解决这个问题,业界比较成熟的方案有几个。一种叫"更新式刷新",不去删 key,而是直接把新值写入缓存,这样就不会留下回填的窗口期。但这么做的条件是:你必须已经拿到了最新数据,适合那种配置修改后主动推送缓存的场景。另一种是"双删"策略,第一次删除,让旧请求回填;等一小会儿,比如一两秒,然后再删一次。这样做的目的是在旧请求基本回填完毕后再清理一轮,尽量清掉残留的旧值。这么做不是完美无缺,但确实能把概率压到很低。第三种更稳妥的做法是给缓存加版本号,写入的时候带上版本号,读取的时候校验版本号,不匹配就重新回源,这就彻底绕开了"删不干净"的问题。

从工程实践角度,我不建议无脑选择"更新式刷新",因为它有一个前提条件:新值来源必须是可靠的、一次性的。在配置类场景下没问题,但在复杂数据聚合类场景下,更新的数据本身可能也只是某个中间结果,这时候用双删加版本号组合会更稳妥。

3. 从表象到根因:完整的排查链路

3.1 第一步:确认"缓存没了"但"数据没变"还是"数据没变也没了"

很多人排查的第一步就错了——他们第一时间去翻业务代码,试图从逻辑上推断问题。我的习惯是反过来,先在缓存层做物理验证。打开 Redis 命令行工具,执行一次 TTL 查询和 GET 查询,看看目标 key 当前的存活状态和实际值。这一步能帮我们快速判定问题的相位。

如果 TTL 返回 -2,说明 key 已经不存在了,那问题就从"缓存没被删掉"变成了"缓存被删了但新值不对"或"缓存被删了但读取链路没走缓存"。如果 TTL 返回 -1 或一个正数,说明 key 还活着,那就回到 2.1 里说的键名核对流程——你删的和别人读的到底是不是同一个。

这里我会顺便记录几个数据快照:当前 Redis 中的旧值、当前数据库中的新值、当前接口返回给用户的值。三个值一对比,问题的边界立刻就清晰了一大半。

现象可能原因排查方向
Redis key 已删,接口仍返回旧值本地缓存未清除检查服务内 Caffeine 缓存,确认是否存在多级缓存
Redis key 已删,接口返回报错回源路径异常,可能依赖的库表不可用检查数据源健康状态,关注 DB 连接池
Redis key 在,但值不是最新键名不匹配或刷新的是另一个 key比对代码中的 key 生成逻辑
刚刷完是新的,过一阵变旧并发旧值回填观察时间窗,排查是否有异步回填逻辑

这三列数据一旦记录下来,后面的排查路径就不太会跑偏了。别嫌这一步麻烦,缓存问题最怕的就是毫无目的地东翻西看。确定了相位之后,再去翻代码和日志就有的放矢了。

3.2 第二步:沿着数据链路走一遍,找到"在哪一层变旧了"

如果确认缓存确实被刷新了,但值不对,那就进入链路排查阶段。我的做法是把数据从源头到展示给用户,按顺序拆成几个环节,在每一层的入口和出口打上标记。以我经历的那个案例为例,链条是这样的:运营配置写进主数据库、一个同步任务把主库数据搬运到查询库、应用查询时先看 Redis 缓存、未命中则读查询库。

明确了链条之后,我在每个环节做了一次"读一下当前值"的操作,记录时间和值的内容。结果很清楚:主库的值已经是新的,查询库里的值还是旧的,等于问题出在同步任务上。这时候再回头去查同步任务为什么没跑,发现是同步任务挂了,所以数据根本没流过来。这也就解释了为什么反复刷新 Redis 都没用——所有刷新动作都在给一条不流通的管道"注水"。

排查这条链路时,有一个小技巧特别实用:不要只在一个时间点做采样,建议隔几分钟做两次采样,因为有些同步任务是分钟级的,你第一次采样时可能恰好赶上同步间隙,数据还没过来。间隔采样能排除掉"只是时机不凑巧"这种假象。

3.3 第三步:把"偶发性"问题和不稳定因素区分开

最头疼的一类问题是偶发性很强,你连续复现五次都正常,第六次突然就旧了,等你想抓现场,它又恢复正常了。这种时候,靠人肉点按钮是抓不到的,必须借助监控和日志。

如果你们的服务有链路追踪系统的话,把接口请求的 traceId 和相关日志拉出来,看看那次返回旧值请求的具体执行路径:它到底是从缓存取的,还是回源取的;如果是从缓存取的,那缓存键是什么;如果是回源取的,回源后有没有写入新缓存。这些内容都会留在日志里,只是平时没人去翻而已。

还有一种情况必须注意——本地调试工具和线上环境行为不一致。比如你在本地连的都是同一个 Redis,但本地代码里可能开启了一个"禁用缓存"的开关,导致你调试时从来不会命中缓存,看起来什么问题都没有,但线上是正常的缓存链路,当然表现不同。所以我排查时永远先确认一件事:当前这个请求,到底走没走缓存代码分支,有没有被开关或环境变量改变过行为。

3.4 排查时随手要记的几条命令和工具

这里把我在上述排查链路里最常用的命令和工具整理出来,方便直接抄作业:

  • redis-cli -n 0 DEL <key>:删除指定库里的键,注意 Redis 默认有 16 个库,别删错库了。
  • redis-cli --scan --pattern "user:*:profile":模糊查找键名,用于定位键名拼写是否一致。
  • redis-cli TTL <key>:查看剩余过期时间,-1 表示永久有效,-2 表示键不存在。
  • redis-cli --bigkeys:扫描大键,当你怀疑缓存对象过大导致回填耗时过长时用。
  • 生产环境优先使用具备审计功能的控制台或运维平台,避免直接暴露可执行任意命令的权限,这个后面会细说。

在非生产环境,我还会用redis-cli MONITOR命令来实时监听一段时间内的键读写操作。它能捕获当前正在被访问的 key,对于确认业务实际在读哪个 key 非常有效。生产环境慎用,因为它会影响性能,但在预发环境或用低峰期窗口执行,是很高效的定位手段。

4. 从源头解决:设计一套可复现的缓存刷新流程

4.1 别靠人肉操作,把刷新动作收敛成统一入口

排查做了很多次之后,我最大的体会是:刷新缓存这件事,最大的风险来源其实是"人"。人记错了键名、人删错了库、人漏了本地缓存、人忘了通知下游——所有这些人为失误叠加起来,才让一次简单的刷新变得危机四伏。

所以后来我在团队里推动的第一件事,就是把散落在各处的缓存刷新操作收敛成一个统一入口。具体做法是做一个简单的缓存管理接口,接受一个业务键名作为入参,然后由代码内部的统一逻辑去执行完整的刷新动作。这个接口的内部实现长这样:

public void refreshCache(String bizKey) { // 1. 从配置中心或数据库中获取该业务键对应的所有 redisKey 模板 List<String> redisKeys = cacheKeyMappingService.getTargetKeys(bizKey); // 2. 依次删除远程缓存 redisTemplate.delete(redisKeys); // 3. 广播本地缓存失效消息 cacheInvalidatePublisher.publish(redisKeys); // 4. 写审计日志 auditLogger.log(bizKey, redisKeys, System.currentTimeMillis()); }

这个方案看起来不复杂,但它解决了几个关键问题:一是键名不再依赖人的记忆,而是由代码依据配置自动生成,杜绝了 2.1 里那个坑;二是刷新动作天然覆盖了远程缓存和本地缓存两层,不用人去记"还要不要清本地缓存";三是所有刷新操作都留痕,出了问题能追溯。

你可能会说,这不就是给运维多做了个接口嘛,有什么大不了的。其实差别很大。没有统一入口之前,每次刷新都是临时的、手工的、不可复现的;有统一入口之后,刷新成了一个定义明确的、可测试的、可回滚的操作。这两者在生产环境的稳定性上完全不是一个量级。

4.2 刷新之前的"预校验"与刷新之后的"确认单"

在把刷新动作做成接口之后,我又在业务流程上加了两道保险。

第一道保险是刷之前的预校验。刷新动作执行前,系统先自动做一轮检查:目标键在 Redis 中是否存在、数据库中的最新值是什么、当前缓存值与数据库值是否一致。如果一致,那就根本不值得刷新,直接返回"无需刷新"即可。这能减少很多无效操作,也能避免那种"运营觉得数据不对,其实是自己看错了"的乌龙。

第二道保险是刷之后的确认单。接口执行完删除和广播动作后,会自动触发一个验证任务:等待一小段时间后重新读取该键,回源后缓存中的新值是什么,与数据库比对是否一致,然后把这些结果汇总成一段摘要,返回给调用方。有了这个确认单,操作的人立刻就能知道本轮刷新是否真的把链条串起来了,不用再去页面反复刷新验证。

这两道保险的代码逻辑都比较直接,核心其实就一句话:把验证从"人肉观察"升级为"系统自动比对"。实践中效果非常明显,团队里后来再也没出现过"刷完之后告诉别人好了,结果过了半小时发现根本没刷掉"的尴尬局面。

4.3 刷新接口的权限与灰度控制,别让所有人都有删除权

缓存刷新操作本质上是一个"高危动作"。一个配置类的 key 被误删还好说,回源就能恢复;但如果存在"防止缓存穿透"这类保护性缓存,被误删之后又碰上高并发流量,数据库被一波打穿也不是没可能。所以在做统一入口的同时,权限控制一定要跟上。

我的建议是至少做两层权限。第一层是操作人权限,只有持有运维角色或指定负责人角色的人才能调用刷新接口,普通业务人员只能查看不能删除。第二层是键范围权限,把 key 按重要程度分层——核心交易链路相关的缓存、非核心业务缓存、短生命周期缓存——不同层级的操作频率和审批流程可以不同,但原则是核心链路必须要有二次确认。

另外还有一个非常容易被忽略的点:刷新接口在生产环境一定要做好限流。原因是如果这个接口自身被脚本或机器人高频调用,等于变相发起了一轮"缓存抖动"攻击,会让回源压力骤增。哪怕是无意的,比如有人写了个定时任务把刷新事逻辑反复触发,也会造成不必要的性能损耗。加上限流之后,高频调用直接被拒掉,也算是保护自己。

4.4 防穿透保护性缓存,要单独设计刷新策略

上面提到防穿透缓存,这块需要单独说,因为它和普通业务缓存的刷新策略完全不同。普通缓存的目标是"在不刷新时保持旧值可靠",防穿透缓存的目标是"在空窗期保护数据库不回源超限"。后者最关键的是不能出现"空窗",一旦缓存被删掉,所有流量直接打到数据库上,风险比数据短暂偏差大得多。

所以对于防穿透缓存,我会采用"用新值覆盖"而不是"删除"的刷新策略。也就是说,请求方拿新数据来,我们先把新数据写进缓存,然后再更新一个逻辑版本标记,读侧看到版本变化就知道数据已更新。整个过程不会出现没有任何缓存数据的空窗期,数据库始终被缓存挡着,安全系数高很多。

这里可以举一个实际例子。我们有个接口,回源成本很高,单次查询要聚合十几张表,平时依赖一个五分钟过期的缓存来扛流量。有一次运营要做全量配置变更,我要是按照老思路直接删缓存,在配置完全生效之前,所有请求都会穿透到聚合计算上,极有可能把数据库连接池占满。所以我们用了覆盖式写入,先让新配置的计算结果落到缓存,再切流量,整个过程数据库的压力几乎没有波动。

5. 刷新之后还要留几个心眼:监控、验证与后续检查

5.1 缓存回源时间窗与新值生效时间窗要分开看

一个让很多人困惑的问题是:刷新缓存之后,到底要等多久才能看到新值?这个答案不是固定的,因为它取决于两个时间窗——回源时间窗和生效时间窗。

回源时间窗是指从缓存清空到新值写入缓存之间的间隔,这个间隔受回源数据复杂度影响,快则几百毫秒,慢则几十秒。生效时间窗则是指本地缓存的过期时间,如果你服务里有 Caffeine 缓存的过期时间是五分钟,那么哪怕 Redis 里已经写入了新值,本地的旧值最多还能存活五分钟。所以如果你刷新之后发现数据瞬间就变了,那说明没有本地缓存这一层;如果发现过了几分钟才变,那多半就是被本地缓存"拖慢"了。

理解了这两个时间窗,你就不会在刷新完成后的一两秒内反复点页面,然后得出"刷新没生效"的错误结论。正确做法是分两阶段验证:Redis 层面的写入是否完成,通过直接读 Redis 确认;应用层面的对外表现是否更新,在本地缓存过期后再次访问接口确认。

5.2 刷新后五分钟内的访问日志,是安全性的试纸

刷新缓存这个动作本身有点像一个将系统短暂暴露在风险中的开关。缓存失效的那一刻,系统会有一段"回源高发期",这时候如果代码里有什么潜在隐患,比如慢查询、死锁、超时,都很容易被放大暴露出来。所以在刷新后的五分钟内,我习惯去看几类指标的波动:数据库连接池活跃连接数、接口平均响应时间、错误率、CPU 使用率。

如果这些指标整体平稳,那说明这次刷新是"顺滑"的。如果有显著的尖峰,哪怕接口最终恢复了正常,也说明系统的回源能力存在隐患,值得在后续优化一下。尤其是数据库连接池,如果刷新后活跃连接数短期爬得很猛,可能意味着回源逻辑的重度超出预期,能优化聚合逻辑就优化,优化不了就要考虑给缓存增加更长的有效期。

5.3 缓存中"脏值"与"旧值"的区分

说到最后,还有一个概念值得理清:旧值和脏值是两码事。旧值是指数据源本身是最新的,但缓存里存的是过期版本,刷新就能解决。脏值是指缓存里的值本身就来自一次错误的写入或计算,它可能不是任何时间点的有效版本,刷新未必能解决——甚至可能又一次把脏值回填回去。

我们线上就出现过一次脏值案例。一批数据导入任务因为代码逻辑缺陷,把一批错误的映射关系写进了缓存,而且有效期长达一天。后来发现问题后,我们在刷新缓存之前先跑了一个清理任务,把可能受影响的键全部枚举出来,批量删除后才触发回源。如果你只是简单调用统一刷新接口去刷其中一个键,回源时用的还是那套错误映射关系,结果照样是脏的。

所以在排查类似"刷新之后数据仍然明显异常"的问题时,别局限于缓存层有没有正常刷新,还要多问一句:这个数据当前来源环节本身是不是就有问题?如果是,刷新只会加速错误的传播。

5.4 一套简单但有效的验证清单

最后分享一份我每次刷新完缓存之后都会过一遍的验证清单,算是个人的土办法,但胜在简单可靠:

  1. 先在 Redis 里直接读一次目标键,确认新值已经写入,这一步最直接;
  2. 再通过业务接口访问一次,确认对外表现一致,注意避开本地缓存生效时间窗;
  3. 检查同一个键在数据库中对应的记录,比对值是否相等,排除中间链路问题;
  4. 观察刷新后五分钟的监控指标,确认没有异常尖峰;
  5. 如果有消息通知机制,确认失效广播已被各节点消费,本地缓存均被清除。

这条清单执行下来通常不超过十分钟,但能覆盖掉我在前面几节里提到的绝大多数根因类型。遇到复杂场景时再往上追溯到数据源链路,基本就不会再有"刷新了个寂寞"的情况了。

6. 踩过几次坑之后的真心话

刷新缓存这事,技术门槛确实不高,命令简单、原理不深,但我现在越来越觉得,它恰恰是一个系统设计是否成熟的试金石。一个团队如果连刷新缓存都要靠人肉记忆键名、靠运气确认生效,那说明缓存相关的基础设施还不够完善,早晚会在某个凌晨被一个问题给教做人。

我的建议是不要等到出事了再来优化流程,可以在平时就花半天左右把统一刷新入口、预校验、验证反馈这几个基础能力搭起来。成本并不高,但收益是长期且持续的——至少以后再有人跟我说"你把缓存刷一下"的时候,我可以用一套稳定的工具去完成,而不是打开命令行赌一把。

这里还有一个我个人的小经验,也分享给你。刷新缓存后如果数据依然表现异常,不要反复执行刷新命令,越刷越乱。正确做法是停下来,回到你说的"现场记录"那一步——当前值是什么,期望值是什么,中间穿过哪些数据环节。把这三个问题想清楚,大多数问题都能自己浮出水面。如果你也经常被缓存刷新问题困扰,希望这篇笔记能给你一些参考,少走一点弯路。

返回列表