前因
項目一直使用的是PageHelper實作分頁功能,項目前期資料量較少一直沒有什麼問題。随着業務擴增,資料庫擴增PageHelper出現了明顯的性能問題。
幾十萬甚至上百萬的單表資料查詢性能緩慢,需要幾秒乃至十幾秒的查詢時間。故此特地研究了一下PageHelper源碼,查找PageHelper分頁的實作方式。
一段較為簡單的查詢,跟随debug開始源碼探尋之旅。
public ResultContent select(Integer id) {
Page<Test> blogPage = PageHelper.startPage(1,3).doSelectPage( () -> testDao.select(id));
List<Test> test = (List<Test>)blogPage.getResult();
return new ResultContent(0, "success", test);
}
主要儲存由前端傳入的pageNum(頁數)、pageSize(每頁顯示數量)和count(是否進行count(0)查詢)資訊。
這裡是簡單的建立page并儲存目前線程的變量副本心裡,不做深究。
public static <E> Page<E> startPage(int pageNum, int pageSize) {
return startPage(pageNum, pageSize, DEFAULT_COUNT);
}
public static <E> Page<E> startPage(int pageNum, int pageSize, boolean count) {
return startPage(pageNum, pageSize, count, (Boolean)null, (Boolean)null);
}
public static <E> Page<E> startPage(int pageNum, int pageSize, String orderBy) {
Page<E> page = startPage(pageNum, pageSize);
page.setOrderBy(orderBy);
return page;
}
public static <E> Page<E> startPage(int pageNum, int pageSize, boolean count, Boolean reasonable, Boolean pageSizeZero) {
Page<E> page = new Page(pageNum, pageSize, count);
page.setReasonable(reasonable);
page.setPageSizeZero(pageSizeZero);
Page<E> oldPage = getLocalPage();
if(oldPage != null && oldPage.isOrderByOnly()) {
page.setOrderBy(oldPage.getOrderBy());
}
setLocalPage(page);
return page;
}
開始執行真正的select語句
public <E> Page<E> doSelectPage(ISelect select) {
select.doSelect();
return this;
}
進入MapperProxy類執行invoke方法擷取到方法名稱及參數值
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if (Object.class.equals(method.getDeclaringClass())) {
try {
return method.invoke(this, args);
} catch (Throwable t) {
throw ExceptionUtil.unwrapThrowable(t);
}
}
final MapperMethod mapperMethod = cachedMapperMethod(method);
return mapperMethod.execute(sqlSession, args);
}
接着是MapperMethod方法執行execute語句,判斷是增、删、改、查。判斷傳回值是多個,進入executeForMany方法
public Object execute(SqlSession sqlSession, Object[] args) {
Object result;
if (SqlCommandType.INSERT == command.getType()) {
Object param = method.convertArgsToSqlCommandParam(args);
result = rowCountResult(sqlSession.insert(command.getName(), param));
} else if (SqlCommandType.UPDATE == command.getType()) {
Object param = method.convertArgsToSqlCommandParam(args);
result = rowCountResult(sqlSession.update(command.getName(), param));
} else if (SqlCommandType.DELETE == command.getType()) {
Object param = method.convertArgsToSqlCommandParam(args);
result = rowCountResult(sqlSession.delete(command.getName(), param));
} else if (SqlCommandType.SELECT == command.getType()) {
if (method.returnsVoid() && method.hasResultHandler()) {
executeWithResultHandler(sqlSession, args);
result = null;
} else if (method.returnsMany()) {
result = executeForMany(sqlSession, args);
} else if (method.returnsMap()) {
result = executeForMap(sqlSession, args);
} else {
Object param = method.convertArgsToSqlCommandParam(args);
result = sqlSession.selectOne(command.getName(), param);
}
} else if (SqlCommandType.FLUSH == command.getType()) {
result = sqlSession.flushStatements();
} else {
throw new BindingException("Unknown execution method for: " + command.getName());
}
if (result == null && method.getReturnType().isPrimitive() && !method.returnsVoid()) {
throw new BindingException("Mapper method '" + command.getName()
+ " attempted to return null from a method with a primitive return type (" + method.getReturnType() + ").");
}
return result;
}
這個方法開始調用SqlSessionTemplate、DefaultSqlSession等類擷取到Mapper.xml檔案的SQL語句
private <E> Object executeForMany(SqlSession sqlSession, Object[] args) {
List<E> result;
Object param = method.convertArgsToSqlCommandParam(args);
if (method.hasRowBounds()) {
RowBounds rowBounds = method.extractRowBounds(args);
result = sqlSession.<E>selectList(command.getName(), param, rowBounds);
} else {
result = sqlSession.<E>selectList(command.getName(), param);
}
// issue #510 Collections & arrays support
if (!method.getReturnType().isAssignableFrom(result.getClass())) {
if (method.getReturnType().isArray()) {
return convertToArray(result);
} else {
return convertToDeclaredCollection(sqlSession.getConfiguration(), result);
}
}
return result;
}
開始進入PageHelper的真正實作,Plugin通過實作InvocationHandler進行動态代理擷取到相關資訊
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);
}
}
PageInterceptor 實作Mybatis的Interceptor 接口,進行攔截
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) {
boundSql = ms.getBoundSql(parameter);
cacheKey = executor.createCacheKey(ms, parameter, rowBounds, boundSql);
} else {
cacheKey = (CacheKey)args[4];
boundSql = (BoundSql)args[5];
}
this.checkDialectExists();
List resultList;
if(!this.dialect.skip(ms, parameter, rowBounds)) {
if(this.dialect.beforeCount(ms, parameter, rowBounds)) {
Long count = this.count(executor, ms, parameter, rowBounds, resultHandler, boundSql);
if(!this.dialect.afterCount(count.longValue(), parameter, rowBounds)) {
Object var12 = this.dialect.afterPage(new ArrayList(), parameter, rowBounds);
return var12;
}
}
resultList = ExecutorUtil.pageQuery(this.dialect, executor, ms, parameter, rowBounds, resultHandler, boundSql, cacheKey);
} else {
resultList = executor.query(ms, parameter, rowBounds, resultHandler, cacheKey, boundSql);
}
Object var16 = this.dialect.afterPage(resultList, parameter, rowBounds);
return var16;
} finally {
this.dialect.afterAll();
}
}
轉到ExecutorUtil抽象類的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 {
if(!dialect.beforePage(ms, parameter, rowBounds)) {
return executor.query(ms, parameter, RowBounds.DEFAULT, resultHandler, cacheKey, boundSql);
} else {
parameter = dialect.processParameterObject(ms, parameter, boundSql, cacheKey);
String pageSql = dialect.getPageSql(ms, boundSql, parameter, rowBounds, cacheKey);
BoundSql pageBoundSql = new BoundSql(ms.getConfiguration(), pageSql, boundSql.getParameterMappings(), parameter);
Map<String, Object> additionalParameters = getAdditionalParameter(boundSql);
Iterator var12 = additionalParameters.keySet().iterator();
while(var12.hasNext()) {
String key = (String)var12.next();
pageBoundSql.setAdditionalParameter(key, additionalParameters.get(key));
}
return executor.query(ms, parameter, RowBounds.DEFAULT, resultHandler, cacheKey, pageBoundSql);
}
}
在抽象類AbstractHelperDialect的getPageSql擷取到對應的Page對象
public String getPageSql(MappedStatement ms, BoundSql boundSql, Object parameterObject, RowBounds rowBounds, CacheKey pageKey) {
String sql = boundSql.getSql();
Page page = this.getLocalPage();
String orderBy = page.getOrderBy();
if(StringUtil.isNotEmpty(orderBy)) {
pageKey.update(orderBy);
sql = OrderByParser.converToOrderBySql(sql, orderBy);
}
return page.isOrderByOnly()?sql:this.getPageSql(sql, page, pageKey);
}
進入到MySqlDialect類的getPageSql方法進行SQL封裝,根據page對象資訊增加Limit。分頁的資訊就是這麼拼裝起來的
public String getPageSql(String sql, Page page, CacheKey pageKey) {
StringBuilder sqlBuilder = new StringBuilder(sql.length() + 14);
sqlBuilder.append(sql);
if(page.getStartRow() == 0) {
sqlBuilder.append(" LIMIT ? ");
} else {
sqlBuilder.append(" LIMIT ?, ? ");
}
return sqlBuilder.toString();
}
将最後拼裝好的SQL傳回給DefaultSqlSession執行查詢并傳回
public <E> List<E> selectList(String statement, Object parameter, RowBounds rowBounds) {
try {
MappedStatement ms = configuration.getMappedStatement(statement);
return executor.query(ms, wrapCollection(parameter), rowBounds, Executor.NO_RESULT_HANDLER);
} catch (Exception e) {
throw ExceptionFactory.wrapException("Error querying database. Cause: " + e, e);
} finally {
ErrorContext.instance().reset();
}
}
至此整個查詢過程完成,原來PageHelper的分頁功能是通過Limit拼接SQL實作的。查詢效率低的問題也找出來了,那麼應該如何解決。
首先分析SQL語句,limit在資料量少或者頁數比較靠前的時候查詢效率是比較高的。(單表資料量百萬進行測試)
select * from user where age = 10 limit 1,10;結果顯示0.43s
當where條件後的結果集較大并且頁數達到一個量級整個SQL的查詢效率就十分低下(哪怕where的條件加上了索引也不行)。
select * from user where age = 10 limit 100000,10;結果顯示4.73s
那有什麼解決方案呢?mysql就不能單表資料量超百萬乃至千萬嘛?答案是NO,顯然是可以的。
SELECT a.* FROM USER a
INNER JOIN
(SELECT id FROM USER WHERE age = 10 LIMIT 100000,10) b
ON a.id = b.id;
結果0.53s
完美解決了查詢效率問題!!!其中需要對where條件增加索引,id因為是主鍵自帶索引。select傳回減少回表可以提升查詢性能,是以采用查詢主鍵字段後進行關聯大幅度提升了查詢效率。
PageHelper想要優化需要在攔截器的拼接SQL部分進行重構,由于部落客能力有限暫未實作。能力較強的讀者可以自己進行重構。