分页这个问题,几乎每个做后端开发的人都会碰上。我记得自己刚接触MyBatis-Plus那会儿,最直观的感受就是:原来分页可以不用手写LIMIT、不用单独维护count语句、不用为切换数据库方言发愁。只要配置一个插件,再传一个Page对象,剩下的事框架全帮你干了。这篇内容不是简单的API罗列,我会把“为什么要用分页插件”“插件底层在做什么”“实际开发中怎么落地”“哪些坑我踩过”这些事从头到尾捋一遍,尽量让刚入门的同学能直接照着用,也让有经验的同事能查到一些平时容易忽略的细节。
1. 为什么需要分页插件:先搞懂原生MyBatis的分页之痛
1.1 传统手写分页的痛点
在MyBatis-Plus普及之前,我们自己写分页大致是这么一套流程:先在Mapper接口里定义两个方法,一个查列表数据,一个查总数,然后在SQL里手动拼接LIMIT参数,再在Service层计算起始偏移量。
// 旧版做法:接口定义两个方法 User selectUserList(@Param("offset") int offset, @Param("size") int size); long countUser(@Param("keyword") String keyword);这种写法乍一看没什么问题,但一到真实业务场景就会很痛苦。第一,每个业务模块都要重复写“查询列表”和“查询总数”两个方法,代码量翻倍;第二,两张表关联分页的时候,count语句要单独处理,稍不留意计数逻辑和列表逻辑就出现了不一致;第三,如果哪天项目从MySQL换成PostgreSQL,LIMIT ? , ? 这种写法又要全部改一遍。
这还不算最难受的。更麻烦的是,很多老项目的分页逻辑散落在Service层里,有人直接用List.subList()内存分页,数据量小的时候无所谓,一旦表里几十万条数据,内存直接被打爆。
1.2 MyBatis-Plus分页插件的核心价值
MyBatis-Plus提供的PaginationInnerInterceptor,本质是一个基于MyBatis拦截器机制实现的SQL增强组件。它做的事情可以简单归纳为三点:拦截需要分页的SQL语句、自动生成COUNT语句、自动拼接数据库方言对应的分页语法。
这样一来,业务代码根本不用关心“怎么分页”这件事。你只需要把“第几页”“每页几条”这两个参数告诉框架,剩下的SQL改写全部由插件内部完成。最直观的好处是,项目里不再需要维护成堆的count查询,也不需要为不同数据库写不同风格的分页SQL。
我举个例子,下面这段代码就是MyBatis-Plus分页的标准姿势:
Page<User> page = new Page<>(1, 10); LambdaQueryWrapper<User> wrapper = Wrappers.lambdaQuery(); wrapper.eq(User::getStatus, 1); Page<User> result = userMapper.selectPage(page, wrapper);一行Page构造、一行selectPage调用,查询条件和分页参数天然解耦。如果后面需求变更,需要增加“按创建时间过滤”的条件,只需要在Wrapper上叠加orderByDesc或者ge方法,完全不影响原有分页逻辑。
1.3 分页插件与其他分页方案的横向对比
我在项目里也用过高性能的MyBatis分页插件PageHelper,两者思路不同,但各有侧重。PageHelper采用的是ThreadLocal参数传递,分页参数放在线程上下文中,使用时需要小心处理“分页后未清除上下文导致后续查询被莫名分页”的经典坑。MyBatis-Plus则是把分页参数显式地封装在Page对象里,作为方法参数传入,这种设计让代码更可控,不会因为漏掉清理ThreadLocal而出现“串页”问题。
从团队协作的角度讲,显式的Page参数有利于代码评审。看代码的时候一眼就能判断这个方法是否分页、分页参数是什么,而不是像PageHelper那样翻到几层调用栈才能确认当前线程有没有分页上下文。
2. 环境准备与插件配置:5分钟把分页跑起来
2.1 依赖引入与版本选择
用MyBatis-Plus分页之前,先确认依赖引入是否正确。目前主流选择是mybatis-plus-boot-starter,具体版本要根据项目的Spring Boot版本搭配。以Spring Boot 2.x为例,通常使用3.5.x版本比较稳妥;如果项目用的是Spring Boot 3.x,考虑到Jakarta命名空间的变更,建议直接上3.5.5以上版本或者跟随官方推荐的最新稳定版。
<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.2</version> </dependency>这里有个容易踩的坑:只引入starter还不够,分页插件不是默认生效的。很多人引完依赖之后直接调selectPage,结果发现SQL语句没有被拦截改写,查出来的数据只有第一页,还以为是自己代码哪里写错了。其实原因很简单——PaginationInnerInterceptor需要手动注册到MybatisPlusInterceptor中。
2.2 插件注册配置类
MyBatis-Plus 3.4.0之后的版本采用拦截器链机制,把所有内置拦截器统一挂载到一个MybatisPlusInterceptor上,再用Spring的@Bean把它交给容器管理。分页功能的配置类通常长这样:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }我在实际项目里看到过有人把DbType直接省略不传,插件会通过JDBC连接自动识别数据库类型,这种方式在大部分场景下也能跑通。但我个人建议还是显式指定数据库类型,一方面省去自动识别的开销,另一方面在多数据源项目中能避免误判。
2.3 数据库方言自动适配的细节
PaginationInnerInterceptor最贴心的地方在于,它内部封装了常见数据库的分页语法转换。MySQL用的是LIMIT offset, size,PostgreSQL和Oracle的用法又不相同,SQL Server还有自己的OFFSET ... FETCH NEXT语法。如果项目需要做多数据库支持,插件帮我们屏蔽了这些差异。
具体实现上,Dialect接口定义了一组和分页语法相关的方法,每种数据库对应一个实现类。插件在改写SQL时,会根据我们配置的DbType找到对应的Dialect实现,再拼接分页语句和count语句。这部分逻辑对业务透明,但理解之后能帮你解决很多“换数据库之后分页不对”的疑问。
3. 基础分页用法剖析:Page对象与Wrapper组合实战
3.1 Page对象的核心参数与构造方式
MyBatis-Plus的Page类提供了好几个构造函数,日常开发用得最多的就是new Page<>(current, size)。current代表当前页码,从1开始;size代表每页条数。构造完之后还能手动设置orderBy字段或者isSearchCount等属性。
// 最常用的构造方式 Page<User> page = new Page<>(1, 10); // 进阶参数 page.setSearchCount(false); // 不查询总条数,节省一次count page.setMaxLimit(100L); // 限制每页最大条数,防止全表扫把searchCount设为false特别适合那种数据量极大、产品上“加载更多”不需要总条数的场景。举个例子,比如查询用户操作日志,每次只滚动加载最近20条,这时候每次查count其实都在白白消耗数据库资源。既然UI层不需要显示“共多少条”,关掉count是一种很直接的性能优化手段。
3.2 无条件分页与条件分页的写法
最基础的分页查询不需要拼接任何查询条件,直接调用selectPage即可。但实际业务里,分页往往伴随筛选条件。假如现在要做一个用户管理列表,支持按用户名模糊搜索、按状态筛选、按创建时间倒序排列,可以这样写:
public PageResult<UserVO> pageUsers(int current, int size, String name, Integer status) { // 分页参数 Page<User> page = new Page<>(current, size); // 查询条件 LambdaQueryWrapper<User> wrapper = Wrappers.lambdaQuery(); wrapper.like(StringUtils.hasText(name), User::getName, name); wrapper.eq(status != null, User::getStatus, status); wrapper.orderByDesc(User::getCreateTime); // 执行查询 Page<User> result = userMapper.selectPage(page, wrapper); // 实体转VO List<UserVO> voList = result.getRecords().stream() .map(user -> convertToVO(user)) .collect(Collectors.toList()); PageResult<UserVO> pageResult = new PageResult<>(); pageResult.setRecords(voList); pageResult.setTotal(result.getTotal()); pageResult.setCurrent(result.getCurrent()); pageResult.setSize(result.getSize()); return pageResult; }这里有几个细节值得拎出来说一说。like方法的第一个参数是布尔值,当name为空字符串时,这个条件会被自动忽略,这种写法能避免业务层写一堆if-else去动态拼条件。selectPage返回的Page对象,records里放的是查询结果实体,total是满足条件的总条数,这两个属性是后续封装返回结构的基础。
3.3 返回结果与Page信息的完整解读
很多人分页的时候只关注records里的列表数据,对Page对象里的其他属性不够上心。其实这些字段在承接前端参数时非常有用。
| 属性名 | 含义 | 典型使用场景 |
|---|---|---|
| records | 当前页的数据列表 | 直接渲染或转换后返回给前端 |
| total | 满足条件的数据总条数 | 前端分页器显示总页数、总条数 |
| current | 当前页码 | 回传给前端做高亮展示 |
| size | 每页显示条数 | 回传给前端保持分页器状态一致 |
| pages | 总页数,由total和size计算得到 | 前端快速判断是否还有下一页 |
这些信息建议统一封装成一个通用的PageResult<T>返回对象,避免把MyBatis-Plus的Page对象直接暴露给Controller层。因为Page类内部还包含orders、optimizeCountSql等其他字段,序列化给前端反而容易造成字段冗余和接口结构不稳定。
3.4 防止全表查询的防线:maxLimit
Page.setMaxLimit这个参数很多人不知道,但它真的能救命。设想一个场景:前端传了个request参数,size = 999999,如果你没做任何限制,这条SQL会变成LIMIT 0, 999999,一次把全表数据捞回来。严重的时候数据库CPU飙高,接口直接超时。
maxLimit的作用就是给每页条数加个上限,超出上限后插件会自动把size强制压到上限值。我习惯在项目里统一配置为page.setMaxLimit(100L),这样即使前端参数异常,数据库也不会被拖垮。
4. 进阶玩法:自定义SQL分页与多表联查
4.1 Mapper接口自定义分页方法
内置的selectPage只能基于单表查询,一旦涉及多表JOIN或者复杂子查询,就不能指望Mapper自带的方法了。好在MyBatis-Plus允许我们在Mapper接口里自定义分页方法,只要方法的第一参数是Page对象,插件就能自动识别并进行分页拦截。
/** * 自定义分页方法:查询用户及其部门信息 */ IPage<UserDeptVO> selectUserDeptPage(Page<UserDeptVO> page, @Param("deptId") Long deptId);接着在对应的XML文件里写SQL,注意一个关键点:XML里的SQL本身不需要写LIMIT,分页插件会在执行前自动帮你拼上分页语句。
<select id="selectUserDeptPage" resultType="com.example.model.vo.UserDeptVO"> SELECT u.id, u.name, u.status, d.dept_name AS deptName FROM user u LEFT JOIN department d ON u.dept_id = d.id <where> <if test="deptId != null"> AND u.dept_id = #{deptId} </if> AND u.deleted = 0 </where> ORDER BY u.create_time DESC </select>调用方式如下,分页交互完全透明:
Page<UserDeptVO> page = new Page<>(1, 10); IPage<UserDeptVO> result = userMapper.selectUserDeptPage(page, 1001L);4.2 自定义分页SQL注意事项
自定义SQL分页有几个很容易踩的坑。
第一,Page泛型要和resultType对应,否则把查询结果映射到错误的类型上,轻则字段丢失,重则类型转换异常。我见过有人把Page<User>传给一个返回UserDeptVO的查询方法,最后编译正常但运行结果全是null。
第二,JOIN查询时count语句的执行效率不可忽视。插件默认会生成SELECT COUNT(*)语句去统计总数,但JOIN本身是有开销的,如果业务允许,优先对主表做count,或者用子查询把复杂关联逻辑收敛起来。比如把JOIN结果先当成一个临时视图再count,数据库优化器通常能给出更合理的执行计划。
第三,XML里如果需要排序,排序列不要使用用户直接传入的字符串做拼接。就算前端没有页面向用户开放排序功能,谁知道哪天有人会从接口参数里注入一个ORDER BY片段?排序字段建议白名单校验,只允许固定的几个字段进入SQL。
4.3 多表联查分页的实战案例
拿一个稍微复杂点的场景举例:用户表、订单表、订单明细表三张表关联,需要按用户维度分页展示下单汇总信息。这时候如果直接用三表JOIN再分页,MySQL要先做关联生成中间结果集,再做排序和分页,数据量上去之后效率会很差。
更稳妥的做法是先在子查询里对订单表做聚合和分页,再关联用户表补全用户信息:
SELECT u.id, u.name, t.order_count, t.total_amount FROM ( SELECT user_id, COUNT(*) AS order_count, SUM(amount) AS total_amount FROM orders WHERE order_status = 1 GROUP BY user_id ORDER BY total_amount DESC LIMIT ?, ? ) t LEFT JOIN user u ON t.user_id = u.id这种“先分页再关联”的模式,能避免大表JOIN后再全量排序的问题,是分页场景下最值得养成的习惯之一。不过在MyBatis-Plus里写这种SQL,注意把分页参数留给框架而不是自己手写LIMIT。
4.4 分页和逻辑删除的联动处理
MyBatis-Plus默认支持逻辑删除,分页查询发的SQL会自动带deleted = 0条件。这个机制在单表查询时很稳,但自定义SQL里如果写了JOIN关联逻辑删除了的表,框架只会对主表自动追加逻辑删除条件,副表的deleted条件必须自己在SQL里写清楚。我复盘过不少线上数据异常,最后都是联合查询阶段副表把已删除数据带进来了,而主表本身筛选得干干净净,排查起来非常隐蔽。
5. 高频踩坑与排查技巧:这里每个坑我都真金白银填过
5.1 分页插件不生效,SELECT没加LIMIT
最经典的问题:代码跑通了,但SQL执行结果就是全量数据,查看日志发现根本没拼接LIMIT。排查思路按以下顺序来:
- 检查配置类是否被Spring扫描到,
@Configuration类不要放在Spring Boot启动类扫描不到的子包里; - 确认引入的是3.4.0以上版本,早期版本不是通过
MybatisPlusInterceptor注册分页插件的; - 检查是否创建了多个
MybatisPlusInterceptor实例,重复注册会互相覆盖; - 看MyBatis-Plus的SQL日志,确认执行时是否经过拦截器链;
- 如果项目里引入了多个MyBatis插件,注意插件拦截器的执行顺序,分页插件尽量靠前。
我在一个多模块项目里就碰到过第三种情况。A模块配置了一个拦截器,B模块又配置了一个,结果B模块的配置覆盖了A的,A模块的分页全部失灵。最后把配置统一到common模块才解决。
5.2 COUNT语句性能差与优化方案
插件自动生成的count语句接近SELECT COUNT(*) FROM ...,对大部分查询来说够用。可要是碰上多表JOIN或者带复杂子查询的场景,count的执行时间可能比列表查询还慢。插件提供了optimizeCountSql优化选项,把它设为true(默认就是true)会尝试去掉无关的ORDER BY和JOIN,生成更轻量的count语句。
如果业务场景真的非常敏感,可以在Page对象上指定自定义count查询。在你的Mapper里单独写一个count方法,然后调用page.setTotal()或者通过专门的支持方式注入自定义count语句。这招适合那些列表查询逻辑复杂、自动生成count不准确的场景。
5.3 深翻页慢:LIMIT 1000000, 20 如何破局
分页翻到很后面的时候,数据库要扫描前1000000行再丢弃,这个开销很恐怖。业界通用的解法有几种,对于网页端管理系统,一般限制最大页码,不允许用户翻到几十万页之后;对于移动端信息流,改成基于游标的分页,用上次查询结果的最后一条ID作为下一次查询的起始条件。
在MyBatis-Plus中实现游标分页也不难,重点是把查询条件从偏移量改成主键范围:
LambdaQueryWrapper<User> wrapper = Wrappers.lambdaQuery(); wrapper.lt(User::getId, lastId) // 向下翻页时带上上次最后一条的ID .orderByDesc(User::getId) .last("LIMIT " + pageSize);这种方式的潜力在于,只要ID上的索引能命中最新的数据区间,翻页再深也不会退化成全表扫描。注意,游标分页的排序字段必须具有唯一性,否则可能出现排序不稳定导致数据漏查或重复。
5.4 排序字段注入与类型转换问题
分页查询接排序参数时,最容易忽略的是SQL注入风险。MyBatis-Plus的orderByAsc/orderByDesc接收的是Lambda字段引用,相对安全。但一旦为了解决灵活性开放了字符串排序字段,比如page.addOrder(OrderItem(...)),就要格外小心字段值的白名单校验。
另一个不起眼但实测会出问题的点:字段类型转换。用Page查出来的实体字段与数据库字段类型不匹配时,比如数据库是DECIMAL,实体里写成了Integer,分页插件在改写SQL时可能不会报错,但结果集映射会出现ClassCastException。排查这类问题,先看MyBatis-Plus是否能正确映射该字段,再看分页插件是否对ORDER BY之后的字段做了类型改写。
5.5 分页与分布式部署的兼容性
MyBatis-Plus分页插件完全基于SQL拦截实现,不持有本地状态,因此天然支持多实例部署。相比ThreadLocal方案,不存在“A机器上的分页上下文被B机器读到”的问题。针对这类天然优势,我在架构选型时也更倾向于使用MyBatis-Plus的显式分页参数。
6. 性能优化与最佳实践:分页查询不只是调一个API
6.1 大页场景下降级为Keyset分页
前文提到的游标分页,也就是Keyset分页,是应对深翻页最有效的方法之一。它利用索引的有序性,每次都从上一个游标位置继续往后扫描,复杂度不随翻页深度增长。MyBatis-Plus本身不直接提供官方的Keyset分页组件,但我们可以在业务层封装一套自己的工具。
具体做法是,在查询参数里增加lastId字段,第一次进页面时传0,后续传上一次列表最后一条记录的ID。Service层构造条件时加上wrapper.lt(User::getId, lastId)并按ID倒序排列,再固定只查pageSize+1条,多出来的那1条用来判断是否还有更多数据。这套方案在我负责的一个流水查询系统里实测,从第1页翻到第100000页都不存在性能衰减的问题。
6.2 统一封装分页请求与返回结构
分页请求参数建议封装成一个统一的PageQuery对象,包含current、size、orderBy、orderDirection等字段,配合Spring MVC的参数绑定直接接收。为了避免有人乱传每页条数,Service层在构造Page对象时统一执行:
current = Math.max(current, 1); size = Math.min(size, 100);这样即使用户暴力传参,也不会对数据库造成实质伤害。对应的返回结构统一为PageResult<T>,内部包含list、total、current、size、pages,前端对接起来非常省心。
6.3 分页查询与缓存策略的配合
分页查询能否使用缓存,得看业务特性。如果列表数据更新频率低、查询性能有瓶颈,可以对热点分页结果做缓存。但要注意,分页结果缓存的核心是“查询条件+页码+每页条数+排序规则”组成的key,任何一个维度变化都会导致缓存命中率下降。
缓存更新策略也是个难点。表数据变更后,如果采用“更新时主动淘汰所有相关分页缓存”的策略,在高频写场景下缓存会被频繁清空。更稳妥的做法是缓存短TTL加上低频写触发全部淘汰,让缓存只挡住热点流量。如果要求数据强一致,就不要引入缓存,分页查询直接走数据库是最简单可靠的方案。
6.4 多数据源场景下的分页配置
多数据源项目里,不同数据库类型可能不一样,分页插件的DbType要跟着数据源切换。MyBatis-Plus的路由数据源方案中,可以在路由时动态修改分页插件的dialect类型。一个比较省心的做法是配置多个PaginationInnerInterceptor,每个拦截器指定一种DbType,按照数据源路由规则挂到不同SqlSessionFactory上。
还有个容易踩的深坑:分页插件会改写SQL语句,加上COUNT查询。在主从分离的架构里,如果写库压力大,建议把分页查询路由到只读从库上,避免每次分页都往主库压一个额外的count操作。别小看这个count,并发高的时候它足够把主库拖慢。
6.5 从性能日志里找出分页瓶颈
排查分页性能问题时,不要只盯着SQL本身。每次分页其实对应两到三次数据库交互:count查询、列表查询,所占用的时间需要分别观察。开启MyBatis-Plus的SQL日志分析,或者接上慢查询日志系统,重点看两个指标:count耗时和列表查询耗时。多数情况下,列表查询慢是因为排序字段没有索引,count慢是因为JOIN了不必要的表。
我在项目里建立过一套简单规则:凡是列表查询响应超过500ms的接口,第一步排查是否存在未走索引大表扫描,第二步用EXPLAIN看执行计划,第三步分析是否因为分页插件自动生成的count拖了后腿,最后再看有没有必要改造成游标分页。
经验小结:把这些扎实落到项目里
我个人在实际项目里最推荐的做法,是把分页相关能力沉淀成一个基础组件,统一处理Page构造、参数校验、结果封装、异常兜底这些事。这样业务代码只需要关心查询条件和返回结果,不再每处都重复写new Page<>()和PageResult转换的样板代码。把分页插件的跳坑经验沉淀在组件里,团队里就算有新同学接手,也不会再踩“插件没配置导致全表查询上线”那种低级事故。
最后再分享一个小技巧:如果你们项目里经常出现“分页返回数据时,JSON序列化Date字段格式不对”这种问题,建议在PageResult里统一使用自定义的序列化器或者格式化注解,把时间格式问题控制在返回层解决,不要让每个业务都各自处理。
分页这件事,看起来简单,但做深了全是对细节的把控。配置对了,只是第一步;真正的分水岭在于对不同数据规模下分页策略的理解。愿你在项目里遇到深翻页的时候,想起这篇文章里提到的游标分页方案。