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端的调用代码大致是DefaultRedisScriptLong 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命令一个按钮就能搞定。这个入口本身不复杂但它能在关键时刻帮你节省大量时间。最后我再多说一句不管方案设计得多么完善上线之后也别放松。监控版本号变化、监控缓存命中率、监控接口响应时间这三项指标缺一不可。我个人的习惯是每次做这种基础设施类的改造都要建一个单独的仪表盘页面把这些核心指标集中展示这样后续有异常出现我能比业务方更早发现、更早处理。如果你正在准备类似的改造建议你也把这一步纳入工作计划里它值得你投入的时间。