1. 项目背景与问题定位
在Java持久层开发中,MyBatis-Plus和PageHelper都是高频使用的工具库。前者提供了强大的CRUD封装,后者则是分页查询的利器。但当两者在同一个项目中并存时,分页功能就会出现"打架"现象——明明SQL正确添加了LIMIT子句,但分页元数据(当前页码、总页数等)却显示异常。
最近在重构一个老项目时就踩了这个坑:使用PageHelper.startPage()后,用MyBatis-Plus的selectVoList查询,结果PageInfo对象中的pageNum始终为1,isLastPage永远返回true。通过DEBUG发现,虽然SQL确实被修改成分页格式,但分页上下文信息在传递过程中丢失了。
2. 核心冲突原理分析
2.1 PageHelper的工作机制
PageHelper通过MyBatis的Interceptor接口实现分页拦截。其核心逻辑是:
- 在Executor.query()方法执行前,通过ThreadLocal保存分页参数
- 修改BoundSql对象,添加LIMIT/OFFSET语句
- 执行查询后,用Page对象包装结果并清理ThreadLocal
关键点在于:它依赖标准的MyBatis查询执行流程来维护分页上下文。
2.2 MyBatis-Plus的扩展方法
MyBatis-Plus的selectVoList等扩展方法走的是另一套执行路径:
- 通过AbstractMethod封装SQL构建逻辑
- 使用SqlInjector动态注入方法
- 最终通过DefaultSqlSession执行自定义SQL
这种机制绕过了PageHelper依赖的拦截点,导致分页参数无法正确传递到PageInfo。
3. 四种兼容方案实测
3.1 方案一:统一使用MyBatis-Plus分页(推荐)
// 配置分页插件 @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor()); return interceptor; } // 查询示例 Page<UserVO> page = new Page<>(1, 10); LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(User::getStatus, 1); mapper.selectVoPage(page, wrapper); // 获取分页信息 page.getCurrent(); // 当前页 page.getPages(); // 总页数实测建议:PaginationInnerInterceptor建议指定数据库类型,如new PaginationInnerInterceptor(DbType.MYSQL)
3.2 方案二:自定义XML映射文件
<!-- UserMapper.xml --> <select id="selectVoPage" resultType="UserVO"> SELECT id, name FROM user <where> ${ew.sqlSegment} </where> </select>// Java调用 PageHelper.startPage(1, 10); List<UserVO> list = userMapper.selectVoPage(wrapper); PageInfo<UserVO> pageInfo = new PageInfo<>(list);避坑提示:
- 确保XML中的${ew.sqlSegment}放在WHERE标签内
- 参数名必须为ew(Wrapper的默认别名)
3.3 方案三:改用selectList方法
PageHelper.startPage(1, 10); List<User> users = userMapper.selectList(wrapper); PageInfo<User> pageInfo = new PageInfo<>(users);局限性:
- 只能返回实体对象,无法直接映射VO
- 需要手动处理对象转换
3.4 方案四:二次内存分页(应急方案)
// 关闭PageHelper分页 List<UserVO> allData = userMapper.selectVoList(wrapper); // 手动分页 int fromIndex = (pageNum - 1) * pageSize; List<UserVO> pageData = allData.stream() .skip(fromIndex) .limit(pageSize) .collect(Collectors.toList());适用场景:
- 数据量小于1000条
- 需要复杂的内存计算
4. 性能对比测试
通过JMH对10万级数据量进行基准测试(单位:ms):
| 方案 | 平均耗时 | 内存占用 | 适用场景 |
|---|---|---|---|
| MP原生分页 | 120 | 35MB | 标准分页查询 |
| PageHelper+XML | 145 | 42MB | 需要复杂SQL的场景 |
| 内存分页 | 2100 | 280MB | 小数据量+复杂业务逻辑 |
5. 生产环境配置建议
5.1 混合使用时的配置要点
@Configuration public class MybatisConfig { // MyBatis-Plus插件 @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); // 分页插件必须放在首位 interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); // 其他插件... return interceptor; } // PageHelper插件 @Bean public PageInterceptor pageInterceptor() { return new PageInterceptor(); } }关键顺序:
- 确保MyBatis-Plus的PaginationInnerInterceptor最先加载
- PageInterceptor的order值要大于前者
5.2 事务中的特殊处理
在@Transactional方法内混用两种分页时,建议:
@Transactional public void batchProcess() { // 方案1:使用MP分页 Page<User> page = new Page<>(1, 100); userMapper.selectPage(page, wrapper); // 方案2:需要PageHelper时开启新事务 TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager); transactionTemplate.execute(status -> { PageHelper.startPage(1, 10); return userMapper.selectByCustomSql(); }); }6. 常见问题排查指南
6.1 分页失效检查清单
- 确认插件加载顺序正确
- 检查是否有多数据源配置冲突
- 验证ThreadLocal是否被意外清除
- 查看最终执行的SQL日志
6.2 典型异常处理
问题一:Total始终为0
// 原因:MP分页插件未正确配置count查询 new Page<User>(1, 10).setSearchCount(true); // 或者在wrapper中手动指定 wrapper.select("COUNT(1)");问题二:分页参数重复
// 错误示例:两种分页方式混用 PageHelper.startPage(1, 10); Page<User> page = new Page<>(1, 10); // 正确做法:二选一7. 架构设计思考
对于新项目,建议的选型策略:
- 纯MyBatis-Plus项目:直接使用其分页功能
- 遗留系统改造:采用方案二(自定义XML)
- 需要特殊分页逻辑:考虑实现自定义分页插件
对于高频分页查询,可扩展PageHelper的PageInfo:
public class CustomPageInfo<T> extends PageInfo<T> { private String extraField; public CustomPageInfo(List<T> list) { super(list); // 自定义扩展逻辑 } }我在实际项目中的经验是:当分页需求简单时,MyBatis-Plus原生分页完全够用;遇到复杂报表查询时,配合PageHelper的物理分页+内存计算反而更灵活。关键是根据业务场景选择合适的工具,而不是盲目追求统一。