在 Spring Boot 项目里引入缓存后,很多团队遇到的第一个问题不是怎么把数据放进缓存,而是怎么在数据变更时把旧缓存正确地清掉。如果一个用户更新了自己的昵称,但缓存里还是旧昵称,这类脏数据问题会让用户直接质疑系统的可靠性。Spring Cache 抽象层提供的 @CacheEvict 注解就是专门用来解决缓存清除问题的,它可以把一条或多条缓存数据从缓存容器里移除。本文结合实际项目经验,详细讲解 @CacheEvict 的各个属性、典型用法以及容易踩的坑。

一、@CacheEvict 的核心属性详解
@CacheEvict 注解的定义并不复杂,但它的几个属性直接决定了缓存清除的行为。先看一个最基础的使用示例:
@Service
public class UserService {
// 查询时写入缓存
@Cacheable(value = "user", key = "#id")
public User getUserById(Long id) {
return userMapper.selectById(id);
}
// 更新时清除对应 key 的缓存
@CacheEvict(value = "user", key = "#id")
public void updateUser(Long id, User user) {
userMapper.updateById(id, user);
}
}上面这段代码中,@Cacheable 和 @CacheEvict 使用了相同的缓存名称 user 和相同的 key 表达式 #id,这保证了写缓存和清缓存操作指向同一条缓存记录。key 属性支持 SpEL 表达式,可以引用方法参数、调用结果等,例如 #user.id 表示取参数 user 的 id 字段作为缓存的键。
真正需要重点理解的是 allEntries 属性。当缓存清除操作无法精确定位到某一条缓存时,比如批量删除用户的方法,可以把 allEntries 设置为 true,表示清空整个缓存名称下的所有条目:
@CacheEvict(value = "user", allEntries = true)
public void deleteAllUsers() {
userMapper.deleteAll();
}需要注意的是,allEntries 为 true 时不能再指定 key 属性,否则启动阶段或运行阶段会抛出异常。另外一个细节是,开启 allEntries 后 Spring 会遍历删除该缓存下的所有 key,如果使用 Redis 作为缓存实现,某些版本底层会执行 keys 命令扫描,在大 key 量的场景下可能造成 Redis 阻塞,这一点在后面章节还会展开讨论。
beforeInvocation 属性也是一个容易被忽视的点。默认值为 false,表示在方法成功执行完毕后才清除缓存;如果方法执行过程中抛出异常,缓存不会被清除。把它设置为 true 则表示方法执行前就先清缓存,无论方法成功与否。两种方式各有适用场景:对于更新操作,通常保持默认即可,因为方法失败时数据没变,缓存也就没必要清;但如果方法内部逻辑复杂、抛异常概率高,且旧缓存确实已经不可信,那么 beforeInvocation = true 会更安全。
二、@CacheEvict 与 @CachePut 的区别和选择
很多初学者会把 @CacheEvict 和 @CachePut 搞混,两者都能配合数据更新操作使用,但语义完全不同。@CachePut 的作用是执行方法并把返回值放进缓存,相当于更新缓存;而 @CacheEvict 是删除缓存,下次查询时再重新加载。两种策略的对比可以用下面的表格来梳理:
| 对比项 | @CachePut | @CacheEvict |
|---|---|---|
| 缓存操作 | 用方法返回值更新缓存 | 删除指定缓存条目 |
| 查询缓存 | 不查询,每次都执行方法 | 不查询 |
| 典型场景 | 更新后立即需要最新数据 | 更新后允许缓存短暂未命中 |
| 风险点 | 返回值与缓存 key 不匹配时缓存污染 | 清除不彻底导致脏数据 |
从工程实践来看,删缓存加懒加载的组合更稳妥。因为更新缓存的方案要求方法返回值能够完整覆盖缓存中的数据结构,一旦返回对象不完整,就会把残缺数据写进缓存。而删除缓存的方案即使出现短暂的缓存未命中,也只是多查一次数据库,不会产生错误数据。这也是业界常说的 Cache Aside 模式中推荐先更新数据库再删缓存的原因。
还要提醒一点,@CacheEvict 和 @CachePut 都是每次都会执行方法的注解,它们不会像 @Cacheable 那样命中缓存就跳过方法执行。如果把 @CacheEvict 放在一个纯查询方法上,既没有意义,还会让每次查询都触发一次缓存删除,纯属浪费。
三、整合 Redis 作为缓存实现时的完整示例
Spring Boot 默认使用内存中的 ConcurrentHashMap 作为缓存实现,只适合单机测试。生产环境一般会整合 Redis。首先引入依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>然后在配置类上开启缓存管理并做一些定制化设置,例如给缓存设置过期时间:
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(30))
.serializeValuesWith(
SerializationPair.fromSerializer(
new GenericJackson2JsonRedisSerializer()))
// key 格式:缓存名::参数值
.computePrefixWith(name -> name + "::");
return RedisCacheManager.builder(factory)
.cacheDefaults(config)
.withCacheConfiguration("user",
config.entryTtl(Duration.ofHours(2)))
.build();
}
}这个配置里给 user 缓存单独设置了两个小时的过期时间,其他缓存默认三十分钟。给缓存设置 TTL 是配合 @CacheEvict 的兜底手段:即使某次清除操作因为 Bug 被漏掉了,缓存到期后也会自动失效,脏数据的存活时间就有了上限。
使用 Redis 时还有一个与 allEntries 相关的性能问题需要留意。RedisCacheManager 在清空整个缓存时,默认采用逐个遍历 key 删除的方式。如果某个缓存名称下的 key 数量达到几十万级别,这个操作可能会持续数秒,期间还会占用 Redis 的 CPU。对此的改进方案是利用 Redis 的缓存空值或者按业务维度拆分缓存名称,让每个缓存下的 key 数量保持可控;也可以通过自定义 RedisCacheWriter 使用 SCAN 命令替代全量遍历。
四、常见踩坑点与排查思路
第一个坑是 key 表达式不一致导致清不掉缓存。写缓存的 key 是 #id,清缓存时写成了 #user.id,但方法参数里根本没有 user,结果表达式解析失败或者解析出错误的 key。建议在团队规范中约定:同一个实体的缓存 key 表达式统一定义,必要时通过自定义 KeyGenerator 来固化 key 生成逻辑,避免每个人手写表达式带来偏差。
第二个坑是同类内部方法调用导致注解失效。Spring Cache 基于动态代理实现,如果在同一个类里,方法 A 直接调用带 @CacheEvict 的方法 B,这次调用不会走代理,注解完全不生效。解决办法要么把方法拆到另一个 Bean 中,要么注入自身代理对象再调用。这是 Spring AOP 的通用限制,不只是缓存注解会遇到。
第三个坑是缓存清理的顺序问题。推荐的做法是先执行数据库更新,再执行缓存删除。如果反过来,在高并发下可能出现这种情况:请求一删除缓存后,请求二查库拿到旧数据(事务还没提交),把旧数据写回缓存,随后请求一提交事务,缓存里就永久留下了旧数据。虽然概率低,但排查起来非常困难。除了调整顺序,还可以通过延迟双删、设置较短 TTL、或者引入消息队列异步删除等方式进一步降低风险。
最后一点,排查缓存清除问题时可以打开 Spring Cache 的日志,或者直接用 redis-cli 的 MONITOR 命令观察 DEL 命令是否发出、目标 key 是否正确。很多时候问题不在框架,而在 key 拼接细节上,肉眼盯一下实际的 key 字符串往往比看代码更快定位问题。
总结
@CacheEvict 看似只是一个简单的注解,实际落地时需要考虑 key 设计、allEntries 的性能影响、beforeInvocation 的异常语义,以及和数据库操作顺序的配合。核心原则可以归纳为一句话:写缓存和清缓存必须指向同一个 key,更新数据库成功后再删缓存,同时用 TTL 做兜底。把这几个细节处理好,缓存方案就能在性能和数据一致性之间取得比较好的平衡。
Spring BootCacheEvictSpring Cache修改时间:2026-09-09 08:12:43