实际工程问题:AI 生成 Spring Cache(@Cacheable / @CachePut / @CacheEvict)缓存注解踩坑

典型:AI 只贴注解,忽略 AOP 代理、key 设计、缓存穿透/击穿/过期、序列化、同步刷新,本地单测正常,上线后缓存脏数据、命中率异常、雪崩。

  1. 场景背景

商品详情服务,要求:商品基础信息走缓存,减少 DB 查询;更新商品时刷新缓存。开发让 AI 写缓存逻辑,AI 直接加上 @Cacheable、@CachePut,简单自测正常。上线后:

• 部分用户读到旧缓存数据;

• 更新商品后缓存没有刷新;

• 高并发下大量请求打穿到数据库;

• 缓存里存的对象序列化异常,反序列化报错。

  1. 现象

  2. 更新商品,缓存依旧是旧值;

  3. 同类内部调用缓存注解失效(和之前事务 AOP 同一个底层原因);

  4. key 设计不合理,缓存互相覆盖,或者 key 过多膨胀;

  5. 没有过期时间,冷数据永久占内存;

  6. 并发查询不存在的商品,大量穿透到 DB;

  7. Jackson 序列化丢失字段、日期格式错乱,或者 Lambda 表达式/内部类序列化失败;

  8. 缓存驱逐时机不对,批量更新时漏删缓存。

  9. 根因分析

AI 容易只做"注解粘贴",遗漏 Spring Cache 的底层约束:

① AOP 代理生效限制

@Cacheable 也是 AOP 切面实现。

• 如果在同一个类里用 this.xxx() 调用带缓存注解的方法,不走代理,缓存直接失效;

• 方法必须是 public;private / final / static 注解无效;

• 如果类没有被 Spring 代理,注解不生效。

② Key 策略漏洞

AI 经常简写 key = "#id",容易忽略:

• 缓存没有区分环境、业务前缀;

• 如果参数是复杂对象,直接用对象 toString(),key 不可控;

• 没有使用 keyGenerator,多参数拼接容易撞 key;

• 若使用 @CacheEvict,只删单个 key,批量更新时漏删。

③ 更新/删除时序问题

AI 常写:先更新缓存,再更新数据库。并发下会出现:

线程 A 更新缓存 → 线程 B 读取旧 DB 数据写入缓存 → 数据库才更新完成,缓存永久脏数据。

正确工程范式一般是:先更新数据库,再删除缓存(延迟双删),而不是直接 @CachePut 无脑覆盖。

④ 缺少过期、防穿透保护

AI 很少自动配置 expire,也不会加上缓存空值、布隆过滤器、限流。不存在的 ID 并发请求直接打 DB,造成缓存穿透。

⑤ 序列化坑

使用 Redis 做缓存时,如果默认 JDK 序列化,可读性差、兼容性差;换成 Jackson 又容易出现:

• 无参构造缺失;

• 日期、BigDecimal 序列化异常;

• 泛型丢失;

• 本地测试和生产 Redis 版本、编码不一致。

⑥ 缓存同步与事务时序

如果业务在事务中,@CacheEvict / @CachePut 如果在事务提交前执行,一旦事务回滚,缓存已经被删掉/更新,数据不一致。

  1. 解决方案

(1)基础配置(Redis + Spring Cache)

依赖:

<!-- maven -->

<dependency>

<groupId>org.springframework.boot</groupId>

<artifactId>spring-boot-starter-cache</artifactId>

</dependency>

<dependency>

<groupId>org.springframework.boot</groupId>

<artifactId>spring-boot-starter-data-redis</artifactId>

</dependency>

启动类开启缓存:

import org.springframework.cache.annotation.EnableCaching;

import org.springframework.boot.SpringApplication;

import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication

@EnableCaching

public class App {

public static void main(String\[\] args) {

SpringApplication.run(App.class, args);

}

}

Redis 缓存配置,自定义序列化 + 过期时间:

import org.springframework.cache.CacheManager;

import org.springframework.cache.annotation.EnableCaching;

import org.springframework.context.annotation.Bean;

import org.springframework.context.annotation.Configuration;

import org.springframework.data.redis.cache.RedisCacheConfiguration;

import org.springframework.data.redis.cache.RedisCacheManager;

import org.springframework.data.redis.connection.RedisConnectionFactory;

import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;

import org.springframework.data.redis.serializer.RedisSerializationContext;

import org.springframework.data.redis.serializer.StringRedisSerializer;

import java.time.Duration;

@Configuration

@EnableCaching

