# 缓存不是加个 `@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。
七、小结
- Cache Aside 的默认写法是"更新库 + 删缓存",两种顺序都有窗口,选窗口小的那个,再用 TTL 兜底。
- 穿透、击穿、雪崩是三种不同的病:不存在的数据、单个热点失效、大批同时失效。用错药比不吃药更糟。
- 一致性先定级,再选方案。定不出级,说明这份数据还不该进缓存。
下一篇换个方向:缓存丢了可以重建,那存储层的数据丢了怎么办?RDB 快照和 AOF 日志到底能保住多少、fork的代价是什么、内存被谁吃掉了——第 03 篇讲持久化与内存管理。
自检三问:
- 你系统的写路径,是"更新库 + 删缓存"还是"先删缓存"?如果读请求正好卡在中间,脏数据会留多久?
- 你最大的那个缓存 key,失效时会有多少并发同时回源?现在是靠锁、靠逻辑过期,还是靠运气?
- 你所有 key 的 TTL 是怎么设的?有没有一批是在同一分钟、同一小时集体到期的?