Administrator
发布于 2021-07-08 / 5365 阅读
66

Spring Cache 抽象与 Redis 集成实践

同事的问题:我加了 @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 = -10999999999,明显是有人(或者脚本)在扫。

这就是缓存穿透:查一个根本不存在的数据,缓存永远不命中,每次都落到数据库。

第一层处理最简单——缓存空值。@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 然后抛 ClassCastExceptiondisableCachingNullValues() 让缓存不存 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 集成实践》本身不算多难,难的是线上真出问题那十分钟里的判断。经验都是这么来的。

参考