news 2026/11/4 23:02:05

02-缓存不是加个 @Cacheable:读写链路设计与三大缓存问题

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
02-缓存不是加个 @Cacheable:读写链路设计与三大缓存问题
# 缓存不是加个 `@Cacheable`:读写链路设计与三大缓存问题

一、榜单把带宽打满之后

第 01 篇结尾留了一个现场:Top 100 榜单直读 Redis,QPS 一上来,承载榜单的那个 key 把所在节点带宽打满,商品详情页跟着超时。

我们的第一反应是给榜单加本地缓存。问题确实缓解了两天,然后换了一副面孔回来:本地缓存把 Redis 的压力卸掉了,商品详情页却开始整点抖动——所有商品 key 的 TTL 都设成 30 分钟,上线时间是 23:30,于是每天 0 点,几万个 key 同一秒全部失效,请求整齐地砸向数据库。

两次故障,一次是读得太集中,一次是失效得太集中。它们对应两条最容易被忽略的规则:热点要打散,失效要错开。

这篇把缓存当成一条链路来设计,而不是给方法上加个注解。


二、先看清有哪几种模式

模式读路径写路径适用
Cache Aside未命中就查库、回填更新库 + 删缓存绝大多数业务,默认答案
Read Through缓存层内部回源只写库有统一缓存中间件时
Write Through同上同步写缓存,缓存层同步写库写少读多、缓存层可靠
Write Behind同上只写缓存,异步批量落库计数、日志等可容忍丢失

国内业务几乎都选 Cache Aside,理由不是它更先进,而是三点现实:不依赖额外中间件;缓存整体故障时业务能降级直查库;逻辑在业务代码里,出问题能改。代价也很明确——一致性要自己兜,这就是后面所有麻烦的来源。

三、写下缓存的那行代码,才是问题开始的地方

3.1 为什么是"删",不是"更新"

更新缓存看起来更"实时",但会引入三个新问题:

  • 写放大:缓存值常是多表聚合的结果,写多读少的场景下大量计算白做。
  • 乱序覆盖:A 把库更新成 1、B 更新成 2,但写缓存的顺序可能是 2 → 1,缓存最终停在 1,与库不一致。
  • 无效缓存:写进去的数据可能根本没人读。

删除的本质是懒加载——下次真正有人读时才重建。代价是删除后第一次读必然 miss,可用预热缓解。

3.2 先删还是后删:画一遍时序

[图:Cache Aside 读路径(未命中→查库→回填)与写路径(更新库→删缓存)的时序,标出两个不一致窗口]

先删缓存、再更新库,窗口在这里:

T1 写请求 DEL cache T2 读请求 未命中 T3 读请求 查库,读到旧值 T4 写请求 更新库,提交 T5 读请求 回填旧值 ← 缓存从此是脏的,直到过期

窗口 = T2 到 T5,也就是一次"查库 + 回填"的耗时。高并发下这个窗口几乎必然被踩到,而且脏数据会一直留到 TTL 到期。

先更新库、再删缓存,窗口变成:读请求必须在 T1 之前读到库、又在 T2 之后才回填,也就是要跨越整个写事务。这种交错明显更难凑齐,而且就算发生,也有 TTL 兜底。

结论:两种顺序都存在窗口,只是后者小得多。默认用"更新库 + 删缓存",别信"先删缓存"能规避并发问题。

3.3 删除失败怎么办

删缓存这一步会失败(网络、超时、实例切换),三种补救手段的复杂度完全不同:

手段解决什么代价
同步重试偶发失败拖长写接口 RT,重试几次要定
MQ 补偿删除动作不丢要保证"消息一定发出",通常得配本地消息表
订阅 binlog(Canal)漏删、删失败、业务代码侵入多一套组件;binlog 投递有延迟

必须说清一点:binlog 订阅解决的是"删不掉",不是"删的顺序"。它把删除动作从事务里摘出来、由 binlog 触发,天然避开了"事务没提交就写缓存"这种错误;但投递延迟依然存在,3.2 那个窗口一个都没少。这两件事经常被混为一谈。

3.4 延迟双删能救多少

标准时序是:删缓存 → 更新库 → 延迟 N → 再删缓存。第二次删除是为了清掉 3.2 里那个"读请求回填的旧值"。

