1. 手写 limit 太痛苦了:分页插件到底帮你省了什么
在 Java 后端项目里,分页是个绕不开的话题。而提到 MyBatis 分页,我几乎每次都要说一遍:真的别手写limit了。最近我接手一个老项目,翻代码时看到 Mapper 里到处是LIMIT #{offset}, #{pageSize},count 查询还要单独写一条,改个小需求得同步改两处 SQL,真是让人头大。更离谱的是,有几个列表因为 offset 算错,前端翻到第三页就开始重复数据,这问题排查起来特别费劲。所以这篇文章我就想聊聊 MyBatis Plus 分页插件,重点说清楚它到底是怎么工作的,以及为什么我强烈建议你用它替代手写limit。
先说结论:MyBatis Plus 的分页插件本质上是一个 MyBatis 拦截器,你只需要在项目里注册一个配置,然后在 Mapper 方法的第一个参数位置传一个Page对象,它就会自动拦截要执行的 SQL,帮你把 count 查询、limit 拼接、数据库方言适配全部处理掉。你不需要操心offset怎么算,也不需要担心换个数据库就要改 SQL。对做企业级应用、后台管理系统、接口分页场景的同学来说,这几乎是必备技能。
1.1 分页不是“拼 SQL”这么简单,手写 limit 的代价你算过吗
很多人觉得分页不就是一句limit的事吗?我之前也这么想过,直到被现实毒打了几次。第一层麻烦是 offset 的计算。前端传过来的是页码current和每页条数size,你需要在 SQL 里写成LIMIT (current-1)*size, size,这个看似简单的公式,写错的人真不少。尤其是分页条件变多、排序字段变复杂以后,一个列表页对应的 SQL 逻辑就越来越难维护。
第二层麻烦是 count 查询。手写分页的时候,你不仅要写数据查询 SQL,还要另写一条select count(*),这两条 SQL 必须保持条件完全一致。只要条件里多了一个 and 或者少了一个括号,列表数据和总数就对不上,前端页码就会显示错乱。这种 bug 在多人协作的项目里尤其常见,因为它不是编译期能发现的。
第三层麻烦是数据库方言。MySQL 用的是limit,Oracle 12c 之前要用rownum,SQL Server 用OFFSET FETCH,PostgreSQL 虽然和 MySQL 有点像但又有细微差别。如果你的系统后期要从 MySQL 迁移到 PostgreSQL,手写limit的 SQL 基本上全部要重写。而分页插件通过DbType帮你屏蔽了这一层差异,迁移数据库时只需要改一个配置项。
所以我会说,手写limit看起来简单,实际上是把分页的逻辑、统计、方言适配全部散落在业务代码和 SQL 里,成本远比想象中高。
1.2 分页插件的工作原理:Page 参数是如何被拦截的
理解分页插件,首先要知道 MyBatis 的四大对象和拦截器机制。简单说,MyBatis 允许我们在执行 SQL 的必经之路上插入自定义逻辑,分页插件就是 Schulte 机制的一个典型应用。MyBatis Plus 内部维护了一个MybatisPlusInterceptor,它本身是一个多功能的拦截器容器,而分页功能是通过向这个容器里添加PaginationInnerInterceptor来实现的。
当你调用一个 Mapper 方法,并且第一个参数是IPage类型(通常是Page对象)时,插件就会在 SQL 执行前做几件事:解析原始 SQL,生成 count 查询语句,根据配置的DbType改写原 SQL,加入分页方言。比如对于 MySQL,它会把你的 SQL 改写成... LIMIT ?, ?,并把Page中的current和size传进去。执行完成后,它还会把 count 的结果回填到Page对象里的total属性上。
这就是为什么使用分页插件时,你不需要在 SQL 里写任何limit,也不需要手算 offset。你只需要构建一个Page对象,然后把它作为 Mapper 方法的第一个参数传进去。查询结束后,page.getRecords()拿当前页数据,page.getTotal()拿总记录数,接口返回给前端的数据结构就完整了。理解了这套机制,后面排查“分页插件不生效”的时候,你就知道该往哪个方向找原因了。
2. 一行配置搞定分页:依赖与拦截器注册实操
“一行配置”这个说法其实是个梗,严格来说是要注册一个拦截器 Bean,然后分页的日常使用代码里基本就只有一行new Page<>(current, size)。很多新手在配置分页插件时踩坑,不是因为配置本身难,而是依赖版本没选对,或者是还在用已经废弃的PaginationInterceptor。下面我把整个操作流程拆开讲。
2.1 版本选不对,配置全白费:依赖引入与版本坑
你用的 MyBatis Plus 版本不同,依赖的坐标有讲究。如果是 Spring Boot 2.x 项目,一般用mybatis-plus-boot-starter;如果是 Spring Boot 3.x 项目,就一定要用mybatis-plus-spring-boot3-starter。我见过有人把 Spring Boot 2 的 starter 硬塞进 Spring Boot 3 项目里,结果启动直接报错,因为 Spring Boot 3 基于 Jakarta EE,包名从javax变成了jakarta,旧依赖完全对不上。
另外还有一个版本坑,是我在 3.5.9 版本之后才遇到的。新版的 MyBatis Plus 把 JSqlParser(SQL 解析器)拆到了独立模块mybatis-plus-jsqlparser中,分页插件执行时会用到它。如果你只引入了 starter,没有引入这个模块,启动时或第一次执行分页查询时会报ClassNotFoundException,比如找不到net.sf.jsqlparser.statement.select.Select这类异常。解决方案很简单,在pom.xml里补上对应版本的依赖即可。我一般会在配置里统一用mybatis-plus.version属性来管理版本,避免多个模块版本不一致。
<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.7</version> </dependency>如果项目用的 Spring Boot 3,就换成:
<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-spring-boot3-starter</artifactId> <version>3.5.7</version> </dependency>2.2 我的标准配置:MybatisPlusInterceptor 注册模板
配置分页插件,核心是把MybatisPlusInterceptor注册成 Spring Bean,然后往里面添加PaginationInnerInterceptor。我写过一个标准模板,项目里一直复用,你可以直接抄作业:
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 pagination = new PaginationInnerInterceptor(DbType.MYSQL); // 单页最大条数限制,防止有人传超大 size 把数据库拖垮 pagination.setMaxLimit(500L); // 生产环境建议 true,开启 count 查询的 join 优化 interceptor.addInnerInterceptor(pagination); return interceptor; } }这里有几个容易踩的细节。第一,DbType.MYSQL要和你实际数据库匹配,别图省事不填或者乱填。第二,PaginationInnerInterceptor必须通过addInnerInterceptor添加到容器里,直接 new 出来是没用的。第三,如果你的项目里之前使用过旧版PaginationInterceptor,记住它已经在 3.5.0 版本后被移除了,千万别再 copy 老代码,否则编译和运行都可能出问题。
配置完成之后,日常分页调用就非常简单了:
Page<User> page = new Page<>(1, 10); IPage<User> result = userMapper.selectPage(page, new QueryWrapper<>()); List<User> records = result.getRecords(); long total = result.getTotal();或者用IService自带的page方法,效果一样。你可以在日志里看到自动拼接的 count 和 limit 语句,看到它们出现,说明分页插件已经开始干活了。
2.3 不用 Spring Boot?给原生 MyBatis 也加上分页
如果你还在用传统的 Spring + MyBatis,没有引入 Spring Boot,上面的自动配置方式就不适用了。这种情况下,你需要在 MyBatis 的 SqlSessionFactory 配置阶段手动加入拦截器。大致思路是拿到MybatisConfiguration或SqlSessionFactoryBean,然后调用它的setPlugins方法。
@Bean public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception { MybatisSqlSessionFactoryBean factoryBean = new MybatisSqlSessionFactoryBean(); factoryBean.setDataSource(dataSource); MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); factoryBean.setPlugins(interceptor); return factoryBean.getObject(); }这里有个小提醒:如果你的项目是公司老框架二次封装,千万别漏掉factoryBean.setPlugins(interceptor)这一步,因为很多封装框架会重新定义 SqlSessionFactory,导致你自己 @Bean 配置的拦截器没被使用。这也是老项目中分页插件“配置了但不生效”的常见原因之一。
3. 分页插件不生效怎么办?按这份清单逐个排查
很多同学遇到分页插件不生效,第一反应是“是不是版本问题”或者“是不是配置问题”,然后百度一圈,改来改去还是不行。我把自己排查过的真实案例整理成了清单,按顺序检查,基本上十分钟能找到问题。
3.1 拦截器没注册:最隐蔽也最常见的坑
先确认你的项目里是否存在MybatisPlusInterceptor这个 Bean。最简单的判断方法:在分页查询的日志里看有没有输出 count 语句。如果没有 count 日志,也没有LIMIT,那基本可以断定拦截器没生效。常见的有两种原因:一种是压根没写配置类;另一种是写了配置类,但因为组件扫描路径不对,Spring 根本没扫到它。我遇到过一个人把MybatisPlusConfig放在com.example.utils包里,但主启动类在com.example.application,默认扫描不到,结果整个配置类形同虚设。
解决方式很直接:把配置类放在启动类所在包或其子包下,或者手动在启动类加@ComponentScan。配置类里加一行日志,启动时确认它被加载了:
@PostConstruct public void init() { System.out.println("[MybatisPlusConfig] pagination interceptor registered"); }如果能看到这行日志,说明配置类本身加载没问题,再往下查。
3.2 Page 参数放错位置:插件根本认不出分页参数
配置没问题,但分页还是不生效,下一步检查 Mapper 方法签名。分页插件的识别规则是:Mapper 方法的第一个参数必须是IPage类型。这句话非常关键,因为我见过太多人把 Page 放在 Map 参数里,或者放在第二个参数位置,结果插件完全没反应。
给你看两个对比:
// 正确写法:Page 作为第一个参数 IPage<User> selectUserPage(Page<User> page, @Param("name") String name); // 错误写法:Page 夹在中间或放在 Map 里 IPage<User> selectUserPage(@Param("name") String name, @Param("page") Page<User> page);第二种写法 MyBatis Plus 也能接收到参数,但它不会自动分页,执行结果还是全量数据。有人把 Page 塞进 Map 再传给 Mapper,同样不会生效。遇到这类问题,建议大家把方法签名统一改成“第一个参数是 Page,后面才是业务条件”。如果你不能改方法名,也可以利用Page作为第一个参数,其他参数用@Param标注在后面,这是官方推荐的用法。
3.3 count 查询出错,导致整个分页异常
分页插件自动生成 count 语句时,大部分场景都正常,但遇到复杂的自定义 SQL 就很容易翻车。比如你的 SQL 里既有left join又有group by,插件生成的 count 语句可能变成select count(*) from (select ... group by ...) t,这种写法本身没问题,但如果外层查询有order by,或者子查询里有两个字段的distinct,就可能导致 SQL 语法错误,页面直接 500。
还有一类问题是 count 语句和实际列表数据条件不一致。比如你的自定义 SQL 里用了 MySQL 的ifnull函数,或者用了case when,插件解析不完整导致 count 结果比实际数据大或者小。遇到这种情况,一个非常实用的处理方案是:手动关掉插件的自动 count,然后自己查一次 total,手动设置到 Page 上。
Page<User> page = new Page<>(current, size); // 关闭自动 count 查询 page.setSearchCount(false); IPage<User> result = userMapper.selectUserPage(page, condition); // 手动查询总数 long total = userMapper.selectUserCount(condition); page.setTotal(total);需要提醒的是,关闭自动 count 会牺牲一点开发效率,因为你要维护两条 SQL,但在特殊复杂查询下,这反而更可控。相比之下,我一直倾向于让项目里的复杂列表 SQL 保持简洁,如果写了 join 和子查询导致分页出问题,先看业务能不能拆成两条查询,不要硬怼分页插件。
3.4 数据库方言选错,分页 SQL 拼成四不像
这个坑在新老项目切换数据库的时候特别常见。如果你把DbType配置成MYSQL,但实际连的是 PostgreSQL,分页插件生成的 SQL 可能是LIMIT ? OFFSET ?格式,对于 PostgreSQL 没问题,但如果反过来,你配置成POSTGRE_SQL却连 MySQL,生成的语句就可能有问题。更糟糕的是 SQL Server,方言语法是OFFSET ? ROWS FETCH NEXT ? ROWS ONLY,如果配置错误,SQL 直接报语法错误。
排查方法是看执行日志里自动拼接的 SQL 长什么样,对照你连接的数据库语法是否正确。如果你不想显式指定某个数据库,可以使用DbType.AUTO,让分页插件根据 JDBC 连接元数据自动判断。但我个人建议,项目里的数据库类型是确定的,直接用固定 DbType 更稳,自动识别在某些代理数据源或者读写分离架构里可能会失灵。
3.5 自定义 SQL 与嵌套 ResultMap 导致的“灵异现象”
还有一种情况,分页后列表数据不足一页,或者某个字段的嵌套集合突然变空了。这种问题通常发生在自定义 SQL 配合 ResultMap 的嵌套结果映射时。原因是 MyBatis 的嵌套结果映射是查询结果一次性全部取回来再内存里组装,而分页插件是在 SQL 层加的 limit,一旦外层主记录被 limit 截断,对应的子集合数据也会不完整。
举个例子,一个订单列表,每条订单下有多个明细,你用了collection关联查询明细。不分页的时候,SQL 返回 100 条明细,MyBatis 能正确映射成 N 个订单。分页后,limit 只取了 10 条外层订单,但明细结果集合可能只包含这 10 条订单的部分关联数据,最终导致嵌套明细丢失。这个问题特别隐蔽,因为它不报错,只是数据看起来“少了点什么”。
我的处理策略是:优先避免在分页的主查询上使用嵌套 ResultMap。用分页查出当前页的主表 ID 集合,再用第二个查询IN (ids)把关联数据查出来,在代码里做组装。这样既保证了分页正确,也避开了 SQL 级 limit 对嵌套映射的干扰。
3.6 多个拦截器叠加,把我给整不会了
如果你在项目里配置了不止一个拦截器,比如有自定义的 SQL 日志拦截器、多租户拦截器、或者历史遗留的旧分页拦截器,就需要注意拦截器的执行顺序。MyBatis 拦截器是有顺序的,如果顺序不对,分页拦截器可能拿不到被其他拦截器修改后的 SQL,或者被其他拦截器提前拦截,导致分页 SQL 没有拼接上。
我遇到过最离奇的一个案例:项目里同时存在MybatisPlusInterceptor和自定义的Interceptor,自定义拦截器先执行,在返回结果时把Page对象的records给替换成了自己处理的 List,分页数据看起来“没生效”。这种情况只能靠检查项目里所有Interceptor实现类来定位。建议大家在新增拦截器时,尽量不要影响 Executor 的 query 方法返回值,有改动一定要先跑一遍分页回归测试。
4. 进阶避坑指南:把分页插件的性能和安全调到位
分页插件配置好了,能分页了,只是第一步。真正常年维护的线上项目,还得关注深分页性能、单页条数限制、多数据源兼容这些细节。这部分属于可以让你在团队里显得很专业的进阶内容,我挑重点讲。
4.1 深分页性能问题:别让 limit 100000, 20 拖垮数据库
分页插件帮你生成的 SQL 是LIMIT ?, ?,当页码很大时,偏移量 offset 会变得非常大,比如用户翻到第 5000 页,offset 就是 100000。MySQL 处理这种深分页的方式其实是先扫描出前 100020 行,然后丢弃前 100000 行,越到后面越慢,甚至会拖垮数据库。
分页插件本身不解决这个问题,它只是帮你生成 SQL。要优化深分页,通常有两种思路。第一种是最常用也最稳定的:禁用随机页码跳转,改成“上一页/下一页”模式,通过记录上一次查询的最后一条 ID,在下一次查询时用where id > #{lastId}来过滤,而不是用 offset。这种方式在 Feed 流、论坛帖子里特别常见。
第二种是子查询方式,MySQL 8 低版本也能用,先查出当前页的最小 ID,再根据这些 ID 查完整数据:
select * from user where id >= ( select id from user order by id limit 100000, 1 ) order by id limit 20;这在 MyBatis Plus 里可以直接用page.setOptimizeCountSql(false)配合last("limit ...")或者自定义 SQL 实现。需要注意的是,这种方式要求主键必须是自增且有顺序的,如果你的排序规则不依赖主键,就得换思路。
4.2 单页数量限制:maxLimit 一定要设
分页接口最容易被人恶意刷的就是超大size。比如一个列表默认每页 10 条,攻击者把size改成 1000000,数据库支撑不住,接口直接超时。分页插件提供了一个现成的保护机制,就是PaginationInnerInterceptor.setMaxLimit(500L)。设置后,如果请求的 size 超过 500,插件会自动把 size 降为 500。
我在多个项目里都强制要求这个配置,并且把阈值设得符合业务场景。比如后台管理列表最大 100 条就够了,但报表导出可能会用到更大数值,这时候可以单独针对部分接口放开,而不是全局放开。另外要注意,maxLimit只限制通过 Page 对象传入的 size,如果你在 SQL 里手写了limit,绕过插件的保护就不受限制了,这也是我坚持不让团队手写limit的另一个原因。
4.3 多数据源场景下,方言怎么正确处理
现在的项目经常会配多个数据源,比如一个主库一个从库,或者模块化拆库。多数据源环境下,分页插件的配置要格外小心,因为不同数据源可能使用不同数据库。如果你的主库是 MySQL,从库也是 MySQL,那没问题;但如果你一个业务表在 MySQL,一个业务表在 PostgreSQL,那DbType.MYSQL就不够用了。
处理方式有两种。一种是在每个数据源对应的 SqlSessionFactory 里分别注册 MybatisPlusInterceptor,并配置对应的 DbType。另一种是使用DbType.AUTO,让插件根据当前连接自动识别。但自动识别有一个潜在问题:如果连接池用了代理或者包装过的连接,JDBC 的getDatabaseProductName()可能拿不到真实值,导致识别失败。所以我的建议是:能确定数据源类型就显式指定;实在需要多方言支持,优先按数据源拆分配置,别在一个拦截器里赌自动识别。
4.4 升级到 3.5.9+ 后遇到 ClassNotFound 怎么办
前文提到了 JSqlParser 依赖拆分的问题,这里再展开一下。从 MyBatis-Plus 3.5.9 开始,分页插件解析 SQL 时依赖的jsqlparser被独立到了mybatis-plus-jsqlparser模块。如果你从旧版本升级,比如从 3.5.3.2 升到 3.5.10,启动时没有任何报错,但第一次执行分页查询时突然报NoClassDefFoundError: net/sf/jsqlparser/...,大概率就是这个原因。
别慌,补一个依赖就行:
<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-jsqlparser</artifactId> <version>3.5.10</version> </dependency>注意版本号要和你的mybatis-plus-boot-starter或mybatis-plus-spring-boot3-starter保持一致。另外,如果你的项目里手动引入了老版本的jsqlparser包,也可能和 MyBatis Plus 自带的解析器产生冲突。遇到这种问题,最好的办法是统一用 MyBatis Plus 提供的模块,不要手动额外引入jsqlparser依赖。
5. 常见问题速查表:现象、原因、处理对照
为了让你在真正出问题的时候能快速对照,我把上面提到的坑整理成了一张速查表。你可以把它保存下来,下次排查分页异常时直接对照。
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 分页查询日志里没有 count 和 LIMIT | MybatisPlusInterceptor 没注册或没被扫描 | 检查配置类位置,确认 Bean 被加载 |
| 返回数据是全部记录,但 total 为 0 | Page 参数不是 Mapper 方法第一个参数 | 调整方法签名,确保 IPage 放第一位 |
| SQL 执行报语法错误,LIMIT 格式怪异 | DbType 配置和实际数据库不一致 | 改成正确的 DbType,或使用 DbType.AUTO |
| count 查询报错,接口 500 | 自动生成的 count SQL 不适配复杂查询 | 关闭自动 count,手动统计 total |
| 分页后嵌套集合数据缺失 | 自定义 SQL 用了嵌套 ResultMap | 改用主表分页后按 ID 二次查询 |
| 多个拦截器时分页失效 | 拦截器顺序或返回值被污染 | 检查所有自定义 Interceptor 实现 |
| 升级版本后 ClassNotFound | 缺少 mybatis-plus-jsqlparser 依赖 | 按版本补全依赖 |
| 接口被传超大 size 拖慢 | 未设置单页最大限制 | 配置 maxLimit 阈值 |
这张表只是帮你定位方向,具体怎么改还是要结合项目里的实际代码。建议每排查完一个问题,就在表后面补一行新的经验总结,时间长了,这份速查表就是你自己的分页插件排障手册。
6. 写在最后:我踩过的那个分页大坑
想起前两年做的一个订单管理系统,上线后运营反馈某个报表页的数据偶尔少几条。我当时第一反应是 SQL 查询条件问题,查了半天没结果。后来发现,那个页面用的是自定义 SQL 加嵌套 ResultMap,分页插件在 SQL 层加了 limit,导致一对多关联查询的子集合被截断。那次之后,我在团队里定了一个规矩:分页主查询和嵌套结果映射不能同时出现在一个方法里,遇到类似业务,全部拆成分页主查询 + ID 二次查询。
还有一个让我记忆犹新的教训是,有一次我把PaginationInnerInterceptor写成了PaginationInterceptor,结果项目启动完全不报错,但分页始终没效果。查了官方文档才发现旧类已经被移除了,新的写法必须通过MybatisPlusInterceptor容器注册。这种“配置了等于没配置”的坑,最可怕的就是它不报错,让你误以为插件没有生效,其实只是类名写错了。
最后再分享一个小技巧:排查分页问题时,不要只盯配置,先把执行的 SQL 日志打开。MyBatis Plus 会打印完整的 count 和 limit 语句,只要你一眼能看到 SQL 里有没有 limit,问题范围就缩小了一大半。分页插件本身很成熟,出问题大概率是参数位置、拦截器注册、数据库方言这三件事。把这三件事查明白了,你就能安心享受“一行配置搞定分页”的爽快感了。