一、核心原理
基于 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
类) 定义一个PaginationInnerInterceptor
Bean。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
类中的MybatisPlusInterceptor
Bean 是否添加了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
注解明确提供高效准确的COUNT
SQL。 - 链式调用:
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 > 1
hasNext()
: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转换、监控)能确保分页功能高效稳定。理解原理有助于快速定位和解决分页过程中遇到的各种问题。