2203 字
约 7 分钟
18
S07-A21 · `@Cacheable` 的四个失效边界:注解没生效时的样子

一个查询方法加了 @Cacheable,压测却发现"每次都查库"。查了半天发现三件事:注解加在同类内部调用的方法上(不生效)、缓存管理器没配 TTL(结果全过期)、以及 key 用了默认生成器(把参数对象拼成了不同的 key)。

@Cacheable 的"失效"几乎从不报错——它只是安静地不缓存。这两件事的"修法"完全不同:前者要"改调用方式",后者要"改 key 或 TTL"——而它们的外部表现(方法反复执行)一模一样,所以排查的第一步是分类,不是找原因。
而"不缓存"与"缓存但没命中"在监控上表现一样,于是排查方向容易错。


1. 四种失效形态

形态 表现
代理未生效 每次都执行方法体
key 不稳定 每次都是新 key(永不命中)
TTL 太短/未配 命中率极低
条件不匹配 condition/unless 恒真/恒假

四种的"外部表现"完全一样:方法被反复执行。所以排查必须逐个排除,不能靠"看日志"。第一项的"隐蔽"在"代码看起来对":注解写了、注解处理器也扫到了,只是"调用没经过代理"——而这件事在静态代码里看不出来。

2. 失效一:代理(最常见)

写法 是否生效
从别的 Bean 调用 生效
同类内 this.method() 不生效
@Cacheable 在私有方法上 不生效
在同一个类里注入自己再调 生效

这个"同一个机制"值得记成一条通用规则:凡是"靠注解改变执行行为"的框架特性,都依赖代理——所以"自调用"是它们共同的盲区。
与 @Transactional、@Async 是同一个机制:它们都依赖代理,于是自调用全部失效。

// 反例:自调用,注解全部失效(缓存、事务、异步)
public User find(Long id) { return load(id); }         // 走 this
@Cacheable("user")
public User load(Long id) { /* 每次都执行 */ }

// 正解:把缓存方法放到独立 Bean
@Service class UserCache { @Cacheable("user") User load(Long id) {...} }
@Service class UserService { UserService(UserCache c) {...} c.load(id); }

3. 失效二:key 生成

写法 key
默认(SimpleKey) 多参数时用全部参数
key = "#id" 明确
key = "#root.methodName + #id" 含方法名
keyGenerator 自定义 自己控制

第一项在多参数时会有个坑:把"参数对象"直接放进 key,于是"内容相同但对象不同"也会生成不同 key(如果该对象没实现 equals)。第一项的"变体"是"参数里含时间戳/随机数":于是每次请求都是新 key,命中率恒为零——而它在代码里只是"传了一个参数"。

第三项的"撞 key"后果是"读到错的数据":findById(1) 与 findByCode("1") 若生成同一个 key,就会互相覆盖——而它比"没命中"危险得多。
第三项在"多个方法共用同一 cache 名"时必要:否则 findById 与 findByCode 的 key 可能撞。

// 推荐的显式 key(含方法名,避免跨方法撞 key)
@Cacheable(cacheNames = "user", key = "'id:' + #id")
public User findById(Long id) { ... }

@Cacheable(cacheNames = "user", key = "'code:' + #code")
public User findByCode(String code) { ... }

4. 失效三与四:TTL 与条件

配置 问题
未配 TTL(用默认) 可能永不过期(内存涨)或极短(命中率低)
condition 用错对象 恒真/恒假
unless 判断返回值 需要 SpEL 写法正确

第四项(condition 恒真/恒假)的"表现"是"缓存有效但从不使用"或"总是使用"——而 SpEL 里一个拼错的变量名会被当成 null,于是"条件恒假"而不报错。
第二项的坑很具体:condition 里用 #result 是无效的(那时还没有结果)——它只用于 unless。

// condition:进入方法前判断(不能用 #result)
@Cacheable(value = "user", condition = "#id != null")
// unless:方法返回后判断(可用 #result)
@Cacheable(value = "user", unless = "#result == null")
// 常见错误:unless = "#result == null" 写成 condition → 启动报错或恒真

"入门必配"的四项,缺一项就会在某个边界上失效:

