1. MyBatis-Plus分页机制原理解析
MyBatis-Plus作为MyBatis的增强工具,其分页功能一直是开发者高频使用的核心特性。在3.5.x版本中,分页实现主要依赖于PaginationInnerInterceptor拦截器。这个拦截器的工作原理是在SQL执行前后进行拦截处理,自动将普通查询转换为分页查询。
当执行一个分页查询时,拦截器会进行以下关键操作:
- 检测当前查询是否需要分页(通过ThreadLocal中的分页参数判断)
- 对原始SQL进行改写,添加LIMIT/OFFSET等分页语法
- 自动执行COUNT查询获取总记录数
- 将分页结果封装到Page对象中
java复制// 典型的分页查询示例
Page<User> page = new Page<>(1, 10); // 当前页,每页大小
userMapper.selectPage(page, Wrappers.<User>query().eq("status", 1));
这种设计虽然方便,但在大数据量场景下会暴露性能问题。特别是在执行COUNT查询时,如果原SQL包含多表关联或复杂条件,这个COUNT查询可能比数据查询本身更耗时。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 分页性能瓶颈深度分析
2.1 COUNT查询的性能陷阱
在分析线上慢SQL日志时,我们发现90%的分页性能问题都源自COUNT查询。以下是几个典型场景:
-
多表关联查询:当主查询包含JOIN操作时,MyBatis-Plus生成的COUNT查询会保留所有JOIN关系,但实际上只需要统计主表记录数即可。
-
复杂子查询:如果WHERE条件中包含子查询,COUNT查询会重复执行这些子查询,造成不必要的开销。
-
全表扫描:在没有合适索引的情况下,COUNT(*)需要对全表进行扫描,在百万级数据表中尤为明显。
2.2 分页偏移量的代价
另一个常被忽视的问题是OFFSET的性能影响。当使用LIMIT 10000, 20这样的语法时,数据库实际上需要先定位到第10000条记录,然后返回接下来的20条。随着页码增大,这个定位操作会越来越慢。
sql复制-- 随着pageNo增大,性能线性下降
SELECT * FROM large_table LIMIT 100000, 20
2.3 内存分页的误用
有些开发者会尝试先查询全部数据到内存,然后在Java层进行分页处理:
java复制List<User> allUsers = userMapper.selectList(null);
List<User> pageData = allUsers.stream()
.skip((pageNo-1)*pageSize)
.limit(pageSize)
.collect(Collectors.toList());
这种方法在小数据量时看似可行,但实际上:
- 完全丧失了数据库的分页能力
- 可能导致OOM(内存溢出)
- 网络传输开销巨大
3. MyBatis-Plus分页优化实战方案
3.1 禁用不必要的COUNT查询
对于不需要显示总页数的场景(如移动端上拉加载),可以完全跳过COUNT查询:
java复制// 使用Page的不带count的构造方法
Page<User> page = new Page<>(1, 10, false); // 第三个参数设置为false
或者在配置中全局禁用COUNT查询:
yaml复制mybatis-plus:
global-config:
db-config:
logic-not-count: true
3.2 优化COUNT查询语句
对于确实需要COUNT的场景,可以通过自定义SQL优化:
java复制@Select("SELECT COUNT(1) FROM user ${ew.customSqlSegment}")
Long countUser(@Param(Constants.WRAPPER) Wrapper<User> wrapper);
@Select("SELECT * FROM user ${ew.customSqlSegment} LIMIT #{page.offset}, #{page.size}")
List<User> selectUserPage(@Param(Constants.WRAPPER) Wrapper<User> wrapper,
@Param("page") Page<User> page);
这样可以在COUNT查询中去除不必要的JOIN操作。
3.3 使用游标分页(Keyset Pagination)
对于深度分页场景,推荐使用基于ID的范围查询代替传统的LIMIT OFFSET:
java复制// 第一页
List<User> users = userMapper.selectList(
Wrappers.<User>query()
.orderByAsc("id")
.last("LIMIT 20"));
// 获取最后一条记录的ID
Long lastId = users.get(users.size()-1).getId();
// 下一页
List<User> nextPage = userMapper.selectList(
Wrappers.<User>query()
.gt("id", lastId)
.orderByAsc("id")
.last("LIMIT 20"));
这种方案的优点是:
- 不受页码深度影响
- 可以利用ID上的索引快速定位
- 适合无限滚动加载场景
3.4 分页缓存策略
对于变化频率不高的数据,可以考虑缓存分页结果:
java复制@Cacheable(value = "userPage", key = "#pageNo + '-' + #pageSize")
public PageResult<User> getUsers(Integer pageNo, Integer pageSize) {
Page<User> page = new Page<>(pageNo, pageSize);
userMapper.selectPage(page, null);
return new PageResult<>(page);
}
注意要合理设置缓存过期时间,并考虑数据一致性问题。
4. 高级优化技巧与版本适配
4.1 MyBatis-Plus 3.5.17的特别优化
在3.5.17版本中,PaginationInnerInterceptor新增了几个重要配置项:
java复制PaginationInnerInterceptor interceptor = new PaginationInnerInterceptor();
// 设置最大单页限制,防止恶意传入超大size
interceptor.setMaxLimit(500L);
// 开启优化JOIN的COUNT查询
interceptor.setOptimizeJoin(true);
// COUNT查询超时时间(ms)
interceptor.setCountSqlTimeout(1000);
4.2 Spring Boot版本兼容性
MyBatis-Plus 3.5.x版本对应的Spring Boot版本兼容范围:
| MyBatis-Plus版本 | Spring Boot兼容范围 |
|---|---|
| 3.5.0-3.5.1 | 2.5.x-2.7.x |
| 3.5.2+ | 2.6.x-3.0.x |
建议使用Spring Boot 2.7.x + MyBatis-Plus 3.5.17的组合,这个组合经过大量生产验证,稳定性最佳。
4.3 自定义分页拦截器
对于特殊需求,可以继承PaginationInnerInterceptor实现自定义逻辑:
java复制public class CustomPaginationInterceptor extends PaginationInnerInterceptor {
@Override
protected void beforeQuery(Executor executor, MappedStatement ms,
Object parameter, RowBounds rowBounds, ResultHandler resultHandler,
BoundSql boundSql) {
// 添加自定义分页逻辑
if (isSpecialCase(parameter)) {
handleSpecialPagination(parameter);
return;
}
super.beforeQuery(executor, ms, parameter, rowBounds,
resultHandler, boundSql);
}
}
5. 生产环境监控与调优
5.1 分页性能监控指标
建议在生产环境中监控以下关键指标:
- 分页查询平均耗时:区分COUNT查询和数据查询
- 大偏移量查询占比:统计OFFSET > 1000的查询比例
- 慢分页查询:定义阈值(如>1s)并记录具体SQL
5.2 阿里Arthas诊断实战
使用Arthas诊断分页性能问题:
bash复制# 监控分页方法调用
watch com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor beforeQuery '{params, returnObj}' -x 3
# 统计方法耗时
trace com.example.mapper.UserMapper selectPage -j
5.3 分页参数合理化
在前端接口设计时,应该对分页参数进行约束:
java复制@GetMapping("/users")
public PageResult<User> listUsers(
@RequestParam(defaultValue = "1") Integer pageNo,
@RequestParam(defaultValue = "10") Integer pageSize) {
// 限制每页最大数量
pageSize = Math.min(pageSize, 100);
// 限制最大页码(根据业务场景)
if (pageNo > 100) {
throw new BusinessException("超出最大查询范围");
}
return userService.pageUsers(pageNo, pageSize);
}
我在实际项目中发现,合理的分页参数限制可以避免80%以上的性能问题。特别是对于C端应用,用户很少会浏览超过前10页的内容,后端应该做好保护性设计。
