ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring缓存抽象与@Cacheable注解:SpEL表达式及Redis实战

Spring缓存抽象与@Cacheable注解:SpEL表达式及Redis实战

做了七八年后端开发,缓存几乎是每天都要打交道的东西。一旦项目里的读接口多了,流量稍微上来一点,最先扛不住的一定是数据库。很多人一提缓存就想到Redis,脑子里全是set、get这两个命令。但真正到了生产环境,尤其是复杂key组装、多环境切换、多级缓存、缓存和DB一致性这些场景时,直接用RedisTemplate散弹式写业务代码,维护成本会迅速失控。Spring的Cache抽象,配合@Cacheable那一组常用注解,再加上SpEL表达式来动态生成key和条件,才是真正值得花时间搞明白的部分。今天就不绕弯子,结合我实际踩过的坑,把这三个东西从原理到实战拆开聊清楚。

1. 为什么我建议你先理解Cache抽象,再写注解

很多培训班出身的同学上手Spring Cache特别快,加一个@Cacheable就能用,运行也正常。但一旦遇到缓存不生效、缓存穿透、多数据源、Redis序列化报错这类问题,就完全没有排查方向。根子在于你不理解Spring到底在“抽象”什么。先把这个地基打牢,后面所有注解和SpEL都只是工具。

1.1 从一段最原始的缓存代码说起

最早的缓存逻辑长什么样?大概是这样:

public OrderInfo getOrderById(String orderId) { String key = "order:" + orderId; Object cached = redisTemplate.opsForValue().get(key); if (cached != null) { return (OrderInfo) cached; } OrderInfo order = orderMapper.selectByOrderId(orderId); if (order != null) { redisTemplate.opsForValue().set(key, order, 30, TimeUnit.MINUTES); } return order; }

这段代码的问题不是运行不了,而是业务方法里混了大量非业务逻辑。每写一个需要缓存的方法,你都要重复“查缓存、判断、查DB、写缓存”这一套模板代码。等接口多起来,key规则不统一、TTL不统一、序列化方式不统一的问题全都会冒出来。更麻烦的是,你很难在不改业务代码的前提下切换缓存组件——今天用Redis,明天要换成本地Caffeine,这个模板代码几乎得重写。

Spring Cache抽象就是为了干掉这堆重复劳动。它把“缓存”这个动作提升为声明式能力,你只需要在方法上加注解,框架在调用方法之前自动去Cache里查,查不到再执行方法体,执行完后自动把结果放回Cache。业务代码里不再出现任何RedisTemplate的痕迹。

1.2 Cache抽象到底抽象了什么

要理解Spring Cache抽象,就要抓住三个核心角色:

  • Cache:一个缓存实例的抽象,对应一个具体的缓存区域,比如“用户缓存”“订单缓存”。它定义了对缓存的基本操作:get、put、evict、clear。这就是一个通用的Map行为,不关心底层是Redis还是ConcurrentHashMap。
  • CacheManager:管理所有Cache实例的工厂。它负责根据缓存名称创建并返回对应的Cache。一个应用里可以同时配置多个CacheManager,比如本地缓存一个、Redis缓存一个,然后用@Cacheable(cacheManager = "...")指定使用哪一个。
  • 注解与切面:@Cacheable、@CacheEvict、@CachePut这些注解只是声明,真正执行逻辑的是Spring AOP拦截器。拦截器拿到注解上的value、key、condition这些属性后,通过CacheManager拿到Cache实例,再执行缓存读写。

你可以把整个流程理解成一个“门卫”:方法被调用时,门卫先看这个方法的缓存key在不在Cache里,在就直接返回,不在就放你进方法执行,执行完再把返回值存进Cache。门卫后面到底是Redis还是内存,业务代码完全不需要关心。

1.3 缓存抽象解决的真实痛点

这套抽象最有价值的一点是让缓存策略与业务方法解耦。说个最常见的变化:某天你发现某个缓存命中率太低,想从Redis换成Caffeine本地缓存,或者想改成二级缓存。如果之前用的是原生RedisTemplate,这个改造会涉及几十个方法;但如果用的Spring Cache抽象,你只需要调整CacheManager的Bean配置,注解代码一行不动。

