深入解析 Spring @Cacheable 注解:从声明式缓存到多维替代方案
本文以一个真实生产项目(yu-ai-code-mother)中的精选应用分页接口为切入点,系统解析 Spring Cache 的
@Cacheable注解。面向中级 Java 开发者,兼顾理论深度与可运行的实践示例。
0. 引言:从一段真实代码说起
在 AppController 的「精选应用分页」接口中,存在如下声明式缓存配置:
java
@PostMapping("/good/list/page/vo")
@Cacheable(
value = "good_app_page",
key = "T(com.yupi.yuaicodemother.utils.CacheKeyUtils).generateKey(#appQueryRequest)",
condition = "#appQueryRequest.pageNum <= 10"
)
public BaseResponse<Page<AppVO>> listGoodAppVOByPage(@RequestBody AppQueryRequest appQueryRequest) {
// ... 限制每页 20 条、设置精选优先级、构建 QueryWrapper、分页查询、封装 VO
return ResultUtils.success(appVOPage);
}
这段代码只有 5 行注解,却涵盖了 缓存命名空间 、SpEL 动态 Key 、条件缓存 三大核心能力,配合 RedisCacheManagerConfig 中针对 good_app_page 设置的 5 分钟 TTL,构成了一个完整的「热点列表 + 短时缓存」方案。
本文将围绕它展开:先讲透注解本身,再给出三种替代实现并横向对比,最后给出选型决策。
1. 注解的基本语法和参数配置说明
@Cacheable 来自 org.springframework.cache.annotation,是 Spring Cache 抽象的核心注解之一,语义为「方法执行前先查缓存,命中则直接返回;未命中则执行方法并把结果写入缓存」。
1.1 完整属性表
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
value / cacheNames |
String[] |
{} |
缓存命名空间(可理解为 cache 的"表名"),必填之一 |
key |
String |
"" |
SpEL 表达式,用于计算缓存 key;不写则用 SimpleKeyGenerator |
keyGenerator |
String |
"" |
自定义 KeyGenerator Bean 名;与 key 互斥 |
cacheManager |
String |
"" |
指定使用的 CacheManager |
cacheResolver |
String |
"" |
动态解析缓存,与 cacheManager 互斥 |
condition |
String |
"" |
SpEL,方法执行前后均判断 ,为 false 则不读写缓存 |
unless |
String |
"" |
SpEL,仅方法执行后 判断,为 true 则不写入缓存(仍会读) |
sync |
boolean |
false |
同步加锁,避免缓存击穿(底层依赖 Cache#get(key, valueLoader)) |
1.2 关键属性详解
① value ------ 缓存命名空间
底层会被拼到最终 Redis key 上。本项目 RedisCacheManagerConfig 中:
java
.withCacheConfiguration("good_app_page",
defaultConfig.entryTtl(Duration.ofMinutes(5)))
表示 good_app_page 这个命名空间单独享受 5 分钟过期,其余走默认 30 分钟。这就是「按命名空间精细控制 TTL」的典型用法。
② key ------ SpEL 表达式
SpEL 是 @Cacheable 的灵魂。可用元数据:
| 表达式 | 含义 |
|---|---|
#param |
方法参数(参数名需 -parameters 编译或使用 #p0) |
#root.methodName |
方法名 |
#root.target |
目标对象 |
#root.args |
参数数组 |
#result |
方法返回值(仅 unless 可用) |
T(全限定类名) |
引用静态方法/字段 |
本项目使用 T(...).generateKey(#appQueryRequest),调用静态工具把整个请求对象压缩成 MD5:
java
public static String generateKey(Object obj) {
if (obj == null) {
return DigestUtil.md5Hex("null");
}
String jsonStr = JSONUtil.toJsonStr(obj);
return DigestUtil.md5Hex(jsonStr);
}
为什么用 JSON + MD5?
- 对象字段多,直接拼 key 又长又易错;
- MD5 固定 32 位,控制 Redis key 长度(Redis 大 key 险情);
- 不同字段组合也能被同一算法覆盖,避免手写
#a + '_' + #b。
⚠️ 隐患:对象字段顺序、Null 字段策略、
transient字段都会影响 JSON 字符串,进而影响命中率。Hutool 的JSONUtil.toJsonStr默认忽略 null 值,需团队知悉。
③ condition ------ 条件缓存
condition = "#appQueryRequest.pageNum <= 10" 表示只缓存前 10 页。这是非常经典的高并发列表缓存策略:
- 前 10 页是绝大多数用户行为(长尾效应),缓存收益最高;
- 第 11 页之后多为"翻到底"的少数请求,缓存命中率低,反而浪费空间。
注意 condition 在「读」和「写」两个阶段都生效:条件不满足时既不查缓存也不写缓存,方法每次都会真正执行。
④ unless vs condition
| 维度 | condition |
unless |
|---|---|---|
| 时机 | 执行前 + 执行后判断 | 仅执行后判断 |
| 语义 | 不满足则完全绕过缓存 | 不满足则不写缓存(但已读) |
能否用 #result |
否 | 是 |
典型场景:unless = "#result == null" 防缓存穿透写 null;unless = "#result.total < 1" 不缓存空结果。
⑤ sync ------ 防击穿
sync = true 时 Spring 会用 Cache#get(key, valueLoader),底层(如 Redis)加分布式锁,保证同一 key 并发只穿透一次。代价是不能与 unless 同时使用。
1.3 底层工作原理(一图胜千言)
请求进入
│
▼
Spring CachingInterceptor (AOP 代理)
│
├─ 解析 cacheNames / key / condition
│
├─ condition 为 false? ── 是 ──► 直接执行方法,返回(不碰缓存)
│ │
│ 否
│ ▼
├─ Cache.get(key)
│ │
│ 命中 ──► 反序列化返回(方法不执行)
│ │
│ 未命中
│ ▼
├─ 执行目标方法
│ │
│ ▼
├─ unless 为 true? ── 是 ──► 返回结果(不写缓存)
│ │
│ 否
│ ▼
└─ Cache.put(key, result) ──► 返回结果
核心实现类:CacheAspectSupport、CacheInterceptor,通过 @EnableCaching 开启,底层由 AnnotationCacheOperationSource 解析注解元数据。
2. 实际项目中的具体应用(代码示例)
2.1 项目原貌
java
@RestController
@RequestMapping("/app")
public class AppController {
@Resource
private AppService appService;
@PostMapping("/good/list/page/vo")
@Cacheable(
value = "good_app_page",
key = "T(com.yupi.yuaicodemother.utils.CacheKeyUtils).generateKey(#appQueryRequest)",
condition = "#appQueryRequest.pageNum <= 10"
)
public BaseResponse<Page<AppVO>> listGoodAppVOByPage(
@RequestBody AppQueryRequest appQueryRequest) {
ThrowUtils.throwIf(appQueryRequest == null, ErrorCode.PARAMS_ERROR);
long pageSize = appQueryRequest.getPageSize();
ThrowUtils.throwIf(pageSize > 20, ErrorCode.PARAMS_ERROR, "每页最多查询 20 个应用");
long pageNum = appQueryRequest.getPageNum();
appQueryRequest.setPriority(AppConstant.GOOD_APP_PRIORITY);
QueryWrapper queryWrapper = appService.getQueryWrapper(appQueryRequest);
Page<App> appPage = appService.page(Page.of(pageNum, pageSize), queryWrapper);
Page<AppVO> appVOPage = new Page<>(pageNum, pageSize, appPage.getTotalRow());
List<AppVO> appVOList = appService.getAppVOList(appPage.getRecords());
appVOPage.setRecords(appVOList);
return ResultUtils.success(appVOPage);
}
}
2.2 配套的缓存基础设施
java
@Configuration
public class RedisCacheManagerConfig {
@Resource
private RedisConnectionFactory redisConnectionFactory;
@Bean
public CacheManager cacheManager() {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(30))
.disableCachingNullValues()
.serializeKeysWith(RedisSerializationContext.SerializationPair
.fromSerializer(new StringRedisSerializer()));
return RedisCacheManager.builder(redisConnectionFactory)
.cacheDefaults(defaultConfig)
// 精选应用列表单独 5 分钟
.withCacheConfiguration("good_app_page",
defaultConfig.entryTtl(Duration.ofMinutes(5)))
.build();
}
}
启动类需开启缓存:
java
@SpringBootApplication
@EnableCaching
public class YuAiCodeMotherApplication { ... }
2.3 缓存 Key 实际形态
最终落到 Redis 的 key 形如:
good_app_page::d41d8cd98f00b204e9800998ecf8427e
└─ 前缀(value + '::') └─ MD5(请求体JSON)
TTL = 5 分钟,序列化为 JSON(取决于是否打开 value 序列化器配置)。
3. 优点与潜在缺点分析
3.1 优点
| 优点 | 说明 |
|---|---|
| 声明式、低侵入 | 业务方法保持纯净,缓存与业务解耦,一行注解搞定读写 |
| SpEL 表达力强 | #param、T()、#result、逻辑运算符组合,能应对绝大多数 key/condition 场景 |
| 统一抽象 | 切换 Caffeine/Redis/Ehcache 只换 CacheManager,注解零改动 |
| 细粒度 TTL | withCacheConfiguration 按命名空间配 TTL,精准控制生命周期 |
| 生态成熟 | 与 Spring Boot 自动配置、Actuator 指标、@CacheEvict/@CachePut 协同完整 |
3.2 潜在缺点
| 缺点 | 说明与示例 |
|---|---|
this 调用失效 |
同类内部方法互调不走代理,注解失效------AOP 老问题 |
| Key 生成隐式 | T(...).generateKey 隐藏在字符串里,重构类名时编译期不报错,运行时才发现 key 漂移 |
| 复杂条件难表达 | 多参数联合判断、依赖外部 Bean 的条件,SpEL 写起来很丑且难调试 |
| 缓存一致性弱 | 写操作未配 @CacheEvict 时会读到脏数据;注解本身无强一致性保证 |
| 无法细控异常 | 缓存层异常(Redis 挂)默认会向上抛,需 CacheErrorHandler 兜底,否则缓存故障拖垮业务 |
| 调试困难 | 命中与否、key 是什么,都"看不见",排查需连 Redis 手查 |
sync 限制 |
与 unless 互斥;某些实现(如早期版本)对 sync 支持不完整 |
| 序列化陷阱 | 本项目注释掉了 value 序列化器,意味着默认用 JDK 序列化------Page<AppVO> 必须 Serializable,否则报错 |
| 缓存穿透/击穿需额外处理 | @Cacheable 默认不防穿透(null 会被 disableCachingNullValues 拒写)、sync 才防击穿 |
4. 三种可替代方案总览
为完整对比,我们用三种不同思路实现「与前 10 页缓存等价」的能力:
| 方案 | 核心思想 | 控制粒度 | 侵入性 |
|---|---|---|---|
| 方案 A | 编程式缓存 :RedisTemplate 手动 get/put |
最细 | 高(业务代码耦合) |
| 方案 B | AOP 自定义切面 :自定义注解 + @Around |
中 | 低(注解 + 切面) |
| 方案 C | 多级缓存 + 装饰器模式:Caffeine(本地) + Redis(分布式) | 细 | 中(封装层) |
5. 替代方案的完整实现与对比
方案 A:编程式缓存(RedisTemplate)
思路:彻底放弃注解,在业务方法内显式 get/put,所有逻辑可视、可控。
java
@Service
public class AppCacheService {
@Resource
private RedisTemplate<String, Object> redisTemplate;
@Resource
private AppService appService;
private static final String CACHE_PREFIX = "good_app_page::";
private static final Duration TTL = Duration.ofMinutes(5);
private static final long MAX_CACHE_PAGE = 10;
@SuppressWarnings("unchecked")
public BaseResponse<Page<AppVO>> listGoodAppVOByPage(AppQueryRequest req) {
ThrowUtils.throwIf(req == null, ErrorCode.PARAMS_ERROR);
ThrowUtils.throwIf(req.getPageSize() > 20, ErrorCode.PARAMS_ERROR, "每页最多查询 20 个应用");
// 1. 条件判断:仅前 10 页缓存
boolean cacheable = req.getPageNum() <= MAX_CACHE_PAGE;
String key = CACHE_PREFIX + CacheKeyUtils.generateKey(req);
// 2. 查缓存(仅缓存范围内)
if (cacheable) {
Object cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
return (BaseResponse<Page<AppVO>>) cached;
}
}
// 3. 执行业务
BaseResponse<Page<AppVO>> result = doQuery(req);
// 4. 写缓存(空结果不写,防穿透可改写空占位)
if (cacheable && result != null && result.getData() != null
&& result.getData().getTotalRow() > 0) {
redisTemplate.opsForValue().set(key, result, TTL);
}
return result;
}
private BaseResponse<Page<AppVO>> doQuery(AppQueryRequest req) {
long pageNum = req.getPageNum();
req.setPriority(AppConstant.GOOD_APP_PRIORITY);
QueryWrapper qw = appService.getQueryWrapper(req);
Page<App> appPage = appService.page(Page.of(pageNum, req.getPageSize()), qw);
Page<AppVO> voPage = new Page<>(pageNum, req.getPageSize(), appPage.getTotalRow());
voPage.setRecords(appService.getAppVOList(appPage.getRecords()));
return ResultUtils.success(voPage);
}
}
为防 Redis 故障拖垮业务,加兜底:
java
@Configuration
public class SafeRedisCacheErrorHandler extends CachingConfigurerSupport {
@Override
public CacheErrorHandler errorHandler() {
return new CacheErrorHandler() {
@Override public void handleCacheGetError(RuntimeException e, Cache c, Object k) { /* log */ }
@Override public void handleCachePutError(RuntimeException e, Cache c, Object k, Object v) {}
@Override public void handleCacheEvictError(RuntimeException e, Cache c, Object k) {}
@Override public void handleCacheClearError(RuntimeException e, Cache c) {}
};
}
}
方案 B:AOP 自定义切面(自定义注解)
思路 :仿照 @Cacheable 写一个轻量注解 @SmartCache,用 @Around 统一处理,把"前 N 页才缓存"等规则参数化。
① 自定义注解
java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface SmartCache {
String namespace(); // 缓存命名空间
String keyExpr(); // SpEL key 表达式
String conditionExpr() default ""; // SpEL 条件
String unlessExpr() default ""; // SpEL unless
long ttlSeconds() default 300; // TTL
boolean sync() default false; // 是否同步
}
② 切面实现
java
@Aspect
@Component
public class SmartCacheAspect {
@Resource
private RedisTemplate<String, Object> redisTemplate;
@Resource
private CacheExpressionEvaluator evaluator; // 封装 SpEL 解析
@Around("@annotation(smartCache)")
public Object around(ProceedingJoinPoint pjp, SmartCache smartCache) throws Throwable {
MethodSignature sig = (MethodSignature) pjp.getSignature();
Method method = sig.getMethod();
Object[] args = pjp.getArgs();
// 1. 解析 key 与 condition
String key = smartCache.namespace() + "::"
+ evaluator.eval(smartCache.keyExpr(), method, args, String.class);
boolean condition = smartCache.conditionExpr().isEmpty()
|| evaluator.eval(smartCache.conditionExpr(), method, args, Boolean.class);
// 2. 条件不满足直接放行
if (!condition) {
return pjp.proceed();
}
// 3. 查缓存
Object cached;
try {
cached = redisTemplate.opsForValue().get(key);
} catch (Exception e) {
// 缓存故障降级:直接执行业务,不向上抛
return pjp.proceed();
}
if (cached != null) {
return cached;
}
// 4. 执行业务(sync 时可在此处加 Redis 分布式锁)
Object result = pjp.proceed();
// 5. unless 判断
boolean unless = !smartCache.unlessExpr().isEmpty()
&& evaluator.eval(smartCache.unlessExpr(), method, args, Boolean.class,
new HashMap<>(Map.of("#result", result)));
if (!unless && result != null) {
try {
redisTemplate.opsForValue().set(key, result,
Duration.ofSeconds(smartCache.ttlSeconds()));
} catch (Exception ignore) { /* 降级 */ }
}
return result;
}
}
③ 使用方式
java
@PostMapping("/good/list/page/vo")
@SmartCache(
namespace = "good_app_page",
keyExpr = "#appQueryRequest",
conditionExpr = "#appQueryRequest.pageNum <= 10",
unlessExpr = "#result.data.totalRow < 1",
ttlSeconds = 300
)
public BaseResponse<Page<AppVO>> listGoodAppVOByPage(
@RequestBody AppQueryRequest appQueryRequest) { ... }
④ SpEL 求值器(核心片段)
java
@Component
public class CacheExpressionEvaluator {
private final ExpressionParser parser = new SpelExpressionParser();
private final ParameterNameDiscoverer pnd = new DefaultParameterNameDiscoverer();
@SuppressWarnings("unchecked")
public <T> T eval(String expr, Method method, Object[] args, Class<T> retType) {
return eval(expr, method, args, retType, Collections.emptyMap());
}
@SuppressWarnings("unchecked")
public <T> T eval(String expr, Method method, Object[] args, Class<T> retType,
Map<String, Object> extraVars) {
EvaluationContext ctx = new StandardEvaluationContext();
String[] names = pnd.getParameterNames(method);
if (names != null) {
for (int i = 0; i < names.length; i++) {
ctx.setVariable(names[i], args[i]);
}
}
extraVars.forEach(ctx::setVariable);
return parser.parseExpression(expr).getValue(ctx, retType);
}
}
此方案本质是把 Spring Cache 抽象「再发明」一遍,但可定制任意规则(如基于用户角色动态 TTL、灰度缓存等)。
方案 C:多级缓存 + 装饰器模式(Caffeine + Redis)
思路:L1 用 Caffeine 进程内缓存(亚微秒),L2 用 Redis(跨实例共享),通过装饰器模式组合,兼具低延迟与一致性。
① 抽象缓存接口
java
public interface MultiLevelCache {
<T> T get(String key, Class<T> type);
void put(String key, Object value, Duration ttl);
void evict(String key);
}
② L1 本地缓存(Caffeine)
java
@Component
public class CaffeineLevelCache implements MultiLevelCache {
private final com.github.benmanes.caffeine.cache.Cache<String, CacheEntry> cache =
Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(1)) // 本地更短,保证尽快感知 Redis 更新
.recordStats()
.build();
@Override
@SuppressWarnings("unchecked")
public <T> T get(String key, Class<T> type) {
CacheEntry e = cache.getIfPresent(key);
if (e == null || e.isExpired()) return null;
return (T) e.value;
}
@Override
public void put(String key, Object value, Duration ttl) {
cache.put(key, new CacheEntry(value, System.currentTimeMillis() + ttl.toMillis()));
}
@Override
public void evict(String key) { cache.invalidate(key); }
private record CacheEntry(Object value, long expireAt) {
boolean isExpired() { return System.currentTimeMillis() > expireAt; }
}
}
③ L2 分布式缓存(Redis)+ 装饰器组合
java
@Component
public class RedisLevelCache implements MultiLevelCache {
@Resource
private RedisTemplate<String, Object> redisTemplate;
@Override @SuppressWarnings("unchecked")
public <T> T get(String key, Class<T> type) {
return (T) redisTemplate.opsForValue().get(key);
}
@Override
public void put(String key, Object value, Duration ttl) {
redisTemplate.opsForValue().set(key, value, ttl);
}
@Override
public void evict(String key) { redisTemplate.delete(key); }
}
④ 装饰器:两级联动
java
@Component
public class TwoLevelCache implements MultiLevelCache {
@Resource private CaffeineLevelCache l1;
@Resource private RedisLevelCache l2;
@Override
public <T> T get(String key, Class<T> type) {
// 1. L1
T v = l1.get(key, type);
if (v != null) return v;
// 2. L2
v = l2.get(key, type);
if (v != null) {
l1.put(key, v, Duration.ofMinutes(1)); // 回填 L1
}
return v;
}
@Override
public void put(String key, Object value, Duration ttl) {
l2.put(key, value, ttl);
l1.put(key, value, Duration.ofMinutes(1));
}
@Override
public void evict(String key) {
// 一致性:Redis 发布失效消息,各节点订阅后清 L1(此处略)
l2.evict(key);
l1.evict(key);
}
}
⑤ 业务调用
java
@Service
public class GoodAppQueryService {
@Resource private TwoLevelCache cache;
@Resource private AppService appService;
public BaseResponse<Page<AppVO>> list(AppQueryRequest req) {
if (req.getPageNum() > 10) return doQuery(req);
String key = "good_app_page::" + CacheKeyUtils.generateKey(req);
BaseResponse<Page<AppVO>> cached = cache.get(key, BaseResponse.class);
if (cached != null) return cached;
BaseResponse<Page<AppVO>> result = doQuery(req);
if (result != null && result.getData() != null && result.getData().getTotalRow() > 0) {
cache.put(key, result, Duration.ofMinutes(5));
}
return result;
}
// doQuery(...) 同方案 A
}
一致性保证:写操作触发
cache.evict(key),并通过 Redis Pub/Sub 广播让所有节点失效本地 L1,避免脏读。
5.x 横向对比
| 维度 | @Cacheable(原方案) |
A. 编程式 | B. AOP 切面 | C. 多级缓存 |
|---|---|---|---|---|
| 读延迟(命中) | Redis 一次 RTT(~1ms) | 同左 | 同左 | L1 命中 ~1μs,未命中同左 |
| 开发效率 | ★★★★★ 一行注解 | ★★ 显式繁琐 | ★★★ 一次开发多次复用 | ★★ 需基础设施 |
| 可读性 | ★★★★ 业务纯净 | ★★ 缓存与业务耦合 | ★★★★ 注解清晰 | ★★★ 需理解层级 |
| 可维护性 | ★★★ 字符串 SpEL 易漂移 | ★★★ 显式但散落 | ★★★★ 集中在切面 | ★★★★ 集中在缓存层 |
| 扩展性 | ★★ 受注解能力上限 | ★★★ 任意逻辑 | ★★★★★ 可加灰度/降级/限流 | ★★★★★ 可加更多层 |
| 缓存击穿防护 | sync=true(受实现限制) |
自行加锁,可控 | 切面统一加锁,可控 | 单飞模式(singleflight)可控 |
| 缓存穿透防护 | 需 unless + 占位 |
直接写空占位 | 切面统一 | 切面统一 + 布隆过滤器 |
| 缓存一致性 | 需配 @CacheEvict |
手动 evict | 切面统一 evict | 多级 evict + Pub/Sub |
| 故障降级 | 需 CacheErrorHandler |
try-catch 直接写 | 切面统一降级 | 各级 try-catch |
| 跨实例共享 | ✅ Redis | ✅ Redis | ✅ Redis | ✅ Redis + 本地加速 |
| 本地命中性能 | ❌ | ❌ | ❌ | ✅ Caffeine |
| 调试难度 | ★★ 隐式 | ★★★ 显式可断点 | ★★★ 切面可断点 | ★★★ 分层可观测 |
| 适合规模 | 中小项目 | 单点/特殊接口 | 中大型可定制项目 | 高并发大流量 |
6. 选型决策建议
不同业务场景的推荐路线:
场景 1:CRUD 后台管理系统、QPS < 1k
推荐:@Cacheable 原生注解
- 理由:开发效率压倒一切,注解一行搞定;TTL 用
withCacheConfiguration精控即可; - 注意:配
@CacheEvict保证写后清缓存;配CacheErrorHandler防 Redis 故障传染。
场景 2:热点接口需精细控制(条件复杂、依赖外部 Bean、需灰度)
推荐:方案 B(AOP 自定义切面)
- 理由:SpEL 写不下的复杂条件、动态 TTL、灰度缓存(如仅对部分用户缓存)都可参数化;
- 适合:电商首页、推荐流、运营位等"规则多变"场景。
场景 3:超高并发读、单接口 QPS > 10k、可容忍秒级不一致
推荐:方案 C(多级缓存 Caffeine + Redis)
- 理由:Redis RTT 在万级 QPS 下也是成本,本地缓存把延迟压到微秒;
- 必须:配 Pub/Sub 或 Canal 订阅做 L1 失效,否则多节点脏读;
- 适合:排行榜、热搜、精选列表(如本文
good_app_page流量爆发时)。
场景 4:特殊接口、缓存逻辑与业务深度耦合(如依赖用户鉴权结果)
推荐:方案 A(编程式缓存)
- 理由:逻辑全部显式,断点可调,最直观;
- 代价:缓存代码散落,复用性差,仅用于个别接口。
通用决策树
是否需要本地缓存加速? ── 是 ──► 方案 C 多级缓存
│否
▼
缓存规则是否超过 @Cacheable 表达力?
│是 ──► 方案 B AOP 切面
│否
▼
是否需要缓存逻辑与业务强耦合/可调试?
│是 ──► 方案 A 编程式
│否 ──► @Cacheable 原生注解(首选)
通用工程实践补充
- Key 规范 :统一前缀
业务:实体:维度,避免命名空间冲突;本项目good_app_page::md5可读性略差,建议app:good:page:md5。 - TTL 加随机抖动 :
TTL + random(0, 60s)防止缓存雪崩同时失效。 - 空值占位:穿透高发接口对 null 写短 TTL(30s)的空标记。
- 监控 :Caffeine
recordStats()+ RedisINFO接入 Prometheus,命中率低于 50% 就要反思 key 策略。 - 序列化 :生产环境推荐打开
GenericJackson2JsonRedisSerializer并启用默认类型,比 JDK 序列化可读、跨语言友好(本项目注释掉了,是已知改进点)。 this调用陷阱 :注解失效首排查项;必要时用AopContext.currentProxy()或拆分 Service。- 写后读一致性:先写 DB 再 evict 缓存(延迟双删更稳:evict → sleep → evict)。