同事的问题:我加了 @Cacheable,为什么没生效
上周三下午,组里的小杨问我:"我在方法上加了 @Cacheable,压了 100 次,数据库慢查询日志里还是 100 条,Redis 里也看不到 key,是不是缓存没配好?"
我过去看了一眼他的代码:
@Service
public class SkuService {
public SkuDTO getSku(Long skuId) {
return getSkuFromCache(skuId); // ← 同一类里自调用
}
@Cacheable(cacheNames = "sku", key = "#skuId")
public SkuDTO getSkuFromCache(Long skuId) {
log.info("query db, skuId={}", skuId);
return skuMapper.selectById(skuId);
}
}
这是 Spring Cache 最常见的坑:同一个类里的方法互相调用,不经过代理。@Cacheable 是靠 AOP 实现的,Spring 给 SkuService 生成了一个代理对象,外部注入的是代理,调用代理方法时才会走 CacheInterceptor。而 this.getSkuFromCache() 里的 this 是原始对象,注解直接被忽略。
而且日志里那句 query db 每次都打出来了,这就是最直接的证据——方法进去了,缓存没拦。
三种解法
第一种是把缓存方法挪到另一个类。干净,但要新建类。
第二种,注入自己(Spring 4.3 之后支持循环注入自身,实测 5.3 上没问题):
@Service
public class SkuService {
@Autowired
private SkuService self; // 注入的是代理
public SkuDTO getSku(Long skuId) {
return self.getSkuFromCache(skuId);
}
@Cacheable(cacheNames = "sku", key = "#skuId")
public SkuDTO getSkuFromCache(Long skuId) {
return skuMapper.selectById(skuId);
}
}
第三种,用 AopContext.currentProxy(),要在启动类加 @EnableAspectJAutoProxy(exposeProxy = true)。我用得少,因为一旦别人不知道这个开关,代码就废了。
小杨最后选了第一种:新建一个 SkuCache 类专门放缓存方法,职责也更清楚。
key 生成策略:默认的不太好用
改完能缓存了,但 Redis 里的 key 长这样:
127.0.0.1:6379> KEYS sku::*
1) "sku::SimpleKey []"
2) "sku::SimpleKey [10037,1]"
没写 key 属性时,Spring 用 SimpleKeyGenerator:0 个参数返回 SimpleKey.EMPTY,1 个参数返回该参数本身,多个参数包一个 SimpleKey 并逐个 toString 拼接。这种 key 可读性和可控性都很差,出问题时想手动删都要 KEYS 半天。
我们统一要求写 SpEL 显式指定:
@Cacheable(cacheNames = "sku", key = "#skuId")
public SkuDTO getSku(Long skuId) { ... }
// 对象参数取字段
@Cacheable(cacheNames = "user:order", key = "#req.userId + ':' + #req.status")
public List<OrderVO> listOrders(OrderQueryReq req) { ... }
// 用根对象,p0 也可以,但可读性差
@Cacheable(cacheNames = "sku", key = "#root.args[0]")
public SkuDTO getSku2(Long skuId) { ... }
SpEL 里可用的上下文:#参数名、#p0、#a0、#root.methodName、#root.target、#result(仅 unless 和 @CachePut 可用)。
注意:参数名能直接用是因为编译时保留了
-parameters。Spring Boot 2.x 的 spring-boot-starter-parent 默认开了这个,如果自己配的 maven-compiler-plugin 要确认一下,否则#skuId会报SpEL evaluation failed。
缓存穿透:查不存在的 ID
接口上线两天,DBA 找过来说 t_sku 上有一批奇怪的查询,全是查不到的主键,每分钟 3000 多次。一看请求参数:skuId = -1、0、999999999,明显是有人(或者脚本)在扫。
这就是缓存穿透:查一个根本不存在的数据,缓存永远不命中,每次都落到数据库。
第一层处理最简单——缓存空值。@Cacheable 默认会缓存 null 返回值(Spring 会存一个 NullValue 占位),但这个空值没有单独的过期时间,会占着缓存位置直到 TTL 到期。我们给它配了较短的 TTL 并显式控制:
@Cacheable(cacheNames = "sku", key = "#skuId",
unless = "#result == null") // 不缓存 null
public SkuDTO getSku(Long skuId) {
return skuMapper.selectById(skuId);
}
@Cacheable(cacheNames = "sku:null", key = "#skuId", unless = "#result != null")
public SkuDTO getSkuNullable(Long skuId) {
return null;
}
这么写有点绕。更实用的做法是加参数校验 + 布隆过滤器:
@PostConstruct
public void initBloomFilter() {
RBloomFilter<Long> filter = redissonClient.getBloomFilter("sku:bloom");
filter.tryInit(2_000_000L, 0.01); // 预期 200 万,误判率 1%
skuMapper.selectAllId().forEach(filter::add);
}
public SkuDTO getSkuSafe(Long skuId) {
if (skuId == null || skuId <= 0) {
return null; // 非法参数直接挡掉
}
if (!bloomFilter.contains(skuId)) {
return null; // 布隆说没有,一定没有
}
return self.getSku(skuId);
}
布隆过滤器有误判率(1% 的"不存在"会被判成"存在"),但不会漏判,用来挡穿透足够了。用的 Redisson 3.16.1,RBloomFilter 底层是 Redis 的 bit 操作,200 万数据约占用 2.4 MB。
加了这两层之后,那批穿透查询从每分钟 3000 次降到 0,无效 key 也不再写进 Redis。
CacheManager 的配置
默认的序列化器是 JDK 序列化,Redis 里存的是二进制,redis-cli 里看不出内容,跨语言也读不了。我们统一改成 JSON:
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
ObjectMapper om = new ObjectMapper();
om.registerModule(new JavaTimeModule());
om.activateDefaultTyping(om.getPolymorphicTypeValidator(),
ObjectMapper.DefaultTyping.NON_FINAL);
GenericJackson2JsonRedisSerializer serializer =
new GenericJackson2JsonRedisSerializer(om);
RedisCacheConfiguration config = RedisCacheConfiguration
.defaultCacheConfig()
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(serializer))
.entryTtl(Duration.ofMinutes(30))
.disableCachingNullValues(); // 不存 null
// 不同 cacheName 单独设 TTL
Map<String, RedisCacheConfiguration> perCache = new HashMap<>();
perCache.put("sku", config.entryTtl(Duration.ofHours(2)));
perCache.put("sku:null", config.entryTtl(Duration.ofMinutes(2)));
perCache.put("user:order", config.entryTtl(Duration.ofMinutes(5)));
return RedisCacheManager.builder(factory)
.cacheDefaults(config)
.withInitialCacheConfigurations(perCache)
.build();
}
}
两个点:activateDefaultTyping 会把类型信息写进 JSON(["com.xxx.SkuDTO", {...}]),反序列化时才能还原成具体类型,否则会变成 LinkedHashMap 然后抛 ClassCastException。disableCachingNullValues() 让缓存不存 null,配合上面的布隆过滤器使用。
其他几个注解
// 更新后同步刷新缓存
@CachePut(cacheNames = "sku", key = "#sku.id")
public SkuDTO update(SkuDTO sku) { ... }
// 删除
@CacheEvict(cacheNames = "sku", key = "#skuId")
public void delete(Long skuId) { ... }
// 清空整个 cacheName(慎用,底层是 KEYS + DEL,key 多的时候会卡 Redis)
@CacheEvict(cacheNames = "sku", allEntries = true)
public void refreshAll() { ... }
// 缓存击穿保护:同一 key 并发只放行一个去查库,其余阻塞等待
@Cacheable(cacheNames = "sku", key = "#skuId", sync = true)
public SkuDTO getSkuSync(Long skuId) { ... }
sync = true 在热点 key 过期的瞬间很有用,缺它的话 1000 个并发会同时打到数据库。代价是同一个 key 的并发请求会串行等待,我们只在几个确定的热点上开了。
写在后面
现在回头看,《Spring Cache 抽象与 Redis 集成实践》本身不算多难,难的是线上真出问题那十分钟里的判断。经验都是这么来的。