另一个痛点是key统一管理。在没有抽象之前,订单缓存的key可能是"order:" + orderId,另一个同学写的用户缓存key可能是"user_" + userId,分隔符、前缀风格完全随缘。用注解加SpEL之后,你可以在配置层面统一设计前缀规范,比如约定所有缓存key都以业务域:实体:字段格式生成,后续排查和清理都方便得多。

2. 四个核心注解的语义拆解与实战姿势

注解本身不难记,难的是搞清楚它们在什么时机执行、返回值如何影响缓存、异常怎么处理。很多人把@Cacheable和@CachePut当成同一个东西用,结果缓存要么永远不更新,要么方法每次都被执行,性能和一致性双双崩掉。下面逐个拆。

2.1 @Cacheable:读缓存的标准姿势

@Cacheable是使用频率最高的注解,它表达的含义是“如果缓存里有就直接用,没有就执行方法并把结果写入缓存”。

@Cacheable(value = "userCache", key = "'user:id:' + #userId") public User getUserById(String userId) { return userMapper.selectByUserId(userId); }

这里的value或cacheNames指定缓存区域,key用SpEL表达式动态生成。不写key时Spring默认用方法参数生成key,但如果参数有多个,默认KeyGenerator的规则不直观,我建议所有关键地方都显式写key。

有几个细节容易踩坑:

  • 方法内部自调用不生效。AOP代理只对外部调用生效,同类内部this.getUserById()绕过了代理,注解完全失效。解决办法是拆到另一个Bean,或者自己注入代理对象,但最推荐的是把缓存逻辑放到独立Service层。
  • @Cacheable的缓存写入发生在目标方法返回之后,如果方法内部抛异常,不会写缓存。
  • 如果目标方法返回null,默认情况下缓存不会写入,因为很多底层缓存不允许存空值。这个后面讲Redis配置时会展开。
  • sync = true可以开启底层缓存的同步锁,让同一个key同时只有一个线程去执行方法,是解决缓存击穿的一个重要手段。但要注意:本地缓存和Redis对sync的实现机制不同,RedisCacheManager底层用的是Redis分布式锁,并发场景下会有额外网络开销,不过通常可以接受。

2.2 @CacheEvict:删缓存的三类选择

缓存删除比写入更需要设计感。@CacheEvict用在删除数据或更新数据后清理缓存。最简单的用法是:

@CacheEvict(value = "userCache", key = "'user:id:' + #userId") public void deleteUser(String userId) { userMapper.deleteByUserId(userId); }

这个注解有两个关键属性:

  • allEntries = true:清空整个缓存区域,而不是只删一个key。适合“批量修改导致大量key失效”的场景。但清空Redis缓存会对同一区域的所有key做批量删除,如果key很多会有性能损耗,而且生产环境直接清空某类缓存往往意味着缓存命中率瞬间归零,要谨慎使用。
  • beforeInvocation = true:默认情况下,@CacheEvict在目标方法执行完之后才清缓存。如果方法抛异常,缓存不会被清除。设置beforeInvocation = true可以在方法执行前先删缓存,保证即使方法失败,旧缓存也被清理掉了。这个属性在“更新DB但不确定一定能成功”的场景非常关键。

实战中我更推荐一个模式:更新数据库时,不要把旧数据改写成新数据塞进缓存,而是直接删除缓存,等下一次读请求再回源加载。原因很简单,删缓存是一个幂等操作,而写缓存需要保证数据正确性和原子性,一旦写缓存和更新DB之间出现并发,很容易留下脏数据。

2.3 @CachePut:写缓存与更新陷阱

@CachePut的作用是“方法总是执行,然后把返回值写入缓存”。它和@Cacheable最大的区别是:@Cacheable可能跳过方法执行,而@CachePut无论如何都会执行方法体。

