一、前情提要
长久以来,对springboot项目中缓存的使用都会有一些争论,一部分人认为缓存就应该具有延时性,即给他设置了10分钟的缓存,就应该10分钟后清理。还有一部分人认为缓存应该具有及时性(或弱及时性),即我设置了缓存后,一旦数据发生变化,缓存需要重新刷新。
对于第一种观点,事实上现有的缓存结构就已经满足了,无需我们进行特殊操作,这里我们不做过多讨论。
对于第二种观点,事实上现有的缓存结构也能够满足,只不过在加缓存的时候好加,可是在清理缓存的时候,我们需要手动对更新接口进行配置,可是由于项目的不断增大,我们很容易遗漏在哪个地方需要清理掉缓存。尤其是涉及到多表操作的时候,清理哪个缓存,将会变得比较困难。
所以,我们在想,能否有一种方式,既能够不改变我们使用缓存的习惯,又不需要我们手动的清理缓存,还能保证缓存清空的及时性。这篇文章将带领大家一同探索缓存自动清理的方案。
本次缓存清理是以Mybatis二级缓存作为研究对象,只要你理解了这个原理,SpringCache缓存同样可以。
二、我们采取的思路
1.先来看一下mybatis二级缓存的用法
@Mapper
@CacheNamespace(flushInterval = 5 * 60 * 1000)
public interface ProductDao {
/**
* 查询产品列表
*
* @param dto 参数
* @return 产品列表
*/
@SelectProvider(type = ProductDaoProvider.class, method = "listAgentProductAsDisplay")
List<DisplayProductVO> listAgentProductAsDisplay(DisplayProductDTO dto);
/**
* 查询产品通过id
*
* @param productId 产品id
* @return 产品
*/
@Select("select * from product where id=#{productId} ")
Product getProductById(Long productId);
/**
* 查询产品通过id(不带缓存)
*
* @param productId 产品id
* @return 产品
*/
@Select("select * from product where id=#{productId} ")
@Options(useCache = false)
Product getProductByIdNoCache(Long productId);
}
- @CacheNamespace(flushInterval = 5 * 60 * 1000)
我们给相应的Dao层,增加这个注解,就说明启用的mybatis二级缓存。flushInterval 代表了缓存了多长时间。- @Options(useCache = false)
当这个查询,不需要进行缓存的时候,我们加上这个注解,说明这条语句不需要进行缓存。
2. 思路原理(可多看几遍)
(1)、鉴于上面mybatis二级缓存的用法,我们发现,在同一个项目中,使用了二级缓存的Dao层,上面都具有@CacheNamespace注解,而且在Dao层下面,都具又@Select 和 @SelectPrivider 注解。
(2)、如果我们能够在项目启动的时候,把所有满足这些注解的数据全部扫描出来,然后分析出每一个@Select或@SelectProvider注解对应的sql语句中的表。组合成 【表 ===>执行sql方法】的数据结构。
(3)、当我们在执行更新/插入/删除操作时,也能够拦截到执行的语句,同时分析出执行的语句中包含哪个表。
(4)、最后一旦有数据更新,拦截器会预先拦截,我们从预先构建好的数据结构中,找到相应的语句,清空掉他的缓存。这样是否就实现了缓存的自动清理了呢?
三、如何实现
1. 改造mybatis查询缓存落到Redis中
- DumasRedisCache.java
import cn.decentchina.dumas.common.utils.BeanUtils;
import org.apache.ibatis.cache.Cache;
import org.springframework.data.redis.core.RedisTemplate;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
/**
* mybatis-redis二级缓存配置
*
* @author wangyx
*/
public class DumasRedisCache implements Cache {
/**
* 读写锁
*/
private final ReadWriteLock readWriteLock = new ReentrantReadWriteLock(true);
private final String id;
/**
* 是mybatis必须要求的,必写。此id是xml中的namespace的值
*
* @param id cacheId
*/
public DumasRedisCache(final String id) {
if (id == null) {
throw new IllegalArgumentException("未获取到缓存实例id");
}
this.id = id;
}
/**
* 返回cache的唯一名称
*
* @return cacheId
*/
@Override
public String getId() {
return this.id;
}
/**
* 缓存存值
*
* @param key 缓存键
* @param value 缓存值
*/
@Override
public void putObject(Object key, Object value) {
//id是namespace的值,key是方法名,value是查询的结果
getRedisTemplate().opsForHash().put(id, key.toString(), value);
}
/**
* 缓存取值
*
* @param key 缓存键
* @return 缓存值
*/
@Override
public Object getObject(Object key) {
return getRedisTemplate().opsForHash().get(id, key.toString());
}
/**
* mybatis保留方法
*
* @param key 键
* @return Object
*/
@Override
public Object removeObject(Object key) {
return null;
}
/**
* 清空缓存,在增删改时会自动调用
*/
@Override
public void clear() {
}
@Override
public int getSize() {
return getRedisTemplate().opsForHash().size(id).intValue();
}
@Override
public ReadWriteLock getReadWriteLock() {
return this.readWriteLock;
}
/**
* 获取RedisTemplate,不能通过注入的方式,原因是此类是由mybatis实例化的
*
* @return redisTemplate
*/
private RedisTemplate getRedisTemplate() {
//从上下文中获取redisTemplate
return BeanUtils.getBean("redisTemplate");
}
}
- BeanUtils.java 工具类
import org.springframework.beans.BeansException;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Component;
/**
* spring bean工具类,用于不自动注入的类从容器中取相应bean
*
* @author wangyx
*/
@Component
public class BeanUtils implements ApplicationContextAware {
private static ApplicationContext applicationContext;
/**
* 服务器启动,Spring容器初始化时,当加载了当前类为bean组件后,
* 将会调用下面方法注入ApplicationContext实例
*/
@Override
public void setApplicationContext(ApplicationContext arg0) throws BeansException {
BeanUtils.applicationContext = arg0;
}
public static ApplicationContext getApplicationContext() {
return applicationContext;
}
/**
* 外部调用这个getBean方法就可以手动获取到bean
* 用bean组件的name来获取bean
*
* @param beanName
* @return
*/
@SuppressWarnings("unchecked")
public static <T> T getBean(String beanName) {
return (T) applicationContext.getBean(beanName);
}
}
- RedisConfig.java
··
/**
* 自定义redis
*
* @author wangyx
*/
@Configuration
public class RedisConfig {
@Bean(name = "redisTemplate")
public RedisTemplate<String, Object> redisTemplateObject(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, Object> redisTemplateObject = new RedisTemplate<>();
redisTemplateObject.setConnectionFactory(redisConnectionFactory);
redisTemplateObject.setKeySerializer(new StringRedisSerializer());
redisTemplateObject.afterPropertiesSet();
return redisTemplateObject;
}
}
- 需要加缓存的dao层使用方法
@CacheNamespace(implementation = DumasRedisCache.class, flushInterval = 10000)
@Mapper
public interface ApiProductTestDao {
...
}
-
dao层方法被调用后的Redis存储数据结构
2.项目启动自动扫描注解
这里主要用到的反射的原理,所以我们需要引入一个处理反射的maven
<!-- https://mvnrepository.com/artifact/org.reflections/reflections -->
<dependency>
<groupId>org.reflections</groupId>
<artifactId>reflections</artifactId>
<version>0.9.12</version>
</dependency>
为了更加精确的分析出每个表对应的值,我们还编写了一个获取当前数据库所有表名的Dao
- TableDao.java
/**
* 表sql管理
*
* @author wangyx
*/
@Mapper
public interface TableDao {
/**
* 读取所有表名
*
* @return set
*/
@Select("show tables from dumas")
Set<String> readAll();
}
集成springboot启动类,启动完成后,自动扫描符合条件的注解。并与表搭配,组合好相应的数据结构,放到Redis中去
- TableMethodInitializer.java
import cn.decentchina.dumas.common.cache.dao.TableDao;
import cn.decentchina.dumas.common.utils.Constant;
import org.apache.ibatis.annotations.CacheNamespace;
import org.apache.ibatis.annotations.Options;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.SelectProvider;
import org.apache.ibatis.session.SqlSessionFactory;
import org.reflections.Reflections;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.ApplicationEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Set;
/**
* mybatis二级缓存,初始化表名与方法名
* 注:mybatis拦截器{@link SqlModifyInterceptor}需要注入{@link GlobalMybatisCacheComponent},
* 原因是{@link GlobalMybatisCacheComponent}中不能有dao层注入(会出现循环注入的情况),因此加载数据库所有表的功能移到此方法中
*
* @author wangyx
*/
@Component
public class TableMethodInitializer implements ApplicationListener {
/**
* 数据库所有表集合
*/
public static final Set<String> ALL_TABLES = new HashSet<>();
/**
* 扫描包作用域
*/
private static final String STRATEGY_IMPLEMENTATION_PACKAGE = "cn.decentchina.dumas.*";
@Resource
private TableDao tableDao;
@Resource
private GlobalMybatisCacheComponent globalMybatisCacheComponent;
@Resource
private SqlSessionFactory sqlSessionFactory;
@Override
public void onApplicationEvent(ApplicationEvent event) {
if (!(event instanceof ApplicationReadyEvent)) {
return;
}
ALL_TABLES.addAll(tableDao.readAll());
// 自动扫描指定包
Reflections reflections = new Reflections(STRATEGY_IMPLEMENTATION_PACKAGE);
// 获取含有 CacheNamespace 注解的所有 dao
Set<Class<?>> classList = reflections.getTypesAnnotatedWith(CacheNamespace.class);
// 遍历所有符合条件的类,进行相关业务处理
classList.forEach(classes -> {
// 获取该类下面所有的方法
Method[] methods = classes.getMethods();
Arrays.stream(methods).forEach(method -> {
Options options = method.getAnnotation(Options.class);
// 方法标明不开启缓存的直接跳过
if (options != null && !options.useCache()) {
return;
}
String methodDetail = classes.getName() + Constant.FULL_STOP + method.getName();
// 遍历类下面的select方法
Select select = method.getAnnotation(Select.class);
if (select != null) {
globalMybatisCacheComponent.initMapper(select.value()[0], methodDetail);
return;
}
// 遍历类下面的selectProvider方法
SelectProvider selectProvider = method.getAnnotation(SelectProvider.class);
if (selectProvider == null) {
return;
}
String sql = sqlSessionFactory.getConfiguration().getMappedStatement(methodDetail).getBoundSql(new HashMap<>(1)).getSql();
globalMybatisCacheComponent.initMapper(sql, methodDetail);
});
});
}
}
-
扫描完成后,存放的数据结构
3.存放和清理缓存工具类
- GlobalMybatisCacheComponent.java
import cn.decentchina.dumas.common.utils.Constant;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.Set;
/**
* mybatis全局二级缓存组件
*
* @author wangyx
*/
@Component
public class GlobalMybatisCacheComponent {
/**
* 全局二级缓存,表名-方法名映射键
* globalMybatisCacheTableMethodsMapper-表名
*/
private static final String TABLE_METHODS_MAPPER_KEY = "globalMybatisCacheTableMethodsMapper-";
/**
* 分页查询count语句后缀
*/
private static final String QUERY_PAGE_SUFFIX = "_COUNT";
@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource
private RedisTemplate<String, Object> redisTemplate;
/**
* 从sql中提取表,与方法进行初始化映射
*
* @param sql 原始sql
* @param methodDetail 类名.方法名
*/
public void initMapper(String sql, String methodDetail) {
TableMethodInitializer.ALL_TABLES.stream().filter(table -> StringUtils.containsIgnoreCase(sql, table))
// 遍历以hash形式存入redis,表名做为hash集合,键和值都是详细方法名
.forEach(table -> stringRedisTemplate.opsForHash().put(TABLE_METHODS_MAPPER_KEY + table, methodDetail, methodDetail));
}
/**
* 清空表对应的所有方法缓存
*
* @param table 表名
*/
public void clearTableCache(String table) {
// 取出表对应的hash集合里的所有键(详细方法名)
Set<Object> allMethods = stringRedisTemplate.opsForHash().keys(TABLE_METHODS_MAPPER_KEY + table);
allMethods.forEach(method -> {
String combineName = String.valueOf(method);
// 遍历表对应的所有方法,从最后一个“.”截取之前的信息(类名),类名正是mybatis二级缓存中hash集合的名称
String className = StringUtils.substringBeforeLast(combineName, Constant.FULL_STOP);
// 从二级缓存hash集合里定向删除此表对应的方法的所有key
Set<Object> mybatisCacheMethods = redisTemplate.opsForHash().keys(className);
mybatisCacheMethods.stream().filter(cacheMethod -> {
String cacheKey = String.valueOf(cacheMethod);
/*
1.分页查询的sql,mybatis二级缓存的键为原方法名+_COUNT
2.mybatis二级缓存的键,包含入参信息,相同方法不同入参是两条缓存信息,这里全部删掉
*/
return StringUtils.containsAnyIgnoreCase(cacheKey, combineName, combineName + QUERY_PAGE_SUFFIX);
}).forEach(key -> redisTemplate.opsForHash().delete(className, key));
});
}
}
4.mybatis 自定义拦截器(拦截update请求)
import org.apache.commons.lang3.StringUtils;
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.plugin.*;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.Properties;
import java.util.stream.Collectors;
/**
* 执行更新命令的数据库脚本拦截器
*
* @author wangyx
*/
@Component
@Intercepts(@Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class}))
public class SqlModifyInterceptor implements Interceptor {
@Resource
private GlobalMybatisCacheComponent globalMybatisCacheComponent;
@Override
public Object intercept(Invocation invocation) throws Throwable {
MappedStatement mappedStatement = (MappedStatement) invocation.getArgs()[0];
//0.sql参数获取
Object parameter = null;
if (invocation.getArgs().length > 1) {
parameter = invocation.getArgs()[1];
}
//1.获取sqlId
BoundSql boundSql = mappedStatement.getBoundSql(parameter);
//获取到原始sql语句
String sql = boundSql.getSql();
TableMethodInitializer.ALL_TABLES.stream()
.filter(table -> StringUtils.containsIgnoreCase(sql, table)).collect(Collectors.toList())
.forEach(table -> globalMybatisCacheComponent.clearTableCache(table));
return invocation.proceed();
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
@Override
public void setProperties(Properties properties) {
}
}
四、结语
本次封装需要所掌握的知识面要求比叫高,涉及到 反射、注解取值、启动类、mybatis缓存原理、mybatis拦截器、Redis操作、数据结构、sqlSessionFactory 掌握、lambda表达式等一系列的内容,可以多研究研究。