public class CacheConfig {

@Bean

public CacheManager redisCacheManager(RedisConnectionFactory factory) {

RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()

.entryTtl(Duration.ofMinutes(30)) // 默认过期时间

.serializeKeysWith(RedisSerializationContext.SerializationPair

.fromSerializer(new StringRedisSerializer()))

.serializeValuesWith(RedisSerializationContext.SerializationPair

.fromSerializer(new GenericJackson2JsonRedisSerializer()))

.computePrefixWith(cacheName -> "cache:" + cacheName + ":")

.disableCachingNullValues(); // 根据业务取舍,防穿透可改为缓存空值

return RedisCacheManager.builder(factory)

.cacheDefaults(config)

.build();

}

}

(2)正确使用示例

import org.springframework.cache.annotation.Cacheable;

import org.springframework.stereotype.Service;

@Service

public class ProductService {

// 缓存:cache:product:{id}

@Cacheable(value = "product", key = "#id", sync = true)

public Product getById(Long id) {

// sync=true:并发时阻塞,避免大量请求同时打到 DB(简易防击穿)

return productMapper.selectById(id);

}

// 更新商品:先更新 DB,再删除缓存,而不是直接 @CachePut

// 如果坚持用 @CachePut,要评估并发风险

public void updateProduct(Product product) {

// 1. 更新数据库

productMapper.updateById(product);

// 2. 删除缓存,可搭配延迟双删

cacheManager.evict("product", product.getId());

}

}

如果必须同类内部调用,和事务一样:AopContext.currentProxy(),并且开启 exposeProxy = true。

(3)延迟双删(解决并发脏读)

// 更新 DB → 立刻删缓存 → 延迟一段时间再删一次

public void updateWithDelayDoubleDelete(Product product) {

productMapper.updateById(product);

cacheManager.evict("product", product.getId());

// 异步线程池,延迟几百毫秒再删一次

taskExecutor.execute(() -> {

try {

Thread.sleep(500);

} catch (InterruptedException e) {

Thread.currentThread().interrupt();

}

cacheManager.evict("product", product.getId());

});

}

(4)配套工程规范

  1. sync = true:热点 key 简单防击穿;超高并发单独用分布式锁;

  2. 缓存空值:针对不存在 ID,缓存空对象,设置较短过期时间,拦截穿透;

  3. key 统一前缀:区分环境、业务模块,防止 key 冲突;

  4. 事务边界:缓存操作放在事务提交之后;

  5. 批量更新:不要逐个删,必要时批量清理或做版本号;

  6. 序列化校验:实体类保证无参构造,日期、金额序列化配置;

  7. 缓存降级:Redis 宕机时,缓存组件可以降级,不拖垮整个服务;

  8. 监控:缓存命中率、key 数量、过期、驱逐埋点。

  9. AI 辅助开发避坑总结

  10. @Cacheable / @CachePut / @CacheEvict 同样受 AOP 代理限制,不能同类 this 调用;

  11. 不要无脑用 @CachePut,并发场景容易脏数据,优先"更新 DB + 删除缓存";

  12. 必须配置过期时间,不能永久缓存;

  13. 序列化、key 前缀、防穿透/击穿是 AI 高频遗漏项;

  14. 缓存和数据库双写一致性没有银弹,根据业务选择最终一致性还是强一致;

  15. 缓存注解、事务、@Async、@Retry 是同一大类 AOP 坑,一套排查思路。

相关推荐
Hone32 分钟前
juejin-skills 系列 ②:我把「看掘金热榜」也做成了 Agent 技能
开源·node.js·ai编程
愿有所息34 分钟前
记一次 ARM64 + JDK8 上 fastjson2 的诡异数据错乱
java
秋饼1 小时前
MCP 2026-07-28 无状态协议大改版:Spring AI 2.1 多轮往返请求与网关路由落地
java·ai·技术分享·后端开发
SL_staff2 小时前
JVS-BI 实战:业务人员如何用零代码 ELT + 可视化配置构建实时经营分析闭环
java·spring·全栈
SL_staff3 小时前
企业AI落地难?JVS-AI渐进式实践:安全可控、小切口验证、标准化沉淀与规模化复制
java·开源·aigc
小虎AI生活3 小时前
Anatomy 接入 WorkBuddy 实操:一句话生成 12 零件可交互科普模型(附2组提示词)
aigc·ai编程
David猪大卫3 小时前
【C++修炼】map 和 set 的使用
c语言·开发语言·数据结构·c++·经验分享·笔记·学习
OpsEye3 小时前
别再挨个充会员了:多模型编码,最麻烦的从来不是选模型
javascript·ai编程
AiMan3 小时前
别再把秘塔会话逐个点开:这可把秘塔多个会话一次导出?AI导出鸭
前端·后端·面试