搞了好几年后端,缓存这块我自认为已经摸得门儿清,结果还是被一个不起眼的需求给教育了一顿。事情是这样的:线上有个核心服务,承担着用户维度的详情查询,流量不算小,QPS常年维持在几万。技术方案看起来也没什么毛病——Redis缓存 + MySQL,读多写少,典型的Cache Aside。可问题就出在"写"上。
运营后台一改配置,用户端却迟迟看不到变化,数据不一致的投诉工单一张接一张。我一开始也按老套路排查:是不是缓存更新没走对链路?是不是binlog监听有延迟?折腾了一圈发现都挺好。真正的问题出在我自己身上——我用了最重的方案去解决最轻的问题,为一个低频率更新、允许最终一致的场景,硬套了强一致的缓存同步机制,还妄想在架构层面上做到"零延迟"。痛定思痛,我把整个缓存链路的思路推倒重来,最后落在了一个看似很"懒"、实际却很聪明的方案上:懒更新 + 单点查询。
这个方案听起来有点像偷懒,但它恰恰解决了我在生产环境里遇到的绝大多数糟心事。如果你也在为"缓存到底要不要主动更新""为什么我清了缓存线上还是有问题""单点查询场景怎么做缓存才最省心"这类问题头疼,那这篇文章应该能帮上忙。我会把这套方案的原理、代码、参数选择、监控指标,以及我踩过的那些坑,全部拆开来讲清楚。
1. 被一个"改配置看不见"的需求,逼出来的方案取舍
动笔之前,我先把当时的场景还原一下。否则直接讲"懒更新有多好",你没有代入感,就只会觉得我在推销一个理论。
1.1 当时的场景:读多写少,但"写的可见性"要求很高
我们的服务是做商品维度的详情查询,用户端下单前会反复戳商品详情接口。数据结构很简单,核心就是一张商品主表,单行记录,按商品ID查。附带一些运营配置字段,比如活动标签、推荐权重、秒杀开关之类的。
这个场景有几个很明显的特征:
- 查询极度集中:某个商品一旦上了首页推荐,QPS瞬间能冲到几千。平时长尾商品的查询量很低,可能几分钟才被戳一次。
- 更新频率低:运营改配置,绝大多数是一次性动作,改完就不动了。真正高频变化的东西,比如库存、价格,走的是另外的独立服务。
- 单点价值高:用户点开详情页,查的是某一个商品,SQL走主键,DB层只要不蠢,单次查询就是亚毫秒级。
听起来是不是特别适合做缓存?对,当时我也是这么想的。一开始我们的方案是"缓存"两个字贯彻落实到了极致——查询接口Guava本地缓存套一层,Redis分布式缓存套一层,DB在最底下兜底。数据更新的时候,应用层主动去删缓存,期望用户下次进来就拿到新数据。
这套方案在流量模型简单的时候跑得挺顺,直到运营后台做了批量改价功能。
1.2 强同步方案的后遗症:删缓存、并发查、DB抖动全来了
运营批量改了100个商品的价格,后台服务调用商品服务接口,服务端做了一件事:把100个商品的缓存key全部删除。逻辑看起来没毛病吧?删掉之后,用户下一次查询就会从DB捞新数据回来。
但问题出在并发上。商品A正在被首页几个高流量入口猛戳,结果运营一改价,缓存key被删了,一瞬间所有落到这个key的请求全部穿透到DB。单量少的时候DB能扛,但上百个key同时失效,DB连接池直接被打穿。慢查询增多,接口RT飙到秒级,最后触发熔断。
更恶心的还有缓存击穿带来的脏数据问题。删除缓存后,第一个请求回源DB读到了新数据,正准备回填缓存,结果第二个请求也穿透进来了,它读到的可能是DB事务还没提交完的中间态数据。就是我们常说的"双花"问题,虽然概率低,但架不住请求量大。
当时我复盘的时候总结了一句话:更新侧主动删缓存,本质上是在用"一次性流量突刺"换取"数据强一致",这在单点查询场景里是得不偿失的。为什么?因为单点查询的更新频率低,大部分key在生命周期内压根不会被触碰,主动删缓存等于把一个低概率事件变成了高概率事故。
1.3 为什么我最终选了"懒更新"而不是引入消息队列
当时团队里还有人提议,干脆上MQ异步更新缓存,或者监听binlog再刷新缓存,给每个key做分布式一致性保障。这套方案我相信很多大厂都在用,但它有一个隐性问题:重。需要引入消息中间件或Canal组件,需要保证消息投递不丢失、顺序不颠倒、幂等消费,还要处理消息积压时缓存和DB的时长窗口。为了一个"运营改完配置后,用户希望十分钟内能看见"的需求,把整个技术链路的复杂度拉高一个量级,我觉得不划算。
最终我拍板的方案,就是"懒更新":缓存不主动更新,也不主动删除,让它自然过期。过期后下一次请求来的时候,再回源DB把新数据捞回来,重新构建缓存。
看起来是"懒"到家了,但它恰恰绕开了我上面说的所有雷点:
- 没有主动删缓存,就不会有瞬时穿透;
- 每个key的过期时间是独立的,天然打散回源压力;
- 实现成本极低,不需要额外引入任何组件。
当然,代价就是数据的可见性会有一个延迟窗口,这个窗口就是缓存的剩余TTL。对于商品详情这类"最终一致可接受"的数据,这个代价几乎可以忽略。后面我会专门讲一致性窗口怎么控制在业务能接受的范围内。
2. 懒更新的运行链路,拆开看其实一点都不玄乎
很多文章一提到"懒更新""懒加载"就各种术语满天飞,什么Lazy Loading、什么惰性淘汰策略、什么惰性删除。其实本质上就一句话:数据不主动维护,等到用的时候才去DB取。但这句话背后的链路细节,如果不展开讲清楚,落地的时候还是会踩坑。这一节我把整个运行机制从头到尾撸一遍。
2.1 核心机制:过期即失效,访问再补种,绝不提前动手
先看这个最基本的流程,你如果有Redis缓存的经验,大概率一眼就懂。
正常情况下:
- 用户发起查询请求;
- 应用先去Redis查缓存;
- 命中了,直接返回缓存数据,链路结束;
- 没有命中,则回源MySQL查一次最新数据,把结果写入Redis,并设置一个TTL过期时间,再返回给用户。
这就是教科书里标准的Cache Aside模式。懒更新和普通Cache Aside的差别只有一个:数据变更侧的时候,不去动缓存。普通Cache Aside在数据更新后通常要"删缓存"或"更新缓存",懒更新则完完全全放弃了这个动作。一切交给时间,缓存到点了自动消失。
那为什么在单点查询场景下这个方案特别行得通?原因在于:大部分单点数据的"单条记录的访问频率"很低,主动维护缓存的操作成本,远高于缓存命中所带来的收益。
来做个粗略估算。假设商品表里有10万个SKU,其中只有1000个SKU会被用户高频访问,其余99000个SKU平均一小时才被查询一次。如果对每一个SKU都做主动缓存更新——不管它是热门还是长尾——那意味着每次运营修改一条数据,都要支付一次网络请求、一次Redis写操作。而长尾数据即使不更新缓存,用户下一次查询时临时回源DB也就1ms的事,根本感知不到。主动更新纯粹是白花钱。
理解了这一点,你再回头看"懒更新"这个名字,就会觉得它其实不是"懒",而是把资源用在了刀刃上。热门数据缓存命中率高,回源次数少;长尾数据本来访问就少,偶尔回源一次系统也扛得住。整个系统的吞吐能力就被最大化利用了。
2.2 从请求到回源的完整调用链,以及谁该在什么环节做什么事
下面我把懒更新在单点查询场景下的完整时序拆成五步,每一步我都标出来容易出错的地方。
- 第一步:接收请求,组装缓存key。单点查询的key一般就是"业务前缀+唯一ID",比如
product:detail:123456。这里有个小细节——前缀命名要区分业务域,不同服务不能共用前缀,否则将来排查问题你连数据是谁写的都分不清。 - 第二步:查询缓存。如果命中,直接返回。这里可以顺带做一个"逻辑过期时间"的判断,比如缓存的value里除了业务数据,还塞了一个时间戳。时间戳快到了说明数据该刷新了,这时候可以触发异步预热,而不是干等它过期。
- 第三步:缓存未命中,回源DB。这一步是懒更新链路里最容易被流量冲垮的环节。因为过期后的第一个请求要独自承担DB查询的耗时,如果DB恰好抖动,这个请求的RT就会异常高。所以一定要有超时控制和熔断降级。
- 第四步:回源成功后回填缓存。写入Redis,设置TTL。这里我特别想强调一个点:TTL不要设成一个固定值,要加随机扰动,否则同一批次创建的key会集体到期,触发缓存雪崩。
- 第五步:返回响应。如果这一步回填失败(比如Redis写超时),宁可让当前请求直连DB返回,也不要把异常抛给上层。这就是"降级保命"的基本操作。
从这五步你也能感受到,懒更新其实没有引入任何高深的东西,它的核心反而是"克制"——不该做的动作坚决不做,该做的动作做到极致。在技术实现上,它把复杂度从"更新链路"平移到了"查询链路",但查询链路恰恰是我们最擅长做优化的地方,因为流量模型是可控的。
2.3 为什么单点查询是懒更新的"黄金搭档"
我在推这个方案的时候,有个同事问了我一句:"懒更新听起来通用,那列表查询能不能也这么干?"我的回答是:能,但千万别这么干。因为懒更新和单点查询就是天生一对,换了场景就变味。
列表查询是这样的:一个接口返回一整个列表,比如"首页商品推荐列表""我的收藏列表"。这个列表可能是由N个商品ID + 排序规则 + 过滤条件组合出来的。如果对它做懒更新,缓存一过期,你回源DB要重算整个列表,成本远高于查单条记录。而且列表key通常很少(可能就几十个),一旦过期,所有用户的请求全部穿透到DB,那是灾难。
单点查询则完全不同——它天然适合懒更新的逻辑,原因有三:
- 缓存key的粒度细。一个商品一个key,每个key独立过期、独立回源,天然打散压力。
- 回源成本低。走主键/唯一索引查一行,DB执行时间通常在1ms以内。
- 数据结构简单。不需要做复杂的聚合、排序、分页,直接存一个JSON字符串就完事了。
所以如果你的业务场景是"查单个对象详情""ID进ID出",就可以大胆用懒更新。如果接口是"查列表""查聚合结果",那我还是建议你老老实实用主动更新或者定时刷新。
3. 落地实现:代码、配置与参数,每一步都能直接抄作业
讲完了理论,下面这部分直接上干货。我这里的落地代码基于Java + Redis + MySQL,但思路是通用的,你用Go、Python,或者哪怕换成其他缓存中间件,核心逻辑都是同一套。
3.1 缓存Key设计与TTL策略:必须带坑位,必须加随机
首先是key设计。单点查询的key格式我建议这样:
业务域:对象类型:ID:版本 例如: product:detail:123456:v2我吃过一个亏:一开始key设计成product:123456,不带业务域。后来商品服务和促销服务都要查商品信息,两边共用了同一个key,结果促销服务在缓存里写入了一个不含价格字段的JSON,商品服务读出来直接反序列化失败,接口大量报错。排查了一下午,最后看缓存里的字符串才明白是key冲突。所以key里一定要带业务域前缀,宁可多写几个字符,也不要省这个事。
然后是TTL。懒更新场景下,TTL就是数据不一致窗口的上限,同时也是回源频率的调节阀。这个参数的设置,我一般按下面这个思路走:
- 数据允许最长看到多旧?比如运营觉得"改完配置后3分钟内用户能看到就行",那TTL可以设30-180秒。
- 数据更新频率多高?如果运营一天只改一次配置,TTL设到15分钟、30分钟甚至1小时都没什么问题。
- 单key的QPS多高?高QPS的热点key,TTL不宜过长,因为一旦过期,回源的瞬时压力会比较大,而且用户看到旧数据的时间越长,体验损耗就越大。
实际操作中,我常用的经验值是:中等热度单点查询,TTL设300秒(5分钟),然后加一个±30秒的随机偏移。为什么一定要加随机偏移?因为如果所有key的TTL都是固定值,比如统一300秒,那么同一批写入的key会在第300秒全部到期,回源请求会在那一瞬间集中爆发。加上随机偏移后,过期时间被摊开,DB压力曲线的峰值能降低一半以上。
3.2 查询主链路:命中、未命中、回源、回填的标准写法
下面这段代码是我根据线上实践整理出来的模板,你可以直接拿去改。核心逻辑就三步:查缓存、查DB、写缓存。
public ProductDetail getProductDetail(Long productId) { String key = "product:detail:" + productId; // 1. 先查缓存 String cachedJson = redis.get(key); if (StringUtils.isNotBlank(cachedJson)) { // 缓存命中的情况 return JSON.parseObject(cachedJson, ProductDetail.class); } // 2. 缓存未命中,回源数据库 // 这里要加锁,防止热点key过期瞬间所有请求同时打到DB String lockKey = "lock:product:detail:" + productId; boolean locked = tryLock(lockKey, 3000); if (!locked) { // 没拿到锁说明其他线程正在回源,短暂等待后重试 try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } String retryJson = redis.get(key); if (StringUtils.isNotBlank(retryJson)) { return JSON.parseObject(retryJson, ProductDetail.class); } } try { // 3. 真正回源 ProductDetail product = productMapper.selectByPrimaryKey(productId); if (product == null) { // 空值保护,防止穿透 redis.set(key, EMPTY_RESULT, 60 + random.nextInt(30)); return null; } // 4. 回填缓存,TTL = 基础值 + 随机偏移 int ttl = 300 + random.nextInt(60); redis.setex(key, ttl, JSON.toJSONString(product)); return product; } finally { // 5. 释放锁 unlock(lockKey); } }这段代码有几个细节我特意加进去,说一下用意:
tryLock用的是 Redis 的SET NX EX,确保同一时刻只有一个线程能回源。这是防止缓存击穿的关键操作。- 没拿到锁的线程不直接放弃,而是睡50ms再查一次缓存。这比直接抛出异常要友好得多,因为回源线程通常几十毫秒就能结束,短暂等待后大概率能命中缓存。
- 空值保护那行代码别省。如果DB里压根没这个商品,每次查询都会回源,穿透问题会非常明显。我见过有团队上线之后不忘加这一句,结果一个不存在的SKU被外部恶意刷,DB直接被压垮。
- 设置TTL时用
setex,注意别和set混用。setex是原子性的,而某些老旧的set方式需要分成两步,中间如果有请求插入,会读到残缺值。
3.3 击穿防护的关键:锁内回源的正确姿势
上面代码里用了锁,但"正确姿势"这四个字是有讲究的。我见过不少团队也加锁,但锁的范围、释放时机不对,导致性能反而更差。
优先锁"回源动作",而不是锁"整个查询方法"。如果你把整个getProductDetail方法都加锁,那么缓存命中后那些已经返回数据的请求也要排队,接口吞吐量直线下降。正确做法是锁只覆盖"回源+回填"这一段,缓存命中的路径不需要锁。
锁的粒度要对key隔离。不同的商品ID要用不同的锁key,这样才能保证热点商品之间的回源互不干扰。如果你用一个全局锁,那么商品A回源的时候,商品B的缓存未命中也要排队,等于把所有热点串行化了。
锁要设置过期时间。线上经常发生一种事:回源线程因为DB慢查询卡了十几秒,锁一直不释放,其他线程全部阻塞。所以tryLock一定要带过期时间,比如上面代码里的3000ms。到了时间锁自动失效,其他线程就能继续尝试回源,而不是无限等待。
3.4 空值保护、穿透治理与多级缓存兜底
懒更新方案的命门在于"缓存失效瞬间的回源压力"。为了把这个风险降到最低,我还会叠加两层防护:
- 第一层是本地缓存(如Caffeine)。在应用进程内再放一层几分钟的缓存,热点key的过期就不再依赖Redis,每次请求先在本地缓存里找,找不到再走Redis。这一层能拦截掉相当大一部分Redis回源流量。懒更新+本地缓存是我目前觉得最稳的组合。
- 第二层是布隆过滤器。这个主要用来拦截"不存在的key"。如果ID都不在过滤器中存在,就直接返回空,根本不查Redis和DB。对于防止恶意ID遍历攻击特别有效。
不过这两层都是加码方案,并不是必须的。如果你的DB负载一直很健康,压力测试10倍峰值也能扛住,那么不加本地缓存和布隆过滤器也不会有大问题。关键你要知道这道"安全阀"在哪里。
4. 线上压测与一致性实测:数据说话,看这套方案到底扛不扛得住
方案上线之前,我在预发环境做了一轮压测和一致性验证。这里把核心数据放出来,给你一个直观的感受,顺便也说说测试过程中暴露的问题。
4.1 压测方案与关键指标
压测场景我分了两组:
- 对照组:使用"主动删除缓存"的方案。模拟运营批量修改100个商品配置,同一时间点触发100个缓存key删除。在这个配置下,压测工具直接对DB施压。
- 实验组:使用"懒更新"方案。模拟同样的批量修改,但只修改DB数据,不删除缓存。压测工具持续查询,等待缓存自然过期。
压测机配置是4核8G的普通云主机,Redis和MySQL都是线上同规格。目标QPS设定为单key 2000和全key 20000。关键指标我盯着三个:接口平均RT、P999延迟、DB连接池占用率。
测试结果如下表:
| 指标 | 对照组(主动删缓存) | 实验组(懒更新) |
|---|---|---|
| 平均RT | 45ms(波动较大) | 12ms(稳定) |
| P999延迟 | 380ms | 150ms |
| 缓存命中率 | 98.50% | 99.70% |
| DB连接数峰值 | 182(接近连接池上限) | 63 |
| 回源失败次数 | 0 | 0 |
对照组的问题在测试里暴露得特别明显:100个key同时删除后,DB连接数直接蹿到182,差一点就触发了连接池限流。平均RT飙到45ms,这个延迟虽然不至于让用户骂娘,但和实验组的12ms一比,差距就很刺眼了。
4.2 一致性窗口的实测记录与分析
接下来看数据可见性。我做了这样一个测试:把商品A的价格从100改成200,然后以1秒一次的频率反复查询详情接口,记录下"能读到新价格"的时间点。测试重复了10次,统计结果如下:
- 最短可见耗时:大约在缓存过期后的第1秒。也就是TTL剩余时间接近于0的那一次请求返回了新数据。
- 最长可见耗时:正好等于TTL剩余时间。我做测试时TTL设置的是300秒,所以最极端的情况是修改后第5分钟用户才看到新值。
- 平均可见耗时:和TTL剩余时间的分布强相关,基本均匀分布在0到300秒之间。
这里有个体验问题你要注意:如果运营恰好在一个key刚被回填缓存之后修改了数据,那用户看到新数据就要等上一整个TTL周期。所以TTL不能设得太长,否则"运营改完配置用户看不见"的问题又会浮出水面。我实际项目里最终把TTL调成了180秒加随机偏移,取了一个"一致性窗口可接受、DB压力可控"的中间值。
另外我自己在测试中还发现,懒更新方案在模拟缓存雪崩时,表现反而比主动删除好得多。原因很好理解:主动删除是在一个时间点上集中失效大量key;懒更新则是每个key按自己的生命周期自然过期,过期时间点是分布在时间轴上的。除非你恶意把所有key的TTL设成同一个值,否则它天然就没有雪崩的土壤。
4.3 缓存命中率为什么反而提升?
这套方案另一个让我意外的收获是缓存命中率上升了。对照组98.5%,实验组99.7%,高了1.2个百分点。别看数字小,在高QPS下这1.2%背后是每天少掉上千万次DB查询。
原因也简单:主动删缓存会让key在"还没到期"的时候提前失效,这段时间本可以命中的请求全部变成了回源。而懒更新只在key真正到期时才失效一次。理论上,只要数据没有变更,懒更新的命中率就应该无限接近100%。所以这个提升不是偶然,而是机制上的必然。
5. 翻车复盘:懒更新最容易被坑的几个细节,全给你列出来
这个方案跑了几个月,踩过的坑和优化过的点我都记下来了。这里挑几个最有代表性的展开说,都是网上教程不爱讲、但你上线就会遇到的东西。
5.1 热点Key的过期瞬间:锁的等待效应比想象中更明显
懒更新最大的风险点,是热点key过期的那一瞬间。假设某商品是爆款,QPS高达5000,它的缓存key刚好到期。这个时候,同一毫秒内可能有50个请求全部未命中缓存,它们都要尝试拿锁回源。
虽然锁保证了只有1个请求去查DB,但其余49个请求并不会立刻拿到缓存——它们会先尝试拿锁,拿不到就睡50ms,然后重试。如果回源DB需要100ms,那这49个请求的平均等待时间可能就是100ms左右。体现在用户侧,就是某个瞬间接口RT从1ms涨到100ms。
这个现象无法完全避免,但可以缓解。我的做法是给热点key单独设置更短的TTL,让它更频繁地过期,换取更小的回源数据量?不对,仔细想了想,其实更短TTL会更频繁地触发回源——这个思路有点问题。正确做法应该是:给热点key增加"逻辑过期时间+异步刷新"机制。也就是缓存数据里不仅返回业务JSON,还塞一个逻辑过期时间戳。请求来了先判断逻辑时间戳是否过期:
- 如果没过期,直接返回数据;
- 如果过期了,先返回旧数据(不让用户等),同时在后台异步发起一次回源刷新。
这个思路本质上是懒更新的升级版,叫"Refresh-Ahead"。牺牲一点代码复杂度,换掉了热点key过期瞬间的RT抖动。效果确实立竿见影,P999延迟从150ms降到了50ms以内。
5.2 更新方"顺手"删缓存,你所有的懒更新设计都会白费
这个坑我必须单独拿出来讲,因为它特别隐蔽。我们上线懒更新后,有几周数据一致性的投诉又冒出了一些。排查到最后发现,是另一个业务组在调用商品服务时,自己封装了一个"更新后删除缓存"的工具方法,他们以为这是最佳实践。
问题来了:他们一删缓存,等于把懒更新辛辛苦苦做的"过期时间平滑分布"直接打回原形。一瞬间所有实时变更的key全部提前失效,回源压力再集中一波,之前所有的优化全部归零。
为了杜绝这种情况,我把商品服务所有对外的写接口都做了调整:写操作只更新DB,彻底不碰缓存。同时我还在缓存服务的监控大盘上盯了一条规则——如果发现某个key的删除操作并非TTL自然过期,而是来自外部手动删除,立刻告警。这几个月抓到了好几个"好心办坏事"的调用方。
说句实话,团队协作中最难的不是技术方案,而是让所有人都理解并遵守同一个技术约定。懒更新方案要求"写侧不干预缓存",这跟很多程序员脑子里的习惯是拧着的,所以必须靠监控和制度把它钉死。
5.3 TTL设置的艺术:太短了打DB,太长了用户骂娘
TTL是懒更新方案里最关键的参数,没有之一。它的设置逻辑,我最后总结成一句话:让TTL略大于"业务可接受的数据延迟",同时小于"DB能承受的回源频率"。
如果你业务的容忍度是"运营改了配置,1个小时内看到就行",那TTL设30分钟到50分钟都是可以的。如果你业务要求"改完三分钟内看到",那就设120秒到180秒。
但也要提防TTL过短的问题。我有个子服务一开始图省事,所有key统一TTL=60秒。结果DB负载飙到70%以上,因为每过60秒,整个缓存层就全部重建一遍。后来我把TTL调成300秒加随机偏移,DB负载降到了30%左右。这说明TTL不是越短越好,你要在"数据新鲜度"和"DB成本"之间找准平衡点。
衡量这个平衡点,我建议你盯两个指标:缓存层回源QPS和DB平均耗时。如果回源QPS高但DB扛得住,TTL还能适当调短;如果DB已经接近瓶颈,果断把TTL拉长,或者升级Refresh-Ahead方案。
5.4 框架封装要小心:别引入隐性的"主动淘汰"
最后提醒一个容易被忽略的坑:你用的Redis客户端、缓存框架,可能默认给你开了"缓存空值淘汰"或者"主动更新"的功能。
举个例子,有些封装的缓存工具类,在写入缓存时会先DEL一下旧key,再SET新key。表面上看没啥问题,但懒更新方案里这个DEL就是多余的——它会让旧key在还没到期的时候就消失,等于主动删缓存。
我处理的方式很简单:把缓存的写入操作全部收敛到自研的CacheService里,只用SETEX原生命令,不套任何花里胡哨的封装。在代码审查的时候,也明确要求同事不允许在查询链路上使用DEL命令。
6. 说句大实话:懒更新不是万能药,用之前先看看你的场景
写到这里,该讲的实现细节基本都讲完了。最后我还是想泼点冷水,把懒更新的适用范围和边界条件说透,免得你回去一看"这方案好"就直接照搬,然后在别的场景里翻车。
懒更新最适合的场景,我总结下来有三个关键词:读多写少、单点查询、最终一致可接受。你的业务如果同时满足这三个条件,那它就是一个性价比很高的方案。反过来,如果你的数据更新频率极高(比如秒级变化的库存),或者业务要求强一致(比如支付金额、订单状态),那懒更新就是灾难。这种场景请老老实实走"写后更新缓存"、"同步双写"或者"基于日志变更同步"的重方案,别想着省事。
另外一个建议是,方案上线初期,最好把所有关键参数都留成配置,不要写死在代码里。我在生产上就经历过好几次,一开始觉得TTL=300秒挺好,后来业务方提出要更快看到数据,我改一个配置就搞定了。如果你把TTL写死在常量里,每次调整都要发版,那就太被动了。
还有一点,懒更新方案的监控可比普通缓存方案更重要。因为你放弃了主动更新,数据出现延迟是正常现象,但这个"正常"也需要可观测——你要能实时看到每个key的缓存命中率、回源耗时、DB压力,一旦某个指标异常,能快速定位是不是哪段业务逻辑破坏了约定。我把这套监控叫做"以防万一"体系,上线三个月后,它帮我抓到了前面说的外部主动删缓存的坑。
我个人在实际操作中的感觉是,懒更新这套方案最大的魅力不在于"懒",而在于它逼我把缓存的核心逻辑想明白了:什么是必须强一致的、什么是可以妥协的、瓶颈到底在哪个环节。想清楚了这些问题,哪怕将来业务复杂度增加,需要换成更重的同步方案,你心里也会比谁都清楚该在哪里动手。