一、核心原理
基于 MyBatis 拦截器 (
Interceptor):- MP 的分页功能本质上是通过实现 MyBatis 的
Interceptor接口实现的。 - 这个拦截器会拦截即将被执行的 SQL 语句(具体是拦截
Executor的query方法)。
- MP 的分页功能本质上是通过实现 MyBatis 的
ThreadLocal 存储分页参数:
- 当调用
Page对象进行分页查询时(如baseMapper.selectPage(page, queryWrapper)),MP 会将当前的Page对象(包含current,size,total等信息)存储在ThreadLocal变量中。 ThreadLocal确保了每个线程(每个请求)的分页参数是独立的,避免了并发问题。
- 当调用
SQL 解析与改写:
- 拦截器从
ThreadLocal中获取到当前线程的Page对象。 - 拦截器会分析被拦截的原始 SQL 语句:
- 如果是
SELECT语句,并且Page对象存在且需要分页(通常是size > 0),则进行改写。 - 自动识别数据库类型(通过配置的
DbType或自动检测),生成对应数据库的分页 SQL。 - 目标 SQL: 将原始查询语句改写成获取指定偏移量(
offset)和数量(limit) 数据的语句。- 例如 MySQL:
SELECT ... FROM ... WHERE ... LIMIT offset, size - 例如 Oracle: 使用嵌套
ROWNUM或OFFSET ... FETCH ...(12c+) - 例如 PostgreSQL:
SELECT ... FROM ... WHERE ... LIMIT size OFFSET offset
- 例如 MySQL:
- 如果是
- 同时,拦截器还会自动生成一个用于计算总记录数(
total)的COUNT语句:- 通常是将原始
SELECT语句中的字段列表替换成COUNT(1)或COUNT(*),并去掉ORDER BY子句(排序对计数无影响)。例如:SELECT COUNT(1) FROM ... WHERE ...
- 通常是将原始
- 拦截器从
执行查询:
- 拦截器首先执行自动生成的
COUNT语句,获取符合条件的总记录数total,并将其设置回Page对象。 - 然后执行改写后的分页 SQL 语句,获取当前页的数据列表
records。 - 将查询到的数据列表
records也设置回Page对象。
- 拦截器首先执行自动生成的
清理 ThreadLocal:
- 在分页查询逻辑执行完成后(无论成功或失败),拦截器或相关的清理机制会清除当前线程
ThreadLocal中的Page对象,防止内存泄漏和后续非分页查询被错误影响。
- 在分页查询逻辑执行完成后(无论成功或失败),拦截器或相关的清理机制会清除当前线程
返回结果:
- 方法最终返回的是填充了
records(当前页数据列表)和total(总记录数)以及其他分页信息(current,size,pages总页数等)的Page<T>对象。这个对象包含了所有前端分页组件需要的信息。
- 方法最终返回的是填充了
总结原理流程:
用户调用分页方法 (selectPage) -> MP 将分页参数 Page 存入 ThreadLocal -> MyBatis 执行器准备执行 SQL -> 分页拦截器介入 -> 从 ThreadLocal 取 Page -> 生成 COUNT SQL 执行获取 total -> 改写原始 SQL 为分页 SQL -> 执行分页 SQL 获取 records -> 将 total 和 records 设置回 Page 对象 -> 清理 ThreadLocal -> 返回填充好的 Page 对象。
二、详细操作步骤 (Spring Boot 环境为例)
引入依赖 (Maven):
<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>最新版本</version> <!-- 例如 3.5.3.1 --> </dependency>配置分页插件 (关键步骤!): 在 Spring Boot 的配置类中 (通常是
@SpringBootApplication主类或一个单独的@Configuration类) 定义一个PaginationInnerInterceptorBean。import com.baomidou.mybatisplus.annotation.DbType; import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); // 添加分页插件 PaginationInnerInterceptor paginationInterceptor = new PaginationInnerInterceptor(); paginationInterceptor.setDbType(DbType.MYSQL); // 根据实际数据库类型设置,可省略(MP通常能自动识别) paginationInterceptor.setOverflow(true); // 设置请求的页面大于最大页后操作 // true调回到首页,false继续请求。默认false。生产环境建议true。 interceptor.addInnerInterceptor(paginationInterceptor); return interceptor; } }DbType: 明确指定数据库类型(如MYSQL,ORACLE,POSTGRE_SQL,SQL_SERVER,H2等)。如果省略,MP 会尝试根据连接自动检测,但在某些环境(如多数据源)下明确指定更可靠。setOverflow(true/false): 处理页码超出范围的情况。true表示请求页码> totalPage时,自动查询第一页(返回首页数据);false表示继续查询该超大页码(通常返回空列表)。推荐设置为true。
使用分页查询: 在 Service 或 Controller 层使用
Page对象和 Mapper 的分页方法。import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @Service public class UserServiceImpl implements UserService { @Autowired private UserMapper userMapper; // 你的 Mapper 接口 public Page<User> getUsersByPage(int currentPage, int pageSize, String searchName) { // 1. 创建分页对象 Page<T> // 参数1:当前页码 (从1开始) // 参数2:每页显示条数 Page<User> page = new Page<>(currentPage, pageSize); // 2. 创建查询条件 (可选) QueryWrapper<User> queryWrapper = new QueryWrapper<>(); if (searchName != null && !searchName.trim().isEmpty()) { queryWrapper.like("name", searchName); // 示例:按名字模糊查询 } // 可以添加其他查询条件:eq, ne, gt, ge, lt, le, between, orderBy... // 3. 执行分页查询 (核心) // 使用 Mapper 的 selectPage(Page<T> page, @Param(Constants.WRAPPER) Wrapper<T> queryWrapper) 方法 Page<User> resultPage = userMapper.selectPage(page, queryWrapper); // 4. 返回结果 (resultPage 已包含数据 records 和分页信息 total, pages 等) return resultPage; } }Page<T> page = new Page<>(current, size);: 创建分页请求对象,指定当前页码(current从 1 开始)和每页大小。- 构建查询条件
QueryWrapper: 定义需要查询的数据范围。这是可选的,如果没有条件,传null即可。 userMapper.selectPage(page, queryWrapper);: 调用 Mapper 继承自BaseMapper的selectPage方法执行分页查询。这是最核心的一步。- 返回值
Page<User> resultPage: 该方法执行后,传入的page对象会被填充:resultPage.getRecords(): 当前页的数据列表 (List<User>).resultPage.getTotal(): 符合条件的所有记录的总数 (不是当前页的数量)。resultPage.getSize(): 每页大小 (与你传入的size一致)。resultPage.getCurrent(): 当前页码 (与你传入的current一致)。resultPage.getPages(): 总页数 (根据total和size计算得出)。
前端使用: 将
Page<User>对象返回给前端(通常通过 JSON)。前端框架(如 Vue, React)的分页组件可以利用current,size,total,pages,records这些属性来渲染分页UI和数据列表。
三、常见错误
分页完全不生效(返回所有数据):
- 原因 99%: 没有在 Spring 配置中注册
PaginationInnerInterceptor插件! 检查你的@Configuration类中的MybatisPlusInterceptorBean 是否添加了PaginationInnerInterceptor。 - 其他原因:传入的
Page对象的size值 <= 0。MP 默认size <= 0时不会进行分页,返回所有数据。
- 原因 99%: 没有在 Spring 配置中注册
total(总记录数) 为 0 或错误:- 复杂 SQL / 自定义 SQL: 如果查询使用了
join、union、复杂的子查询或者你在 Mapper XML 中自定义了 SQL (@Select或 XML 中的<select>),MP 自动生成的COUNT语句可能不正确(语法错误或语义错误),导致total查询失败或结果错误。 - 解决方案: 为该方法提供自定义的
COUNT查询语句。在 Mapper 接口中:
或者在 XML 中定义两个@Select("SELECT * FROM user ${ew.customSqlSegment}") // 你的复杂查询 @Select("SELECT COUNT(*) FROM user ${ew.customSqlSegment}") // 对应的COUNT查询 Page<User> selectMyComplexPage(@Param("page") Page<User> page, @Param(Constants.WRAPPER) QueryWrapper<User> wrapper);<select>语句,id 分别为selectMyComplexPage和selectMyComplexPage_COUNT(MP 约定后缀_COUNT)。_COUNT方法的返回类型是Long或Integer。 DbType设置错误: 导致生成的COUNT或分页 SQL 语法错误。- 查询条件
Wrapper在COUNT查询中未生效: 确保你的Wrapper条件在COUNT查询中也正确应用了。MP 默认会传递相同的Wrapper。
- 复杂 SQL / 自定义 SQL: 如果查询使用了
Page对象中的records为空,但total大于 0:- 页码超出范围: 当前页码
current大于总页数pages。检查你的页码计算逻辑。注意setOverflow的设置:如果设置为false且请求超大页码,MP 会尝试查询,但数据库没有数据,所以records为空;如果设置为true,MP 会自动将页码置为 1 并返回第一页数据。
- 页码超出范围: 当前页码
性能问题:
COUNT查询慢: 当数据量极大 (total很大) 且查询条件复杂时,COUNT语句可能执行很慢。- 深度分页慢: 使用
LIMIT offset, size方式的分页(如 MySQL),当offset非常大时(如第 10000 页),数据库需要扫描和跳过大量记录,性能急剧下降。
内存泄漏 (罕见):
- 理论上,如果
ThreadLocal中的Page对象没有被正确清理,在长时间运行的线程(如线程池中的线程)复用时就可能造成内存泄漏。MP 插件内部通常有清理机制,但极端情况或自定义插件不当可能引发。遵循标准配置和使用一般不会有问题。
- 理论上,如果
四、注意事项
current从 1 开始: 这是 MP 分页的约定,传入 0 会被当作 1 处理。- 必须配置插件: 这是分页功能生效的前提,务必确保
PaginationInnerInterceptor被添加到MybatisPlusInterceptor中并注册为 Spring Bean。 DbType设置: 明确指定数据库类型能提高兼容性和稳定性,尤其是在多数据源或自动检测不可靠的环境中。- 复杂 SQL 与
COUNT查询: 对于包含多表连接 (JOIN)、GROUP BY、UNION或复杂子查询的 SQL,强烈建议提供自定义的、高效的COUNT查询语句。不要依赖 MP 自动生成,它很可能出错或效率低下。 Wrapper条件一致性: 确保用于数据查询 (selectPage) 的Wrapper条件与用于COUNT查询的条件是一致的,否则total将不准确。MP 默认会将同一个Wrapper应用于两者。Page对象的作用域:Page对象通常应在单个分页查询请求的方法内创建和使用。避免将其长期存储或跨请求/线程传递。- 深度分页问题: 认识到
LIMIT offset, size在超大offset时的性能瓶颈。对于深度分页需求,考虑其他方案(见性能优化)。 - 无
COUNT查询需求: 如果前端只需要当前页数据,不需要知道总记录数和总页数(例如移动端上拉加载更多,只关心“是否有下一页”),可以使用Page的特殊构造函数Page(long current, long size, boolean isSearchCount)。将isSearchCount设为false可以跳过COUNT查询,大幅提升性能。MP 3.5+ 更推荐使用Page的无count模式构造方法Page(long current, long size)结合特定 Mapper 方法(如selectPage(Page, Wrapper)默认还是会查 count,需要使用selectList配合手动设置Page的records,或者使用selectPageWithoutCount这类扩展方法 - 需自定义或关注 MP 更新)。更直接的方式是在 3.5+ 中:Page<User> page = new Page<>(current, size, false); // 第三个参数 false 表示不执行 count 查询 userMapper.selectPage(page, queryWrapper); // 注意:此时 page.getTotal() 为 0, page.getPages() 为 0 // 只有 page.getRecords() 有数据。你需要自己判断是否有下一页?(通常根据返回的 records.size() < pageSize 判断是最后一页)
五、使用技巧
- 自定义
COUNT语句: 如前所述,对于复杂查询,在 Mapper 中定义对应的xxx_COUNT方法或使用@Select注解明确提供高效准确的COUNTSQL。 - 链式调用:
QueryWrapper支持链式调用构建条件,使代码更简洁。queryWrapper.like("name", "张").eq("status", 1).orderByDesc("create_time"); - 只返回特定字段: 在
selectPage前,使用queryWrapper.select("id", "name", "email")指定只查询需要的字段,减少数据传输量,提高效率。 - 利用
Page的convert方法: 如果返回给前端的对象需要转换(如User->UserVO),可以在获取Page<User>后使用:Page<UserVO> userVoPage = resultPage.convert(user -> { UserVO vo = new UserVO(); // ... 转换逻辑 user -> vo ... return vo; }); return userVoPage; - 判断是否有上一页/下一页: 利用
Page的属性:hasPrevious():current > 1hasNext():current < pages(注意:如果使用了isSearchCount=false或者setOverflow等,这些方法可能不准确,需自行根据数据判断,如records.size() == size则认为可能有下一页)。
六、最佳实践与性能优化
避免不必要的
COUNT查询:- 前端场景: 如上拉加载更多(无限滚动),通常只需要知道“是否有下一页”,不需要精确的总记录数和总页数。使用
Page(long current, long size, boolean isSearchCount)并将isSearchCount设为false(MP 3.5+ 更推荐无count模式)。 在业务层判断:如果返回的records.size() < size,说明是最后一页。 - 缓存
COUNT结果: 如果数据变化不频繁,且精确total是必须的,考虑将COUNT结果(或计算total的关键信息)进行缓存(如 Redis),避免每次分页都执行昂贵的COUNT查询。注意缓存更新策略(数据变更时失效缓存)。
- 前端场景: 如上拉加载更多(无限滚动),通常只需要知道“是否有下一页”,不需要精确的总记录数和总页数。使用
优化
COUNT查询:- 自定义高效
COUNT语句: 对于复杂查询,编写优化的COUNT语句。避免使用SELECT COUNT(*)扫描所有行,尝试使用覆盖索引或更精确的计数方式。 - 近似计数: 如果业务可以接受近似值(如大型列表的概览),一些数据库提供了快速近似行数的方法(如 MySQL 的
SHOW TABLE STATUS或EXPLAIN SELECT ...的rows列,但都不精确)。InnoDB 的COUNT(*)本身需要扫描索引,大数据量下可能较慢。
- 自定义高效
解决深度分页性能问题:
WHERE+id > last_id+ORDER BY id LIMIT: 适用于主键或唯一索引有序的情况。记录上一页最后一条记录的 ID (last_id),下一页查询改为WHERE id > last_id ORDER BY id LIMIT size。性能极佳(O(1)),但不支持跳页,只适合“加载更多”场景。MP 本身不直接支持此模式,需要手动实现。- 游标分页 (Cursor): 类似
WHERE id > last_id的思想,数据库提供游标机制(如基于keyset)。MP 对游标分页的支持有限,可能需要自定义或使用其他库。适合大数据量连续遍历。 - 业务层限制: 产品设计上限制可查询的最大页码或深度。
- 搜索引擎/NoSQL: 对于海量数据分页,考虑将数据导入 Elasticsearch 等搜索引擎,它们对深度分页有更好的支持(但也有限制)。
索引优化:
- 确保分页查询涉及的
WHERE条件字段、ORDER BY字段以及连接字段都建立了合适的索引。这是提升分页查询性能(尤其是带条件的)的基础。
- 确保分页查询涉及的
返回 VO/DTO:
- 不要直接将包含敏感信息或过多字段的 Entity 对象 (
User) 直接通过Page返回给前端。在 Service 层将Page<Entity>转换为Page<VO/DTO>再返回(使用convert方法或手动转换),只暴露必要的字段。
- 不要直接将包含敏感信息或过多字段的 Entity 对象 (
监控与分析:
- 对慢分页查询进行监控。分析执行计划 (
EXPLAIN),确认COUNT语句和分页数据查询语句是否高效利用了索引。
- 对慢分页查询进行监控。分析执行计划 (
总结:
MyBatis-Plus 的分页插件通过拦截器 + ThreadLocal + SQL 改写的机制,极大地简化了分页开发。正确配置插件是前提。使用时需注意 current 从 1 开始,复杂 SQL 要自定义 COUNT 语句。性能优化的核心在于 避免不必要的 COUNT 查询、优化 COUNT 查询本身 以及 解决深度分页问题(考虑替代方案)。遵循最佳实践(索引、VO转换、监控)能确保分页功能高效稳定。理解原理有助于快速定位和解决分页过程中遇到的各种问题。