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

一、前情提要

长久以来,对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表达式等一系列的内容,可以多研究研究。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,723评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,485评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,998评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,323评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,355评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,079评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,389评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,019评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,519评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,971评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,100评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,738评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,293评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,289评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,517评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,547评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,834评论 2 345

推荐阅读更多精彩内容