1. 一次线上事故引发的思考:缓存刷新到底难在哪
事情是这样的,某个周五下午,我正在处理一个看似"人畜无害"的需求:给后台管理系统加一个按钮,点击之后刷新某个业务模块的缓存。当时我的第一反应很简单——不就是把Redis里对应的key删掉嘛,几秒钟的活。结果真正动手之后才发现,这个"简单"需求背后的坑远比想象中多,而且这些坑还不是代码层面的,更多是设计层面的。
场景还原:系统里有这样一个模块,它从数据库读取配置数据,然后组装成一份比较昂贵的中间结果,缓存在Redis里。缓存过期时间设置的是24小时。数据本身的变更频率其实很低,但一旦变更,业务方希望"立刻生效",而不是等缓存自然过期。于是"手动刷新缓存"这个需求就诞生了。
但这里的核心问题不是"怎么刷新",而是"刷新哪部分"、"什么时候刷新"、"刷新失败怎么办"、"刷新期间用户请求怎么处理"这四件事。如果你只是简单地把Redis里的key删了,那接下来第一个请求会穿透缓存直击数据库,如果这个配置数据本身要经过复杂的计算逻辑,这第一个请求的响应时间会直接飙升十几倍,而用户根本不知道发生了什么。
我在这篇笔记里把整个过程完整记录下来,包含我在实际操作中踩过的坑、验证过的方案、以及最终沉淀下来的一套通用做法。如果你也在维护类似带缓存的后台系统,或者正准备给系统加"刷新缓存"功能,这篇文章应该能帮你省下不少调试时间。
2. 先想明白:你刷新的到底是哪一层缓存
2.1 缓存不是只有一层,先盘点你的缓存拓扑
很多人一说"刷新缓存",第一反应就是Redis。但实际生产环境里的缓存往往是一个分层的结构,每一层都有它自己的生命周期和刷新策略。如果只盯着最下面那一层操作,往往解决不了问题,甚至会让情况更复杂。
我这里列一个最常见的缓存分层,你对照自己的系统盘一下:
- 浏览器缓存:静态资源、页面本身的缓存,由HTTP响应头控制,比如
Cache-Control、ETag。这一层和业务数据关系不大,但如果你做的是前后端不分离的项目,刷新页面缓存也属于"刷新缓存"的范畴。 - CDN缓存:静态资源或部分动态页面的缓存,节点遍布各地,刷新的时候需要调用CDN服务商的API,而不是你本地删个key就行。
- 应用本地缓存:JVM堆内缓存,比如Caffeine、Guava Cache。这类缓存的刷新往往需要逐台机器操作,因为每台JVM都是独立的一份。
- 分布式缓存:通常是Redis,这是大多数人认知里的"缓存",也是我们这次要重点处理的对象。
- 数据库查询缓存:MySQL的Query Cache(虽然现代版本已经废弃了,但我碰到有些老项目还在用),这个也需要单独考虑。
我这边的场景是典型的第四层,即Redis分布式缓存。但要注意一个问题:我们的应用做了多实例部署,也就是说有多个Java进程在同时运行。如果我只刷新了一个实例的本地缓存,其他实例依然是旧数据,那对用户来说"刷新缓存"这个功能就是失灵的。所以,在设计刷新方案的时候,必须把所有实例都纳入考虑范围。
2.2 刷新的本质:不是删key,而是让旧数据失去合法性
很多人理解的刷新缓存就是"删掉旧key,让它重新生成"。这个理解本身没错,但在分布式环境下,这个操作会引发另一个更难缠的问题——缓存击穿。
什么叫缓存击穿?就是一个非常热门的key在失效的瞬间,大量请求同时打到数据库,数据库压力瞬间飙高。如果这个缓存的生成逻辑比较复杂,比如要调用外部接口、要做大量计算,那这些请求就会在数据库层排队,最终表现为接口超时。
所以,我理解"刷新缓存"的本质,不是"删除旧数据",而是让旧数据在逻辑上失效,同时保证新数据能够被快速、平滑地加载出来。这两件事必须同时完成,缺一个都会出问题。
注意:如果你觉得"删了就完事",那你大概率没经历过凌晨两点被报警电话叫醒的酸爽。
基于这个认知,我后面所有的方案设计都围绕三个目标展开:平滑性(不让请求阻塞)、一致性(所有实例最终看到同一份数据)、可观测性(刷新动作本身可以被追踪和审计)。
3. 核心方案选型:双缓存加版本号,这是我认为最稳的组合
3.1 方案A:直接删除key——最简单但最危险
先说我一开始尝试的傻瓜方案:直接在Redis里删除对应的业务key。
DEL biz_config:platform:2024代码逻辑上完全没问题,DEL之后下一次请求进来发现缓存没有,于是回源数据库加载并重新写入缓存。这个过程在功能上完全正确。
但它的问题我在前面已经说过:高并发场景下会形成缓存击穿。尤其是那种承载着核心入口流量的配置数据,一个删除操作可能引发数据库的瞬时压力高峰。如果你负责的系统月活百万级以上,这种操作是要出事故的。
另外还有一个隐藏问题:删除key之后,如果新的请求还没来得及写缓存,这时候又有另一个"刷新缓存"的操作执行了同样的删除,那这个key就长时间处于空洞状态,始终没有一个请求能成功构建缓存。这种互相打架的情况在定时任务和多管理端并存的系统里特别常见。
所以"直接删key"这个方案,我把它定性为"演示可以用、生产不建议、高并发绝对不能"。
3.2 方案B:逻辑过期标记——比物理删除优雅,但状态容易脏
既然物理删除会产生击穿问题,那换个思路:不物理删除key,而是给key设置一个"逻辑过期时间"。比如,原本key的过期时间是24小时,我手动把它改为"立即过期",但保留key本身的值。请求进来的时候发现逻辑过期,于是手动触发重建流程。
这种设计确实能避免缓存击穿,因为旧数据还在,可以作为"降级数据"兜底。但它有个绕不开的麻烦:逻辑过期状态需要维护,而且分布式的多个实例之间很难同步这个状态。
比如实例A发现缓存逻辑过期了,开始重建;实例B也发现逻辑过期了,也开始重建。这时候你仍然需要一把分布式锁来保证只有一个实例在重建,否则还是会有瞬时压力。
逻辑过期本身不是坏方案,坏在它引入了额外的复杂度——你需要维护状态标记,需要分布式锁,需要处理"重建失败之后怎么办"的问题。作为一个小功能来说,太重了。
3.3 方案C(最终采用):双缓存 + 版本号控制
最终我采用的是双缓存加版本号的方案。这个方案在我经历过的几个高并发项目中都验证过稳定性,实现成本中等,但能非常干净地解决击穿、一致性、刷新失败这几个核心问题。
整体设计是这样的:
- 主缓存:业务key,存储真实的业务数据。比如
biz:config:platform,值是一份格式化的配置JSON。这个key的过期时间设置为24小时,正常业务读取走这里。 - 版本缓存:一个专门存储版本号的key,比如
biz:config:platform:ver,值是一个整数,从1开始,每次刷新就加1。 - 刷新逻辑:需要刷新缓存的时候,不删除主缓存key,而是把版本号加1,同时主动把主缓存的数据更新为最新。
读取逻辑变成两步:
- 第一步:读取版本号
ver。 - 第二步:读取主缓存数据,这时候key既可以保持原有方式,也可以把版本号拼进去,比如
biz:config:platform:v2。
这里有个细节值得仔细考虑:到底要不要把版本号拼进主key?
我试过两种做法。做法一:不拼版本号,主key不变,版本号仅仅作为一个"信号",触发各实例重新加载。这种做法的问题是:如果各实例没有及时监听到版本号变化,读到的还是旧数据。做法二:把版本号拼进主key,这样每次刷新之后,新的请求会读取到一个全新的key,这从机制上天然避开了本地缓存的旧数据问题。
最终我选择的是做法二。也就是说,主缓存key的完整形式是biz:config:platform:v{版本号}。版本号一变,对应的key就变了,新请求读取新key,旧请求如果还拿着旧key,那它读到的也只是旧数据,不影响一致性判定的核心诉求。
3.4 为什么这个方案能稳定工作:三个致命问题的针对性解决
我实际跑了一段时间之后,总结了一下这个方案为什么能站得住脚,逐个对应前面的三个核心问题:
第一个问题击穿。击穿的本质是"一个key失效,大量请求同时回源"。但双缓存方案里,版本号切换之后,新key可能还没有值——这时候如果大量请求同时来读新key,依然会击穿。所以这里我加了一个动作:在版本号切换的同时,立即执行一次缓存预热,直接把新key的值写进去。预热完成之后,版本号切换和缓存数据写入是"尽量原子"的。
有人说,预热代码如果还没执行完,请求就进来了怎么办?这个好办:在读取逻辑加一个"双重检测"——读到新版本号后,如果新key不存在,先用分布式锁锁住,让第一个线程回源加载数据,其余线程自旋等待或直接降级读取旧key。这一步和我前面说的"逻辑过期"方案其实有些异曲同工,但区别在于,这里是在"新key尚未构建"的短暂时窗内兜底,而不是常态化的状态判断。
第二个问题一致性。因为版本号驱动了key的切换,每个实例只要能够正常读取Redis,它就一定读取的是最新版本的数据。不存在"某个实例的本地缓存还存着旧key"的情况——因为本地缓存的key本身如果也拼了版本号,那在新版本下它拿不到值,自然会回到Redis去加载。这就把强一致性的校验点收敛到"Redis里是否已有新值"这一条路径上。
第三个问题刷新失败。比如版本号已经改成v10,但预热失败了,新key是空的。这时候业务方看到的是"配置没变",但其实版本号已经变了。怎么排查?我定期扫描一下版本号过大的key是否存在对应的主缓存数据,不存在就报警。也就是说,刷新的成功与否,不仅取决于版本号有没有改,还取决于主缓存是否成功重建。这个检查逻辑很简单,但很多人会忽略。
3.5 刷新操作的核心伪代码:直接从我的工程里抽出来的
下面这段伪代码,是我从工程里抽掉了业务细节之后的骨架,你直接参考这个结构就能在项目里落地:
public void refreshCache(String bizKey) { // 1. 获取当前版本号 long currentVersion = redisTemplate.opsForValue().get(bizKey + ":ver"); if (currentVersion == null) { currentVersion = 0L; } // 2. 计算新版本号 long newVersion = currentVersion + 1; // 3. 构建新版本的数据 Object data = loadDataFromDatabase(); // 4. 写入新版本key String newKey = bizKey + ":v" + newVersion; redisTemplate.opsForValue().set(newKey, data, 24, TimeUnit.HOURS); // 5. 最后切换版本号(这个顺序很重要) redisTemplate.opsForValue().set(bizKey + ":ver", newVersion); }看到第5步没有?版本号的切换被刻意放在了最后。这保证了一个关键规则:只要版本号没有切换,所有请求依然读取旧版本的缓存,系统状态是安全的;只有版本号切换了,新逻辑才生效。这个过程虽然不满足严格的原子性,但已经足够应对绝大多数业务场景。
当然,如果你希望彻底杜绝"版本号变了但数据还没写入"这种中间状态,可以把步骤3–5包在一个Lua脚本里面做原子执行。Redis单线程执行Lua脚本的特性,能保证脚本执行期间不会有其他Redis命令穿插进来。这块我在第六章会单独讲。
4. 实操过程:从本地验证到灰度上线的完整记录
4.1 本地环境搭建与最小化验证
动手之前,我先在本地搭了一个最小化的验证环境。这一步很重要,因为直接在生产环境改缓存逻辑,出了事故你连回滚的时间都没有。
本地环境我用了Docker起了一个Redis实例,然后Java端模拟了一个简单的接口调用链路。工程结构大概是这样的:
- 一个Controller接口,用于模拟用户请求读取配置。
- 一个Service,负责读取缓存,缓存不存在就走数据库。
- 一个管理接口,用于触发缓存刷新。
这个最小模型跑起来之后,我先验证了一个关键点:连续读取一个key,确认命中缓存;接着触发刷新;再读取时,确认拿到的数据是新的。
验证的逻辑很简单,我往数据库里改了一个字段的值,刷新前接口返回旧值,刷新后接口返回新值,功能层面就验证通过了。
4.2 高并发场景下的验证:用压测工具验证击穿问题是否被解决
功能验证通过之后,还远远不够。因为我最担心的不是"能不能刷新成功",而是"刷新过程中系统会不会被打挂"。所以我用压测工具做了一轮模拟。
我模拟了这样一个场景:某个key承载了每秒约5000次的读取量,在压测工具运行到中间的时候,触发刷新缓存操作。观察指标有三个:接口平均响应时间、数据库连接池活跃连接数、以及有没有超时报错。
如果用的是"直接删key"的方案,这时候能看到曲线是这样的:删key的瞬间,接口响应时间从平均20ms直接飙升到800ms以上,数据库连接数从几十条涨到几百条,部分请求直接超时。而用双缓存加版本号方案的时候,我压测下来,接口响应时间几乎没有波动,数据库连接数也很平稳。
这个对比实验让我彻底放下了对双缓存方案的疑虑,也让我意识到一个道理:很多问题,压测之前你觉得方案可行,压测之后你才知道方案是"看起来可行"还是"真的可行"。
4.3 灰度上线的过程:先切管理端,再切业务端
本地验证和压测都通过后,我开始灰度上线。注意,这里我没有把刷新接口和读取逻辑一次性全量发布,而是分了两步。
第一步,先发布管理端的"刷新缓存"入口,但底层实现暂时还是老逻辑——也就是直接删key。这一步主要是让管理端的功能先暴露出来,让测试人员、产品经理能上手操作,但风险是可控的,因为删key逻辑已经跑了很多年,就算出问题也只是老问题。
第二步,等待管理端功能验证没问题之后,再发布业务端的读取逻辑改造——也就是把从"读取固定key"改为"先读版本号,再读拼接key"。这一步才是整个方案的核心改造点。发布完成之后,再把管理端的刷新逻辑从"删key"切换到"版本号切换"。
这样分两步走的好处很明显:每一层的改动都可以独立回滚。如果第一步出了问题,管理端可以立即下线,不影响线上业务;如果第二步出了问题,业务端的读取逻辑可以快速回退到旧版本,管理端继续用删key的方式顶着——虽然会有击穿风险,但至少系统是通的。
4.4 上线之后我做的三件事:监控、日志、演练
功能上线不是终点。我后续做了三件事,这三件事帮我提前发现了很多潜在问题。
第一件事:监控版本号变化频率。我加了一个埋点,记录每个业务key的版本号变化时间、操作人和操作来源IP。这样一旦出现"缓存被意外刷新"的情况,我可以快速定位是谁做的操作。
第二件事:日志链路透传。我在刷新缓存的日志里加了requestId,这样从点击"刷新按钮"到实际看到缓存数据变化,整条链路的日志可以被串联起来排查。这个在分布式系统里特别有用,不然排查问题要把多个服务里的日志翻个底朝天。
第三件事:定期演练缓存重建。我写了一个定时任务,每天凌晨两点低峰期自动触发一次缓存预热,验证数据源到缓存的全链路是通的。这样即使数据源出了问题,我们也能在低峰期提前发现,而不是等到白天业务高峰期被打个措手不及。
5. 刷新期间业务请求的处理:自旋等待与降级兜底,二者缺一不可
5.1 为什么预热期间不能直接放行所有请求
前面我提到过,版本号切换之后会主动预热新key。但预热总有一个毫秒级甚至几十毫秒级的时窗,在这个时窗内,新key是不存在的。如果这时候来了1000个请求,都发现新key没有数据,那怎么办?答案显而易见:绝对不能放行这1000个请求全部去打数据库。
很多人第一反应是"让它们等一下就好"。但这里有个实际问题:分布式环境下,不同请求等待的方式不一样,如果没有任何控制机制,这些请求最终还是会同一时刻冲向数据库。这本质上就是把击穿问题从"删key瞬间"转移到了"预热期间"。
5.2 分布式锁控制:只有一个请求去重建缓存
我的处理方案是引入一张分布式锁,Key设计为biz:config:platform:lock。当某个请求发现新key不存在时,它先尝试获取这把锁。
String lockKey = bizKey + ":lock"; boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 3, TimeUnit.SECONDS);如果获取成功,这个请求就负责回源加载数据并写入缓存。如果获取失败,说明已经有一个请求在加载了,这个请求就进入自旋等待,每50ms检查一次新key是否已存在,最多等待2秒。2秒后如果还没等到,它也不会直接打数据库,而是做降级处理——读取旧版本的缓存值。
这个降级动作是第一道保险,让极少数请求在极端情况下依然能拿到"旧但可用"的数据。
// 降级兜底:读取旧版本数据 String oldVersionKey = bizKey + ":v" + (newVersion - 1); Object oldData = redisTemplate.opsForValue().get(oldVersionKey);这段逻辑确保了一个事实:无论发生什么情况,用户的请求永远不会因为缓存刷新而直接报错。
5.3 锁的超时时间设多少合适:3秒还是更长
关于锁的超时时间,我踩过一个坑。最开始我设置的是1秒,结果压测的时候发现,偶尔有请求在1秒后锁还没释放,导致部分请求走了降级逻辑。后来我统计了一下,从数据库加载数据并写入缓存,平均耗时在200ms左右,最慢的时候可能到1.5秒。于是我把锁的超时时间调成了3秒。
这里有个权衡:锁超时时间设置太长,如果加载线程因为某种原因崩溃了,锁要很久才能被其他请求重新获取;设置太短,又会出现前面说的"上个请求还在加载,锁已经没了"的问题。我的经验值是,锁超时时间至少是正常加载耗时的2到3倍,最长不超过5秒。当然这个数值要根据你实际的数据加载耗时来定,没有一刀切的标准。
5.4 兜底降级不能随便写:旧数据要用对地方
降级读取旧版本缓存这个操作,看起来简单,其实有个细节要注意:旧版本数据必须是"前一个版本",而不是"随便一个旧数据"。
因为你版本号是递增的,从v1刷到v2再刷到v3,那么v2和v3之间可能间隔了比较长的时间,数据差异也会比较大。降级方案里读取的应该是紧邻当前版本的前一个版本,也就是newVersion - 1。如果这个key也不存在,那说明系统已经非常不健康了,这时候再往上追溯老版本,意义也不大,直接抛出友好错误提示即可。
我见过一些实现,降级逻辑写的是"读取任意存在的缓存key",结果一个业务key下残留了很多历史版本的数据,降级读到的数据压根不是用户期望的,排查起来一头雾水。
6. 高并发原子性保障:Lua脚本解决版本切换与数据写入的非原子问题
6.1 为什么需要Lua脚本:一个时序问题引发的思考
如果你觉得上面那套逻辑已经很完善了,那你可能忽略了一个极端情况:版本号切换和数据写入之间存在时间窗口。
具体点讲,假设发生了这样一个时序:
- 时间点T1:线程A触发刷新,计算出新版本号v10。
- 时间点T2:线程A写入新key的数据。
- 时间点T3:线程A更新版本号为v10。
- 时间点T4:线程B也触发刷新,计算出当前版本还是v9,于是写成v10的数据,更新版本号为v10。
这个例子看起来没问题,因为数据一致。但换个顺序就麻烦了:
- 时间点T1:线程A触发刷新,计算出新版本号v10。
- 时间点T2:线程A还没有来得及写入新key数据。
- 时间点T3:线程B读取当前版本号,发现是v9,于是也计算出v10。
- 时间点T4:线程B写入数据到v10 key,更新版本号为v10。
- 时间点T5:线程A再写入数据到v10 key,覆盖了线程B的数据。
如果线程B的数据比线程A更准确(业务上不一定,但可能发生),那么线程A就破坏了线程B的结果。更致命的是,如果线程A写入数据之后,线程B又更新了版本号,但版本号已经飘到v11了,整个版本链条就乱了。
要彻底解决这个问题,就得保证"数据写入"和"版本号更新"这两个操作在Redis层面是原子性的。Redis的Lua脚本就是干这个的。
6.2 完整的Lua脚本:版本校验、数据写入、版本更新一步到位
我这里提供一个可以直接参考的Lua脚本结构:
local current = redis.call('GET', KEYS[1]) if current == false then current = 0 end local newVersion = tonumber(current) + 1 local newKey = KEYS[2] .. ':v' .. newVersion redis.call('SET', newKey, ARGV[1], 'EX', ARGV[2]) redis.call('SET', KEYS[1], newVersion) return newVersion对应Java端的调用代码大致是:
DefaultRedisScript<Long> script = new DefaultRedisScript<>(); script.setScriptText(luaScript); script.setResultType(Long.class); Long newVersion = redisTemplate.execute(script, Arrays.asList(bizKey + ":ver", bizKey), JSON.toJSONString(data), String.valueOf(24 * 60 * 60));这里的重点在于,Lua脚本在Redis中是串行执行的,执行期间不会有其他命令插入。所以你只要把"读取版本号、计算新版本、写入数据、更新版本号"这四步都放进脚本里,它们就是原子的。
如果有人担心并发刷新时版本号会重复,那这个Lua脚本天然解决了——因为Redis单线程执行,同一时刻只有一个脚本实例在跑,后续的并发刷新会依次执行,版本号依然是严格递增的。这也是我为什么最终选择用Lua脚本来收口的根本原因。
6.3 Lua脚本的边界条件:版本号从0到1的初始化
注意一个细节:第一个版本号是怎么定义的?我的做法是,如果版本号key不存在,就当作0处理,刷新之后变成1。这样新key就是biz:config:platform:v1,后续依此类推。
但你要考虑到"现存系统里已经有一个老的缓存key,没有版本号体系"这种情况。上线改造的时候,如果用户请求还是读取旧key,而你的新代码已经去读版本号和拼接key了,那会造成数据断裂。所以我在上线切换的时候做了一个初始化动作:先把旧key的value迁移到v1 key下,并初始化版本号为1,然后才能平滑切换。
这块我实际做的时候,写了一个一次性脚本,把线上所有业务key的基本信息查询出来,批量初始化。虽然麻烦,但这一步不做,线上就会出现"合成数据"的问题:一部分用户读到的是新key的数据,一部分用户读到的还是旧key的残留数据。
7. 多实例部署下的一致性问题:广播通知与本地缓存联动
7.1 多实例时你面临的不只是Redis问题
前面讲的方案主要围绕Redis层展开,但别忘了你的业务服务是多个实例部署的。假设你有4个Java实例,每个实例内部都有一个Caffeine本地缓存来处理热点请求。这时候你触发了一次"刷新缓存"操作,Redis里的数据已经更新了,但每个实例的Caffeine缓存里还存着旧数据。
这时候用户请求打到不同的实例上,看到的数据就可能不同。如果你以为"刷新Redis就够了",那这个问题早晚会以事故的形式教育你。
分布式缓存和本地缓存之间的一致性问题,是缓存架构里最容易忽略的一环。尤其是很多团队为了性能,把热点数据做了二级缓存——Redis一层,JVM本地一层。本地缓存的好处是快,坏处是刷新不透明。
7.2 我的做法:Redis订阅发布做广播通知
我的做法比较务实,利用Redis自带的发布订阅功能,做一个简单的广播通知:
- 刷新缓存的时候,除了更新Redis版本号和数据,还会往一个固定的channel发送一条消息,内容是谁在什么时间刷新了哪个key。
- 所有实例都订阅这个channel。收到消息之后,判断消息里的key是否在自己的本地缓存中,如果有,就清除对应key的本地缓存条目。
- 本地缓存被清掉之后,下一个请求进来发现本地没有缓存,就会重新去Redis读取——而此刻Redis里已经是新数据了。
这个方案的实现成本极低,总共不到五十行代码,但它解决的问题很实在:多实例的本地缓存同步。
// 发布端 stringRedisTemplate.convertAndSend("cache-refresh-channel", bizKey);// 订阅端(每个实例启动时注册监听) MessageListener listener = (message, pattern) -> { String refreshKey = new String(message.getBody()); localCache.invalidate(refreshKey); };这里我建议把channel名称设计成可区分的,比如cache:refresh:config,这样你后面如果要针对不同类型的缓存做差异化处理,可以分别订阅不同的channel,不至于所有消息混在一起。
7.3 本地缓存要不要加版本号:能加尽量加
如果你在设计新的缓存体系,我更推荐一个做法:本地缓存的key里也拼上版本号。这样,当Redis版本号变化之后,本地缓存通过拼接新版本号去查找key,天然找不到旧值,只能回源Redis。这样你连"广播清除本地缓存"这一步都可以省略了。
但现实是,大多数老项目的本地缓存key是写死的,不方便大改。听说有团队把本地缓存的过期时间设置得很短,比如30秒,让一致性窗口期控制在秒级。这个方法也能用,但本质上是用过期时间换一致性,评估下来,如果你的业务能接受几秒到几十秒的延迟,这也是一种省力的做法。
8. 操作记忆中的常见问题与排查教训
我做了这件事之后,整理了几个问题,也算是我踩过坑之后的教训沉淀,给大家做个参考。
8.1 明明刷新了,为什么页面数据还是旧的
这个现象最常见的原因有两个:一是你的业务服务有多层缓存,刷新只处理了其中一层,比如Redis刷新了但本地缓存还在;二是版本号key和主缓存key的过期时间不一致,版本号key先过期了,重新生成从0开始,导致key切换回v1,旧数据自然被读到。
排查这类问题的顺序,我建议是先看版本号当前值是多少,再看实际读到的数据是哪个版本生成的。如果你的接口响应里没有透出版本号,那你查起来会很费劲。所以我在改造方案的时候,特意在日志里加了版本号字段,每次读取都打出来,排查问题的时候一眼就能定位。
8.2 刷新之后,数据库被瞬时打爆
出现这个问题的原因大概率是你没有做预热,或者预热逻辑没有生效。我见过一个真实案例,开发同学在刷新逻辑里直接删key,删完之后新key数据由"第一个读到空缓存的请求"生成,结果那个人多的业务线,直接干到了数据库连接池打满。
排查时,来看看"从删key到第一次请求回源"的间隔是多久。如果间隔很短,那就是击穿了,需要回到双缓存方案解决。如果间隔很长,说明低峰期可能没问题的,但高峰期仍然会抖。
8.3 版本号无限增长,Redis内存会不会炸
随着每次刷新都生成一个新的key,旧版本的key虽然会过期,但如果在24小时内刷新次数特别多,Redis里会积压不少旧key数据。我从监控看到过一次极端情况,一天内某个key被刷新了几千次,虽然单个key很小,但积少成多也会占内存。
所以,我给刷新逻辑加了一个保护机制:刷新时,除了写新key,还会顺手把前一个版本的key删掉,只保留当前版本和上一个版本,保留上一个版本是为了降级兜底。这样版本号虽然一直在涨,但Redis里同一个业务只保留两个版本的key,内存占用完全可控。
8.4 Redis的过期策略不是实时的,别指望它秒删
有很多人以为设置了过期时间,Redis就会在那个时间点立即删除key。实际上,Redis的过期删除策略是惰性删除加定期删除的结合。这意味着,即使一个key已经过期了,它也可能在内存里存活一小段时间,直到被访问或者被后台任务清理。
对缓存业务来说,这个特性影响不大,因为你读的时候会判断过期。但如果你在监控Redis内存,你会奇怪"为什么过期了的key还在列表里"。这不是bug,是Redis的正常行为。放下心来,别把时间耗在这个上面。
8.5 大key刷新要注意序列化格式变化
最后一个容易踩坑的点:如果你的缓存数据是对象序列化后写入Redis的,刷新前后数据用到了不同的序列化方式,那么刷新后的数据可能无法被旧代码反序列化。这个问题通常在版本兼容测试里才会暴露。
我建议你在设计刷新功能时,统一用一个稳定的序列化方案,如果可能的话,在Redis里存JSON格式而不是Java原生序列化格式。JSON的好处是跨语言、跨版本兼容性都更好,排查问题的时候也能直接用Redis客户端看内容。
9. 个人总结:这半个月踩坑下来,我对缓存刷新这件事有了新的理解
把这段工作做完之后,我回头看这个需求,发现它表面是一个"加个按钮刷新一下"的功能,实际背后牵扯的是缓存架构设计、并发控制、一致性保障、可观测性建设这么一长串事情。任何一个环节考虑不周,线上都可能有隐患。
我自己实际操作下来的体会是:处理缓存刷新这类问题,不建议一上来就写代码,先把系统的缓存链路完整画出来——数据从数据库到用户浏览器,经过了哪些层、每一层谁在读谁在写、过期策略是什么、有没有本地缓存、有没有多实例——画清楚之后,再围绕关键节点设计方案。很多时候你觉得"缓存没刷新"其实不是Redis的问题,是链路里某个节点没有同步。
另外一个值得分享的小技巧是,给所有涉及缓存的操作加上一个"手动触发"的入口,通过后台管理界面的按钮暴露出来。这样当你需要紧急修复数据、紧急切换配置的时候,不用去连服务器敲Redis命令,一个按钮就能搞定。这个入口本身不复杂,但它能在关键时刻帮你节省大量时间。
最后我再多说一句:不管方案设计得多么完善,上线之后也别放松。监控版本号变化、监控缓存命中率、监控接口响应时间,这三项指标缺一不可。我个人的习惯是,每次做这种基础设施类的改造,都要建一个单独的仪表盘页面,把这些核心指标集中展示,这样后续有异常出现,我能比业务方更早发现、更早处理。如果你正在准备类似的改造,建议你也把这一步纳入工作计划里,它值得你投入的时间。