Mybatis-Pagehelper详细解析及优化插件开发

588 阅读6分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第5天,点击查看活动详情

背景

项目数据库数据量较大,分页查询要很久,所以要对分页优化,项目使用的分页是mybatis的Pagehelper,于是在Pagehelper的基础上进行了本次分页查询的优化

Mybatis-Pagehelper

优化是基于mybatis-Pagehelper的,我们先看一下mybatis-Pagehelper这个插件,他是怎么实现mybatis分页的,比如,基本上我们每个人在分页查询时都会看到先查询count 再 具体查询,那他们在哪个环节查询,这里就有答案,首先我们先对这个插件进行集成

  1. 集成

    <dependency>
    
    <groupId>com.github.pagehelper</groupId>
    
    <artifactId>pagehelper-spring-boot-starter</artifactId>
    
    </dependency>
    
  2. 配置

    pagehelper.helperDialect=mysql
    
    pagehelper.reasonable=false
    
    pagehelper.params=count=countSql
    
  3. 使用 使用起来很简单,这样就可以完成了

    public PageInfo<MessageSmsSendTaskVO> queryByPage(MessageSmsSendTaskQuery messageSmsSendTaskQuery) {
    
    messageSmsSendTaskQuery.enablePage();
    
    List<MessageSmsSendTaskVO> list = this.messageSmsSendTaskMapper.queryList(messageSmsSendTaskQuery);
    
    STranslationConvertUtil.convertList(list);
    
    return toPageInfo(list);
    
    }
    
    /**
    * 初始化分页信息,且开启分页线程控制器
    */
    
    public <E> Page<E> enablePage() {
        pageInfoInit();
        return PageHelper.startPage(pageNum, pageSize);
    }
    
    /**
    * 初始化分页信息
    */
    
    public void pageInfoInit() {
        if (pageNum == null || pageNum < 1) {
        pageNum = 1;
        }
        if (pageSize == null || pageSize < 0) {
        pageSize = 15;
        }
    }
    
    
  4. 源码分析

    我们可以在idea上直接看源码,也可以在码云下载源码项目分析,

    项目地址 : http://git.oschina.net/free/Mybatis_PageHelper
    

    我们从PageHelper.startPage(pageNum, pageSize)这个调用开始分析 他调用的是这个方法

    public static <E> Page<E> startPage(int pageNum, int pageSize, boolean count, Boolean reasonable, Boolean pageSizeZero) {
        Page<E> page = new Page<E>(pageNum, pageSize, count);
        page.setReasonable(reasonable);
        page.setPageSizeZero(pageSizeZero);
        //当已经执行过orderBy的时候
        Page<E> oldPage = getLocalPage();
        if (oldPage != null && oldPage.isOrderByOnly()) {
            page.setOrderBy(oldPage.getOrderBy());
        }
        setLocalPage(page);
        return page;
    }
    

    其实这个方法就一个目的就是初始化Page对象,也就是setLocalPage(page)中完成初始化

    public abstract class PageMethod {
        protected static final ThreadLocal<Page> LOCAL_PAGE = new ThreadLocal<Page>();
        protected static boolean DEFAULT_COUNT = true;
    
        /**
         * 设置 Page 参数
         *
         * @param page
         */
        protected static void setLocalPage(Page page) {
            LOCAL_PAGE.set(page);
        }
    

    我们看到最终把实例化好的page对象放入到了ThreadLocal中,ThreadLocal之前我有写文章说过,他是线程本地变量,使用他不会存在并发问题,因为他会为每一个放在他里面的对象都开辟一块独立的内存空间。 这个方法就结束了,我们发现后面只调用了new PageInfo<>(list)方法,并没有看到任何关于count查询的方法,那么这部分查询在哪里呢,其实是在实现了拦截器的PageInterceptor中

    public class PageInterceptor implements Interceptor {
        private volatile Dialect dialect;
        private String countSuffix = "_COUNT";
        protected Cache<String, MappedStatement> msCountMap = null;
        private String default_dialect_class = "com.github.pagehelper.PageHelper";
        @Override
        public Object intercept(Invocation invocation) throws Throwable {
            try {
                Object[] args = invocation.getArgs();
                MappedStatement ms = (MappedStatement) args[0];
                Object parameter = args[1];
                RowBounds rowBounds = (RowBounds) args[2];
                ResultHandler resultHandler = (ResultHandler) args[3];
                Executor executor = (Executor) invocation.getTarget();
                CacheKey cacheKey;
                BoundSql boundSql;
                //由于逻辑关系,只会进入一次
                if (args.length == 4) {
                //4 个参数时
                boundSql = ms.getBoundSql(parameter);
                cacheKey = executor.createCacheKey(ms, parameter, rowBounds, boundSql);
            } else {
                //6 个参数时
                cacheKey = (CacheKey) args[4];
                boundSql = (BoundSql) args[5];
                }
                checkDialectExists();
                //对 boundSql 的拦截处理
                if (dialect instanceof BoundSqlInterceptor.Chain) {
                boundSql = ((BoundSqlInterceptor.Chain) dialect).doBoundSql(BoundSqlInterceptor.Type.ORIGINAL, boundSql, cacheKey);
                }
                List resultList;
                //调用方法判断是否需要进行分页,如果不需要,直接返回结果
                if (!dialect.skip(ms, parameter, rowBounds)) {
                //判断是否需要进行 count 查询
                if (dialect.beforeCount(ms, parameter, rowBounds)) {
                    //查询总数
                    Long count = count(executor, ms, parameter, rowBounds, null, boundSql);
                    //处理查询总数,返回 true 时继续分页查询,false 时直接返回
                    if (!dialect.afterCount(count, parameter, rowBounds)) {
                    //当查询总数为 0 时,直接返回空的结果
                    return dialect.afterPage(new ArrayList(), parameter, rowBounds);
                    }
                }
                resultList = ExecutorUtil.pageQuery(dialect, executor,
                ms, parameter, rowBounds, resultHandler, boundSql, cacheKey);
                } else {
                //rowBounds用参数值,不使用分页插件处理时,仍然支持默认的内存分页
                resultList = executor.query(ms, parameter, rowBounds, resultHandler, cacheKey, boundSql);
                }
                return dialect.afterPage(resultList, parameter, rowBounds);
                } finally {
                if(dialect != null){
                dialect.afterAll();
            }
        }
    }
    

    这个方法里面实现了count查询和方法查询,但是其实它不仅仅是只有分页查询调用,部分页面也会调用,那他又是被谁调用的,我们继续前推

        public class Plugin implements InvocationHandler {
    
          private final Object target;
          private final Interceptor interceptor;
          private final Map<Class<?>, Set<Method>> signatureMap;
    
          private Plugin(Object target, Interceptor interceptor, Map<Class<?>, Set<Method>> signatureMap) {
            this.target = target;
            this.interceptor = interceptor;
            this.signatureMap = signatureMap;
          }
    
          public static Object wrap(Object target, Interceptor interceptor) {
            Map<Class<?>, Set<Method>> signatureMap = getSignatureMap(interceptor);
            Class<?> type = target.getClass();
            Class<?>[] interfaces = getAllInterfaces(type, signatureMap);
            if (interfaces.length > 0) {
              return Proxy.newProxyInstance(
                  type.getClassLoader(),
                  interfaces,
                  new Plugin(target, interceptor, signatureMap));
            }
            return target;
          }
    
          @Override
          public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
            try {
              Set<Method> methods = signatureMap.get(method.getDeclaringClass());
              if (methods != null && methods.contains(method)) {
                return interceptor.intercept(new Invocation(target, method, args));
              }
              return method.invoke(target, args);
            } catch (Exception e) {
              throw ExceptionUtil.unwrapThrowable(e);
            }
          }
        ```
    

    我们看到调用他的类是Plugin类,而且是jdk动态代理来实现的,所以真相大白了,这个就是mybatis通过动态代理查询。回到PageInterceptor # intercept方法,他取出参数后通过skip方法判断要不要进行分页,我们看下skip方法的实现

    public boolean skip(MappedStatement ms, Object parameterObject, RowBounds rowBounds) {
        if (ms.getId().endsWith(MSUtils.COUNT)) {
            throw new RuntimeException("在系统中发现了多个分页插件,请检查系统配置!");
        }
        Page page = pageParams.getPage(parameterObject, rowBounds);
        if (page == null) {
            return true;
        } else {
            //设置默认的 count 列
            if (StringUtil.isEmpty(page.getCountColumn())) {
                page.setCountColumn(pageParams.getCountColumn());
            }
            autoDialect.initDelegateDialect(ms);
            return false;
        }
    }
    

    他会调用getPage方法获取page,老铁们这个就和开头对应上了,因为开头时我们只是做一个赋值操作,没有做其他的,而这个通过判断有没有值来判断是不是分页,所以很合理。 如果不进行分页,那么就回调用

    resultList = executor.query(ms, parameter, rowBounds, resultHandler, cacheKey, boundSql);
    

    这个方法就是获取mapper文件中的sql来执行查询了。这些就是分页插件的基本原理了,现在回到最开始里的问题,数据量很大,我们要提速,要基于mybatis-Pagehelper新写功能

  5. 基于mybatis-Pagehelper的插件

    新建MidPageHelper类

     public class MidPageHelper extends PageMethod implements Dialect, BoundSqlInterceptor.Chain
    

    这个类和PageHelper一样,也就是在调用时初始化

    PageHelperHandler类 这个类里面就是访写PageInterceptor类

    
     @Slf4j
     @Intercepts({
             @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}),
             @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class}),
     })
     public class PageHelperHandler implements MidExpandMybatisInterceptor {
    
         private volatile Dialect dialect;
         private String countSuffix = "_MID_COUNT";
         protected Cache<String, MappedStatement> msCountMap = null;
         private String default_dialect_class = "com.dst.mid.expand.page.MidPageHelper";
    
         @Override
         public Object intercept(Invocation invocation) throws Throwable {
    

    我们刚刚说

    resultList = executor.query(ms, parameter, rowBounds, resultHandler, cacheKey, boundSql);
    

    这个方法是最后的执行方法,其实这次分页的优化也是对这里进行处理 我们在前面加一些逻辑,增加pageQuery方法

    public static <E> List<E> pageQuery方法(Dialect dialect, Executor executor, MappedStatement ms, Object parameter,
                                        RowBounds rowBounds, ResultHandler resultHandler,
                                        BoundSql boundSql, CacheKey cacheKey) throws SQLException, JSQLParserException {
        //生成分页的缓存 key
        CacheKey pageKey = cacheKey;
        //处理参数对象
        parameter = dialect.processParameterObject(ms, parameter, boundSql, pageKey);
        //调用方言获取分页 sql
        //String pageSql = dialect.getPageSql(ms, boundSql, parameter, rowBounds, pageKey);
        String pageSql = getPageQuerySql(boundSql.getSql(), PageHelperThreadLocal.getPage());
        BoundSql pageBoundSql = new BoundSql(ms.getConfiguration(), pageSql, boundSql.getParameterMappings(), parameter);
    
        Map<String, Object> additionalParameters = ExecutorUtil.getAdditionalParameter(boundSql);
        //设置动态参数
        for (String key : additionalParameters.keySet()) {
            pageBoundSql.setAdditionalParameter(key, additionalParameters.get(key));
        }
        //对 boundSql 的拦截处理
        if (dialect instanceof BoundSqlInterceptor.Chain) {
            pageBoundSql = ((BoundSqlInterceptor.Chain) dialect).doBoundSql(BoundSqlInterceptor.Type.PAGE_SQL, pageBoundSql, pageKey);
        }
        //执行分页查询
        return executor.query(ms, parameter, RowBounds.DEFAULT, resultHandler, pageKey, pageBoundSql);
    }
    

    getPageSql方法就是这次优化的核心

    public static String getPageQuerySql(String sql, Page page) throws JSQLParserException {
        String innerSql = getInnerSql(sql);
        String fromSql = getFromSql(sql);
        String limit;
        if (page.getStartRow() == 0) {
            limit = "\n LIMIT ? ";
        } else {
            limit = "\n LIMIT ?, ? ";
        }
        String querySql = String.format("SELECT * FROM (%s) from_tbl INNER JOIN (%s \n %s) inner_tbl on from_tbl.id = inner_tbl.id", fromSql, innerSql, limit);
        log.info("querySql: {}", querySql);
        return querySql;
    }
    

    原理其实就是,先把要查看数据的id查出来,再把id结果集取一个别名和真正要查的所有数据关联获取结果,id在数据库里面是b+树中所以就算排序他也很快,如果用之前的方式,直接通过limit获取结果集再加上排序,时间就很长了,经过测试600w条数据从一分钟,优化到花费3s,缺点是对复杂查询还是有些不兼容,不过个人觉得瑕不掩瑜了。

结语

又是一篇对源码的分析,的确很耗时间,不过也解答了一些困惑的问题,一些不明觉厉的东西也解决了,我们常说我们很少用到一些技术,的确不少技术都是在特定场景下使用的,通过阅读源码,我们可以仿造,在仿造的过程中也得到了提高,也是不错的选择,