1. 为什么不能直接在每个接口里手写缓存代码:存量系统的困境
先把场景说清楚。去年我接手一个运营后台系统,接口 RT 从几十毫秒一路飙到两秒,数据库连接池被打满,DBA 半夜给我发截图——慢查询列表里一眼望过去全是那几个高频查询。业务方催得急,开发周期又短,最直接的办法当然是加缓存。但问题来了:这个项目是老代码,Service 层逻辑写得七零八落,有的接口还夹着各种第三方调用,你让我一个个去改方法内部逻辑塞缓存代码,风险极大。
很多人第一反应是"那就用 Spring Cache 啊,@Cacheable 码上加码"。Spring Cache 确实是个好东西,但它处理不了我们这里的几个实际问题:一是缓存过期时间不够灵活,不同接口需要不同 TTL;二是 key 前缀散乱,想按业务维度统一管理很费劲;三是缓存不可用时不能直接拖垮主流程,需要降级处理。所以我当时选了一条折中路线:自己做一个轻量注解,专门给存量接口"贴标签",让缓存逻辑和业务逻辑彻底分离。
这个方案的核心价值就三个字:非侵入。原有的方法签名不动,业务代码不动,只需要在方法上增加一个注解,缓存逻辑由统一切面处理。对团队来说,新成员上手也快——不用理解每个接口内部怎么写的,只看注解就知道有没有缓存、缓存多久。这种做法非常适合"给现有接口加缓存"这类改造场景,而不是从零开始的新项目。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 注解缓存背后的关键机制:AOP 代理与缓存抽象
2.1 Spring Cache 是原理样板,但不是最终答案
要理解自定义缓存注解,先要搞清楚 Spring Cache 是怎么实现的。@Cacheable 之所以能被"感知",靠的是 Spring AOP 动态代理。容器里注册的 Bean 在初始化阶段会生成一个代理对象,外部调用方法时经过代理,代理在方法执行前检查缓存,命中就直接返回,没命中才执行真实方法,再把返回值放进缓存。整个过程对业务代码完全透明。
明白了这个原理,你就能理解为什么"直接调方法内部"绕开了缓存——代理只对"外部调用"有效。
Spring Cache 其实已经帮我解决了一部分问题:CacheManager 抽象、CacheInterceptor、KeyGenerator、Condition/Unless 表达式解析。但我最终没有直接用它,原因很现实:它的一些默认行为在存量系统改造里不好控制。比如缓存空值需要额外配置,缓存名的粒度粗,TTL 只能按 CacheManager 级别统一设置,代码里一旦用了 @Cacheable(cacheNames="user", key="#userId"),想单独给某个方法设置 10 秒、给另一个方法设置 30 分钟,就很别扭。Spring Boot 2.x 之后虽然可以用 CacheManagerCustomizer 做个性化配置,但配置代码一点都不少,还不如自己做一个切面,想怎么控制就怎么控制。
2.2 代理模式与注解生效的前提条件
这里必须强调一个概念:Spring 容器中的代理机制。Spring Boot 默认开启 CGLIB 代理(spring.aop.proxy-target-class=true),也就是说被代理类会生成一个子类,子类重写目标方法,在方法前后插入拦截逻辑。这个子类是被 Spring 管理的,而你在 @Service 类内部用 this.xxx() 调用,走的是原始对象的方法,不是代理对象的方法——所以注解失效。
还有一点:要求方法必须是 public 的。CGLIB 对 protected 和 private 方法的处理很有限,注解处理器不一定拦得到。所以我给出的方案里,强制要求加缓存注解的方法必须是 public,调用方必须通过 Spring Bean 注入。
code复制调用链路示例:
外部调用 -> Spring代理对象 -> 缓存切面(ApiCacheAspect) -> 真实方法
如果外部调用 -> 真实对象,则切面不参与,注解不生效
2.3 缓存抽象层:CacheManager 与 Cache
我在自定义注解的时候,复用了 Spring 缓存抽象,但把实现简化了。核心就三样东西:
CacheManager:管理多个 Cache,可以通过getCache("userCache")获取指定缓存。Cache:一个命名的存储桶,可以往里放 key/value,也可以取、删。Cache.ValueWrapper:取缓存时的包装类,避免缓存值本身为 null 时无法区分"有缓存但值是 null"和"没有缓存"。
我自己做注解的时候,没有直接用 CacheManager,而是直接操作 StringRedisTemplate。因为我要处理的是 Redis 缓存,而 Redis 的操作远比 Cache 接口的 get/put 丰富:可以设置过期时间、可以做增量操作、可以模糊删除。如果你只需要本地缓存,可以用 CacheManager 来适配;但如果对接 Redis,直接操作 RedisTemplate 反而灵活得多。
3. 手写一个可落地的 @ApiCache 注解:从参数设计到切面实现
3.1 注解参数设计:要满足什么场景
动手写注解前,先想清楚需要哪些配置项。我总结下来,一个能应对存量系统的接口缓存注解,至少要支持以下参数:
| 参数 | 作用 | 默认值 | 说明 |
|---|---|---|---|
cacheName |
缓存分区 | "api" |
用于管理,不同业务用不同分区 |
key |
缓存 key,支持 SpEL | "" |
空时用方法全限定名+参数拼接 |
ttl |
过期时间数值 | 300 |
过期时间长度 |
unit |
过期时间单位 | TimeUnit.SECONDS |
支持秒/分钟/小时等 |
cacheEmpty |
是否缓存空值 | true |
开启可防穿透 |
condition |
满足条件才写缓存 | "" |
同样用 SpEL 表达式 |
key 设计是最重要的。缓存 key 粒度太粗会被不同参数互相覆盖,太细则缓存命中率低下。我推荐的做法是:方法全限定名 + 业务唯一标识。比如 getUserById 方法,key 可以写成 "user:getUserById:" + #id,用一个统一前缀区分不同系统。前缀最好在注解里别写死,通过配置中心下发,方便以后迁移 Redis 集群时批量改 key。
3.2 注解定义与切面完整实现
先看注解定义:
java复制@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ApiCache {
/** 缓存分区名称,默认 api */
String cacheName() default "api";
/** 动态 key,支持 SpEL 表达式,如 "#id"、"'user:' + #user.id" */
String key() default "";
/** 过期时间数值,默认 300 */
long ttl() default 300L;
/** 过期时间单位,默认秒 */
TimeUnit unit() de