关键在 N 怎么定:它要覆盖"读请求从查库到回填完成"的整段时间,通常取业务读耗时上限的 1~2 倍(具体值必须按你系统的实际压测结果定,没有通用数字)。

它在读写分离下会打折:第二次删除之后,从库可能还没同步到新值,新的读请求打到从库又读到旧值并回填——双删只对"读主库"有效。要处理就得让写后一段时间内的读强制走主库,或者等主从位点追上。

还有两个成本:sleep占线程,异步化后要保证延迟任务不丢。所以它是"缩小窗口"的补丁,不是一致性银弹。

3.5 先定一致性等级,再选方案

等级实现方式适用数据
强一致不用缓存,或读也加锁余额、库存扣减
会话内一致写后删缓存 + 短时间读主库用户刚改的昵称、刚下的单
最终一致TTL 兜底,接受窗口内旧值商品标题、榜单、推荐位

先定级再动手。绝大多数"缓存一致性"的争论,本质是两个人对同一份数据定了不同的级。

3.6 哪些数据压根不该进缓存

定级之后还有一刀要砍:强一致的数据(余额、可用库存)不该进缓存;极低频的数据(一年改两次的配置)也不该——命中率低,只是多占一份内存、多一份失效维护,反而增加出错面。

至于预热:它不是优化,是消除冷启动的第一波冲击。新实例上线、缓存被清空、大批 key 同时过期之后,如果不预热,第一波请求会全量回源。做法很简单——发布后由定时任务按热点清单批量加载,别等用户来触发。


四、三大问题:穿透、击穿、雪崩

4.1 穿透:查一个根本不存在的东西

请求的 key 在库里也不存在,缓存永远不会命中,每次都打到库。三招要叠加使用,不是三选一:

  • 空值缓存:把__EMPTY__写进缓存,TTL 设短(如 60 秒,按业务定)。风险是攻击者用随机 key 刷,等于把 DB 的压力转移成 Redis 的内存压力,必须配合限流。
  • 布隆过滤器(Bloom Filter):用位数组判断"这个 key 可能存在吗"。误判方向是单向的——说不存在就一定不存在,说存在可能不存在。它不支持删除元素(计数布隆可缓解,但要维护计数)。
  • 参数校验 + 限流:id <= 0直接拒,别让它走到缓存;再按 IP/用户维度限流。

4.2 击穿:一个热点 key 失效的瞬间

定义很具体:单个热点 key 在失效的那一刻,大量并发同时回源。第 01 篇那个榜单 key 就是标本——它一过期,所有读榜单的请求在同一秒查库。

两种解法,取舍不同:

  • 互斥重建(缓存失效时只允许一个请求去查库并回填,其余请求等待或返回旧值):数据新鲜,但等待方必须有超时和降级路径,否则一个慢查询会把整批请求拖死。
  • 逻辑过期(value 里包一层{data, expireAt},物理上永不过期,由业务判断是否异步刷新):可用性最好,代价是会返回旧值,而且实现上要兼容历史数据格式。
互斥重建逻辑过期
数据新鲜度高(重建后即新)有旧值窗口
可用性依赖锁,抢不到要降级始终可返回
实现复杂度中(锁 + 超时 + 释放)高(包装类型 + 异步刷新 + 清理)

4.3 雪崩:失效得太整齐

大量 key 同时失效,或 Redis 整体不可用。手段有五个:TTL 随机化(base + 随机偏移)、多级缓存、限流降级(数据库侧也要有连接池上限保护)、缓存预热、Redis 高可用(第 04 篇)。

开场那次整点抖动就是标准雪崩:不是 Redis 挂了,是失效时间被人为对齐了。

4.4 热点 key:回到第 01 篇那个现场

  • 本地缓存 + 短 TTL:把热点挡在进程内,TTL 要短(秒级),因为多实例之间没有失效广播。
  • key 打散:把hot:article:1001复制成 N 份,读时随机挑一份,写时全写。代价是读写都放大 N 倍,且 N 份之间的一致性要自己保证。
  • 读写分离的局限:加从库只能分担读,单个 key 的写仍是单点,还额外引入主从延迟。它不是热点写问题的解。

五、代码:从能跑,到能扛住失效

环境同第 01 篇:Redis 7.x、JDK 17、spring-boot-starter-data-redis(Lettuce)、jackson-databind;布隆过滤器需额外引入redisson-spring-boot-starter。

5.1getProduct():完整失败分支