@CachePut(value = "userCache", key = "'user:id:' + #user.getUserId()") public User saveUser(User user) { userMapper.updateByPrimaryKey(user); return user; }

很多人会在更新接口上用@CachePut,想着“更新完顺手把新值更新到缓存,省一次读回源”。这个想法本身没错,但有两个隐患:

  • @CachePut里的key基于入参生成,如果入参里没有完整的缓存标识,或者方法返回值和缓存key不一致,就会导致缓存里写入了一个对不上的数据。
  • 并发更新时,两个线程同时执行更新方法,返回顺序不确定,最终缓存里写入的值可能是旧的那个。这就是典型的“后写覆盖先写”问题。相比之下,直接删除缓存再等回源,天然规避了这个并发覆盖问题。

所以我的建议是:对于读多写少的业务,更新方法上优先使用@CacheEvict删除,而不是@CachePut写入。@CachePut更多用在那些“方法返回值必然代表最新状态、且写入过程可控”的场景。

2.4 @Caching和@CacheConfig:组合规则怎么落地

有些场景一个注解不够用。比如一个更新操作,需要删除两个不同区域的缓存,同时还要更新第三个缓存,这时可以用@Caching把它们组合起来:

@Caching( evict = { @CacheEvict(value = "orderCache", key = "'order:id:' + #orderId"), @CacheEvict(value = "userCache", key = "'user:id:' + #userId", beforeInvocation = true) }, put = { @CachePut(value = "statCache", key = "'total:order:' + #userId") } ) public Order updateOrder(String orderId, String userId) { ... }

@Caching只是聚合,语义仍然是各自独立执行。这个注解最大的价值是让你不要在同一个方法上重复写多个同类型注解——Java里同注解本来就不能直接重复标注,组合也是一种变通。

@CacheConfig是类级别的共享配置。它可以把cacheNames、keyGenerator、cacheManager这些公共属性提到类上,方法注解里就不必重复写:

@CacheConfig(cacheNames = "productCache", cacheManager = "redisCacheManager") @Service public class ProductService { @Cacheable(key = "'product:id:' + #productId") public Product getById(String productId) { ... } }

注意@CacheConfig只是默认值,方法上的显式属性会覆盖类级配置。别在这上面搞些花里胡哨的继承逻辑,保持最简单直白的对应关系最好。

3. SpEL表达式:缓存key和条件的正确打开方式

SpEL(Spring Expression Language)是Spring里用来写表达式的一套语言,玩得转它,你的缓存key和条件控制会非常灵活。很多人只会#参数名拼个key,遇到复杂对象、组合条件就卡住了。

3.1 注解里SpEL的表达上下文

在@Cacheable、@CacheEvict、@CachePut里,SpEL能访问的对象有一组内置根对象。搞懂这些根对象,就知道表达式能写什么。

属性名访问方式含义
methodName#root.methodName当前被调用方法名
method#root.method.name当前方法对象,可以用.name拿方法名
target#root.target当前目标对象实例
targetClass#root.targetClass目标对象的Class
args#root.args方法参数数组
caches#root.caches当前方法上命中的缓存数组
方法参数名#userId、#user.id直接按参数名访问方法入参
返回结果#result仅在@Cacheable的unless和@CachePut的condition/unless中可用

注意,方法参数名能否被识别取决于编译时的-parameters参数。如果IDE或构建工具没开启参数名保留,Spring拿不到参数名,这时必须以#args[0]、#args[1]的方式访问。我建议在Maven或Gradle里显式开启-parameters,否则代码在本地能跑,换个环境就报SpEL解析失败。

3.2 高频key表达式示例

代码写多了之后,你会发现常用的key规律就那几种。我实际项目里用过的:

  • 单参数固定前缀:
@Cacheable(value = "userCache", key = "'user:id:' + #userId")
  • 多个参数组合:
@Cacheable(value = "pageCache", key = "'page:list:' + #pageNum + ':' + #pageSize")
  • 对象属性作为key:
