1. 为什么需要分页功能?
想象一下你去图书馆借书,管理员把全馆100万本书一次性堆在你面前让你挑,这场景是不是很崩溃?数据库查询也是同样的道理。当数据量达到百万级时,一次性加载所有数据会导致内存溢出、网络阻塞、页面卡死等问题。去年我接手过一个老项目,用户列表查询居然没有分页,每次点击查询按钮浏览器都要卡死30秒,用户体验堪比灾难现场。
分页的核心价值在于三点:
- 性能优化:每次只加载必要数据量,减轻数据库和网络压力
- 用户体验:避免无限滚动的瀑布流,让用户明确知道数据总量和当前位置
- 系统稳定性:防止单次查询耗尽内存,这点在移动端尤为重要
传统分页需要手动计算limit/offset,还要写两条SQL(查数据+查总数),而MybatisPlus的IPage把这些脏活累活都封装好了。就像点外卖时,你只需要告诉平台"我要第二页的10条数据",不用关心商家是怎么分装的。
2. IPage分页的实现原理
2.1 拦截器工作机制
MybatisPlus的分页拦截器就像个尽职的快递分拣员。当你的查询请求到达时,它会进行三重检查:
- 方法拦截:只拦截Mapper接口中的查询方法(SELECT操作)
- 参数扫描:通过反射检查方法参数中是否存在IPage实现类
- SQL改造:对原生SQL进行智能拼接,添加
LIMIT ?,?和计算总数COUNT语句
实测发现个有趣现象:如果同时存在多个IPage参数,拦截器只会处理第一个遇到的IPage对象。这个设计避免了分页逻辑混乱,就像快递员不会把同一个包裹分到两个派送区域。
2.2 SQL拼接黑科技
拦截器内部处理流程比想象中聪明:
原始SQL: SELECT * FROM user WHERE age > 18 改造后: SELECT COUNT(1) FROM user WHERE age > 18 -- 总数计算 SELECT * FROM user WHERE age > 18 LIMIT 0,10 -- 分页数据特别要注意的是,当遇到复杂SQL(如包含UNION或子查询)时,MybatisPlus 3.4+版本会使用JSqlParser进行语法树分析,确保COUNT语句的正确性。有次我写了个带WITH子句的CTE查询,分页居然正常工作,这让我对MybatisPlus的SQL解析能力刮目相看。
3. 从零搭建分页环境
3.1 依赖配置避坑指南
建议直接使用starter而不是手动组合依赖,这里有个血泪教训:
<!-- 推荐写法 --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <!-- 典型错误示范 --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-core</artifactId> <version>3.5.2</version> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-extension</artifactId> <version>3.4.3.1</version> <!-- 版本不一致会导致诡异问题 --> </dependency>3.2 配置拦截器的正确姿势
SpringBoot配置类要这么写才专业:
@Configuration public class MybatisPlusConfig { /** * 新版分页插件建议这样配置 */ @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); // 分页插件 interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL){ // 防止全表更新与删除 @Override protected void handlerBlockAttack(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) { // 安全策略实现... } }); return interceptor; } }注意点:
- 老版本的
PaginationInterceptor已在3.4+废弃 - 建议指定DbType参数,不同数据库分页语法有差异
- 生产环境一定要开启防全表更新功能
4. 分页实战进阶技巧
4.1 Controller层的优雅设计
推荐使用DTO包装分页参数,避免魔法数字:
@GetMapping("/users") public R<PageResult<UserVO>> queryUsers(UserQueryDTO query) { // 使用LambdaQueryWrapper更类型安全 LambdaQueryWrapper<User> wrapper = Wrappers.lambdaQuery(User.class) .like(StringUtils.isNotBlank(query.getName()), User::getName, query.getName()) .gt(query.getMinAge() != null, User::getAge, query.getMinAge()); // 自动处理页数越界问题 IPage<User> page = userService.page( new PageDTO(query.getPage(), query.getSize()).check(), wrapper); return R.success(PageResult.build(page.convert(this::convertToVO))); }几个实用技巧:
- 使用
PageDTO.check()自动校正页数(如-1页转为第1页) - 通过
convert方法实现Entity到VO的自动转换 - 统一返回格式包含分页元数据(total/page/size等)
4.2 特殊场景处理方案
百万级数据优化:
// 使用游标分页避免深分页性能问题 try (Cursor<User> cursor = userMapper.scanCursor(new Page(1, 1000))) { cursor.forEach(user -> { // 处理每条数据 }); }多表联查分页:
// 使用JOIN+子查询优化 IPage<UserDeptVO> page = userMapper.selectUserDeptPage( new Page<>(1, 10), new QueryWrapper<User>() .eq("dept.status", 1) );对应的XML写法:
<select id="selectUserDeptPage" resultType="UserDeptVO"> SELECT u.*, d.name as deptName FROM user u LEFT JOIN department d ON u.dept_id = d.id ${ew.customSqlSegment} </select>5. 常见问题排查指南
问题1:分页失效,返回全部数据
- 检查点:是否忘记加拦截器?Page参数是否传到了Mapper层?
问题2:总数count查询报错
- 解决方案:添加
@InterceptorIgnore注解跳过特定方法
@InterceptorIgnore(tenantLine = "true", blockAttack = "true") IPage<User> selectSpecialPage(Page<User> page);问题3:自定义SQL分页异常
- 正确写法:保持
${ew.customSqlSegment}在WHERE后
<!-- 错误示例 --> <select id="selectWrongPage"> SELECT * FROM user WHERE 1=1 <if test="ew != null"> AND ${ew.sqlSegment} <!-- 这样写分页会失效 --> </if> </select>性能监控小技巧:启用SQL日志分析实际执行语句
mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl看到控制台输出类似以下内容说明分页生效:
==> Preparing: SELECT COUNT(*) FROM user WHERE age > ? ==> Preparing: SELECT * FROM user WHERE age > ? LIMIT ?,?