典型:AI 只贴注解,忽略 AOP 代理、key 设计、缓存穿透/击穿/过期、序列化、同步刷新,本地单测正常,上线后缓存脏数据、命中率异常、雪崩。
- 场景背景
商品详情服务,要求:商品基础信息走缓存,减少 DB 查询;更新商品时刷新缓存。开发让 AI 写缓存逻辑,AI 直接加上 @Cacheable、@CachePut,简单自测正常。上线后:
• 部分用户读到旧缓存数据;
• 更新商品后缓存没有刷新;
• 高并发下大量请求打穿到数据库;
• 缓存里存的对象序列化异常,反序列化报错。
-
现象
-
更新商品,缓存依旧是旧值;
-
同类内部调用缓存注解失效(和之前事务 AOP 同一个底层原因);
-
key 设计不合理,缓存互相覆盖,或者 key 过多膨胀;
-
没有过期时间,冷数据永久占内存;
-
并发查询不存在的商品,大量穿透到 DB;
-
Jackson 序列化丢失字段、日期格式错乱,或者 Lambda 表达式/内部类序列化失败;
-
缓存驱逐时机不对,批量更新时漏删缓存。
-
根因分析
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)基础配置(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)配套工程规范
-
sync = true:热点 key 简单防击穿;超高并发单独用分布式锁;
-
缓存空值:针对不存在 ID,缓存空对象,设置较短过期时间,拦截穿透;
-
key 统一前缀:区分环境、业务模块,防止 key 冲突;
-
事务边界:缓存操作放在事务提交之后;
-
批量更新:不要逐个删,必要时批量清理或做版本号;
-
序列化校验:实体类保证无参构造,日期、金额序列化配置;
-
缓存降级:Redis 宕机时,缓存组件可以降级,不拖垮整个服务;
-
监控:缓存命中率、key 数量、过期、驱逐埋点。
-
AI 辅助开发避坑总结
-
@Cacheable / @CachePut / @CacheEvict 同样受 AOP 代理限制,不能同类 this 调用;
-
不要无脑用 @CachePut,并发场景容易脏数据,优先"更新 DB + 删除缓存";
-
必须配置过期时间,不能永久缓存;
-
序列化、key 前缀、防穿透/击穿是 AI 高频遗漏项;
-
缓存和数据库双写一致性没有银弹,根据业务选择最终一致性还是强一致;
-
缓存注解、事务、@Async、@Retry 是同一大类 AOP 坑,一套排查思路。