
刚刚处理完一个线上事故后台数据已经顶不住了。Redis命中率从99.2%直线掉到23%数据库CPU瞬间飙满告警群炸了锅。事后拉日志复盘根因并不复杂——某个活动配置的缓存key在流量峰值被主动删掉然后大批线程同时回源数据库重建缓存直接把主库打挂了。这个场景太典型了可以说每个做后端的人早晚都会碰上而它的根源就是“缓存更新策略”这个老生常谈又极容易踩坑的话题。我写这篇文章不是为了把教科书上的理论再抄一遍而是想结合自己这几年在真实业务里踩过的坑、做过的治理把缓存更新策略这件事从头到尾捋清楚主流策略各自适合什么场景、为什么会有缓存与数据库不一致的问题、怎么在代码层面保证原子性、线上缓存治理该监控哪些指标等等。无论你是在做高并发电商、优惠券系统还是普通的CRUD应用这篇文章应该都能帮你少走点弯路。1. 一份真实的P0事故缓存更新策略为什么值得被认真对待先说说开头那次事故的完整链路。当时我们做的是一个促销活动页商品和优惠券信息都缓存在Redis里缓存策略很简单查询时先读缓存缓存没有就查数据库再把结果写回缓存也就是典型的Cache Aside模式。这个模式本身没什么问题问题出在活动运营需要在后台修改某个商品的状态当时负责这块的代码是这样的public void updateProduct(Product product) { // 1. 先更新数据库 productDao.update(product); // 2. 再删除缓存 redisTemplate.delete(product: product.getId()); }看起来好像没毛病先更新DB再删缓存这是很多教程里推荐的做法。但如果删缓存这步失败了呢如果Redis在这个瞬间正好发生了主从切换呢如果恰好有大量流量在这几毫秒内读到了旧缓存呢更关键的是当你删掉缓存之后瞬间有几千个请求发现缓存不存在它们会同时扎进数据库去查数据、重建缓存。结果就是这里提到的——数据库被打爆、缓存被大量重建、命中率骤降。很多人对缓存更新策略的理解停留在“先更新数据库再删除缓存”或者“更新数据库的同时更新缓存”这个层面上觉得能跑就行。但真实线上环境里并发量一上来数据一致性、缓存穿透、击穿、雪崩这些问题会一起冒出来每一条都能让你加班到凌晨。所以在讨论任何策略之前先把一个认知立住缓存本质上是用“最终一致性”换“低延迟响应”不存在既要绝对一致又要高性能的免费午餐。你的任务不是追求“绝对一致”而是把不一致的概率降到可接受范围同时保证系统不出故障。2. 四种主流更新策略的选型逻辑与适用场景缓存更新策略严格来说有四套经典的玩法Cache Aside旁路缓存、Read Through读穿透、Write Through写穿透、Write Behind写回/异步写。大部分团队其实从头到尾只用了Cache Aside另外三种听过但没用过。我先逐一拆解它们的适用场景再说为什么现实中大多数系统最终都落在Cache Aside上。2.1 Cache Aside最常用但最容易被用错Cache Aside的逻辑很朴素读请求先读缓存命中就直接返回没命中则查数据库写回缓存返回。写请求先更新数据库然后删除缓存或者更新缓存。这个策略只要求应用层自己维护缓存和数据库的一致性不要求缓存中间件提供任何额外特性Redis、Memcached、本地缓存都能跑。它最大的优势就是“可控”把一致性责任交给业务代码出了事知道从哪里查。但它也是最容易被用错的常见的错误有这三种第一删除缓存失败后没有补偿机制。你更新了DB结果Redis删除超时或者异常了旧数据继续留在缓存里。解决办法是加一个失败重试的队列或者直接用后面会讲的延迟双删binlog订阅兜底。第二并发时序控制。线程A更新了数据库准备删缓存线程B在这之前刚好把旧数据读出来准备写回缓存也就是典型的“脏写”。第三大流量下热点key失效导致同时穿透打到DB。所以如果你选Cache Aside就要接受一件事它的正确性离不开“人工保证”每一步都要有兜底。2.2 Read Through / Write Through让缓存自己管自己Read Through和Write Through的意思是缓存组件自己负责和数据库的同步应用层不再直接操作数据库。Read Through应用读缓存缓存没有时由缓存组件自己去数据库捞数据并回填之后返回给应用。应用完全感知不到数据库的存在。Write Through应用写缓存缓存组件同步把数据写入数据库写成功后才返回成功。这两者通常由缓存中间件或者ORM框架提供支持比如一些本地缓存框架、部分云数据库代理层。好处是应用代码极简一致性逻辑集中在缓存层坏处是缓存组件的复杂度飙升如果它挂了数据库读写通道也一起挂了而且不同中间件对“写穿透”的实现细节差异很大遇到问题很难排查。实际业务里一般只有在团队对底层组件有很强掌控力的时候才用。2.3 Write Behind性能最大化的代价Write Behind也叫Write Back逻辑是应用只写缓存立即返回成功后台异步批量地把缓存中的脏数据刷到数据库。这个策略性能极高特别适合写入量大、允许短时间数据丢失或延迟落库的场景比如点赞数、播放量、库存预扣等。但它有一个根本性的问题——一致性窗口不可控。如果数据还没落库缓存节点就崩溃了或者我们要下线清理数据那这部分更新就永久丢了。而且数据库和缓存的数据会长期不一致如果业务上对一致性要求很高比如订单金额、账号余额千万别用Write Behind。我见过最典型的翻车场景某团队把用户余额也做成了Write Behind美其名曰“提升性能”结果一次缓存集群扩容导致部分key丢失用户余额凭空消失最后花了两天从日志里逐条捞数据恢复。所以Write Behind这个策略用之前一定要先问一句数据丢了赔得起吗2.4 更新缓存 vs 删除缓存为什么多数场景应该选删除这里专门聊一个争论很多的问题数据库更新之后到底是“更新缓存”还是“删除缓存”我的结论很明确优先选择删除缓存。理由有三点第一更新缓存存在“写放大”问题。如果缓存的key被更新了但是后续没人读它这次更新操作就白做了。删除缓存是惰性的只有下次读的时候才回填天然节省资源。第二更新缓存很难处理并发时序。多个线程并发更新同一数据后写DB的可能先写缓存也可能先写DB的后写缓存你根本没法保证缓存最终等于DB。删除缓存没有这个问题因为反正下次读会把最终值拉回来。第三更新缓存往往还要重新计算序列化结果比如商品详情需要拼接大量字段成本比单纯delete高得多。也许你会问那如果删了缓存之后正好有请求来读发现缓存为空又打到数据库上怎么办这就引出了后面要重点讲的穿透/击穿问题。但删除缓存本身在一致性上一定优于直接更新缓存这个方向不用摇摆。3. 缓存与数据库一致的三个魔鬼细节时序、回滚与重试很多人觉得“先更新DB再删缓存”已经足够了但在并发场景下这中间藏着的细节比我以为的多得多。下面逐一拆解。3.1 并发时序问题为什么A线程先更新DB却把旧值写进了缓存假设我们有这样一个执行序列线程T1查询商品缓存未命中。线程T2更新商品先更新DB再删除缓存。线程T1查DB拿到的是更新前的旧值T2还没提交事务写回缓存。从后往前看T1执行顺序在T2之后可写进缓存的却是旧值。此时缓存里就是一条永久性的脏数据直到下一次更新才会被纠正。这个问题的根源是删除缓存和读DB回填缓存这两个动作之间没有互斥关系。解决办法通常有两个方向一是延迟双删也就是更新DB。删除缓存。等一段时间比如300ms。再删除一次缓存。第二次删除可以把第一步里并发读请求写回的旧缓存再清掉。这个方案写起来不难但“等一段时间”的窗口不好选不够优雅。二是用版本号或者更新时间戳来控制缓存有效性。比如每条数据带一个version字段写缓存时把version一起存进Redis读缓存时如果发现缓存里的version小于数据库当前版本就认为过期重新回源。这本质上把“删除缓存”变成了“过期缓存”准确性更高但要侵入业务模型。3.2 延迟双删的局限与补偿方案延迟双删几乎是我见过最多人问的“网上抄来的方案”它的完整形态一般长这样public void updateWithDelayDelete(Product product) { // 1. 更新数据库 productDao.update(product); // 2. 立即删除缓存 redisTemplate.delete(product: product.getId()); // 3. 延迟一段时间后再次删除 executor.schedule(() - redisTemplate.delete(product: product.getId()), 500, TimeUnit.MILLISECONDS); }但这里有几个坑要提醒你其一延迟时间很难拍准。如果读请求回填缓存耗时很长比如DB慢查询、序列化耗时大500ms根本不够旧数据还是会被写回。其二第二次删除如果也失败了还是会出现脏数据。其三如果某个读请求在第二次删除之后才慢悠悠地读DB并回填那旧值又会复活——这就是“缓存雪崩未清的僵尸数据”。所以延迟双删只能降低概率不能根除问题。更稳的做法是好几个大厂在用的binlog订阅方案应用更新数据库不主动去删缓存有一个消费者监听数据库的binlog变更事件解析出变更的主键然后执行缓存删除。这样应用层不用关心缓存缓存删除也一定发生在数据库事务提交成功之后从根源上规避了“DB更新成功但缓存删除失败”和“回滚后却把旧值写回缓存”这两个问题。3.3 可靠的最终一致性方案binlog订阅与版本号具体落地binlog订阅时通常借助成熟框架比如Canal这样的组件来伪装成MySQL从库解析binlog并推送给MQ业务侧再消费MQ删除对应缓存。整体链路大约是这样的MySQL - binlog - Canal - MQ - 消费者 - Redis DEL这条链路有两点比较重要。一是要保证MQ消费成功后再确认手动ack否则消息丢了缓存没删脏数据就持久了。二是如果MQ下游处理能力跟不上缓存删除会被延迟这一小段时间内业务会读到旧值。一般来说业务上会接受“秒级最终一致”所以这个方案在绝大多数场景是能扛住的。版本号方案则更轻量。你可以在缓存里同时存数据和版本号比如// 写缓存时 stringRedisTemplate.opsForValue().set(key, jsonString, 30, TimeUnit.MINUTES); stringRedisTemplate.opsForValue().set(key :version, String.valueOf(version), 30, TimeUnit.MINUTES); // 读缓存时校验版本读的时候如果发现缓存里的版本号小于DB当前版本则直接穿透回源。这个方案适合能轻松拿到版本号或者更新时间的业务比如文章表里有update_time。唯一的前提是事务和版本号的更新必须在同一个事务里完成否则版本号先变数据还没变读请求又会拿到旧数据。4. 压测遇上缓存穿透、击穿、雪崩Redis治理的实战链路光讲一致性策略还不够缓存更新策略落地之后紧接着要面对的就是三个经典故障穿透、击穿、雪崩。它们经常一起出现在压测报告里处理不好事故就一个接一个。4.1 缓存穿透布隆过滤器与空值缓存的取舍缓存穿透是指查询的数据在数据库里根本不存在所以缓存永远没有值每次请求都直接落到数据库。攻击者可以伪造一批不存在的ID把DB活活打死。治理手段主要有两个思路。思路一是布隆过滤器Bloom Filter。把所有合法ID在启动时预加载到一个大位图里查询前先过过滤器过滤器说不存在就不查后面的存储。但布隆过滤器有个特性是“可能有误判但绝不会有漏判”也就是说它能拦截掉绝大多数无效ID但偶尔会把不存在的ID误判成存在此时请求还是会落库问题不大因为概率低。另一个缺点是ID集合变动时布隆过滤器更新成本比较高。思路二是缓存空值。如果DB查不到就在Redis里写一个特殊占位符比如value 并且设置一个比较短的过期时间比如30到60秒。这样后续相同请求会在短期内命中缓存空值不会每次穿透到DB。这个方案实现简单是目前我最常用的。但要注意两点一是空值key的量可能很大需要设置过期时间防堆积二是如果有人用随机ID攻击那空值缓存反而成了内存垃圾的放大镜可能把Redis内存打满。因此更稳妥的是两个方案结合布隆过滤器拦大流量空值缓存作为兜底。4.2 缓存击穿热点key重建的互斥锁击穿和穿透的区别在于击穿是缓存key确实存在只是刚好在某个时刻过期了结果瞬时请求全部打到DB。尤其是热门key比如秒杀商品的详情、排行榜第一名的用户信息一旦过期QPS可能会从几百瞬间飙到几万。解决办法里最简单有效的就是互斥锁Mutex Key。当查询发现缓存为空时不是立刻回源DB而是先尝试获取一把Redis锁只有拿到锁的线程才去查库并回填缓存其他线程等待短暂的时间后重新从缓存读取。大致代码如下public Product getProductWithLock(Long id) { String key product: id; Product product redisTemplate.opsForValue().get(key); if (product ! null) { return product; } // 尝试获取锁防止大量线程同时回源 String lockKey lock:product: id; Boolean locked stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, 1, 5, TimeUnit.SECONDS); if (Boolean.TRUE.equals(locked)) { try { // 二次检查可能在等待锁的过程中缓存已被填充 product redisTemplate.opsForValue().get(key); if (product ! null) { return product; } // 回源DB并回填缓存 product productDao.findById(id); if (product ! null) { redisTemplate.opsForValue().set(key, product, 30, TimeUnit.MINUTES); } return product; } finally { redisTemplate.delete(lockKey); } } else { // 没抢到锁短暂sleep后重试 try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return getProductWithLock(id); } }这个小技巧能直接把热点key过期的“惊群效应”变成“单线程回源”DB压力大幅下降。注意锁的过期时间要合理如果回源DB和序列化时间超过了锁的超时时间会导致锁提前释放其他线程又抢着回源反而放大压力。所以在高并发场景下最好的做法是把锁的超时时间设置得比“最坏情况下回填耗时”更长一点或者用Redisson这种支持看门狗自动续期的锁。4.3 缓存雪崩过期时间抖动的工程实践雪崩比击穿更严重它是“大量key在同一时间过期”缓存集体失效所有请求瞬间打到数据库。常见诱因有两个一是代码里给key设置了相同的过期时间比如所有商品都是30分钟过期导致它们在同一时刻成批失效二是缓存服务本身宕机。针对第一类诱因最简单的做法就是过期时间加随机抖动int baseExpire 30 * 60; // 30分钟 int randomOffset ThreadLocalRandom.current().nextInt(0, 300); // 随机0-300秒 redisTemplate.opsForValue().set(key, value, baseExpire randomOffset, TimeUnit.SECONDS);这样可以保证即使所有key同时写入它们的过期时刻也会打散不会形成同一秒的“集体死亡”。我认为这个技巧是一个性价比极高的上线规范值得写进团队的编码checklist里。针对第二类诱因就涉及到Redis本身的高可用建设了主从架构、哨兵、Cluster模式以及本地缓存兜底。在极端情况下如果Redis直接不可用可以用一层短暂的本地缓存如Caffeine挡在前面让请求尽量不直接打到DB。但引入本地缓存之后多级缓存之间的一致性又会变成新的问题这块放在后面展开。5. 从代码到监控一套可落地的缓存更新最佳实践前面讲了很多选型和原理这里沉淀一套我认为可以直接抄作业的工程实践把操作层面的事说透。5.1 Lua脚本与Redis原语保证更新操作原子性有时候删除缓存和后续操作之间需要保证原子性最典型的就是“先比较后删除”的场景。比如你想实现这样一个逻辑只有当缓存里的值没有发生变化时才删除它否则就放弃删除。如果分两步走先GET再DEL两步之间一定有间隙并发场景下就会出问题。Redis官方推荐的方案是使用Lua脚本比如这面这个脚本实现“只有value等于期望值时才删除”-- KEYS[1]: 缓存key -- ARGV[1]: 期望的value if redis.call(GET, KEYS[1]) ARGV[1] then return redis.call(DEL, KEYS[1]) else return 0 end这个脚本天然具备原子性执行过程中不会被其他命令插入。你也可以用类似思路实现“获取锁”“检查锁持有者身份再释放”等操作。在Spring Data Redis里调用Lua脚本可以直接用DefaultRedisScript我不多贴代码但强烈建议凡是涉及“读写”两步的Redis操作都优先考虑用Lua脚本合并成一步。另外同缓存更新密切相关的还有Redis的事务命令MULTI/EXEC。但注意Redis事务和MySQL事务不是一回事Redis事务只是把多个命令打包按顺序执行中间不会被其他客户端命令插入而已它不会回滚已经执行的命令。如果你需要“要么全做要么全不做的回滚语义”Redis原生是给不了的得自己设计补偿。5.2 缓存治理的监控指标命中率、延迟、key分布与失效曲线只要用了缓存就必须建立对应的监控体系。踩过几次坑之后我的经验是至少盯住四个指标每一项都在事故前兆里有明显变化指标作用报警建议缓存命中率hit ratio低于90%说明回源频繁可能穿透或key设计有问题连续5分钟低于80%触发缓存读写时延latency突刺往往代表bigkey、热key或者网络抖动p99超过10ms触发热点key分布hot keys单个key QPS过大说明存在集中访问需考虑本地缓存拆分单key QPS超过阈值触发过期key批量失效曲线expire graph某时间窗口内大量失效可能雪崩每分钟失效数量超过阈值触发针对命中率一个常见误区的纠正——缓存命中率不是越高越好。如果命中率高达99.9%说明缓存里的数据几乎不更新那还算是“更新策略”吗真实业务里很多key本来就应该经常失效因为底层数据变化频繁。关键不是追求99.9%的命中率而是确保DB流量在系统可承载的范围内、数据一致性在业务可接受范围内。平衡才是核心。5.3 二级缓存与多级缓存的一致性思考很多系统在高并发下会考虑“本地缓存CaffeineRedis”的二级缓存架构用本地缓存挡住最热门的读请求。但引入一级缓存之后一致性问题的复杂度是直线上升的。比如Redis里的key更新了但本机缓存还躺在JVM内存里读请求依然读到旧值怎么办此时通常要引入一个“版本号广播”机制业务侧更新完DB和Redis后通过MQ或者Redis Pub/Sub发布一个消息让所有应用节点收到消息后主动失效本地缓存。这个机制在Java后端里很常见尤其是配合Spring Cache、MyBatis缓存等框架时更要小心。这里顺便提一下热搜词里经常看到的Spring三级缓存和MyBatis缓存。Spring的三级缓存是用来解决循环依赖的本质上是“早期引用”的缓存跟业务缓存不同但它同样有“先引用后可用”的状态管理理解Spring三级缓存有助于你理解“部分初始化对象为何能提前暴露”。MyBatis的二级缓存则是namespace级别的本地缓存它的更新策略同样遵循Cache Aside的逻辑——在SQL执行更新时清空对应namespace的缓存但由于它是应用内存缓存多实例部署时会遇到各个节点缓存不一致的情况此时最简单可靠的方案是直接关闭二级缓存、只使用一级缓存或者引入Redis作为二级缓存的外部存储。我的建议是多级缓存是性能利器也是运维噩梦。如果团队人数不多、监控不完善宁可先只保留Redis一层把击穿/穿透/雪崩都治理好再考虑加本地缓存。5.4 缓存更新的“封板”规范最后我把自己这几年沉淀下来的缓存更新规范浓缩成几条可以直接用在团队评审里数据库更新之后一律先删缓存不直接更新缓存内容除非极个别允许“写放大”的读多写少场景。删除缓存必须考虑失败重试至少有一个内存重试队列或者MQ兜底。所有缓存key必须有过期时间并且加上随机抖动禁止使用“永久不过期”的key除非你已经为它的内存生命周期负责过。热点key过期必然带来击穿风险提前设计互斥锁不存在的ID查询必须处理穿透至少要缓存空值。缓存删除尽量保证原子性复杂的“读-改-写”操作交给Lua脚本。上线前就要部署监控至少覆盖命中率、时延、热点key、批量失效曲线。数据能接受秒级不一致的业务优先用binlog订阅方案从根上绕开“人为删缓存可能失败”的坑。我在实际项目里常跟团队说这样一句话缓存不是一层KV存储而是一条需要设计的异步数据链路。你写的每一条get/set背后都牵涉到数据库事务边界、并发时序、失败补偿、容量规划和监控告警。把“缓存更新策略”当成一个完整的设计题来做而不是一个注解或者一个工具方法系统才能在真正的流量洪峰里站得住。最后再分享一个经验当你不确定这套策略该怎么做的时候先把“如果缓存全部丢失系统会怎样”这个问题回答清楚。如果答案是“数据库会挂”那缓存策略的设计就还没有完成别急着上线。回答清楚这个问题之后你会发现自己已经绕开了绝大多数新手团队掉进去的坑。