Spring Cache五大注解详解:从入门到缓存实战避坑指南

做后端开发的,几乎都见过这种场景:QPS 稍微上来一点,数据库连接先报警,然后代码里开始到处塞缓存逻辑。读的时候先查 Redis 再查库,写的时候先更库再删缓存,每个方法前面都要手写一坨缓存模板代码,业务逻辑还没写几句,缓存代码倒是占了大半屏,而且换个缓存中间件还得全局搜索替换。Spring 缓存注解就是用来解决这个痛点的,基于 Spring Cache 抽象层,@Cacheable、@CachePut、@CacheEvict、@Caching、@CacheConfig这五个核心注解基本覆盖了日常开发里九成以上的缓存场景。这篇文章会把每个注解的常用属性拆开讲透,比如 key 怎么用 SpEL 生成、condition 和 unless 有什么区别、allEntries 和 beforeInvocation 会在什么场景下坑你一次,再配合一个基于 Redis 的完整可运行示例。适合两类人看:刚接触 Spring Cache 想快点上手的新人,以及用了一两年注解但总是在缓存失效、key 冲突、序列化乱码这些暗坑里翻车的老同学。

1. 为什么 Spring 缓存注解能帮你少写一半缓存代码

1.1 手写缓存代码到底疼在哪里

先回想一下没有 Spring Cache 之前我们是怎么写缓存的。一个查询用户的方法,逻辑往往长得差不多:

public User getUserById(Long id) { String key = "user:" + id; // 1.查缓存 Object cached = redisTemplate.opsForValue().get(key); if (cached != null) { return (User) cached; } // 2.缓存没有,查数据库 User user = userMapper.selectById(id); // 3.回填缓存 if (user != null) { redisTemplate.opsForValue().set(key, JSON.toJSONString(user), 30, TimeUnit.MINUTES); } return user; }

这个写法的问题不是“能不能跑”,而是它把缓存行为和业务查询逻辑强行耦合在了一个方法里。每多一个查询接口,就要复制一遍这段模板;缓存中间件从 Redis 换成 Caffeine,所有方法都要动;key 的生成规则散落在各个方法里,时间长了根本没有办法统一管理。本地缓存、分布式缓存、多级缓存这些更复杂的策略更是想都不要想。说白了,这种代码在前期能顶一阵子,一旦项目进入维护期,“查缓存、回填缓存”这部分代码就成了纯粹的噪音。

Spring Cache 做的事情,就是把“缓存读写”这个横切逻辑从业务方法里抽出来。业务方法只管查数据库、返回结果,至于结果要不要缓存、缓存 key 是什么、过期时间多久,全部通过注解声明。框架在方法调用前后自动完成缓存的读取、写入和清理,开发者不需要关心缓存的底层实现到底是 ConcurrentMap、Redis 还是 Caffeine。

1.2 理解缓存抽象的三个角色

要会用注解,建议先花两分钟理解 Spring Cache 抽象层里的三个角色,概念通了,很多属性配置就是水到渠成的事情。

第一个角色是CacheManager,负责缓存区域的管理者。一个应用里可以有多个 CacheManager,比如一个本地 Caffeine 一个 Redis,也可以只有一个。它的职责是创建并管理所有Cache实例。

第二个角色是Cache,代表一个具体命名的缓存区域。这个概念容易理解成“一个缓存”,其实更准确地说它是“一类缓存”。比如我们把用户信息放user:detail,把用户列表放user:list,这是两个不同的 Cache 区域。每个 Cache 区域内部才是具体的 key-value。为什么要区分区域?因为不同业务的 key 可能同名,分开存放能天然隔离,也方便单独设置过期时间、单独清空。

第三个角色就是注解本身。@Cacheable、@CachePut、@CacheEvict这些注解是开发者跟缓存打交道的主要入口。Spring 通过 AOP 拦截被注解标注的方法,在方法前后织入缓存逻辑。理解到这一层,后面的属性配置就只是“查手册”了。

一个最简单的缓存用法,只需要三步:在启动类加@EnableCaching开启缓存能力,在方法上加@Cacheable(cacheNames = "user", key = "#id"),然后配置一个可用的 CacheManager,比如 Spring Boot 默认的ConcurrentMapCacheManager,或者集成 Redis。一个查询方法就具备“先查缓存、未命中再查库、查完回填缓存”的能力了。

2. 五大核心注解属性拆解,每个属性都有示例

2.1 @Cacheable:查询缓存的主力,六个属性一次说透

@Cacheable是使用频率最高的缓存注解,它标注在查询方法上,表示“方法结果需要缓存”。请求进来时,Spring 先根据缓存区域和 key 去缓存里查,命中就直接返回结果,不再执行方法体;未命中才执行方法,并把返回值写进缓存。

先看一个完整示例,再逐个属性拆解:

@Cacheable( cacheNames = "user:detail", key = "#id", condition = "#id > 0", unless = "#result == null", sync = true ) public User getUserById(Long id) { return userMapper.selectById(id); }

第一个属性是cacheNames,也可以写成value,两者等价。它指定结果放到哪个缓存区域,支持数组,也就是一个方法的结果可以同时写入多个缓存区域。这个属性是必填的,不填启动不会报错,但运行时无法确定放进哪个 Cache 区域,等于没写。建议在项目里统一使用带业务前缀的名字,比如user:detail、order:list,可读性比user、order好很多,排查问题的时候能省不少时间。

第二个属性是key,默认情况下 Spring 使用 SimpleKeyGenerator,把方法的所有参数拼成一个 key。如果方法只有一个参数,key 就是这个参数本身;如果有多个参数,key 就是参数组合。绝大多数场景我们不满足于默认,比如getUserById(Long id)没问题,但getUserByName(String name, String tenant)这种多参数方法,默认把所有参数都拼进去,可能会导致缓存粒度过细或者 key 重复。用key = "#tenant + ':' + #name"就能自定义成业务期望的形式。

key 属性支持 Spring 表达式语言(SpEL),有几个内置对象和变量非常常用:#root.methodName取方法名,#root.targetClass取目标类,#参数名取方法入参,#result取方法返回值。比如key = "#root.methodName + ':' + #id"就是把方法名和参数拼在一起,能避免两个不同方法使用相同缓存区域时 key 冲突。不过更推荐的做法是直接在缓存区域上区分业务,区域不同基本不会冲突。

第三个属性是keyGenerator,这是一个自定义 key 生成器。它和key是互斥的,两者同时指定会报错。什么时候需要自定义?当项目里 key 的生成规则非常统一,比如都要拼接系统标识、租户 ID,每个方法都写 SpEL 容易遗漏且难维护。实现KeyGenerator接口,重写generate方法,然后在注解里指定keyGenerator = "customKeyGenerator"即可。注意这里的值不是类名,而是 Bean 名称。

第四个属性是condition,方法调用前评估的布尔条件。condition 为 true 才执行方法的缓存逻辑,为 false 则方法正常执行,但结果不进缓存。例如condition = "#id > 0",id 小于等于 0 的请求直接放过,不查缓存也不写缓存。这个属性适合给参数加前置校验,比如有些查询参数是空字符串或默认值,完全没有缓存意义。

第五个属性是unless,和 condition 看起来像,实际触发时机不同。unless 是在方法调用完成后,根据返回值#result来评估的。unless = "#result == null"的意思是“当返回结果为空时不缓存”,这在逻辑上很自然。但要注意,这意味着 null 结果每次都穿透到数据库。如果你的接口容易被恶意刷,null 也是一种攻击放大信号,后面第四章会详细说空值缓存的问题。

第六个属性是sync,这是解决缓存击穿的一个开关。默认 false,多个线程同时请求一个未命中的 key,都会去执行方法查库。开启sync = true后,同一个 key 的并发请求只有一个线程会真正执行方法,其他线程等待结果。代价是 sync 模式下不能与 unless 一起使用,因为 unless 要执行完方法才能评估,而 sync 的语义是提前锁住 key 避免并发重复查询,两个逻辑有冲突,Spring 会直接报错。

@Cacheable还有一个冷门但实用的属性cacheManager,当应用里配置了多个 CacheManager 时,用它指定当前方法走哪个缓存管理器。比如本地缓存和 Redis 缓存混合的场景,不同方法可以选择不同缓存介质。

2.2 @CachePut:只写缓存不读缓存,更新数据时同步刷新

@CachePut和@Cacheable长得一样,但语义完全相反。它标注的方法每次都会执行,执行完把返回值写入缓存。它存在的意义是解决“数据更新后缓存怎么刷新”的问题。

打个比方,@Cacheable是“先看冰箱里有没有菜,没有就去买菜并放进去”,@CachePut是“直接买菜放进冰箱,下一次再看冰箱”。所以@CachePut模式的接口每次调用都会触碰数据库,适合新增、更新这类操作执行后同步刷缓存。

@CachePut(cacheNames = "user:detail", key = "#user.id") public User updateUser(User user) { userMapper.updateById(user); return user; }

这里有一个很容易忽略的坑:@CachePut写入缓存的是方法的返回值。如果更新方法返回值是 void 或者不是完整对象,缓存里存的就可能是 null 或残缺对象,下次查询直接翻车。我在项目里见过有人把 update 方法写成public void updateUser(User user),然后在方法里调用了 cacheManager 显式写缓存才避开问题。在 Spring Cache 的声明式编程里,返回值的类型和完整性直接决定缓存质量,这一点务必注意。

@CachePut支持的属性和@Cacheable几乎一致,包括 cacheNames、key、condition、unless,也支持 SpEL。不过 sync 对它没有意义,官方文档也没有要求。同类的key = "#user.id"写法,要保证更新操作传入的 user 对象里主键不为空,否则会把一个没意义的 key 写进缓存。

2.3 @CacheEvict:删除缓存也有讲究

有写入就有清理,@CacheEvict就是干这个的。它标注在方法上,方法执行后(或执行前),删除指定缓存区域中的指定 key,或者清空整个缓存区域。

@CacheEvict(cacheNames = "user:detail", key = "#id") public void deleteUser(Long id) { userMapper.deleteById(id); }

基础用法很简单,但有两个属性非常关键,理解不深很容易出生产事故。

第一个是allEntries,默认 false,表示只删除 key 对应的缓存条目;设为 true 表示清空该缓存区域的所有缓存。什么时候用 true?当缓存区域里的 key 无法穷举,或者数据批量变更导致大量 key 失效时。比如用户列表缓存,删除一个用户会影响列表数据,但列表缓存的 key 可能是page=1&size=10这种组合,没法精确定位到某个 key,干脆allEntries = true把整个区域清掉。代价就是清空瞬间所有请求都会打到数据库,要做好这个心理准备。

第二个是beforeInvocation,默认 false,表示方法成功执行完之后再删缓存;设为 true 表示方法执行之前就删缓存。默认行为有个隐患:如果方法抛异常,缓存就不会被删除,此时数据库没变、缓存还是旧数据,短期内会读到脏数据。那是不是设成 true 就万事大吉了?也不是。beforeInvocation=true 时,即使方法后续抛异常,缓存也已经删掉了,下一次查询会重新加载数据,效果等于是“宁可多查一次库,也不留脏缓存”。对于删除操作,我个人的习惯是删除方法返回值不是关键时用默认的 false 即可,因为如果数据库都删失败了,旧缓存留着反而能兜底(虽然也是脏数据),但删除缓存失败造成下次查库才是更大的损耗。更稳妥的做法是配合事务同步,这一点在第四章展开。

@CacheEvict同样支持多个缓存区域数组清理,比如cacheNames = {"user:detail", "user:list"}。注意它与@CachePut的区别:@CacheEvict只看缓存操作成功与否,与方法的返回值没有约束关系,所以删除方法写成 void 完全没问题。

2.4 @Caching:一个方法要组合多个缓存操作时就靠它

有些场景一个方法既想更新 A 缓存,又想删除 B 缓存,比如更新用户信息之后,用户详情缓存要刷新,用户列表缓存要清空。一个注解显然不够用,@Caching就是把多个缓存注解组合在一起的聚合注解。

@Caching( put = { @CachePut(cacheNames = "user:detail", key = "#user.id") }, evict = { @CacheEvict(cacheNames = "user:list", allEntries = true) } ) public User updateUserAndRefreshCache(User user) { userMapper.updateById(user); return user; }

@Caching内部有三个数组属性:cacheable放@Cacheable,put放@CachePut,evict放@CacheEvict。组合起来的使用场景非常灵活。要注意的是,放进去的每个注解都会独立生效,它们的 key、condition、unless 都是独立配置的,彼此不干扰。

我见过的一个坑是有人在@Caching里放多个@CachePut,结果 key 写成了同一个,后面的覆盖前面的,白写了一遍。组合注解在使用前要自己想清楚:同一个缓存区域内,多个 put 操作到底是想合并还是覆盖?覆盖是默认行为,所以 key 尽量不要重复。

@Caching看起来解决了很多复杂场景,但我也要泼一盆冷水:如果发现一个方法上的缓存注解组合超过三个以上,缓存逻辑就有点复杂了,优先考虑是不是缓存区域的划分有问题。把缓存区域设计得更细一点,比如列表缓存和详情缓存分开管理,大部分场景用一两个注解就够了。

2.5 @CacheConfig:类级别配置,减少重复代码

前几个注解都是加在方法上的。如果一个类里所有方法都要用同一个缓存区域,每个方法写一遍cacheNames = "user:detail"就太啰嗦了。@CacheConfig就是用来做类级别的公共配置的。

@CacheConfig(cacheNames = "user:detail", keyGenerator = "customKeyGenerator") @Service public class UserService { @Cacheable(key = "#id") public User getUserById(Long id) { ... } }

@CacheConfig支持的属性包括 cacheNames、keyGenerator、cacheManager、cacheResolver。类级别的配置生效后,方法上的注解可以省略相同属性的配置。如果方法上也写了同样的属性,方法级别的配置优先,这种就近覆盖的原则和 Spring 的配置优先级思想一致。

这里容易有一个理解偏差:@CacheConfig里写的 cacheNames 是“默认缓存区域”,不是把类里所有缓存都用这个区域。方法上可以用@Cacheable(cacheNames = "其他区域")自己覆盖。如果方法上没写 cacheNames 而类上写了,就沿用类上的;如果两个都没写,运行时会因为找不到缓存区域直接报错。

顺带说一句,@CacheConfig的 keyGenerator 如果指定了,它会让这个类里的所有缓存注解都用自定义生成器,除非方法上单独指定 key 属性。key 和 keyGenerator 的互斥规则在这里同样生效,类级别指定了 keyGenerator 后,方法里千万不能再写 key,否则启动会报配置冲突。

2.6 三个核心注解的属性对比速查

我整理了@Cacheable、@CachePut、@CacheEvict最常用的属性对比,方便日常查阅:

属性@Cacheable@CachePut@CacheEvict作用说明
cacheNames / value必填必填必填缓存区域,支持数组
keySpEL 指定SpEL 指定SpEL 指定缓存 key,默认按参数生成
keyGenerator自定义 Bean自定义 Bean自定义 Bean与 key 互斥
condition方法调用前判断方法调用前判断方法调用前判断为 true 才执行缓存逻辑
unless方法调用后按返回值判断同左无为 true 则不缓存
sync支持不支持无同步阻塞防止击穿
allEntries无无支持清空整个缓存区域
beforeInvocation无无支持方法调用前删缓存

这个表格建议收藏,面试问 Spring Cache 底层原理也能答出个大概:@Cacheable是“读多写少”场景的主力,@CachePut是“写后刷新”,@CacheEvict是“写后失效”。三者的核心区别就一句话:Cacheable 可能不执行方法,CachePut 一定执行方法,CacheEvict 只清理不查询。

3. 从零搭建一个基于 Redis 的 Spring Cache 实战示例

3.1 场景设计与依赖搭建

理论讲再多,不如一个完整的例子直观。我拿一个典型的用户服务来说明。业务场景有三个接口:

  • 按 ID 查询用户详情,高频查询,需要缓存
  • 更新用户信息,需要刷新用户详情缓存,同时清理用户列表缓存
  • 删除用户,需要清理用户详情缓存

这种场景在企业系统里非常普遍,比如后台用户管理、会员中心、订单详情,套路完全一致。基础环境用 Spring Boot 2.7 或 3.x 都可以,Spring Cache 的注解 API 没有太大变化。先在 pom 里引入依赖:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <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>

Redis 连接信息在 application.yml 里配置,基本的 host、port、password 按实际环境来。注意 Spring Boot 2.x 默认的 Redis 客户端是 Lettuce,对于绝大多数业务系统性能足够了,不需要额外引入 Jedis。

启动类上加@EnableCaching是必须的,不加注解再全也不会有任何缓存行为。这个注解会导入缓存相关的自动配置,并开启 AOP 代理,这是整套机制生效的前提。

@SpringBootApplication @EnableCaching public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }

3.2 配置 RedisCacheManager,解决序列化和过期时间

@EnableCaching只是开启能力,具体用哪个缓存管理器需要配置。Spring Boot 的 Redis Cache 自动配置会生成一个默认的RedisCacheManager,但默认配置有个大坑:序列化方式用的是 JDK 序列化,缓存到 Redis 里的值是一串二进制乱码,肉眼不可读,排查问题非常痛苦。所以项目落地时几乎都要自定义序列化。

@Configuration public class CacheConfig { @Bean public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) { RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(30)) .prefixCacheNameWith("myapp:") .serializeKeysWith(RedisSerializationContext.SerializationPair .fromSerializer(new StringRedisSerializer())) .serializeValuesWith(RedisSerializationContext.SerializationPair .fromSerializer(RedisSerializer.json())); return RedisCacheManager.builder(connectionFactory) .cacheDefaults(config) .withCacheConfiguration("user:list", RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(5)) .prefixCacheNameWith("myapp:") .serializeKeysWith(RedisSerializationContext.SerializationPair .fromSerializer(new StringRedisSerializer())) .serializeValuesWith(RedisSerializationContext.SerializationPair .fromSerializer(RedisSerializer.json()))) .build(); } }

这段配置里的几个细节值得单独说明。

第一,.prefixCacheNameWith("myapp:")会为所有 key 统一加上项目前缀,这样不同应用共用 Redis 时不会互相覆盖。这个前缀会在实际 key 中体现,比如myapp:user:detail::1,注意 Spring Cache 默认用两个冒号分隔缓存区域和 key,这是它的默认 key 格式。