privatestaticfinalStringEMPTY="__EMPTY__";privatestaticfinalDurationNULL_TTL=Duration.ofSeconds(60);// 空值缓存要短// 每次回填重新取随机偏移;若写成 static final 常量,类加载时只算一次,所有 key 的 TTL 还是同一个值privatestaticDurationdataTtl(){returnDuration.ofMinutes(30).plusSeconds(ThreadLocalRandom.current().nextInt(300));}// 位于 @Service 中:redis / mapper 为注入字段,log 由类上的 @Slf4j(Lombok)生成// 下面两行是桩体:后文每一段都用到它们,先给出实现,免得读者复制后编译不过privatefinalExecutorServiceasyncExecutor=Executors.newFixedThreadPool(4);// 仅 5.3 用;生产换成有界队列的 ThreadPoolExecutorprivateProductloadFromDb(longid){returnproductDao.selectById(id);}// productDao 为注入的 DAO,查不到返回 nullpublicProductgetProduct(longid){if(id<=0)returnnull;// 参数校验,挡掉一部分穿透Stringkey="product:"+id;Stringcached;try{cached=redis.opsForValue().get(key);}catch(RedisConnectionFailureExceptione){// 缓存故障不该变成业务故障:降级直查库log.warn("redis down, fallback to db. key={}",key,e);returnloadFromDb(id);}if(EMPTY.equals(cached))returnnull;// 空值标记命中if(cached!=null){Productp=parseQuietly(key,cached);// 直接复用清理脏数据的逻辑if(p!=null)returnp;// 解析成功才返回,失败走下面回源}returnloadAndBackfill(key,id);}privateProductloadAndBackfill(Stringkey,longid){Productp=loadFromDb(id);try{if(p==null){redis.opsForValue().set(key,EMPTY,NULL_TTL);}else{redis.opsForValue().set(key,mapper.writeValueAsString(p),dataTtl());}}catch(Exceptione){log.warn("backfill failed, ignore. key={}",key,e);// 回填失败不影响本次返回}returnp;}privateProductparseQuietly(Stringkey,Stringraw){try{returnmapper.readValue(raw,Product.class);}catch(JsonProcessingExceptione){log.warn("cached value broken, evict. key={}",key,e);// 静默删除会让脏数据事故查无实据redis.delete(key);// 脏数据删掉,当作 missreturnnull;}}

注意这里没有互斥。商品详情这种低并发 key 够用;一旦是榜单那种热点,失效瞬间就会集体回源——这正是 4.2 要解决的。

5.2 互斥重建:只放一个请求进库

// 第 01 篇的 buildScript() 需改为接收脚本路径参数privatestaticfinalRedisScript<Long>UNLOCK=buildScript("lua/unlock.lua");// unlock.lua:校验 value 再删,避免误删他人的锁// if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 endpublicProductgetProductWithMutex(longid){Stringkey="product:"+id;Stringcached=redis.opsForValue().get(key);if(cached!=null)returnEMPTY.equals(cached)?null:parseQuietly(key,cached);StringlockKey="lock:product:"+id;Stringtoken=UUID.randomUUID().toString();for(inti=0;i<3;i++){// 锁过期时间设为 10 秒,生产环境必须大于查库+回填的最大耗时(第 05 篇详解看门狗续期)Booleangot=redis.opsForValue().setIfAbsent(lockKey,token,Duration.ofSeconds(10));if(Boolean.TRUE.equals(got)){try{returnloadAndBackfill(key,id);// 只有这一个线程查库}finally{redis.execute(UNLOCK,List.of(lockKey),token);}}try{Thread.sleep(50);// 短暂自旋,等别人回填}catch(InterruptedExceptione){Thread.currentThread().interrupt();break;}cached=redis.opsForValue().get(key);if(cached!=null)returnEMPTY.equals(cached)?null:parseQuietly(key,cached);}// 抢不到锁、缓存仍是空:降级,不能死等log.warn("rebuild timeout, degrade to db. key={}",key);returnloadFromDb(id);}

失败分支有三处:抢锁超时后降级直查库(必须在入口配限流,否则降级就是打库)、回填失败、锁释放用 Lua 保证原子。

buildScript要从第 01 篇那个无参版本改成带路径参数,否则UNLOCK一行编译不过:

privatestaticRedisScript<Long>buildScript(Stringpath){try{Stringlua=newClassPathResource(path).getContentAsString(StandardCharsets.UTF_8);returnnewDefaultRedisScript<>(lua,Long.class);}catch(IOExceptione){thrownewIllegalStateException(path+" 加载失败",e);}}

锁超时为什么给 10 秒,而不是随手填个 3 秒:一旦查库+序列化+回填超过锁的过期时间,锁会在业务还没做完时自动释放,第二个线程进来重复回源;更糟的是第一个线程的finally这时可能删掉别人刚加的锁。所以过期时间必须明显大于这条链路的最大耗时——具体填多少只能拿自己的 P99 去定,没有通用值。长尾靠看门狗续期(第 05 篇),不是把数字往上堆。

5.3 逻辑过期:永不失效,异步刷新

staticclassCacheWrapper<T>{Tdata;longexpireAt;// 毫秒时间戳,业务判断用}publicProductgetProductLogical(longid){Stringkey="product:"+id;Stringraw=redis.opsForValue().get(key);if(raw==null)returnloadAndBackfillWrapped(key,id);// 冷启动仍同步加载一次CacheWrapper<Product>w=parseWrapper(key,raw);if(w==null)returnloadAndBackfillWrapped(key,id);// 如果数据是 null,也必须走过期刷新逻辑,否则数据库后来新增了数据,缓存里永远是 nullif(w.expireAt<System.currentTimeMillis()){StringlockKey="lock:refresh:product:"+id;if(Boolean.TRUE.equals(redis.opsForValue().setIfAbsent(lockKey,"1",Duration.ofSeconds(3)))){asyncExecutor.execute(()->{try{loadAndBackfillWrapped(key,id);}catch(Exceptione){log.warn("async refresh failed. key={}",key,e);}finally{redis.delete(lockKey);}});}}returnw.data;// 无论是否触发刷新,本次都返回手上这份(可能是旧值或 null)}privateCacheWrapper<Product>parseWrapper(Stringkey,Stringraw){try{returnmapper.readValue(raw,newTypeReference<CacheWrapper<Product>>(){});}catch(JsonProcessingExceptione){redis.delete(key);// 旧格式或脏数据:删掉,下次同步重建returnnull;}}privateProductloadAndBackfillWrapped(Stringkey,longid){Productp=loadFromDb(id);CacheWrapper<Product>w=newCacheWrapper<>();w.data=p;w.expireAt=System.currentTimeMillis()+Duration.ofMinutes(5).toMillis();try{redis.opsForValue().set(key,mapper.writeValueAsString(w));// 不设 TTL:物理永不过期}catch(JsonProcessingExceptione){log.warn("wrapped backfill failed. key={}",key,e);}returnp;}

代价要写清楚:key 物理上永不过期,就不会被自动清理,得额外做低频清理;历史数据是旧格式时,解析失败必须能降级重建,否则上线当天就会炸。

还有一个我自己踩过的坑,值得单独说:这个方法的早期版本里有这么一行——

if(w.data==null)returnnull;// 库里也没有,别把它当热点反复刷新

看起来是省了一次异步刷新,实际上制造了死缓存。设想一个上架时间在未来的商品:第一次访问时库里查不到,缓存里写进{data: null, expireAt: T};等商品真的上架了,expireAt早就过了,但这行守卫在刷新之前就把请求挡回去,于是这条 key 永远返回 null,只能等人工删 key 或低频清理任务扫到。空值能不能缓存,取决于"这个 null 会不会变",而逻辑过期这条路径上没有任何 TTL 兜底,所以只能让它照常刷新。真正要防的"无效 key 被反复刷新",应该交给空值短 TTL(5.1 的NULL_TTL)或布隆过滤器(5.4),而不是在这里拦。

5.4 布隆过滤器:只挡"一定不存在"

@BeanpublicRBloomFilter<Long>productBloom(RedissonClientclient){RBloomFilter<Long>filter=client.getBloomFilter("bloom:product");// 预计元素数、误判率;tryInit 只在 key 不存在时生效,已有 key 不会重建filter.tryInit(1_000_000L,0.01);returnfilter;}publicProductgetProductWithBloom(longid){if(id<=0)returnnull;if(!productBloom.contains(id))returnnull;// 一定不存在,直接挡掉returngetProduct(id);}

两个坑:tryInit对已存在的 key 无效,容量要一次估够;Cluster 下布隆 key 要带 hash tag 固定到同一 slot,否则contains直接报跨 slot 错误。它不支持删除元素,所以商品下架后仍会占位,只能靠定期重建。

5.5 两级缓存:先删 Redis 还是先删本地

顺序脏读窗口
先删本地,再删 Redis删本地后,别的请求回源读到 Redis 旧值并回填本地,然后 Redis 才被删——本地会长期脏到下次失效
先删 Redis,再删本地只在本实例还有旧本地值的毫秒级窗口内可能读到旧数据

推荐先删 Redis、再删本地,多实例场景再补一条广播失效(Redis pub/sub 或 MQ),否则别的实例的本地缓存只能等 TTL 自然过期。TTL 因此要设短。


六、踩坑清单

  • 不看窗口就"先删缓存"—— 我写过。以为删了就干净了,结果并发一压就脏。
  • 以为缓存了null就解决了穿透—— 攻击者用随机 id,每次都是新 key、照样打到库,还顺带在 Redis 里塞进一堆空值 key,把内存占掉。
  • 雪崩只加 TTL 不随机—— 开场那次整点抖动,就是把几万个 key 的过期时间对齐到了同一秒。
  • 加了分布式锁就认为数据一致了—— 锁只保证"同时只有一个请求重建缓存",不保证"缓存和库一致",写路径该删的缓存一步都不能少。
  • 把延迟双删当银弹—— 读写分离下从库延迟会让它失效,而很多人根本没意识到自己在读从库。
  • 事务没提交就写缓存—— 我见过在@Transactional方法里回填缓存的,事务回滚后缓存成了脏数据,而且没人发现。
  • 给所有接口统一加@Cacheable—— 写接口和强一致接口加这个注解,等于主动制造 bug。

七、小结

  1. Cache Aside 的默认写法是"更新库 + 删缓存",两种顺序都有窗口,选窗口小的那个,再用 TTL 兜底。
  2. 穿透、击穿、雪崩是三种不同的病:不存在的数据、单个热点失效、大批同时失效。用错药比不吃药更糟。
  3. 一致性先定级,再选方案。定不出级,说明这份数据还不该进缓存。

下一篇换个方向:缓存丢了可以重建,那存储层的数据丢了怎么办?RDB 快照和 AOF 日志到底能保住多少、fork的代价是什么、内存被谁吃掉了——第 03 篇讲持久化与内存管理。

自检三问:

  1. 你系统的写路径,是"更新库 + 删缓存"还是"先删缓存"?如果读请求正好卡在中间,脏数据会留多久?
  2. 你最大的那个缓存 key,失效时会有多少并发同时回源?现在是靠锁、靠逻辑过期,还是靠运气?
  3. 你所有 key 的 TTL 是怎么设的?有没有一批是在同一分钟、同一小时集体到期的?
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/4 22:12:08

如何快速上手 LinkSwift:网盘直链解析完整使用指南

如何快速上手 LinkSwift&#xff1a;网盘直链解析完整使用指南 【免费下载链接】Online-disk-direct-link-download-assistant 一个基于 JavaScript 的网盘文件下载地址获取工具。基于【网盘直链下载助手】修改 &#xff0c;支持 百度网盘 / 阿里云盘 / 中国移动云盘 / 天翼云盘…

作者头像 李华
网站建设 2026/11/4 22:08:46

基于ADS122U04与TM4C1299的工业温度采集系统设计

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/11/4 22:02:51

35ms 抗量子握手:ESP32-S3 跑通 ML-KEM-768

量子计算机还没造出来&#xff0c;“先存后解”&#xff08;harvest now, decrypt later&#xff09;的攻击已经在发生——现在抓走的加密流量&#xff0c;等量子计算成熟后再解。IoT 设备寿命动辄十年&#xff0c;这笔账必须现在算。 最近 Aethyr Research 开源了一套 ESP32-S…

作者头像 李华
网站建设 2026/11/4 22:02:19

superpowers加持Codex:让AI编程代理读懂大型Java项目

最近一直在折腾如何把 AI 编程代理真正用进日常开发&#xff0c;尤其是把 Codex 接进我那堆老旧的 Java 项目里。一开始裸跑 Codex 的效果说实话挺尴尬&#xff0c;它在小项目上很惊艳&#xff0c;一旦面对多模块工程就频频失控&#xff0c;改一个方法能顺便把不相关的代码也动…

作者头像 李华