美文网首页
MyBatis二级缓存自动清理(独家封装)

MyBatis二级缓存自动清理(独家封装)

作者: 一击必中 | 来源:发表于2021-08-14 11:32 被阅读0次

    一、前情提要

    长久以来,对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);
    }
    
    1. @CacheNamespace(flushInterval = 5 * 60 * 1000)
      我们给相应的Dao层,增加这个注解,就说明启用的mybatis二级缓存。flushInterval 代表了缓存了多长时间。
    2. @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表达式等一系列的内容,可以多研究研究。

    相关文章

      网友评论

          本文标题:MyBatis二级缓存自动清理(独家封装)

          本文链接:https://www.haomeiwen.com/subject/cdmsbltx.html