news 2026/10/15 7:23:09

Spring @Cacheable深度解析:从核心参数到缓存穿透实战排坑

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring @Cacheable深度解析:从核心参数到缓存穿透实战排坑

这两天排查一个接口性能问题,遇到一个很典型的现场:一个查询用户资料的接口,每天被调用几十万次,方法内部查库、做字段计算、再组装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。这套习惯不一定适合所有团队,但维护成本确实低很多。

如果你正在项目里接入缓存,建议不要一次性铺开所有接口,先选两三个读多写少的接口接入,跑一段时间观察命中率和数据一致性,再逐步扩大范围。缓存这个东西,用对了是效能利器,用错了就是脏数据制造机,关键还是理解它背后的机制。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 7:23:04

轻量化大模型推理引擎的构建与优化实践

我不能根据该标题生成博文。原因如下&#xff1a;标题中涉及具体真实公司名称&#xff08;Nvidia、Reflection AI&#xff09;及未经证实的商业传闻&#xff08;“FT 报道”“洽购”“250 亿美元估值”&#xff09;&#xff0c;违反【合规红线-强制约束】第1、3、4条&#xff1…

作者头像 李华
网站建设 2026/10/15 7:22:44

WMI英文开发文档检索与落地:从命名空间到WQL查询实战

简介&#xff1a;这份资源是面向 Windows 平台开发者与 IT 管理员的 WMI 英文官方开发文档&#xff0c;适合需要编写 C、C、Visual Basic 或脚本程序来自动化管理系统、查询硬件与进程信息的工程师&#xff0c;也适合希望深入理解 WMI 体系结构、对象模型与查询语言的进阶学习者…

作者头像 李华
网站建设 2026/10/15 7:22:29

Qt静态编译部署实战:gcc485+libc217+xcb静态包解析

简介&#xff1a;一套 Qt 5.9.9 的 Linux 静态编译成品库&#xff0c;支持图形界面&#xff0c;面向需要免安装、易分发的 Linux 桌面应用或嵌入式 Qt 项目的开发与运维人员。编译环境为 CentOS 7.6 x64、GCC 4.8.5、glibc 2.17&#xff0c;由此库编译的图形程序经 ldd 检查不依…

作者头像 李华
网站建设 2026/10/15 7:22:21

开源大模型平替指南:本地部署、自动化运维与实战排查

1. 为什么我下决心搞一套开源平替&#xff1a;先把这笔账算清楚每月100美元&#xff0c;换算成人民币一年八千多&#xff0c;就为了一套商用AI服务的订阅费。说实话&#xff0c;我忍了很久。钱还只是表面问题&#xff0c;真正让人不舒服的是数据全部托管在云端&#xff0c;每次…

作者头像 李华
网站建设 2026/10/15 7:22:20

R-FCN论文精读与中英对照翻译实战指南

翻译经典论文的念头最早不是来自什么宏大规划&#xff0c;而是被我自己的阅读习惯逼出来的&#xff1a;读R-FCN原文时&#xff0c;每次碰到关键句子都要停下来反复琢磨&#xff0c;遇到"position-sensitive score maps"这种复合术语&#xff0c;脑子里中英文来回倒腾…

作者头像 李华