@Cacheable(value = "orderCache", key = "'order:userId:' + #query.getUserId()")
  • 带条件拼接:
@Cacheable(value = "productCache", key = "#type != null ? 'product:type:' + #type : 'product:all'")
  • 使用T表达式调用静态方法:
@Cacheable(value = "skuCache", key = "'sku:code:' + T(java.lang.String).valueOf(#code).toUpperCase()")

最后一招用静态方法处理字符串很实用,能统一缓存key的格式,比如把订单号变成大写、去空格。但别滥用,表达式复杂度上去了,排查问题时的脑负担也会增加。

还有一个安全红线:缓存key不要直接拼用户输入的大字符串。一旦某个参数值超长,Redis key会占内存,也容易被攻击。建议在key里只保留id、短编码这类可控字段。

3.3 condition与unless的边界关系

condition和unless都用来控制缓存动作,但时机完全不同。很多人把这两个搞混,导致缓存结果不符合预期。

  • condition是在方法调用之前基于入参判断,决定是否使用缓存。如果condition不成立,则既不查缓存,也不写缓存,相当于注解完全失效。
  • unless是在方法调用之后基于返回结果判断,决定是否不写入缓存。它只影响写缓存,不影响读取。

一个典型例子:低于100元的商品不走缓存。

@Cacheable(value = "productCache", key = "'product:id:' + #productId", condition = "#price >= 100", unless = "#result == null") public Product getProduct(String productId, BigDecimal price) { return productService.loadById(productId); }

这里用condition过滤低价商品,用unless防止结果为null时写入缓存。要注意unless里访问#result的前提是方法正常返回,如果方法抛异常,unless不会被执行。

日常中还有个常用组合:缓存空结果。Redis默认不能存null,那么你可以配置allowNullValues支持空值缓存,或者用unless = "#result == null"让它直接不缓存,但这样空请求每次都会打DB。更好的方案是在缓存里存一个特定空对象或空标记,后面讲穿透时会展开。

3.4 自定义KeyGenerator的补充方案

如果SpEL表达式写累了,或者key规则要全局统一,可以自定义KeyGenerator。比如默认的key生成方式是基于参数生成,但我们想要“类名+方法名+参数”的唯一格式:

@Component("customKeyGenerator") public class CustomKeyGenerator implements KeyGenerator { @Override public Object generate(Object target, Method method, Object... params) { StringBuilder sb = new StringBuilder(); sb.append(target.getClass().getSimpleName()) .append(":") .append(method.getName()); for (Object param : params) { sb.append(":").append(param); } return sb.toString(); } }

然后在注解里指定:

@Cacheable(value = "userCache", keyGenerator = "customKeyGenerator")

这里有个要注意的点:一旦指定了keyGenerator,就不能同时指定key,两者冲突。还有,KeyGenerator生成的结果会被当作最终的缓存key,如果你的缓存管理器需要对key做序列化,请确保生成对象可序列化。

我的经验是:简单场景用SpEL直接写,复杂场景或要全局统一规则时用KeyGenerator。不要每个方法一套key写法,项目半年后你再回来看,自己都想骂自己。

4. CacheManager与集成Redis时需要避开的坑

注解只是皮囊,真正干活的是底层CacheManager。Spring默认提供ConcurrentMapCacheManager,但那只适合本地调试。生产环境里用到最多的还是RedisCacheManager。切换和配置过程中,坑非常多。

4.1 内存缓存到Redis缓存的切换关键

默认的ConcurrentMapCacheManager用本地Map存值,应用重启缓存就没了,多实例部署时每个节点缓存还不一致。要切换到Redis,不是只换个CacheManagerBean那么简单,你需要理解RedisCacheManager的构建参数。

@Bean public RedisCacheManager redisCacheManager(RedisConnectionFactory connectionFactory) { RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(30)) .serializeValuesWith(RedisSerializationContext.SerializationPair .fromSerializer(new GenericJackson2JsonRedisSerializer())) .disableCachingNullValues(); return RedisCacheManager.builder(connectionFactory) .cacheDefaults(defaultConfig) .withInitialCacheConfigurations( Map.of( "userCache", RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofHours(2)), "orderCache", RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(10)) ) ) .build(); }

