1. Mybatis分页机制的核心设计思想
Mybatis的分页实现本质上是一种"逻辑分页"机制,这与数据库层面的物理分页有着本质区别。当我们调用分页查询时,Mybatis会先获取全部结果集,然后在内存中进行切片处理。这种设计源于早期Mybatis的架构哲学——将复杂SQL与Java代码解耦,同时保持对各类数据库的兼容性。
在3.5.6版本源码中,DefaultResultSetHandler.handleResultSets()方法揭示了关键实现:通过ResultSetWrapper对原始结果集进行包装,RowBounds参数指定偏移量和限制数,最终由DefaultResultHandler存储符合范围的数据。这种机制虽然简单通用,但在处理大数据量时会出现明显的性能瓶颈。
重要提示:虽然逻辑分页易于实现,但在生产环境中处理超过10万条记录时,必须考虑替换为物理分页方案。
2. 原生RowBounds分页的实战解析
2.1 基础使用模式
RowBounds是Mybatis最原始的分页方案,通过在Mapper接口方法中添加RowBounds参数实现:
java复制List<User> selectUsers(RowBounds rowBounds);
// 使用示例:查询第2页,每页10条
List<User> users = mapper.selectUsers(new RowBounds(10, 10));
这种方式的实质是"后分页"——先执行完整SQL获取所有数据,再在内存中截取指定范围。查看SqlSession.selectList()源码会发现,当检测到RowBounds参数时,执行器会调用ResultHandler进行结果过滤。
2.2 性能陷阱与规避方案
在开发测试中,对一个包含5万条记录的表进行分页测试:
- 使用RowBounds查询第500页(offset 4990):耗时2.3秒
- 相同条件下使用物理分页:仅需0.15秒
性能差异主要来自:
- 网络传输:全部结果集需要从数据库服务器传输到应用服务器
- 内存消耗:JVM需要为完整结果集分配内存空间
- 无用计算:数据库仍需完成全表扫描和排序
解决方案:
- 中小型结果集(<1000条):可继续使用RowBounds
- 大型结果集:必须改用拦截器方案或MyBatis-Plus分页
3. 分页插件的工作原理与实现
3.1 拦截器机制深度剖析
Mybatis通过Interceptor接口提供插件扩展能力。典型分页插件实现流程:
- 拦截Executor.query()方法
- 改写原始SQL:添加LIMIT/OFFSET或ROWNUM等方言语句
- 执行修改后的SQL获取分页结果
- 可选执行count查询获取总数
关键代码片段:
java复制@Intercepts(@Signature(type=Executor.class, method="query",
args={MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}))
public class PaginationInterceptor implements Interceptor {
// 改写SQL的核心逻辑
private String getPageSql(String originalSql, RowBounds rowBounds) {
return dialect.getLimitString(originalSql,
rowBounds.getOffset(),
rowBounds.getLimit());
}
}
3.2 主流分页插件对比
| 插件名称 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| PageHelper | 配置简单,支持多种数据库 | 复杂SQL可能解析错误 | 快速开发的中小型项目 |
| MyBatis-Plus | 功能全面,与MP其他组件深度整合 | 学习曲线稍陡 | 使用MP的全套技术栈项目 |
| 自定义拦截器 | 完全可控,可针对业务定制 | 开发维护成本高 | 有特殊分页需求的项目 |
4. MyBatis-Plus分页的进阶用法
4.1 配置与基础查询
Spring Boot集成示例:
java复制@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor paginationInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}
// 使用示例
Page<User> page = new Page<>(1, 10);
Page<User> result = userMapper.selectPage(page, Wrappers.<User>query().eq("status", 1));
4.2 自定义分页SQL处理
对于复杂查询,需要手动编写分页SQL并在Mapper中定义:
xml复制<select id="selectUserPage" resultType="User">
SELECT * FROM user
WHERE department_id = #{deptId}
ORDER BY create_time DESC
</select>
Java调用端:
java复制// 手动分页查询
IPage<User> page = new Page<>(1, 10);
userMapper.selectUserPage(page, 123);
long total = page.getTotal();
List<User> records = page.getRecords();
4.3 性能优化实践
- COUNT优化:对于百万级数据,避免全表count
java复制page.setSearchCount(false); // 禁用自动count
// 手动使用缓存计数或近似计数
- 延迟关联:先分页获取ID,再关联查询详情
sql复制-- 原始查询
SELECT * FROM large_table ORDER BY id LIMIT 10000, 10
-- 优化后
SELECT * FROM large_table
WHERE id IN (SELECT id FROM large_table ORDER BY id LIMIT 10000, 10)
- 游标分页:基于上次查询最后一条记录的条件
java复制// 使用create_time和id作为游标
List<User> users = userMapper.selectAfterCursor(
lastUser.getCreateTime(),
lastUser.getId(),
10);
5. 特殊场景的分页解决方案
5.1 一对多关联分页的坑
典型错误案例:
xml复制<resultMap id="userWithOrders" type="User">
<collection property="orders" ofType="Order"
select="selectOrdersByUser" column="id"/>
</resultMap>
<select id="selectUsers" resultMap="userWithOrders">
SELECT * FROM user LIMIT 0, 10
</select>
问题分析:这种写法会导致"分页膨胀"——主表10条记录可能关联出100+订单,内存中实际对象数量远超预期。
解决方案:
- 先分页查询主表ID
- 批量查询关联表数据
- 在内存中组装对象关系
5.2 大数据量导出分页
对于Excel导出等场景,推荐采用流式查询:
java复制@Select("SELECT * FROM large_table")
@Options(resultSetType = ResultSetType.FORWARD_ONLY,
fetchSize = Integer.MIN_VALUE)
void streamLargeData(ResultHandler<User> handler);
// 使用示例
sqlSession.select("streamLargeData", resultContext -> {
User user = resultContext.getResultObject();
// 分批处理逻辑
});
5.3 分布式环境下的分页挑战
在分库分表环境中,常规分页会出现问题:
- 排序字段重复导致结果不稳定
- 跨库分页结果不准确
解决方案:
- 使用全局唯一且有序的字段作为排序依据(如雪花ID)
- 采用广播查询+内存归并(适合中小数据量)
- 使用Elasticsearch等中间层统一分页
6. 源码级性能调优技巧
6.1 结果集处理优化
跟踪DefaultResultSetHandler发现,结果集转换时存在优化空间:
- 避免不必要的类型转换:确保JavaType与数据库类型精确匹配
- 使用resultOrdered="true"优化嵌套结果处理
- 对于只读查询,启用Statement.fetchSize优化网络传输
6.2 缓存与分页的协同
分页查询与二级缓存的交互策略:
xml复制<select id="selectPage" resultType="User" useCache="true"
flushCache="false" resultOrdered="true">
SELECT * FROM user ORDER BY id
</select>
缓存注意事项:
- 分页参数不同视为不同缓存key
- 数据修改时需要清理相关分页缓存
- 考虑使用Redis实现分布式分页缓存
6.3 监控与诊断
在连接池层面监控分页查询:
java复制// Druid配置
spring.datasource.druid.filter.stat.log-slow-sql=true
spring.datasource.druid.filter.stat.slow-sql-millis=1000
诊断慢分页查询的三板斧:
- 检查是否真正走了索引(EXPLAIN)
- 确认分页参数没有导致全表扫描
- 验证网络传输数据量是否合理
我在实际项目中处理过一个典型案例:某分页查询响应缓慢,最终发现是ORM层在转换CLOB字段时产生了性能瓶颈。通过将该字段改为延迟加载,查询时间从3秒降至200毫秒。这提醒我们:分页性能优化需要全链路分析,不能只关注SQL层面。
