MyBatis系列之分页插件及问题
概述
无论是C端产品页面,还是后台系统页面,不可能一次性将全部数据加载出来。后台系统一般都是PC端登录,用Table组件(如Ant Design Table)渲染展示数据,可点击列表的下一页(或指定某一页)查看数据。C端产品如App,在下滑时可查看更多数据,看起来像是一次性加载数据,实际上也是分批请求后台系统获取数据。而这,就是分页功能。
如果没有使用Hibernate或MyBatis这样的ORM工具,假如面对的是MySQL数据库,则可考虑自己拼接SQL,在末尾加上LIMIT M OFFSET N
,有两种写法:
select * from order LIMIT 1, 3;
select * from order LIMIT 3 OFFSET 1;
解释:
- limit后面跟两个参数时,第一个数表示要跳过的数量,后一位表示要取的数量
- OFFSET表示要跳过的数量,LIMIT表示要取的数量
问题
大部分人几乎不会看到上面这种分页查询数据的写法,因为这种写法存在性能问题。如果表的数据量级只有几万或十几万,单表查询的性能损耗几乎可省略不计。但如果面对单表高达百万级别的数据量时,上面这种写法的执行耗时就不能忽略不计。
为了实现分页,每次收到分页请求时,数据库都需要进行低效的全表遍历。全表遍历,就是根据双向链表把磁盘上的数据页加载到磁盘的缓存页里去,然后在缓存页内部查找那条数据。
解决方案:
- 增加where条件,并且在where条件里使用索引过滤掉无关数据,即想要通过OFFSET跳过的数据,一般都是
where id > 3000000
; - 增加
order by
子句并确保order by
的字段上有索引,这样可利用索引进行排序,而不是在内存中对所有行进行排序; - 使用覆盖索引优化:
SELECT * FROM order a INNER JOIN (SELECT id FROM order LIMIT 3000000, 20) b USING (id);
总之,可通过MySQL explain命令验证一下SQL。如果索引被使用,输出中的type
列通常会显示range、ref或index等,而不是ALL(表示全表扫描)。
使用MyBatis后时,如何分页查询数据?一般会考虑使用分页插件,主要有以下3个(实际上远远不止这3个,记得刚工作时还用过一个MyBatis-Pagination,在Maven里搜索不到,不是下面列出的MyBatis-Paginator):
PageHelper
支持多种数据库(如MySQL、PostgreSQL、Oracle等)且配置简单,支持多数据库、自动分页、分页参数合理化、分页插件链式调用、自定义count查询。开源GitHub。
对应的Maven依赖为:
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper</artifactId>
<version>6.1.0</version>
</dependency>
有提供对应的spring-boot-starter,依赖如下:
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
<version>2.1.0</version>
</dependency>
pagehelper-spring-boot-starter
某个版本具体使用什么版本的pagehelper,可通过IDEA查看pom文件得知。
一定要知道**-spring-boot-starter
实际上引用的还是**
。
简单使用:
// 省略其他非分页插件相关的import语句,下同
import com.github.pagehelper.PageHelper;
import com.github.pagehelper.PageInfo;
public void query() {
SqlSession session = MyBatisUtil.getSqlSessionFactory().openSession();
try {
MyMapper mapper = session.getMapper(MyMapper.class);
PageHelper.startPage(1, 10);
List<MyObject> list = mapper.selectMyObjects();
PageInfo<MyObject> pageInfo = new PageInfo<>(list);
} finally {
session.close();
}
}
MyBatis-Plus
MyBatis的一个增强工具包,提供许多开箱即用的功能(除mybatis-plus
外无需额外引入其他依赖),包括分页。目标是简化开发,提高生产力。支持自动分页、多数据库、代码生成器,提供丰富的条件构造器。开源GitHub。
对应的Maven依赖最新版为:
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus</artifactId>
<version>3.5.7</version>
</dependency>
有提供对应的spring-boot-starter,依赖如下:
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.7</version>
</dependency>
可知,mybatis-plus-boot-starter
和mybatis-plus
保持同步更新和版本发布。
需要配置@Bean方法:
import com.baomidou.mybatisplus.extension.plugins.PaginationInterceptor;
@Configuration
public class MyBatisPlusConfig {
@Bean
public PaginationInterceptor paginationInterceptor() {
return new PaginationInterceptor();
}
}
高版本的配置方法:
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
简单使用:
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
@Service
public class MyService {
@Autowired
private MyMapper myMapper;
public IPage<MyObject> getMyObjects(int page, int size) {
Page<MyObject> myPage = new Page<>(page, size);
return myMapper.selectPage(myPage, new QueryWrapper<>());
}
}
MyBatis-Paginator
对应的Maven依赖为:
<dependency>
<groupId>com.github.miemiedev</groupId>
<artifactId>mybatis-paginator</artifactId>
<version>1.2.17</version>
</dependency>
开源GitHub。
简单使用:
import com.github.miemiedev.mybatis.paginator.domain.PageBounds;
import com.github.miemiedev.mybatis.paginator.domain.PageList;
public void example() {
SqlSession session = MyBatisUtil.getSqlSessionFactory().openSession();
try {
MyMapper mapper = session.getMapper(MyMapper.class);
PageBounds pageBounds = new PageBounds(1, 10);
List<MyObject> list = mapper.selectMyObjects(pageBounds);
PageList<MyObject> pageList = (PageList<MyObject>) list;
} finally {
session.close();
}
}
选型
插件\对比项 | 最后发布时间 | Fork | Star |
---|---|---|---|
PageHelper | 2023.12.16 | 3.1k | 12.1k |
MyBatis-Plus | 2024.06.10 | 4.2k | 16 |
MyBatis-Paginator | 2015.05.07 | 218 | 368 |
如果项目使用的是MyBatis,则可考虑使用PageHelper。
如果项目使用的是MyBatis-Plus,则可直接使用自带的分页功能。
原理
面试时可能会遇到的一个问题,MyBatis-Plus(或PageHelper)的实现原理是什么?
MybatisPlus基于MyBatis物理分页
以MyBatis-Plus低版本为例,分析分页原理的入口类是PaginationInterceptor,核心方法是:
public Object intercept(Invocation invocation) throws Throwable {
StatementHandler statementHandler = (StatementHandler)PluginUtils.realTarget(invocation.getTarget());
MetaObject metaObject = SystemMetaObject.forObject(statementHandler);
this.sqlParser(metaObject);
MappedStatement mappedStatement = (MappedStatement)metaObject.getValue("delegate.mappedStatement");
// 只考虑SELECT
if (!SqlCommandType.SELECT.equals(mappedStatement.getSqlCommandType())) {
return invocation.proceed();
} else {
RowBounds rowBounds = (RowBounds)metaObject.getValue("delegate.rowBounds");
// 不需要分页
if (rowBounds == null || rowBounds == RowBounds.DEFAULT) {
if (!this.localPage) {
return invocation.proceed();
}
// 从ThreadLocal获取本地线程Pagination
rowBounds = PageHelper.getPagination();
if (rowBounds == null) {
return invocation.proceed();
}
}
BoundSql boundSql = (BoundSql)metaObject.getValue("delegate.boundSql");
String originalSql = boundSql.getSql();
Connection connection = (Connection)invocation.getArgs()[0];
DBType dbType = StringUtils.isNotEmpty(this.dialectType) ? DBType.getDBType(this.dialectType) : JdbcUtils.getDbType(connection.getMetaData().getURL());
// Pagination是RowBounds的子类
if (rowBounds instanceof Pagination) {
Pagination page = (Pagination)rowBounds;
boolean orderBy = true;
// searchCount默认为true
if (page.isSearchCount()) {
//
SqlInfo sqlInfo = SqlUtils.getOptimizeCountSql(page.isOptimizeCountSql(), this.sqlParser, originalSql);
orderBy = sqlInfo.isOrderBy();
this.queryTotal(this.overflowCurrent, sqlInfo.getSql(), mappedStatement, boundSql, page, connection);
// 可等价替换为==0L,应该不存在<0的情况?
if (page.getTotal() <= 0L) {
return invocation.proceed();
}
}
// 构建SQL追加order by子句
String buildSql = SqlUtils.concatOrderBy(originalSql, page, orderBy);
originalSql = DialectFactory.buildPaginationSql(page, buildSql, dbType, this.dialectClazz);
} else {
originalSql = DialectFactory.buildPaginationSql((RowBounds)rowBounds, originalSql, dbType, this.dialectClazz);
}
metaObject.setValue("delegate.boundSql.sql", originalSql);
// 禁用内存分页,内存分页会查询所有结果出来处理,如果结果变化频繁这个数据还会不准
metaObject.setValue("delegate.rowBounds.offset", 0);
metaObject.setValue("delegate.rowBounds.limit", Integer.MAX_VALUE);
return invocation.proceed();
}
}
高版本的拦截器类是PaginationInnerInterceptor,比PaginationInterceptor要复杂一些,核心方法有两个:
/**
* 进行count,如果count为0返回false,不再执行sql
*/
@Override
public boolean willDoQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException {
// 都是先解析Page信息
IPage<?> page = ParameterUtils.findPage(parameter).orElse(null);
if (page == null || page.getSize() < 0 || !page.searchCount() || resultHandler != Executor.NO_RESULT_HANDLER) {
return true;
}
BoundSql countSql;
MappedStatement countMs = buildCountMappedStatement(ms, page.countId());
if (countMs != null) {
countSql = countMs.getBoundSql(parameter);
} else {
countMs = buildAutoCountMappedStatement(ms);
String countSqlStr = autoCountSql(page, boundSql.getSql());
PluginUtils.MPBoundSql mpBoundSql = PluginUtils.mpBoundSql(boundSql);
countSql = new BoundSql(countMs.getConfiguration(), countSqlStr, mpBoundSql.parameterMappings(), parameter);
PluginUtils.setAdditionalParameter(countSql, mpBoundSql.additionalParameters());
}
// 缓存
CacheKey cacheKey = executor.createCacheKey(countMs, parameter, rowBounds, countSql);
List<Object> result = executor.query(countMs, parameter, rowBounds, resultHandler, cacheKey, countSql);
long total = 0;
if (CollectionUtils.isNotEmpty(result)) {
// 个别数据库 count 没数据不会返回 0
Object o = result.get(0);
if (o != null) {
total = Long.parseLong(o.toString());
}
}
page.setTotal(total);
return continuePage(page);
}
@Override
public void beforeQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException {
IPage<?> page = ParameterUtils.findPage(parameter).orElse(null);
if (null == page) {
return;
}
// 处理 orderBy 拼接
boolean addOrdered = false;
String buildSql = boundSql.getSql();
List<OrderItem> orders = page.orders();
if (CollectionUtils.isNotEmpty(orders)) {
addOrdered = true;
buildSql = this.concatOrderBy(buildSql, orders);
}
// size 小于 0 且不限制返回值则不构造分页sql
Long _limit = page.maxLimit() != null ? page.maxLimit() : maxLimit;
if (page.getSize() < 0 && null == _limit) {
if (addOrdered) {
PluginUtils.mpBoundSql(boundSql).sql(buildSql);
}
return;
}
// 最大为_limit
handlerLimit(page, _limit);
// 解析dialect
IDialect dialect = findIDialect(executor);
// 核心方法,根据各个Dialect构建SQL
DialectModel model = dialect.buildPaginationSql(buildSql, page.offset(), page.getSize());
PluginUtils.MPBoundSql mpBoundSql = PluginUtils.mpBoundSql(boundSql);
List<ParameterMapping> mappings = mpBoundSql.parameterMappings();
Map<String, Object> additionalParameter = mpBoundSql.additionalParameters();
final Configuration configuration = ms.getConfiguration();
model.consumers(mappings, configuration, additionalParameter);
mpBoundSql.sql(model.getDialectSql());
mpBoundSql.parameterMappings(mappings);
}
伏笔
个人猜测:很多开发者(和团队)最开始使用MyBatis,并使用PageHelper分页插件。后发现MyBatis-Plus确实比MyBatis好用,于是迁移到MP,形成MP+PageHelper共存的局面。在MP框架下PageHelper插件依然可以正常使用(有条件)。鄙人已经在至少两个公司的项目团队里看到这种混杂使用的情况:
问题
多依赖
后台系统,有一个列表页,点击第2页,没有响应。F12查看Chrome Console,控制台没有报错,说明不是前端JS报错。查看接口responseBody,发现分页有问题,nextPage=0:
后端分页有问题,对应的分页代码片段:
public String strategyList(JSONObject jsonObject) {
PageHelper.startPage(Integer.parseInt(jsonObject.get("pageNo") + ""), Integer.parseInt(jsonObject.get("pageSize") + ""));
list = channelPublicStrategyMapper.strategyList(jsonObject);
PageInfo<Map> pageInfo = new PageInfo<>(list);
return JSONObject.toJSONString(ServiceUtil.returnSuccessData(pageInfo));
}
看不出任何问题。调试,入参jsonObject.get("pageSize") == 10
,前端传参没问题,但是最后返回的pageInfo
包装信息不对劲:
到此时还是一脸懵逼。。
后来无意中点到源码,才发现PageHelper
和PageInfo
不是同一个依赖包的API:
同事随手提交的代码,这不坑人么?
反思
推荐在同一个项目中,只选用一种分页方式,统一代码风格。