这个配置里.entryTtl设置默认过期时间,.disableCachingNullValues()表示默认不允许缓存空值。如果你用@Cacheable缓存了null,Redis会抛异常。要支持空值,必须改为.enableCachingNullValues(),但这不是无代价的:Redis里会存一个“空值”标记,取出时要特殊处理,且空值缓存时间过长会造成内存浪费。

还有一个容易忽略的点:withInitialCacheConfigurations里如果对userCache配置了单独的TTL,那么该类缓存的所有注解都会走这个配置,方法注解里无法再单独指定TTL。所以TTL策略最好在设计阶段就明确,而不是每个方法各定各的。

4.2 序列化导致的反序列化异常

Spring Cache默认的Redis序列化方式可能是JDK序列化或JDK Serializer,存到Redis里是一堆乱码字节。如果直接在注解缓存里放一个实体类,再用字符串工具去redis-cli查看,你基本看不懂里面是什么。更重要的是,一旦实体类结构发生字段变更,JDK反序列化直接抛异常,缓存全部打挂。

建议使用GenericJackson2JsonRedisSerializer。它可以存入对象类型信息,取出时自动转换。但这个序列化器也有坑:如果实体类里包含LocalDateTime、泛型对象,可能序列化输出带@class信息,反序列化时可能因为类型不匹配报错。更稳的方式是定义统一的DTO,而不是直接把DO丢到缓存里。比如把数据库实体和缓存对象隔离,DO变更字段不会影响线上缓存结构。

我踩过一次:把某个DO直接缓存,后来DO里加了一个字段,老数据反序列化时因为缺少新字段直接抛异常,整个查询接口不可用。从那以后我坚持缓存对象单独定义,字段用基本类型和String,不塞复杂嵌套结构。

4.3 缓存穿透、击穿、雪崩的注解侧应对

理论不多说,直接讲注解层面怎么应对:

  • 缓存穿透:查询一个不存在的id,缓存和DB都没有,每次请求都穿透到数据库。最常见做法是缓存空值。使用Spring Cache时,要么enableCachingNullValues(),要么在unless = "#result == null"的基础上手动写一个空对象缓存。我更喜欢后者,因为能精确控制空值缓存的TTL,避免空值长时间堆积。
  • 缓存击穿:热点key过期瞬间,大量并发请求同时回源。@Cacheable(sync = true)是最直接的解法。它保证同一key只有一个线程执行方法,其他线程等待结果。不过要注意本地CacheManager对sync = true的支持有限,Redis下才可靠。
  • 缓存雪崩:大量key在同一时间过期。注解本身解决不了雪崩,需要你在配置层设计TTL随机化。比如上面RedisCacheManager配置里,不为每个缓存区域设置固定TTL,而是加一个随机偏移量,比如基础TTL 30分钟,再加0到300秒的随机值。

还有一个注解层容易被忽略的细节:@Cacheable方法执行时间长,一旦缓存失效,所有线程全去执行DB查询,如果没有sync = true,就可能把DB打垮。所以对热点接口,建议宁可让一个线程重建缓存,也不要让所有线程同时回源。

4.4 事务与缓存一致性的实操经验

Spring Cache注解默认不感知事务。最常见的问题:一个事务方法里先更新了DB,又用@CacheEvict清缓存,但事务还没提交,此时来了一个新请求,正好读到旧缓存,或者清缓存后其他线程读到DB的旧值。更麻烦的是,事务方法里@CacheEvict执行了删除,但同一事务稍后回滚,DB没变,缓存已经被删了,下次读回源拿到旧数据,又会把旧数据写进缓存,造成数据一致性问题。

我推荐的方案是事务提交后清缓存。通过TransactionSynchronizationManager注册同步回调,在afterCommit阶段再执行缓存删除:

