这两天排查一个接口性能问题,遇到一个很典型的现场:一个查询用户资料的接口,每天被调用几十万次,方法内部查库、做字段计算、再组装DTO,所有事情都堆在一起硬扛,数据库压力大,接口响应时间也一路飙到几百毫秒。当时的处理办法很直接——在方法上加了@Cacheable注解,配好缓存策略,接口耗时直接掉到个位数毫秒。这个注解看起来简单,真正用好它却需要理解缓存抽象的底层机制、key 设计、CacheManager 选型,以及一堆实战中才会暴露的坑。这篇文章把@Cacheable掰开揉碎讲清楚,包括核心参数、缓存配置、联动更新、常见故障排查,每一块我都会给出能直接落地的方案和代码。
1. Spring Cache 到底解决了什么问题:先搞清楚缓存抽象的设计逻辑
1.1 缓存抽象里的三个角色
很多人第一次接触 Spring Cache 时有个误解,以为@Cacheable本身是一个缓存实现,类似于 Redis 或 Caffeine。其实它只是一个注解,真正干活的是背后的 Spring Cache 抽象框架。理解这一点,你后面所有配置才不会乱。
Spring Cache 把缓存这件事拆成了三层角色:
CacheManager:缓存管理器,负责创建和管理缓存实例,你可以把它理解成“储物柜管理员”。Cache:具体的缓存容器,可能对应 Redis 中的某个 key 空间,也可能对应 Caffeine 的一个实例,相当于一排“储物柜”。- 被
@Cacheable标注的方法:业务代码,相当于“存放和取用物品的人”。
当你给方法加上@Cacheable,Spring 会在方法调用前先通过 AOP 拦截,拿着请求参数去对应的Cache里找数据。找到就直接返回,方法体根本不会执行;找不到才执行方法体,然后把结果塞进缓存。这套逻辑完全由 Spring Cache 框架接管,业务方法本身不需要写任何缓存判断代码。
1.2 注解驱动为什么比到处写 RedisTemplate 更靠谱
我见过不少项目,缓存逻辑是直接用 RedisTemplate 硬写的,每个方法里先查缓存、判断空、再查库、再回写,代码长这样:
public User getUserById(Long id) { String key = "user:" + id; User user = redisTemplate.opsForValue().get(key); if (user == null) { user = userRepository.findById(id); if (user != null) { redisTemplate.opsForValue().set(key, user, 30, TimeUnit.MINUTES); } } return user; }这种写法不是不行,但如果项目里有几十个方法都要走缓存,每个方法粘贴一份类似的模板代码,后续维护就是灾难。你改一个 key 规则要全局搜一遍,想换个缓存中间件更是伤筋动骨。
用@Cacheable重写同一个场景:
@Cacheable(cacheNames = "user", key = "#id", unless = "#result == null") public User getUserById(Long id) { return userRepository.findById(id).orElse(null); }缓存读写逻辑完全被 AOP 抽走,业务方法只关心数据库查询。以后想从本地缓存切到 Redis,不用改业务代码,只改配置;想统一调整缓存过期时间,也只需要在 CacheManager 层面动一次。这也是 Spring Cache 被大规模使用的原因:它是一种标准化的缓存接入方式,而不是某个具体缓存的绑定方案。
当然,注解驱动也有学习成本,尤其是 key 怎么写、缓存失效怎么处理、内部调用为什么不生效,这些坑后面会逐个讲。
2. @Cacheable 核心参数实战拆解:从最简用法到 SpEL 表达式
2.1 最简入口:cacheNames 和 key
先看@Cacheable最基本的属性。完整注解定义里有一堆参数,但日常使用频率最高的就这么几个:
public @interface Cacheable { String[] value() default {}; // 缓存名称,等价于 cacheNames String[] cacheNames() default {}; // 推荐使用这个,更语义化 String key() default ""; // 缓存 key 的 SpEL 表达式 String keyGenerator() default ""; // 自定义 key 生成器 String condition() default ""; // 前置条件,满足才查缓存 String unless() default ""; // 排除条件,满足就不写缓存 boolean sync() default false; // 是否同步加载 }value和cacheNames是同一个意思,都是给缓存起名字。我建议代码里统一用cacheNames,一眼就能看出这是缓存区域名称。一个缓存区域在 Redis 里通常对应一类数据的前缀,比如 user、order、product。
key决定缓存的唯一标识。Spring Cache 默认的 key 生成规则是SimpleKeyGenerator:如果方法没有参数,生成SimpleKey.EMPTY;一个参数就用参数对象本身;多个参数用所有参数组合的SimpleKey。这个默认规则在简单场景够用,但在复杂查询条件下容易产生你想不到的 key 冲突,所以实战中我基本都显式指定key。
最基础的两种写法:
@Cacheable(cacheNames = "user", key = "#id") public User getUserById(Long id) { // 根据用户id查询 } @Cacheable(cacheNames = "user", key = "#user.id") public User getUserByUser(User user) { // 根据对象属性做key }2.2 key 的 SpEL 表达式:几个必须掌握的写法
SpEL(Spring Expression Language)是 Spring 提供的一套表达式语言,#id这种写法本质就是在取值。除了直接引用方法参数,还有很多实用技巧。
常见写法整理成了一张表:
| 需求 | SpEL 写法 | 说明 |
|---|---|---|
| 单个参数 | #id | 直接引用参数名 |
| 对象属性 | #user.id | 取参数对象的属性 |
| 参数名不可用时 | #p0/#a0 | 按参数位置取值 |
| 多个参数拼接 | #id + ':' + #type | 字符串拼接 |
| 调用方法 | #user.getId() | 调用参数对象的方法 |
| 类全局对象 | #root.methodName | 当前方法名 |
| 类全局对象 | #root.targetClass | 当前类的 Class 对象 |
| 所有参数数组 | #root.args | 参数数组 |
组合 key 的实际场景很常见。比如商品查询,既要按分类又要按价格区间缓存,直观写法:
@Cacheable(cacheNames = "product", key = "#categoryId + '_' + #minPrice + '_' + #maxPrice") public List<Product> searchProducts(Long categoryId, Double minPrice, Double maxPrice) { // 查询逻辑 }这里有个细节值得注意:如果项目没有开启参数名保留,那么#categoryId这种写法在运行时可能报错,因为反射拿不到参数名。Spring Boot 项目在 Maven 编译时默认会带上-parameters参数,但有些老项目不是。出现这个问题的排查标志是报错信息里有Unknown parameter name或 SpEL 解析异常。解决办法有两个:
- 在 Maven 编译插件里把
parameters设为true,让编译后的字节码保留参数名。
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <configuration> <parameters>true</parameters> </configuration> </plugin>- 暂时改用位置参数
#p0、#p1,但这可读性太差,不推荐长期用。
写 key 还有一个容易犯的错:直接用对象本身当 key。比如key = "#user",如果这个对象没有重写 equals/hashCode,缓存 key 的计算结果跟对象引用有关,可能同一个业务主键产生多个缓存项。所以别偷懒,指定属性作为 key。
2.3 condition 和 unless:很多人搞反的两个条件
condition和unless看着很像,执行时机却完全不同。
condition:在方法执行之前判断,决定整个缓存操作是否进行。如果条件不满足,后面缓存读写都不执行,方法直接跑原始逻辑。unless:在方法执行完成后判断,主要看返回值#result,决定要不要把结果写入缓存。
我把两者的区别用一个实际场景说清楚。假设订单查询接口,普通订单可以缓存,但某种特殊订单实时性要求高不能缓存:
@Cacheable(cacheNames = "order", key = "#orderId", condition = "#orderId.startsWith('NORMAL')", unless = "#result.status == 'CLOSED'") public Order getOrder(String orderId) { // 查询订单 }这个例子中,condition先把订单号不是 NORMAL 开头的请求全部放行,根本不碰缓存;unless等对象返回后再判断,如果订单已经 CLOSED 关闭,也不写入缓存。
写unless最常见的需求是处理 null:方法返回 null 时不缓存,否则每次查询到不存在的数据都会把 null 缓存起来,下次拿到的就是过期数据。写成:
@Cacheable(cacheNames = "user", key = "#id", unless = "#result == null") public User getUserById(Long id) { return userRepository.findById(id).orElse(null); }这个写法需要在方法返回 null 时正确识别,如果你的方法返回的是 Optional,记得先转成实体,否则#result拿到的还是 Optional 对象。
2.4 sync:解决同一 key 并发击穿的简单方案
缓存击穿是指某个热点 key 过期瞬间,大量并发请求同时打到底层数据库。@Cacheable有个很实用的属性sync,把它设为 true,Spring 会用缓存实现的同步加载机制,保证同一个 key 只有一个线程真正执行方法,其余线程等待结果。
@Cacheable(cacheNames = "hotProduct", key = "#productId", sync = true) public Product getProductById(String productId) { // 大量并发进来时,这里只有一个线程会执行 return productRepository.findById(productId).orElse(null); }注意一个限制:sync=true不能和unless同时使用。原因也不难理解,sync模式下缓存是按 key 加锁加载的,方法还没执行,框架根本拿不到返回值去做unless判断,所以两个属性放一起会直接抛异常。想把空值排除又要防击穿,就得在代码里单独处理,或者选择其他方案。
3. 缓存管理器配置:从默认内存缓存到 Caffeine 和 Redis
3.1 默认缓存和 Caffeine 本地缓存
Spring Boot 项目只要引入spring-boot-starter-cache,再在启动类或配置类上加@EnableCaching,注解就能用起来。第一次用的时候你会发现什么都没配置也能跑,那是因为 Spring Boot 自动装配了ConcurrentMapCacheManager,底层就是 ConcurrentHashMap,缓存存活时间只受 JVM 内存限制。这套默认实现只适合本地测试,生产环境基本不会用,因为应用一重启缓存全丢,而且多实例部署时每个实例的缓存互相独立,数据一致性完全是问题。
生产环境选本地缓存,我通常用 Caffeine。它性能好,还支持过期、驱逐、统计等能力。配置一个CacheManager非常简单:
@Configuration @EnableCaching public class CacheConfig { @Bean public CacheManager cacheManager() { CaffeineCacheManager cacheManager = new CaffeineCacheManager("user", "product"); cacheManager.setCaffeine(Caffeine.newBuilder() .initialCapacity(100) .maximumSize(1000) .expireAfterWrite(Duration.ofMinutes(30))); return cacheManager; } }这里CaffeineCacheManager构造器里传的"user", "product"是预声明的缓存区域名称,好处是启动时就确定这些缓存规格。如果调用方用了未声明的新缓存名,CaffeineCacheManager 会按默认配置动态创建。如果你希望动态创建的缓存也套用同一套 Caffeine 规范,setCaffeine会在创建时作为模板使用。
3.2 RedisCacheManager 从零到可用
当系统有多个实例,或者希望缓存能被跨服务共享,本地缓存就不够了,需要把缓存放到 Redis。Spring Data Redis 提供了RedisCacheManager,配合RedisCacheConfiguration做详细配置。
一个比较完整的配置示例:
@Configuration @EnableCaching public class RedisCacheConfig { @Bean public RedisCacheManager redisCacheManager(RedisConnectionFactory connectionFactory) { RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig() .prefixCacheNameWith("demo:") .entryTtl(Duration.ofMinutes(30)) .serializeKeysWith(RedisSerializationContext.SerializationPair .fromSerializer(new StringRedisSerializer())) .serializeValuesWith(RedisSerializationContext.SerializationPair .fromSerializer(new GenericJackson2JsonRedisSerializer())) .disableCachingNullValues(); return RedisCacheManager.builder(connectionFactory) .cacheDefaults(defaultConfig) .withCacheConfiguration("userTtlLong", RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofHours(2))) .build(); } }逐个解释这里的配置项:
prefixCacheNameWith("demo:"):给所有 Redis key 加统一前缀,多环境部署时能避免不同环境 key 冲突。entryTtl(Duration.ofMinutes(30)):默认过期时间。不同区域可以单独覆盖,比如"userTtlLong"这个缓存区域设置了 2 小时。serializeKeysWith:key 用 String 序列化,可读性好,没问题。serializeValuesWith:value 用 JSON 序列化,存储内容能直接查看。disableCachingNullValues():禁止缓存 null。后面会专门提这个选择的影响。
在 Redis 里浏览缓存数据时,能看到类似demo:user::123这样的 key。这里的 key 结构是“前缀 + cacheNames + 分隔符 + 业务key”,Spring Boot 2.x 默认 cacheNames 和 key 之间有两个冒号,旧版本行为略有区别,排查时注意别对不上。
3.3 序列化方案的隐患
缓存写到 Redis 里,如果不管序列化方式,默认用的是 JDK 序列化。后果是数据变成一堆不可读的二进制,而且 JDK 序列化性能差、字节占用大,还可能因为类结构变化导致反序列化失败。
改用 JSON 序列化后确实好很多,但也不是没坑。有一个典型问题:如果缓存对象里带LocalDateTime这类 Java 8 时间类型,使用GenericJackson2JsonRedisSerializer时需要在 ObjectMapper 里注册 JavaTimeModule,否则反序列化会报错,常见错误信息是Java 8 date/time type not supported。
另一个更隐蔽的问题:GenericJackson2JsonRedisSerializer会在 JSON 里写入@class类型信息,用来在反序列化时还原具体类型。一旦你的接口返回类型是抽象的接口或者父类,实际数据是子类,反序列化时容易出类型映射问题。实践经验是:缓存的数据尽量用简单的 DTO,避免复杂的继承关系内部类,也不要图省事直接把 Entity 丢进缓存,等后续字段改动时你会体会到什么叫连锁反应。
在配置层面,如果你选 Redis 缓存,建议一开始就确认序列化方案并写成配置沉淀下来,避免不同开发各自用默认方式,最后 Redis 里一半 JSON 一半二进制,排查问题时会很崩溃。
4. 缓存联动:@CachePut 和 @CacheEvict 把缓存维护闭环
4.1 @CachePut:更新缓存而不是跳过方法
光有@Cacheable还不够——缓存里的数据一旦过期,或者数据被修改,如果不同步更新,前端拿到的一直是旧数据。这时候就要用@CachePut。它的作用是:方法一定会执行,执行完成后把返回值写入缓存,不会像@Cacheable那样在缓存命中的时候跳过方法。
典型场景是更新用户信息后同步刷新缓存:
@CachePut(cacheNames = "user", key = "#user.id") public User updateUser(User user) { userRepository.save(user); return user; }这个方法的返回值会成为新的缓存值。所以写@CachePut方法时一定要保证返回值能正确塞进缓存,别图省事返回 void,那缓存就会存一个 null,后续@Cacheable命中也是白命。
使用@CachePut要注意幂等性和缓存刷新时机。因为在事务里执行时,如果事务还没提交就更新了缓存,而另一个线程读缓存拿到的可能还是旧事务的数据,这一点需要结合事务边界仔细设计。实际项目里我很少单独使用@CachePut,更多时候直接用@CacheEvict删除缓存,让下次查询重建。
4.2 @CacheEvict:什么时候删缓存,怎么删
@CacheEvict用来移除缓存数据。拿删除用户的场景举例:
@CacheEvict(cacheNames = "user", key = "#id") public void deleteUser(Long id) { userRepository.deleteById(id); }方法执行完成后,缓存里对应的 user 数据会被清除。
再看两个常用属性:
allEntries = true:清空整个缓存区域。如果业务上需要一次性作废所有用户缓存,比如批量导入数据,就用这个。这时就不用指定 key。
@CacheEvict(cacheNames = "user", allEntries = true) public void batchImportUsers(List<User> users) { // 批量导入逻辑 }beforeInvocation = true:默认情况下,缓存清除发生在方法执行成功之后。如果方法抛异常,缓存不会被清除。某些场景中即使方法执行失败,你也希望缓存先失效,避免一直拿到旧数据,就把它设置为 true。
@CacheEvict放在方法上也支持声明多个缓存区域:
@Caching(evict = { @CacheEvict(cacheNames = "user", key = "#id"), @CacheEvict(cacheNames = "userOrder", key = "#id") }) public void deleteUserData(Long id) { // 删除用户相关数据 }如果更新操作同时要更新多个缓存区域,用@Caching可以把@Cacheable、@CachePut、@CacheEvict组合起来。
4.3 一套完整的读写缓存流程
把三种注解放在一起,一次业务的产品查询与更新流程会是这样:
读取侧:
@Cacheable(cacheNames = "product", key = "#productId", unless = "#result == null") public Product getProduct(String productId) { return productRepository.findByProductId(productId).orElse(null); }更新侧:
@CachePut(cacheNames = "product", key = "#product.productId") public Product updateProduct(Product product) { Product saved = productRepository.save(product); return saved; } @CacheEvict(cacheNames = "product", key = "#productId") public void deleteProduct(String productId) { productRepository.deleteByProductId(productId); }这样一套读写链路下来,读侧命中缓存,写侧及时更新或删除缓存,整体闭环是清晰的。
但有经验的读者会发现,发有缓存一致性问题:
- 先更新数据库再删除缓存,和先删除缓存再更新数据库,并发之下都可能出现短暂脏读。
- 更新缓存比删除缓存更容易导致不一致,因为缓存重建的逻辑往往和真实查询逻辑存在细微差别。
所以我的默认原则是:更新操作优先用@CacheEvict让缓存失效,而不是@CachePut去主动设置缓存。删缓存成本低,下次读取时会自动重建;而更新缓存一旦和真实数据有偏差,问题很难发现。这个原则在实际项目中能帮你省掉很多脏数据排查时间。
5. 实战排坑:缓存失效和故障修复
5.1 自调用导致的缓存失效,这是代理模式的死角
最容易踩的坑之一,是这个场景:同一个类里,一个方法调用另一个带@Cacheable注解的方法,缓存不生效,方法每次都执行。原因要先理解代理机制:Spring Cache 基于 AOP 实现,实际的缓存逻辑被包在代理对象里。当你从外部注入并使用这个类时,调用的其实是代理对象;但你在类内部通过this去调用另一个方法,是直接调用原始对象,根本没有经过代理,注解自然不生效。
看个具体例子:
@Service public class UserServiceImpl { public User getUserWithDetail(Long id) { User user = this.getUserById(id); // 内部调用,不经过代理 // 添加额外的组装逻辑 return user; } @Cacheable(cacheNames = "user", key = "#id") public User getUserById(Long id) { return userRepository.findById(id).orElse(null); } }即使getUserById加了缓存注解,通过getUserWithDetail内部调用时,缓存逻辑也不会触发。这个问题的解决思路有三个,按推荐程度排序:
- 拆类:把
getUserById放到另一个 Service 类里,然后注入调用,这是最清晰的方式,也符合单一职责。 - 注入自身代理:使用
@Autowired把当前 Bean 注入进来,通过注入的代理对象调用方法,也可以。 - 开启 AopContext 暴露代理:在配置类上加
@EnableAspectJAutoProxy(exposeProxy = true),然后代码里用((UserService) AopContext.currentProxy()).getUserById(id)。这个方式能解决问题,但代码里到处出现强制转型很不优雅,我一般只用来紧急修复。
记住一个判断技巧:只要方法是从同类内部this.xxx()直接调用的,所有 Spring 的 AOP 注解(事务、缓存、异步)都可能不生效,不只是@Cacheable。
5.2 缓存穿透、击穿、雪崩的处理套装
这三个名次是缓存系统的经典问题,每个都有对应解法,遇到时别慌。
| 问题 | 表现 | 推荐方案 |
|---|---|---|
| 缓存穿透 | 请求查询不存在的数据,缓存没有,每次都打数据库 | 缓存空值短时间 / 布隆过滤器 |
| 缓存击穿 | 热点 key 过期瞬间,大量并发打进数据库 | sync = true/ 互斥锁 |
| 缓存雪崩 | 大量 key 同时过期,数据库被集中压垮 | 过期时间随机化 / 多级缓存 |
针对穿透,一个常见的做法是缓存空值并设置很短的 TTL。用 RedisCacheManager 时如果调用了disableCachingNullValues(),缓存里不会存 null,所以你需要考虑用其他方式兜底,比如代码里对不存在的结果自己构造一个空对象并设置短过期,或者引入布隆过滤器做预判断。在这个问题上很多项目开始追求很复杂的设计,我的看法是:如果底层数据库查询本身不重,直接短路处理就够了,不要为了“高级方案”过度设计。
针对击穿,sync = true是最轻量的解法,上一章已经演示过。需要注意,这个属性只对同一个缓存 key 有效,它保证同一个 key 只有一个请求在加载数据,其他请求等待结果。对不同的 key,可以并行加载,不受影响。
针对雪崩,比较简单的处理是在配置 TTL 时加入随机抖动:
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMillis(30000 + ThreadLocalRandom.current().nextLong(15000)));这样每个 key 的过期时间在 30 到 45 秒之间分散,避免大批 key 在同一时刻集体失效。
5.3 缓存一致性怎么做取舍
缓存和数据库之间没有完美的一致性,只能根据业务场景选择合适策略。对一个普通业务系统来说,缓存一致性的日常维护应该遵循三个方向。
第一个方向:写操作先更新数据库,再删除缓存。原因很简单:更新数据库这个操作是业务的主路径,不能省略;删除缓存则很轻量。顺序上先 DB 后缓存,可以降低缓存里留旧数据的概率。反过来如果先删缓存后更新 DB,在 DB 还没更新完成时,其他线程查了一次数据库并把旧数据写回缓存,那缓存里就长期是旧数据,问题更隐蔽。
第二个方向:优先考虑删除缓存而不是更新缓存。删除缓存只是让一次读请求重新走库并回填,而更新缓存要求你保证回填数据的逻辑和查询逻辑完全一致。事实上有时候两个逻辑很难一致,比如查询结果做了复杂的组装或过滤,更新方法只知道部分字段,你根本算不出最终要存的样子。删缓存明显更稳妥。
第三个方向:对一致性要求极高的场景,比如核心资金数据,不要过度依赖缓存,直接用数据库查询,或者严格限制缓存时间,把它作为数据库前面的一道加速层,而不是数据主源。
我还尝试过延迟双删方案:先删缓存,更新 DB,等几百毫秒再删一次缓存。这种方式确实能解决一部分并发导致的脏数据问题,但实现起来需要引入延迟任务组件,复杂度明显上升。普通业务不建议上这一套,只有在确实无法容忍短暂不一致时才考虑。
6. 缓存到底该不该上:选型判断与效果验证
6.1 什么业务才配得上 @Cacheable
不是所有方法加注解都是好事。判断一个接口是否适合用缓存,我一般看三个特征,缺一不可。
- 读多写少:方法被高频调用,底层数据很少变化。比如商品详情、用户基本信息、配置项读取,这类非常适合。反例是库存扣减、订单状态流转,每次读都可能紧跟写,缓存完全没意义,还容易出错。
- 热点集中:请求中的 key 分布比较集中,少数几个 key 占用绝大多数访问量。如果请求每个 key 都不同,缓存命中率会非常低,存进去的数据还没被读第二次就过期了,不仅不能提速,还白白占内存。
- 容忍短暂延迟一致:缓存本质上是允许数据在一段时间内不实时。如果业务要求写入后立刻能在查询中看到,或者允许最多延迟几秒,那可以用缓存;如果绝对要实时,那缓存不适合。
判断时还有一个很实用的做法:先用监控统计一下目标接口当前的读 QPS、数据库查询耗时、缓存命中率预期,用数据说话。不要看接口名字觉得热门就上缓存。
6.2 怎么确认缓存真的生效了
排查缓存问题时,第一步永远是确认缓存到底有没有生效,不要凭感觉猜。确认手段按场景分:
- 本地内嵌缓存(Caffeine/ConcurrentMap):在 CacheManager 里打印或日志输出当前缓存统计。Caffeine 可以暴露命中率、加载计数等信息,通过 Actuator 暴露指标是最方便的路径。
- Redis 缓存:直接用 Redis 客户端查看 key 是否存在、TTL 还剩多少、value 是不是预期结构。比如执行
KEYS demo:user:*或TTL demo:user::123,一目了然。
在临时排障时,我会在缓存方法里加一行非常短的条件日志:
log.debug("getUserById cache miss, query db, id = {}", id);缓存命中时这行日志不出现,没命中时出现,用来验证缓存是否生效。上线验证没问题后记得删掉或者调低日志级别,避免刷日志。
还有一个容易忽略的点:检查方法是否被 Spring 代理处理。如果一个方法加了缓存注解却不生效,先看一眼类有没有被 final 修饰,或者方法是不是 private——@Cacheable放在 private 方法上不会生效,放在 final 类上的代理策略也会失效。这个和事务注解同样考察。
关于@Cacheable的用法,我前前后后在多个项目里调整过好多次,踩过的坑包括缓存脏数据、key 冲突、自调用失效、序列化异常。我个人在项目中已经形成一套固定习惯:缓存名称统一用业务模块命名,key 统一用业务主键拼接维度字段,返回值统一缓存 DTO 而不是实体,更新操作默认走@CacheEvict,并发高的热点 key 才开sync=true。这套习惯不一定适合所有团队,但维护成本确实低很多。
如果你正在项目里接入缓存,建议不要一次性铺开所有接口,先选两三个读多写少的接口接入,跑一段时间观察命中率和数据一致性,再逐步扩大范围。缓存这个东西,用对了是效能利器,用错了就是脏数据制造机,关键还是理解它背后的机制。