缓存抽象 @Cacheable 与 Redis 集成:让重复查询不必重复算
一个接口第一次要 800 毫秒,加缓存后 3 毫秒——这是后端最立竿见影的优化。但缓存也是"引入 bug 最快"的技术:数据不一致、雪崩、内存爆掉,全是它的锅。这一章先讲 Spring 的缓存抽象层(让业务代码不感知具体缓存实现),再讲和生产级 Redis 打交道的那些真实细节。
缓存抽象是什么:一层接口,多种实现
Spring 从 3.1 开始提供缓存抽象。它的核心思想是:业务代码只声明"这个方法的结果可以缓存",至于缓存存在哪里、怎么存,由配置决定。今天用内存 Map 跑单元测试,明天换成 Redis,业务代码一行不改。
抽象层的四个核心接口
| CacheManager 实现 | 接入方式 | 特点 |
|---|---|---|
ConcurrentMapCacheManager | 零依赖,默认 | 就是 ConcurrentHashMap。没有过期、没有容量上限、进程内。只适合单机演示和测试,生产用了迟早 OOM。 |
RedisCacheManager | 加 spring-boot-starter-data-redis | 分布式缓存的主力。支持 TTL、支持多节点共享,是这一章的重点。 |
CaffeineCacheManager | 加 com.github.ben-manes.caffeine:caffeine | 进程内高性能缓存,有 TTL、有 LRU/LFU 淘汰、有容量上限。用作一级缓存非常合适。 |
CompositeCacheManager | 代码组合 | 把多个 CacheManager 串起来,按顺序查找——两级缓存的官方组合方式。 |
论三个必须记住的抽象层规则
① 缓存注解基于 AOP,所以自调用失效:跟 @Transactional、@PreAuthorize 是同一个坑。this.cachedMethod() 不会走代理,缓存直接不生效。要自调用就注入自己(@Lazy 注入本类)或抽到另一个 Bean 里。
② 默认按"方法返回值"缓存 null 会出问题:默认 @Cacheable 会把 null 也缓存起来(cacheNullValues 在 Redis 实现里默认是 true),这恰好是防缓存穿透的免费方案。但如果你的缓存实现不支持存 null,就会每次都穿透。要用 unless = "#result == null" 明确表态。
③ 缓存的是"返回值对象",不是方法调用:所以序列化格式极其重要。对象改了字段、加了字段,反序列化时可能直接报 ClassCastException 或丢字段。缓存里的数据格式也是一种 API,改 DTO 要想着清缓存。
四个注解的语义差别:别再混用 @Cacheable 和 @CachePut
这四个注解长得像,但语义完全不同。用错了不会报错,只会静默地返回脏数据——这是最难受的一类 bug。
| 注解 | 对缓存做什么 | 典型场景与注意点 |
|---|---|---|
@Cacheable | 先查缓存:命中就直接返回不执行方法;未命中执行方法并写入缓存 | 查询方法。标志性特征是"方法可能一次都不执行"。 |
@CachePut | 一定执行方法,然后把返回值写入缓存(不是跳过) | 更新方法:需要更新数据库并把新值刷进缓存。注意它不走"先查缓存"逻辑,所以绝不会返回旧值。 |
@CacheEvict | 删除指定 key;allEntries = true 时清空整个区域 | 删除/更新方法。更新时最常用它而不是 @CachePut——理由见下一节的一致性讨论。 |
@Caching | 把上面三个组合在一个方法上 | 一个方法要同时操作多个缓存区域,例如"更新商品后,既删商品缓存又删分类下的商品列表缓存"。 |
四个注解的真实用法
有人想"查询时缓存、更新时刷新",就在一个方法上同时贴 @Cacheable 和 @CachePut。结果是每次调用都执行方法、每次调用都写缓存——缓存命中率永远是 0,白搭一层开销。正确做法:查和改是两个方法,分别贴两个注解。
另一个经典错误:@CacheEvict 贴在 @Transactional 方法上,而 beforeInvocation 默认是 false(方法成功后才删)。这本身是对的,但如果事务后面回滚了,缓存已经被删了一次——空删除无害,反而安全。真正危险的是用 @CachePut 在事务里提前写缓存:事务回滚后,缓存里却留着没提交的数据,这就是脏数据。
key 与 condition:SpEL 的正确打开方式
默认的 key 生成器叫 SimpleKeyGenerator:无参方法用 SimpleKey.EMPTY,单参直接用该参数,多参拼成 SimpleKey。它能用,但可读性差、跨版本不稳定,所以生产代码基本都会显式写 key。
| SpEL 写法 | 说明 |
|---|---|
"#id" | 引用方法参数。需要编译时保留参数名(-parameters,Spring Boot 默认开启)。 |
"#p0" / "#a0" | 按下标引用第 1 个参数。参数名丢失时的保底写法,推荐用于关键缓存,不怕重构改参数名。 |
"#user.id" | 引用对象属性的 getter(getId())。 |
"#root.methodName" | 方法名。#root 还可取 target、args、caches。 |
"#id + ':' + #type" | 字符串拼接,构造复合 key。建议加分隔符,避免 "1"+"2" 和 "12" 撞车。 |
"'PRODUCT_' + #id" | 加业务前缀。多个缓存共用一个 Redis DB 时便于排查和批量清理。 |
condition = "#id > 0" | 调用前判断:不满足就完全不走缓存逻辑(既不查也不写)。适合"非法参数不缓存"。 |
unless = "#result == null" | 调用后判断:只看结果,不满足就不写缓存。注意 unless 里才有 #result,condition 里没有。 |
一个容易踩的写法对比
论为什么强烈建议显式写 key
① 参数名丢失的隐性故障:如果编译时没带 -parameters(老项目 Gradle/Maven 配置漏了),#id 会解析失败,启动时不一定报错,运行时才炸。用 #p0 或 #a0 完全免疫这个问题。
② 默认 SimpleKey 的可读性太差:多参方法生成的 key 在 Redis 里长这样:SimpleKey [1001,SHANGHAI],还带不可见字符。运维排查时看到这个只能靠猜。显式写成 "order:1001:SHANGHAI" 一眼就懂。
③ 跨方法共享缓存时必须自定义:getById(Long id) 和 getDetail(Long id, String lang) 如果要复用同一份缓存数据,就必须让两者的 key 生成规则一致,否则永远命中不了。
缓存三大坑:穿透、击穿、雪崩
这三个词在面试里常被问,在线上则常被"三连击"——它们经常同时发生,所以也要一起防。
| 名字 | 现象 | 对策 |
|---|---|---|
| 穿透 | 查询根本不存在的数据。缓存里没有,数据库里也没有,于是每个请求都落到数据库。典型是攻击者用 id=-1 疯狂刷接口。 | ① 缓存空值:查不到也写一个 null(或哨兵值),TTL 设短(如 60 秒)。② 布隆过滤器:启动时把所有合法 ID 灌进去,请求先过过滤器。③ 参数前置校验,拒绝非法 ID。 |
| 击穿 | 某一个热点 key 恰好在高并发瞬间过期,几千个请求同时发现缓存没了,同时去查数据库("缓存失效时的惊群")。 | ① 互斥锁重建:只让一个线程去查库,其他线程等待或用旧值。② 逻辑过期:value 里存一个逻辑过期时间,过期后异步续,不删 key——保证永远有值可返回。③ 热点数据设置长 TTL 或永不过期。 |
| 雪崩 | 大批 key 同时过期(例如凌晨统一预热,TTL 都设成 1 小时),或者缓存节点整体挂掉,流量瞬间全压到数据库。 | ① TTL 加随机抖动:base + random(0, base*0.2),把过期时刻打散。② 多级缓存:本地 Caffeine 先扛一层。③ 缓存集群高可用(哨兵/Cluster)。④ 数据库侧限流与熔断兜底。 |
穿透与击穿的代码级对策
① 用 synchronized 防击穿:单机有效,多实例部署时每个实例各锁各的,照样全量打库。分布式场景必须用 Redis 锁或本地"逻辑过期"方案。
② 把所有 TTL 设成永不过期防雪崩:那是把雪崩换成了"内存爆满 + 数据永久脏"。永不过期只适合真·热点且变更极少的数据(如省市字典),并且必须配合主动刷新。
③ 用布隆过滤器但忘了处理"删除":标准布隆过滤器不支持删除元素——你删了一个商品,过滤器里那个 ID 还在,请求会穿过过滤器去查库。要么用计数型布隆过滤器(占更多内存),要么接受这个"漏判"(它只会让少量请求漏过去,不会误杀合法请求)。记住布隆过滤器的特性是"说不存在就一定不存在,说存在可能不存在"。
Spring Cache + Redis:TTL 与序列化怎么配
这是落地时最花时间的一节。默认配置能跑,但几乎一定不能满足生产要求,主要卡在两个点:TTL 不受 Spring Cache 控制,默认序列化格式不可读。
| 序列化器 | Redis 里长什么样 | 评价 |
|---|---|---|
JdkSerializationRedisSerializer | \xac\xed\x00\x05sr\x00...(二进制乱码) | RedisCacheManager 的默认值。缺点:① 不可读,运维无法排查;② 类必须先实现 Serializable;③ 类一改(加字段、改包名)就 InvalidClassException;④ 体积大、跨语言不可用。生产必须换掉。 |
GenericJackson2JsonRedisSerializer | {"@class":"com.example.Product","id":1,...} | 最常用的替换方案。可读、跨语言、支持任意类型(靠 @class 元数据还原类型)。缺点:写入 @class 有额外体积,且包名改了老缓存就反序列化失败。 |
Jackson2JsonRedisSerializer | {"id":1,"name":"x"} | 不带类型信息,体积最小。缺点:必须为每个类型单独构造序列化器,用一个序列化器缓存多种类型时会失败。适合缓存类型单一的场景。 |
StringRedisSerializer | 纯字符串 | 缓存 JSON 字符串或简单值时用,key 一定要用它(否则 key 也是二进制乱码)。 |
生产级 RedisCacheManager 配置:JSON 序列化 + 全局 TTL + 按缓存名定制
① @EnableCaching 漏了:注解全都不生效,而且没有任何警告。项目里第一次加缓存时最容易忘。
② key 用了 JDK 序列化:如果你只替换了 value 的序列化器而没管 key,Redis 里 key 也是二进制乱码,redis-cli KEYS * 看不见,运维无法清理。key 一定要用 RedisSerializer.string()。
③ TTL 设成 0 或负数:entryTtl(Duration.ZERO) 表示永不过期。很多人以为 0 是"立即过期",结果缓存永不失效,内存缓慢增长直到 Redis 被撑爆。
④ 忘了配 timeout:Redis 抖一下(比如主从切换),没设超时的客户端会让业务线程无限期阻塞,Tomcat 线程池几秒钟内被打满,整个服务雪崩。缓存是"可降级"的组件,连不上应该快速失败,而不是拖死主流程。
⑤ 大 key 与热 key:把整个列表(几万个元素)塞进一个 key,每次读写都要传输几 MB;或者一个热点 key 集中落在单个 Redis 节点上。大 key 用 Hash 分片或拆分,热 key 做本地缓存或 key 打散。
本地 Caffeine + 分布式 Redis:两级缓存怎么搭
Redis 再快也是一次网络往返(同机房约 0.5~1ms)。对于"每个请求都要读"的字典、配置类数据,进程内缓存能把这一跳也省掉。两级缓存的思路是:L1 本地兜住绝大部分流量,L2 Redis 保证多实例共享和一致性。
| 层 | 实现 | 职责与代价 |
|---|---|---|
| L1 | Caffeine | 亚微秒访问、零网络开销。容量小(几千到几万条)。致命缺点:多个实例的 L1 之间无法互相感知,一致性最差。所以只适合"变更极少、短时不一致可接受"的数据。 |
| L2 | Redis | 容量大、多实例共享。约 1ms 开销。是跨实例一致性的锚点。 |
| 回源 | 数据库 | 真正的数据源。两级都没命中才来,必须靠"空值缓存 + 互斥重建"保护。 |
两级缓存的两种实现路径
论两级缓存的代价:一致性窗口被拉长了
① 你以为的不一致只是"两个实例":没有 L1 时,删除 Redis 的 key,所有实例立刻都读不到缓存。有了 L1,你删了 Redis,但其他实例的 L1 里还留着旧值,最长能撑到 L1 的 TTL(上面例子是 30~60 秒)。
② 缓解手段:① 把 L1 TTL 设得很短(30 秒以内),把不一致窗口压到业务可接受;② 用 Redis 的 Pub/Sub 或消息队列广播"key 失效"事件,各实例收到后清本地 L1;③ 只对"几乎不变"的数据用 L1(数据字典、地区表、配置项)。
③ 判断标准很简单:问业务方一句话——"这个数据最多可以脏 30 秒吗?"能接受就用两级缓存,不能接受就只用 Redis,或者干脆不缓存。不要自己替业务方做这个决定。
缓存一致性:先删缓存还是先更新数据库
这是缓存领域最长寿的争论。先把结论说了:没有"完美"的方案,只有"在你的场景下足够好"的方案。下面把三种常见组合的失败场景摊开讲。
| 策略 | 怎么执行 | 失败场景 |
|---|---|---|
| 先更新库,再删缓存 推荐 | ① 更新数据库 ② 删除缓存 key | 极小概率:删缓存失败 → 缓存里留旧值直到 TTL 到期。这是业界默认方案(Cache-Aside 变体),因为出问题的概率最低、后果最轻(TTL 自动兜底)。 |
| 先删缓存,再更新库 | ① 删除缓存 ② 更新数据库 | 并发下必然出问题:线程 A 删了缓存 → 线程 B 读缓存未命中、读到旧库值并写回缓存 → 线程 A 才更新数据库。结果缓存里是旧值,且没有后续事件去纠正它。 |
| 先更新库,再更新缓存 (而不是删) | ① 更新数据库 ② 把新值写进缓存 | 两个并发更新会乱序:A 更新库为 v1 → B 更新库为 v2 → B 写缓存 v2 → A 写缓存 v1。缓存最终是 v1,比数据库还旧。所以更新缓存比删除缓存危险得多。 |
| 延迟双删 | ① 删缓存 ② 更新库 ③ 延迟 500ms 再删一次 | 能覆盖大部分并发脏读,但延迟时间靠猜,且第二次删除依赖异步任务,任务丢了就失效。属于"补丁式方案",不如直接用「先更库再删缓存」。 |
① 删缓存失败怎么办:这是"先更库再删缓存"唯一的漏洞。解法是把删除操作放进消息队列/事务后置事件里重试,例如用 @TransactionalEventListener(phase = AFTER_COMMIT) 发一条 MQ 消息,消费端负责删除,失败自动重试。记住:不要因为"删缓存可能失败"就不要缓存——TTL 本身就是最终的兜底一致性。
② 缓存必须有 TTL,没有例外:TTL 是"最终一致"的最后一道保险。如果某个缓存设了永不过期,那么任何一次更新遗漏都会造成永久性脏数据,只能人工介入。宁可 TTL 短一点,也不要永不过期。
③ 强一致场景不要用缓存:账户余额、库存扣减、订单状态这类"读错就会造成资金损失"的数据,正确做法是不缓存,直接读库 + 合理加索引;或者缓存只用来做展示、真实校验走数据库。缓存是性能工具,不是一致性工具,别让它承担做不到的责任。
① Spring Cache 是抽象层:CacheManager + Cache,换实现不改业务代码;但它是 AOP,自调用一定失效。
② 四个注解语义不同:@Cacheable 先查缓存可能不执行方法;@CachePut 一定执行并覆盖;@CacheEvict 删;@Caching 组合。更新用 Evict,别用 Put。
③ 显式写 key,用 #p0 兜底;判参数用 condition,判结果用 unless。
④ 穿透靠空值缓存 + 布隆过滤器,击穿靠互斥重建,雪崩靠随机 TTL + 多级缓存。
⑤ Redis 一定换掉 JDK 序列化(key 用 String,value 用 JSON),一定配 TTL 和 timeout,一定配连接池。
⑥ 两级缓存用 L1 换延迟,代价是不一致窗口变长;只在"能脏几十秒"的数据上用。
⑦ 一致性选「先更库、再删缓存」,并且所有缓存都必须有 TTL。强一致数据干脆别缓存。
1.(概念题)@Cacheable 和 @CachePut 最本质的区别是什么?
查看答案
答案:@Cacheable 是"读"语义:先查缓存,命中就直接返回、方法体不执行;@CachePut 是"写"语义:方法体一定执行,然后把返回值写入缓存。所以 @CachePut 永远不会返回旧缓存值,而 @Cacheable 有可能永远不执行方法。两者贴在同一个方法上会导致缓存完全失效。解析:记一句话——Cacheable 可能不执行,CachePut 一定执行。
2.(排错题)服务重启后 Redis 里 key 还在,但读缓存全部 miss,日志里有大量反序列化异常。最可能是什么原因?
查看答案
答案:序列化器不匹配。最常见的情况是:改代码时换了序列化器(比如从 JDK 换成 JSON),或者用了 GenericJackson2JsonRedisSerializer 而缓存对象改过包名/类名/字段类型,导致 @class 指向的类加载不到,抛 ClassNotFoundException 或 InvalidClassException。解决:① 换序列化格式时清空对应 cache 前缀的 key;② 缓存 DTO 只改字段不改类名包名,加字段用 @JsonIgnoreProperties(ignoreUnknown = true) 兜底;③ 给缓存加版本号前缀,如 cache:v2:product:。解析:缓存里的数据格式也是一种对外契约,改它要像改接口一样谨慎。
3.(选择题)凌晨 2 点,一批 TTL 为 1 小时的缓存同时过期,数据库连接数瞬间打满。这是哪种问题?A. 缓存穿透 B. 缓存击穿 C. 缓存雪崩
查看答案
答案:C,缓存雪崩。特征是"大批 key 同时失效"导致整体流量压向数据库。解析:对比记忆——穿透是查不存在的数据;击穿是单个热点 key 过期;雪崩是大批 key 同时过期或缓存节点宕机。修法是给 TTL 加随机抖动、搭多级缓存、保证 Redis 高可用。
4.(设计题)"先删缓存再更新数据库"和"先更新数据库再删缓存",你选哪个?为什么?
查看答案
答案:选先更新数据库、再删缓存。理由:① 先删缓存再更库,在"删完缓存、还没更新库"的窗口内,其他线程会读到旧库值并回填缓存,导致缓存长期存旧值;② 先更库再删缓存,最坏情况只是"删缓存失败,缓存里暂时是旧值",而TTL 会自动纠正,后果轻得多且自愈;③ 可以配合事务后置事件 + MQ 重试删除,把失败概率进一步压低。加分:强调"更新缓存"比"删除缓存"更危险,因为并发更新会乱序,最终缓存可能比库还旧。解析:面试官想听的是"你按失败概率和后果严重程度做选择",而不是背一个绝对答案。
5.(理解题)为什么缓存一定要设 TTL?"永不过期"在什么场景下才是可接受的?
查看答案
答案:TTL 是最终一致性的兜底:任何一次"更新数据库但没删缓存"的遗漏,都会在有 TTL 时自动恢复;没有 TTL 就变成需要人工介入的永久脏数据。只有同时满足三个条件时"永不过期"才勉强可接受:① 数据几乎不变(如省市字典、货币代码);② 有可靠的主动刷新机制;③ 能接受"变更后需要人工清缓存"。即便满足,也建议设一个很长(如 24 小时)的 TTL 而不是真的永不过期。解析:把"永不过期"当成一个需要论证的例外,而不是默认选项。