public void updateOrder(String orderId) { // 业务数据库更新 orderMapper.updateByOrderId(orderId); TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { @Override public void afterCommit() { cacheService.evict("orderCache", "order:id:" + orderId); } }); }

注意如果方法不是事务方法,TransactionSynchronizationManager.getSynchronization()为null,直接调用注册会空指针,需要先判断当前是否存在事务同步。另外,如果你的项目里同时用了事务切面和缓存切面,一定要理清两个切面的顺序。缓存切面在事务外层时,@CacheEvict可能在事务提交前执行;缓存切面在事务内层时,上面手动注册同步的方式更可靠。

5. 从能用走向好用:缓存方案的工程化清单

最后这部分不是讲具体的API,而是讲讲把缓存体系沉淀成工程资产的经验。很多人注解用得很溜,但线上缓存策略一塌糊涂,出问题只能靠重启和清缓存来“续命”。

5.1 缓存key和TTL的规划设计

我在团队里推的是“缓存key三段式规范”:业务域:实体:唯一标识。比如user:info:123456、order:detail:ORDER20250101。不要用没有业务含义的a:123这种key,也不要只写123。前缀越清晰,排查问题时用工具按前缀批量扫描越方便。

TTL规划要区分数据性质:

数据特征建议TTL典型例子
高频访问、变更极少2小时以上商品规格、系统配置
高频访问、可能偶尔变更10-30分钟用户基础信息
与DB写入强相关随业务事件清空,而不是等TTL订单详情
统计类结果5-15分钟首页报表、聚合数据

TTL不要整齐划一,加随机偏移来防雪崩。还要记得空值缓存和正常缓存的TTL分开设置,空值缓存建议1~2分钟就过期。

5.2 监控、日志与排查思路

Spring Cache注解不像原生RedisTemplate可以很方便地埋点统计。你需要自己做一层切面或者直接利用Redis的监控指标。最简单的起手式是:

  • 在Redis客户端统计每个缓存区域的hit和miss,结合命中率判断key策略是否合理。
  • 对慢查询做日志:@Cacheable方法本身执行时间超过阈值时,记录回源SQL和参数。
  • 上线前用测试脚本压一下热点key的并发回源,验证sync = true是否真的生效。

有一个排查小技巧:给缓存key统一加一个可辨识的版本号,比如user:v2:info:123456。这样发新版逻辑时不会读旧版本缓存,省得手动清理。等运行稳定后,再定期清理旧版本key。

5.3 常见问题速查表

现象可能原因处理方案
注解方法没有被缓存方法内部this自调用,绕过代理拆分Bean或注入代理对象
Redis反序列化异常JDK序列化或DTO结构变更换GenericJackson序列化器,缓存DTO不与DO耦合
缓存了null导致Redis异常默认禁用空值缓存开启空值缓存或手动缓存空标记
key全部一样导致缓存互串SpEL写错或没带参数显式写key,开启-parameters
更新DB后缓存没清事务尚未提交就清缓存事务afterCommit后再evict
缓存穿透DB被打垮空结果未缓存空值短TTL缓存 + 布隆过滤器
热点key失效瞬间拖垮DB无锁回源@Cacheable(sync = true)
多实例缓存不一致本地缓存未同步改用Redis或组件间做消息同步
注解不生效但没报错切面没扫描到配置确认 @EnableCaching 在当前配置类被加载

这张表基本覆盖了我这两年在实际项目里见到的高频问题。遇到缓存相关故障,先对照这个清单排查,能省掉很多不必要的看代码时间。

最后再分享一个我自己的小习惯:每个业务模块的缓存key,我都会抽到一个单独的常量类里集中管理,比如CacheKeys.USER_INFO、CacheKeys.ORDER_DETAIL,生成key的方法也统一封装。虽然用SpEL拼字符串看起来很灵活,但分散在几十个方法里的魔法字符串,迟早会变成事故现场。把key规则收拢到一处,以后全局改格式、加版本号,都只动一个文件。这个习惯谈不上高深,但长期坚持下来,缓存维护的幸福感会提升非常多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进