@Cacheable和@CacheEvict的坑, 批量key过期失效的原因分析
- 前言
- 测试代码
- 源码
- put缓存时最终key的产生
- 看不同情况下, 是否能匹配Evict过期缓存
- 1. 没有入参没有指定key的情况
- 2. 有入参的情况
- 3. 配置了allEntries的情况
- 总结
- 补充
前言
最近发现自己搭的一个项目返回的数据不太准确, 第一时间想到了是缓存的问题, 缓存代码如下
@Cacheable(value = "allroom")
public List<Roomuserinfo> queryAll(){
return mapper.queryAll();
}
@CacheEvict(value = {"roominfosbrobbot","roominfobyid","allroom"},key = "#roominfo.room_id")
public Roominfo insert(Roominfo roominfo){
boolean ans=mapper.insert(roominfo);
return ans?roominfo:null;
}
有个困扰我很久的问题就是allroom的evict是否生效.
类似上面这个场景, 我们经常有list()
方法没指定key缓存了全部数据, 以及get(Integer id)
指定了key是id缓存了单个数据, 最后我们在update(Integer id)
方法想evict过期上面两种缓存
众所周知 @Cacheable 和 @CacheEvict 的使用要求其中之一就是value和key一样, 以及方法参数要一样
. 因此单个的evict肯定没问题, 问题是所有的如何过期呢
测试代码
如下, 我将平时常见的几个场景都列出来了. 前提@Cacheable
是仅指定了value,没有指定key
再看@CacheEvict
- 有value没指定key
- 有入参
insert
- 没入参
flush
- 有入参且配置allEntries
insertAndAllEntries
- 有入参
- 有value且指定key
- 有入参
insertWithKey
- 有入参且配置allEntries
insertWithKeyAndAllEntries
- 有入参
private List<String> data = new ArrayList<>();
@Cacheable("testcach")
public List<String> getAll(){
return data;
}
@CacheEvict("testcach")
public void insert(String aa){
data.add(aa);
}
@CacheEvict(value = "testcach",allEntries = true)
public void insertAndAllEntries(String aa){
data.add(aa);
}
@CacheEvict("testcach")
public void flush(){
}
@CacheEvict(value = "testcach",key = "#aa")
public void insertWithKey(String aa){
data.add(aa);
}
@CacheEvict(value = "testcach",key = "#aa",allEntries = true)
public void insertWithKeyAndAllEntries(String aa){
data.add(aa);
}
直接放测试结果
@Test
public void fun01(){
cacheBiz.insert("aa"); //key aa, 无效
List<String> all = cacheBiz.getAll();
cacheBiz.insert("bb");//key bb, 无效
all = cacheBiz.getAll();
cacheBiz.insertAndAllEntries("cc");//有效
all = cacheBiz.getAll();
cacheBiz.insertWithKey("dd"); //key dd,无效
all = cacheBiz.getAll();
cacheBiz.insertWithKeyAndAllEntries("ee");//有效
all = cacheBiz.getAll();
cacheBiz.flush(); //有效
}
源码
来看源码是为什么
put缓存时最终key的产生
- 首先我们的缓存注解的方法, 都会被代理并触发拦截器org.springframework.cache.interceptor.CacheInterceptor的invoke()方法
- invoke方法里跳转到org.springframework.cache.interceptor.CacheAspectSupport#execute()方法
- 重点来了, 这个方法做的事包括: 过期缓存, 判断条件然后获取缓存, put缓存
- 在按顺序处理缓存时, 会调用一个重要的方法
private void performCacheEvict(
CacheOperationContext context, CacheEvictOperation operation, @Nullable Object result) {
Object key = null;
for (Cache cache : context.getCaches()) {
if (operation.isCacheWide()) {
logInvalidating(context, operation, null);
doClear(cache);
}
else {
if (key == null) {
key = generateKey(context, result);
}
logInvalidating(context, operation, key);
doEvict(cache, key);
}
}
}
protected Object generateKey(@Nullable Object result) {
if (StringUtils.hasText(this.metadata.operation.getKey())) {
//如果我们写了el表达式的key
EvaluationContext evaluationContext = createEvaluationContext(result);
return evaluator.key(this.metadata.operation.getKey(), this.metadata.methodKey, evaluationContext);
}
//如果没有,默认生成
return this.metadata.keyGenerator.generate(this.target, this.metadata.method, this.args);
}
/**
* 生成key
*/
public static Object generateKey(Object... params) {
if (params.length == 0) {
//如果没有入参, 返回SimpleKey对象作为入参
return SimpleKey.EMPTY;
}
if (params.length == 1) {
//如果有一个, 直接返回
Object param = params[0];
if (param != null && !param.getClass().isArray()) {
return param;
}
}
//如果有多个入参, 包装返回
return new SimpleKey(params);
}
- 如果key为null, 会生成一个key, 再看generateKey里, 第一个if就是我们手动指定了EL表达式的key, 就会解析表达式, 否则就用默认的key生成器, 也就是org.springframework.cache.interceptor.SimpleKeyGenerator返回的SimpleKey实例.
- 再回到execute方法, apply方法里把put请求一个个执行
for (CachePutRequest cachePutRequest : cachePutRequests) {
cachePutRequest.apply(cacheValue);
}
public void apply(@Nullable Object result) {
if (this.context.canPutToCache(result)) {
for (Cache cache : this.context.getCaches()) {
doPut(cache, this.key, result);
}
}
}
- 按doPut一路点下来, 一直到RedisCache的put方法, 里面
cacheWriter.put(name, createAndConvertCacheKey(key), serializeCacheValue(cacheValue), cacheConfig.getTtl());
这就是我们很熟悉的redis.setValue(name,key,value,expireTime) - 再从createAndConvertCacheKey方法一路点下来, 可以看到将key对象转成了string, 并和name(即@cacheable的value)拼装在一起. 就得到了
testcach::SimpleKey []
protected String createCacheKey(Object key) {
//将object转string, 默认就是 simpleKey[]
String convertedKey = convertKey(key);
if (!cacheConfig.usePrefix()) {
return convertedKey;
}
return prefixCacheKey(convertedKey);
}
private String prefixCacheKey(String key) {
// allow contextual cache names by computing the key prefix on every call.
//这里拼装name和key. 后面其实name就只用于了执行命令时lock的锁(取决于你配置的是lock还是非lock的RedisWiter
//@see RedisCacheWriter.nonLockingRedisCacheWriter)
return cacheConfig.getKeyPrefixFor(name) + key;
}
看不同情况下, 是否能匹配Evict过期缓存
1. 没有入参没有指定key的情况
这也是我测试的时候使用的@Cacheable
的情况
可以看到key也是一样SimpleKey对象, name和key匹配, 可以删除
2. 有入参的情况
有入参, 按上面的generateKey
方法可得知, 如果有el表达式则解析, 没有的话, 如果有一个参数, 则以这个参数为key, 我测试方法正好是1个参数
name 和 key 不匹配, 不会过期缓存
3. 配置了allEntries的情况
从图中可知, 方法会走到isCacheWide
调用clear方法而不是evict方法
到RedisCache的clear方法看看
@Override
public void clear() {
byte[] pattern = conversionService.convert(createCacheKey("*"), byte[].class);
cacheWriter.clean(name, pattern);
}
...
byte[][] keys = Optional.ofNullable(connection.keys(pattern)).orElse(Collections.emptySet())
.toArray(new byte[0][]);
if (keys.length > 0) {
connection.del(keys);
}
可以看到使用的*模糊匹配, keys命令, 这个在一般生产环境是禁止使用的, 性能影响很大
总结
经上总结, 如果有这种getAll()
方法, 只有value没有key的话, 比较推荐两种Evict方法:
- 使用常量key, 即用单引号包裹的el表达式. @Cacheable(value=“test”,key=“‘list’”), @CacheEvict同理.
- 不要和有入参的方法混合使用, 单独开一个无参方法和Cacheable无参一样.
- 如果非要和有入参的一起用, 用@Caching
@Caching(
evict = {
@CacheEvict(value = {"roominfosbrobbot","roominfobyid"},key = "#roominfo.room_id"),
@CacheEvict(value = "allroom",key = "'list'")
}
)
补充
在测试的时候发现了一个坑.
配置redis序列器Jackson2JsonRedisSerializer
在反序列化字符串数组时报错springboot redis: xxx as a subtype of [simple type, class java.lang.Object]: no such class found
是这个序列器的问题
后面换成GenericFastJsonRedisSerializer
, 能是能反序列化解析了, 但是解析后是JsonArray对象, 强转String[] 同样报错
JSONArray cannot be cast to class [Ljava.lang.String
解决:
- 尽量不要用字符串数组作为缓存的return结果
- 用
GenericFastJsonRedisSerializer
, 但是用List来接收 (因为JSONArray实现了List接口)