背景
项目数据库数据量较大,分页查询要很久,所以要对分页优化,项目使用的分页是mybatis的Pagehelper,于是在Pagehelper的基础上进行了本次分页查询的优化
Mybatis-Pagehelper
优化是基于mybatis-Pagehelper的,我们先看一下mybatis-Pagehelper这个插件,他是怎么实现mybatis分页的,比如,基本上我们每个人在分页查询时都会看到先查询count 再 具体查询,那他们在哪个环节查询,这里就有答案,首先我们先对这个插件进行集成
集成
<dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper-spring-boot-starter</artifactId>
配置
pagehelper.helperDialect=mysql pagehelper.reasonable=false
使用
使用起来很简单,这样就可以完成了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 Page 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<Page>中,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新写功能
基于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,缺点是对复杂查询还是有些不兼容,不过个人觉得瑕不掩瑜了。