这个是我在做树结构存储的时候遇到的问题,需要做到模糊匹配来批量删除。
为什么要做这个
- 树是一个抽象的组件,其他项目可以用这个树来表示任何可以以树形结构表示的事务。
- 树要被权限控制,即每个权限组有属于自己的树
- 不同级别的权限组有不同的级别,能够操作属于其他权限组的树。
为什么要作缓存
- 节点数量完全能够达到上百万,以每个节点拥有20个孩子为例,当有6层时(包括根节点),就会拥有320万多个节点
- 在mysql里面,树是通过闭包表存储,所以关系表能够达到将近2000万行
- 在工业环境下,很少有规模集群的客户,所以一般都是mysql和redis,而且都是一台机器
要做成什么样
- 当权限组的某个用户创建节点时,该权限组的树缓存应当更新,不应当更新太多的缓存,否则缓存的意义也不大
- 权限组的树缓存的key,应当这样设计:权限组-节点-几代-结构,这样设计能够满足所有查询情况,包括查N代祖先,N代后代,查树结构的还是列表,查哪些权限组的树等
- 应当从缓存中拿出来直接返回给controller,而不应该再做过多的操作,否则缓存意义不大
怎么做的
- 不做@CachePut,因为创建时是一个节点一个节点的创建的,而查询时又很少会一次查一个节点
- 查某个节点的往前(往后)N代的树结构(列表)时,最好拆分成小部分查缓存,因为小部分能够适应将来的更多未知需求。然后再service层合并,当然service层合并工作不能太多,否则缓存意义不大。
- 首先第一反应是将@Cacheable的value,也就是相当于namespace的东西,来模糊匹配删除,如当某个权限组的用户,对树做了任何操作,则将该权限组相关的所有缓存都删掉,因为我无法确定权限组相关的哪些缓存中有被修改过的节点来精确删除,这个工作量太大了,但是也不是不能做,后面会说
- 后来发现value属性无法做到模糊匹配,因为spring boot生成完整key(不是@Cacheable里的key属性)的方法是,value::key,将value和key通过::接起来,所以我想应该是能通过key来模糊匹配
- 通过传入各种*,最终确定了key也无法模糊匹配,当然也没法通过@CacheEvict里面的allEntries,因为这个设定true后,key这个东西就不管用了,并且value这个东西是死的,不能跟key一样动态生成
先看下spring boot是如何处理cache相关的注解的
我们已知的几个缓存组件有:
- 注解,如@Cacheable和@CacheEvict
- KeyGenerator,用来自定义key,通过反射代理拿到该方法的参数列表以及值,以及方法名
- CacheManager,用来管理缓存的各种东西,包括连接池、写入类、缓存配置(也就是注解)
那么答案或许就在CacheManager里面,刚好发现里面有个设置写入类的地方:
public static RedisCacheManagerBuilder builder(RedisCacheWriter cacheWriter) {
Assert.notNull(cacheWriter, "CacheWriter must not be null!");
return RedisCacheManagerBuilder.fromCacheWriter(cacheWriter);
}
然后发现这个RedisCacheWriter是个接口,但是这个接口的方法有点多,包括很多同步代码块,如果自己写的话很容易出问题。
那么如果找到RedisCacheWriter真正的实现类,然后复制出来并把里面实现@CacheEvict的地方重写,不就没问题了。
然后就发现了DefaultRedisCacheWriter和里面的
@Override
public void remove(String name, byte[] key) {
Assert.notNull(name, "Name must not be null!");
Assert.notNull(key, "Key must not be null!");
execute(name, connection -> connection.del(key));
}
@Override
public void clean(String name, byte[] pattern) {
Assert.notNull(name, "Name must not be null!");
Assert.notNull(pattern, "Pattern must not be null!");
execute(name, connection -> {
boolean wasLocked = false;
try {
if (isLockingCacheWriter()) {
doLock(name, connection);
wasLocked = true;
}
byte[][] keys = Optional.ofNullable(connection.keys(pattern)).orElse(Collections.emptySet())
.toArray(new byte[0][]);
if (keys.length > 0) {
connection.del(keys);
}
} finally {
if (wasLocked && isLockingCacheWriter()) {
doUnlock(name, connection);
}
}
return "OK";
});
}
那么很明确了,我的目的就是将remove,改成类似clean的东西,然后在KeyGenerator里面,或者是remove里面,自动拼接上*,然后在Configuration里面,设置好重写完的类就好了。
下面是修改后的remove方法
@Override
public void remove(String name, byte[] key) {
Assert.notNull(name, "Name must not be null!");
Assert.notNull(key, "Key must not be null!");
logger.info("remove name:" + name + " key:" + new String(key));
String keyStr = new String(key);
keyStr += "*";
clean(name,keyStr.getBytes());
}
下面是添加配置的代码
@Bean(name=CacheConstant.TREE_CACHE_MANAGER)
public RedisCacheManager treeCacheManager(RedisConnectionFactory connectionFactory) {
RedisCacheConfiguration config = RedisCacheConfiguration
.defaultCacheConfig()
.prefixKeysWith(CacheConstant.TREE_CACHE_NAME + CacheConstant.TREE_CACHE_SPLITOR)
.entryTtl(Duration.ofSeconds(CacheConstant.CACHE_TTL));
RedisCacheManager redisCacheManager = RedisCacheManager
.builder(new TreeRedisCacheWriter(connectionFactory))
.cacheDefaults(config)
.transactionAware()
.build();
return redisCacheManager;
}
但是这样改了会有问题,就是所有的用到缓存的地方,remove都被改了。
当然这个很好解决,只需要给定一个默认的Primary的CacheManager,然后在Configuration里面配置就好了,这样如果不指定特定的CacheManager,就会用spring boot默认的。
@Primary
@Bean(name=CacheConstant.DEFAULT_CACHE_MANAGER)
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
RedisCacheConfiguration config = RedisCacheConfiguration
.defaultCacheConfig()
.entryTtl(Duration.ofSeconds(CacheConstant.CACHE_TTL));
RedisCacheManager redisCacheManager = RedisCacheManager.builder(connectionFactory).cacheDefaults(config).build();
return redisCacheManager;
}