第二,.entryTtl(Duration.ofMinutes(30))是全局默认过期时间。不同业务对过期时间的要求不同,比如用户列表可能只需要 5 分钟的强一致,用户详情可以放宽到 30 分钟。用.withCacheConfiguration为特定缓存区域单独覆盖 TTL 是推荐做法,比所有缓存一刀切更合理。

第三,StringRedisSerializer序列化 key,RedisSerializer.json()序列化 value。这样 Redis 里存的是可读的 JSON 字符串,对排查问题非常友好。需要注意,JSON 序列化要求缓存的对象有可用的无参构造函数和 getter/setter,绝大多数 POJO 都能满足。如果你的对象里有 LocalDateTime 这类时间类型,建议在 ObjectMapper 里注册 JavaTimeModule,否则序列化会报错,这是一个容易踩的隐藏坑。

3.3 Service 层的缓存注解实战

依赖和配置就绪后,写业务代码。用户实体:

public class User { private Long id; private String name; private Integer age; // getter/setter 省略 }

UserService 核心逻辑:

@Service public class UserService { @Autowired private UserMapper userMapper; @Cacheable(cacheNames = "user:detail", key = "#id", sync = true) public User getUserById(Long id) { System.out.println("查询数据库获取用户:" + id); return userMapper.selectById(id); } @Caching( put = { @CachePut(cacheNames = "user:detail", key = "#user.id") }, evict = { @CacheEvict(cacheNames = "user:list", allEntries = true) } ) public User updateUser(User user) { userMapper.updateById(user); return user; } @CacheEvict(cacheNames = "user:detail", key = "#id") public void deleteUser(Long id) { userMapper.deleteById(id); } }

这套代码跑起来之后,第一次调用getUserById(1)会打印“查询数据库获取用户:1”,第二次调用不会打印,直接在 Redis 命中缓存。调用updateUser后,用户详情缓存会被覆盖成最新数据,同时用户列表缓存被整体清空。调用deleteUser后,对应用户详情缓存被移除。

这个示例的逻辑链路非常清晰,推荐作为 Spring Cache 的入门模板。其中@CachePut和@CacheEvict组合通过@Caching实现了“详情刷新 + 列表失效”,真实项目中这种场景出现的频率极高,直接抄作业就行。

Controller 层就没什么特殊了,正常的 RestController 调用 UserService 的方法即可。有一点值得提醒:缓存注解要加在 Service 层方法上,不要加到 Controller 方法上。原因在于 Service 层是业务逻辑的边界,一个 Service 方法可能被多个 Controller 复用,缓存写在 Service 上才能保证所有入口都命中同一份缓存。如果写在 Controller 上,不同 Controller 的缓存互相独立,等于缓存白做了。

4. 缓存失效、穿透、序列化问题排查实录

4.1 同类内部调用导致缓存注解失效

这是 Spring Cache 使用中排名第一的暗坑,很多老手都踩过。在同一个类里,一个方法调用另一个被@Cacheable标注的方法时,注解不生效。

@Service public class UserService { public User getUserWithVerify(Long id) { // 这里的 getUserById 是 this.getUserById,不经代理 User user = getUserById(id); return user; } @Cacheable(cacheNames = "user:detail", key = "#id") public User getUserById(Long id) { return userMapper.selectById(id); } }

原因在于 Spring Cache 基于 AOP 代理实现,代理对象只对“外部调用”生效。同类里的this.getUserById()直接走原对象的方法,绕过了代理,缓存逻辑自然被跳过。解决方式有三种:

第一种最直接,把被缓存的方法拆到另一个 Bean 里,比如建一个UserCacheService,注入它再调用。这符合职责单一的思路,也绕开了代理问题,我推荐这种。

第二种是注入自身代理,Spring 支持@Autowired @Lazy注入一个本类代理到自己属性中,然后通过代理调用。这种方式能解决同类自调用,但会有循环依赖隐患,代码可读性也一般。

第三种是从 AopContext 获取代理,需要启动@EnableAspectJAutoProxy(exposeProxy = true),然后((UserService) AopContext.currentProxy()).getUserById(id)。这个方法能在不改结构的情况下解决自调用,但侵入性不小,而且强制要求开启 exposeProxy,平时不太建议。

怎么排查这个问题?最简单的方法是看日志:如果第二次调用同一个 id 时,方法里的 SQL 日志还是打出来了,十有八九是走了自调用。

4.2 缓存穿透:null 结果不缓存,恶意请求直接打穿库

前一章提到unless = "#result == null"虽然符合直觉,但它有个副作用:恶意请求一个不存在的 id 时,每次都会穿透缓存查数据库。攻击者只要循环传不存在的 id,缓存就永远命中不了,数据库压力直接拉满。这就是典型的缓存穿透。

要解决穿透,一个成熟的做法是把空值也缓存起来。但 Spring Cache 默认不缓存 null,因为 null 代表“方法没有返回对象”。我们需要在代码层面把空值包一层,或者借助Optional。Spring Cache 在较新版本中对Optional有特殊处理:如果方法返回Optional.empty(),框架会特殊标记,不会对Optional本身做序列化,但 null 依然不会缓存。

我实际项目中更常用的方案是:查询接口在 Service 内做一层约束,当方法返回 null 时,构造一个“空对象”返回,并设置较短的 TTL。比如User为空时返回一个User对象,id 为 null,名称是空字符串,缓存 3 到 5 分钟。这样至少把穿透放大效应限制住了,代价是空对象会占用一点缓存空间,但收益远大于成本。

如果系统对缓存穿透特别敏感,还可以在缓存前加一层布隆过滤器,把所有存在的 id 预加载到过滤器里,不存在直接拦截。布隆过滤器方案适合 id 集合稳定、查询量极大的场景,引入成本稍高,中小项目未必值得。

还有一个细节:Spring Cache 的condition和unless都能部分缓解穿透,但它们严格来说只是“过滤”不是“防御”。真正要防穿透,靠的还是空值缓存和过滤器这类工程手段。

4.3 缓存与数据库一致性:先删缓存还是先更库

用 Spring Cache 注解天然会面对一致性问题。一个经典场景:更新用户信息时,如果直接@CacheEvict删除缓存,但数据库更新失败,缓存被删了,下次查询会重新加载旧数据,然后缓存里又是旧值,风险不大;反过来,如果数据库更新成功,但缓存删除失败,那缓存里就一直留着旧数据,用户看到的是过期信息。

解决一致性的主流思路是“先更新数据库,再删除缓存”,这也是很多公司内部推广的准则。Spring Cache 默认的@CacheEvict行为是方法成功执行后删除缓存,在纯 Service 场景下基本能满足这个顺序。但一旦方法加了@Transactional事务,事情就变复杂了。因为 Spring Cache 的缓存清理发生在方法返回后、事务提交前还是提交后,存在时序问题。如果缓存删除在事务提交前完成,删除后事务又回滚了,此时缓存被删,数据库还是旧值,下一次查询加载回旧数据,一致性被轻微破坏,问题不大;更大的隐患是,缓存删除发生在事务提交之前,事务还没提交其他线程已经读到旧缓存,不可控窗口被拉长。

更稳妥的做法是把缓存操作纳入事务同步。Spring 提供了TransactionAwareCacheManagerDecorator,它把 CacheManager 包装一层,能让缓存操作延迟到事务提交之后再执行,保证“数据库先变、缓存后变”。用法很简单:

@Bean public CacheManager cacheManager(RedisConnectionFactory connectionFactory) { RedisCacheManager base = ...; return new TransactionAwareCacheManagerDecorator(base); }

这个细节很容易被忽略,但事务和缓存同时出现时强烈建议加上。

另一个被讨论得比较多的是“延迟双删”:先删缓存,再更新数据库,再延迟几百毫秒删一次缓存。这种做法能解决并发场景下旧缓存被回填的问题,但实现成本高、延迟时间不好确定,我一般不建议在中小项目里硬上。先更新库再删缓存,配合 Redis 主从同步和短暂的缓存过期时间,已经能满足绝大多数业务的一致性要求了。

4.4 Redis 里的 Key 是乱码,如何配置序列化器

这个问题的定位成本很低,看到 Redis 里 key 变成\xAC\xED\x00\x05t\x00开头的一串乱码,基本就是用了默认的 JDK 序列化。JDK 序列化既浪费空间,又不可读,线上想手动删一个 key 都难以下手。

解决办法就是第三章已经展示过的配置:key 用StringRedisSerializer,value 用 JSON 序列化器。注意两个序列化器要同时设置,只设置其中一个容易报错。有些文章只贴了serializeValuesWith,忽略 key 的序列化器,结果 key 仍是乱的,因为配置没全改。

如果项目里用的是 Spring Boot,启动日志有时候会显示默认RedisCacheManager,说明你的自定义配置没有生效。排查顺序是:确认是否配置类被扫描到、确认@Bean方法返回类型是否为RedisCacheManager、确认配置类上没有多余的@ConditionalOnMissingBean干扰。

JSON 序列化后的 value 在 Redis 里是一段 JSON 字符串,比如{"id":1,"name":"张三","age":20},看到这种数据才说明配置成功。还有一个冷门提醒:JSON 序列化会把类型信息写进缓存,如果前后端对象的包名或字段发生变化,旧缓存里的类型信息和当前代码对不上,反序列化可能报错。为了平滑升级,一般要控制好缓存 TTL,或者上线前做一次缓存清空。

4.5 常见问题速查表与几个实战习惯

再花点篇幅把这类问题整理成速查表,方便复制到项目 Wiki 里:

现象可能原因解决思路
缓存注解完全不生效启动类没加 @EnableCaching检查注解和 AOP 代理
同类调用不掉缓存自调用绕过代理拆 Bean、自注入代理
key 是乱码JDK 序列化配置 StringRedisSerializer
value 不可读默认 JSON 配置覆盖不彻底检查 RedisCacheManager 自定义
缓存穿透打爆 DBnull 不缓存缓存空对象、布隆过滤器
更新后缓存还是旧的缓存删除失败或顺序不对先更库再删缓存、事务同步
多个线程查同一个 key未开启 sync设置 sync = true
缓存区域 key 冲突没有业务前缀用 cacheNames 区分 + prefixCacheNameWith

最后分享几个我长期维护项目时沉淀下来的习惯。第一个,所有 cacheNames 统一使用“业务域:子域”的命名风格,比如user:detail、order:list,前缀和项目名无关,只和业务有关,这样清理缓存时一眼能分辨归属。第二个,给每一个缓存区域想清楚 TTL,不设置 TTL 的缓存基本等于慢性毒药,数据变更逻辑一旦漏掉,脏数据能存活到地老天荒。第三条,代码 review 阶段,如果发现一个方法上的缓存注解超过三个,先停下来讨论一下缓存区域设计是不是有问题,而不是直接让 @Caching 把逻辑堆上去。第四条,也是最重要的一条——多环境一定要区分缓存隔离。测试环境可以配置spring.cache.type=none直接关闭缓存,让测试同事不受残留缓存干扰,上线时再开启。Spring Cache 的抽象层让这个开关变得异常简单,用一个配置项就能切换“全缓存”和“无缓存”两种模式,这是手写缓存代码永远给不了你的便利。

相关新闻

基于粒子群算法的电力系统最优潮流经济调度模型

基于粒子群算法的电力系统最优潮流经济调度模型

电力系统的经济调度&#xff0c;说白了就一个问题&#xff1a;负荷就在那儿摆着&#xff0c;各台发电机组出多少力&#xff0c;才能既满足安全约束又把总成本压到最低&#xff1f;这句话听着简单&#xff0c;真上手写模型的时候就会发现&#xff0c;负荷平衡、节点电压、线路容…

2026/10/20 7:23:00 阅读更多 →
把对话模型塞进机器猫:低配开发板部署Jev的实战记录

把对话模型塞进机器猫:低配开发板部署Jev的实战记录

最近的状态有点魔怔。白天写技术方案&#xff0c;晚上回家还在折腾一个听起来像玩物丧志的项目&#xff1a;我想把一个叫 Jev 的对话模型&#xff0c;塞进桌面上那只机器猫的脑壳里。准确说&#xff0c;这不是一只真猫&#xff0c;而是我攒的一套猫头造型桌面机器人套件&#x…

2026/10/20 7:23:00 阅读更多 →
对话式Agent已死?智能界面:模型直接生成UI的新范式

对话式Agent已死?智能界面:模型直接生成UI的新范式

1. 从“对话式Agent”到“智能界面”的范式转移1.1 为什么说“对话式Agent没戏了”并非危言耸听过去两年&#xff0c;整个行业几乎把“对话式Agent”当成了大模型落地的唯一形态。你打开任何一个AI产品&#xff0c;看到的都是一个聊天框&#xff0c;用户输入一句话&#xff0c;…

2026/10/20 7:23:00 阅读更多 →

最新新闻

RAG面试高频考点全解析:从检索到生成的优化链路

RAG面试高频考点全解析:从检索到生成的优化链路

1. 为什么面试官揪着RAG不放&#xff1a;先把这个动机吃透我最近在系统梳理大模型方向的面试题&#xff0c;RAG&#xff08;Retrieval-Augmented Generation&#xff0c;检索增强生成&#xff09;几乎是每家必问的模块。尤其是做应用层项目的岗位&#xff0c;RAG的提问密度比微…

2026/10/20 8:14:30 阅读更多 →
机票电商平台重构实战:领域拆分与异步订单链路设计

机票电商平台重构实战:领域拆分与异步订单链路设计

简介&#xff1a;eyoo旅游机票电商平台&#xff08;重构版&#xff09;是一份基于Ruby与Rails技术构建的在线机票销售系统项目资源&#xff0c;面向Ruby后端开发者、电商平台学习者以及关注系统重构优化的人群。项目覆盖业务逻辑处理、ActiveRecord数据库操作、RESTful API接口…

2026/10/20 8:14:30 阅读更多 →
Docker容器化实战指南:从DockerFile到Compose编排全解析

Docker容器化实战指南:从DockerFile到Compose编排全解析

1. 先从一张架构图说起&#xff1a;为什么我们绕不开Docker我的个人项目X&#xff0c;从零开始做全栈实践。最初只开了一个云服务器&#xff0c;上面跑着数据库、缓存、定时任务&#xff0c;后来要加前后端服务&#xff0c;每个环境都折腾一遍依赖。结果不到三个月&#xff0c;…

2026/10/20 8:14:30 阅读更多 →
PHP借贷源码拆解与本地部署:从业务闭环到避坑实践

PHP借贷源码拆解与本地部署:从业务闭环到避坑实践

简介&#xff1a;一套基于 PHP 实现的借贷公司/网贷平台整站源码&#xff0c;面向需要快速搭建在线借贷系统的技术团队、中小型网贷项目开发者和 PHP 学习者。源码在得得系统基础上完成改编&#xff0c;并经过实际测试可用&#xff0c;覆盖用户注册、信用评估、借款申请、还款计…

2026/10/20 8:14:30 阅读更多 →
实战推演:用 esp32-iphone-agent 的思路,给自己 iPhone 做个“自动化开关“,可行吗

实战推演:用 esp32-iphone-agent 的思路,给自己 iPhone 做个“自动化开关“,可行吗

实战推演&#xff1a;用 esp32-iphone-agent 的思路&#xff0c;给自己 iPhone 做个"自动化开关"&#xff0c;可行吗 【免费下载链接】esp32-iphone-agent Let an AI model use a real iPhone with a $7 ESP32: AirPlay eyes, USB HID hands, any OpenAI-compatible …

2026/10/20 8:14:30 阅读更多 →
Agent上线就崩?从状态管理到可观测性,拆解工程化落地的五大核心难题

Agent上线就崩?从状态管理到可观测性,拆解工程化落地的五大核心难题

1. 演示与上线之间的鸿沟到底在哪做 Agent 项目的人大概都经历过这种场面&#xff1a;内部演示的时候&#xff0c;Agent 流畅地理解需求、调用工具、多步推理、最后给出一个漂亮的答案&#xff0c;会议室里一片掌声。然后老板说“下周上线吧”&#xff0c;结果一上线&#xff0…

2026/10/20 8:13:30 阅读更多 →

日新闻

CLRC663芯片Pin-to-Pin替代实战:从评估到量产全记录

CLRC663芯片Pin-to-Pin替代实战:从评估到量产全记录

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 1:01:30 阅读更多 →
ESP32舵机控制指南:原理、多路驱动与避坑实战

ESP32舵机控制指南:原理、多路驱动与避坑实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 1:01:30 阅读更多 →
位移法实现一阶低通滤波:单片机ADC采样降噪实战

位移法实现一阶低通滤波:单片机ADC采样降噪实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 1:01:30 阅读更多 →

周新闻

基于GMM的语音识别系统原理与MATLAB实现详解

基于GMM的语音识别系统原理与MATLAB实现详解

说到基于高斯混合模型&#xff08;GMM&#xff09;的语音识别系统&#xff0c;很多人第一反应就是一堆数学公式&#xff0c;觉得门槛很高。但如果你真的在MATLAB里把它落地跑通&#xff0c;会发现核心其实就是三件事&#xff1a;把声音变成特征向量&#xff0c;用GMM给每个类别…

2026/10/19 10:02:04 阅读更多 →
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理这四个字听起来挺唬人&#xff0c;但落到具体操作上&#xff0c;无非就是让计算机按你的想法“摆弄”像素。我在一个模拟项目X里用了一套特别经典的基础处理流程&#xff1a;对图像做反白、改调色板、彩色转灰度&#xff0c;最后把像素数据导出成txt文件。单看每一…

2026/10/19 17:32:41 阅读更多 →
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

机器人大脑技术盘点:具身智能的感知、决策与数据闭环

如果你的信息流最近也天天刷到“机器人大脑”这个词&#xff0c;你会发现一个很有意思的现象&#xff1a;每个人说的“大脑”根本不是同一个东西。有人指的是大语言模型&#xff0c;有人指的是运动控制系统&#xff0c;还有人指的是能理解物理世界的端到端模型。在2026年这个时…

2026/10/20 2:50:50 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/19 2:54:50 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/19 9:10:22 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/18 12:16:16 阅读更多 →