spring:
  cache:
    type: caffeine           # 明确实现(不要靠自动探测)
    caffeine:
      spec: maximumSize=10000,expireAfterWrite=300s   # 上限 + TTL 必须都配
// 注解侧再补两项
// @Cacheable(cacheNames="user", key="'id:'+#id", unless="#result==null")
// 收益:容量可控(不会 OOM)、TTL 明确(不会永不淘汰)、key 唯一(不会撞)

5. 怎么确认"到底是哪一种"

步骤 动作
1 在方法体里打日志,看是否每次都进
2 打开缓存的统计(命中/未命中)
3 打印实际 key(Cache 的键集合)
4 检查配置的 TTL

第一项区分"代理失效"与"缓存未命中":方法体每次都执行 = 没走缓存(代理问题)这个"二分"的成本是"一行日志":在方法体第一行打 debug 日志,压测 10 秒看它出现几次——次数等于请求数就是代理问题,远小于请求数就是命中问题。
;方法体偶尔执行 = 缓存生效但命中率低(key/TTL 问题)。

这个二分只要一次观察,就能把四种形态分成两类。

// 看缓存统计(Spring Boot Actuator)
curl -s localhost:8080/actuator/metrics/cache.gets | jq '.measurements'
// 关注 tags: cache=user, result=hit/miss

"缓存统计"是唯一能把"命中/未命中"分开的证据,而它需要显式开启:

// 开启缓存指标(Spring Boot Actuator)
management:
  endpoints.web.exposure.include: health,metrics
  metrics.enabled: true
// 然后:curl /actuator/metrics/cache.gets
// 输出里按 tag 区分 cache=user 与 result=hit/miss

6. 排错:五种"缓存没生效"

现象 成因 处理方向
方法每次都执行 自调用/私有 拆 Bean
命中率极低 key 不稳定 显式 key
内存持续增长 无 TTL/无上限 配 TTL 与 maxSize
缓存里有多余 key 跨方法共用 cache key 加方法名
启动报 SpEL 错 #result 用错位置 改用 unless

第三项是最危险的一类:**"缓存生效了但永不淘汰"**第三项的"判据"是"缓存条目数是否随时间单调增长"。若在涨,说明"没有 TTL 或没有上限"——而"命中率"这个指标完全看不到它。
,于是"内存涨到 OOM"而"命中率看起来很好"。

最后一个"分布式"边界:注解缓存默认走"本地实现"。多实例部署时,每个实例各有一份缓存——于是"命中率随实例数下降"(与"多副本前缀缓存"是同一个问题)。

// 本地缓存与分布式缓存的分工
本地(Caffeine):极热数据,容忍不一致(秒级),命中率高
分布式(Redis):共享数据,一致性强,但有网络成本
// 注解缓存默认本地;要共享需显式配 CacheManager,且注意"序列化 + 键空间"

7. 边界:什么情况不该用注解缓存

场景 为什么 做法
查询本身很快(< 2ms) 缓存开销更大 不用
结果高度个性化(每人不同) 键空间无限 不用
需要强一致(读自己的写) 注解缓存无失效控制 手动缓存
已有分布式缓存抽象 注解可能绑死实现 用统一抽象

判断口径一句话:"同一个 key 会不会被短时间内重复查询"。最后一条与"一致性"有关的边界:注解缓存没有"细粒度失效"——它只能"整块清空"或"按 key 删"。所以"一个字段变了要失效所有相关 key"的场景,注解缓存不合适。
会(热点数据、字典),注解缓存合适;不会(个性化、一次性),它只是增加复杂度。


8. 自测

  1. 四种失效形态为什么"外部表现完全一样"?这给排查带来什么困难?
  2. "代理"为什么会让 @Cacheable、@Transactional、@Async 一起失效?
  3. 多参数时默认 key 会有什么问题?为什么"key 里加方法名"是必要的?
  4. condition 与 unless 的区别是什么?为什么 #result 只能用在 unless?
  5. 怎么用"方法体是否每次执行"把四种形态分成两类?
  6. 判断"该不该用注解缓存"的那个问题是什么?
S07-A21 · `@Cacheable` 的四个失效边界:注解没生效时的样子
https://www.clxhxhhr.top/posts/6590/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。