先抛个我踩过的场景:项目从零搭起来,DAO层用的是MyBatisPlus,列表查询本来想省事直接selectList一把梭,结果数据量过了十万之后接口肉眼可见地变慢,前端表格滚动起来直接卡成PPT。后来接上了MyBatisPlus的分页插件,一个Page对象把分页这块彻底解放了。但真正用起来之后才发现,分页插件这东西“能用”和“用明白”之间隔着好几道坎——单页500条限制、count语句不准、JOIN查询翻车、Page对象复用串数据,这些坑我基本都踩了一遍。
这篇文章就把分页拦截器的原理、配置、坑位和优化思路一次性讲透。不管你是刚接触MyBatisPlus的新手,还是已经用了很久但对内部机制一知半解的老手,这篇都能帮你少走弯路。
1. 为什么需要分页拦截器:手写分页的痛点与插件定位
1.1 没有插件时,一篇正常的分页SQL要写多少重复代码
先回到最原始的场景。假设你手上的ORM还是纯MyBatis,没有MyBatisPlus,也没有PageHelper这类分页插件。要实现一个列表分页,你需要做两件事:
第一件事,查当前页数据:
SELECT * FROM user WHERE status = 1 ORDER BY create_time DESC LIMIT #{offset}, #{pageSize};第二件事,查总条数:
SELECT COUNT(*) FROM user WHERE status = 1;两段SQL要写两次,还要手动维护offset和pageSize两个参数,Mapper接口里每个列表查询都得带上这两个参数。这还只是单表最简单的情况。一旦查询条件复杂起来,比如动态拼接了多个and条件,那么数据SQL和countSQL就必须同步维护。改了一个条件忘了改另一个,就会出现“页面显示总条数100,实际翻到第5页只剩2条”这种诡异问题。
更麻烦的是分页方言。MySQL用LIMIT offset, size,Oracle要用ROWNUM或者12c以后的FETCH FIRST ? ROWS ONLY,SQL Server用TOP或者OFFSET FETCH。系统哪天要从MySQL迁移到PostgreSQL或者国产数据库,所有分页SQL全得重写。这也是为什么很多人对“分页插件”有执念——它不只是省几行代码,而是把方言差异统一收口了。
1.2 MyBatisPlus分页拦截器的角色定位
MyBatisPlus的分页拦截器,本质上是一个MybatisPlusInterceptor中注册的PaginationInnerInterceptor。它不是Spring MVC那个处理HTTP请求的HandlerInterceptor,两者名字里都有“Interceptor”,但工作位置完全不是一个层级。
Spring MVC的拦截器拦截的是Controller方法调用,发生在Web层,你可以在方法执行前后做登录校验、日志记录。MyBatisPlus的拦截器拦截的是Executor的query方法,工作在MyBatis框架内部,也就是SQL真正要发给数据库执行之前的那一层。
它的定位是“物理分页”。也就是说,插件会把你的原始SQL改写成语义等价、但带有数据库方言分页语法的SQL,然后真正把LIMIT、OFFSET、ROWNUM这些限制下推到数据库执行。数据库只返回当前页那几条数据,内存里不会塞入全表结果。这一点和“先把所有数据查出来再在Java里做个subList”的内存分页有本质区别,也是线上千万级数据量场景下必须用它的原因。
这类插件的核心价值有三点:一是把你从手动维护SQL和count的重复劳动里解放出来;二是统一了多数据库方言;三是底层是物理分页,性能可控。理解了这些,后面看到插件内部那些SQL改写逻辑就不会觉得玄学了。
2. 分页拦截器的内部原理:你的SQL在拦截器里经历了什么
2.1 从 MybatisPlusInterceptor 到 PaginationInnerInterceptor
很多人配置分页插件时会写这么一段:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }这里有两个关键类:MybatisPlusInterceptor是所有内置拦截器的总入口,它实现了MyBatis的Interceptor接口;PaginationInnerInterceptor是真正负责分页SQL改写的内部拦截器。
MybatisPlusInterceptor内部维护了一个List<InnerInterceptor>,通过addInnerInterceptor方法按顺序追加。除了PaginationInnerInterceptor,你还可以追加OptimisticLockerInnerInterceptor(乐观锁)、BlockAttackInnerInterceptor(防全表更新删除)等。它们按注册顺序依次处理SQL语句。
日常开发里最常见的错误是把PaginationInnerInterceptor直接当成@Bean注册:
// 错误示例 @Bean public PaginationInnerInterceptor paginationInnerInterceptor() { return new PaginationInnerInterceptor(DbType.MYSQL); }这样写分页插件不会生效,因为MyBatis的插件机制要求被拦截的目标必须是实现了Interceptor接口的类,而PaginationInnerInterceptor本身没有实现MyBatis的拦截器接口,它只是被MybatisPlusInterceptor调用的一个内部策略。这个坑我见过不少同事踩过,配置完之后怎么查都是全量数据,翻页毫无反应。
2.2 一次分页查询的完整执行链路
假设你在Service层写了这么一段代码:
Page<UserDO> page = new Page<>(1, 10); LambdaQueryWrapper<UserDO> wrapper = Wrappers.<UserDO>lambdaQuery() .eq(UserDO::getStatus, 1) .orderByDesc(UserDO::getCreateTime); userMapper.selectPage(page, wrapper); List<UserDO> records = page.getRecords(); long total = page.getTotal();执行过程中,MyBatisPlus的分页拦截器做了这几件事:
第一步,拦截到SELECT * FROM user WHERE status = 1 ORDER BY create_time DESC这条原SQL后,PaginationInnerInterceptor先用JSqlParser把SQL解析成抽象语法树。JSqlParser是专门解析SQL语句的Java库,它能识别出SQL里的SELECT、FROM、WHERE、ORDER BY等关键字位置。
第二步,生成count SQL。插件把原始SQL的SELECT子句替换为SELECT COUNT(*)或SELECT COUNT(1),去掉ORDER BY,保留WHERE条件。如果查询里没有聚合函数和GROUP BY,count语句通常没问题。但如果SQL里带了GROUP BY,count语句会变成SELECT COUNT(*) FROM (SELECT ... GROUP BY ...) TOTAL这种嵌套形式。
第三步,生成分页SQL。插件根据配置的数据库方言,把原SQL改写成对应的分页形式。比如MySQL下会追加LIMIT 0, 10,Oracle 12c以下会套一层ROWNUM,PostgreSQL则追加LIMIT 10 OFFSET 0。
第四步,先执行count SQL拿到总条数,填充到Page对象的total字段。
第五步,执行分页SQL拿到当前页数据,填充到Page对象的records列表。
最后你在Service层拿到page.getRecords()和page.getTotal()后,把total返回给前端做分页组件渲染。Element UI里的el-pagination组件需要total和currentPage、pageSize,正好对应Page对象的三个字段。
2.3 方言适配:为什么换个数据库你的分页代码不用改
这是分页插件最爽的一点。PaginationInnerInterceptor构造时可以传入一个DbType枚举,比如DbType.MYSQL、DbType.ORACLE、DbType.POSTGRE_SQL、DbType.SQL_SERVER。插件内部维护了每种数据库对应的分页SQL生成策略接口IDialect。
如果你不指定数据库类型,插件在大部分情况下也能通过JdbcUtils.getDbType(rawJdbcUrl)从数据源连接URL里推断出数据库类型。所以配置里甚至可以简写:
interceptor.addInnerInterceptor(new PaginationInnerInterceptor());不过我还是建议显式指定DbType,省得在多数据源场景下推断错误。比如同一个应用里既连了MySQL又连了PostgreSQL,不指定的话插件是根据当前线程上下文的数据源连接URL判断的,一般没问题,但显式指定更可控,也方便阅读代码的人一眼看出你当前环境是什么库。
一旦要把系统从MySQL迁移到Oracle,你只需要改DbType.ORACLE,业务代码里的LIMIT、offset这些手写分页全部可以不做改动。这就是把方言差异收口到插件层的价值。
3. 把分页插件跑起来:依赖、配置与常见误区
3.1 Maven依赖与版本匹配
MyBatisPlus分页插件被打包在mybatis-plus-extension这个模块里,大部分情况下你引入mybatis-plus-boot-starter就会传递依赖进来。Maven坐标:
<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency>如果你的项目用的是Spring Boot 3.x,需要引入适配Javax到Jakarta迁移的版本,比如mybatis-plus-spring-boot3-starter。版本不匹配的典型症状是启动时报ClassNotFoundException: org.apache.ibatis.session.Configuration或NoSuchMethodError,这多半是MyBatis版本和MyBatisPlus版本相互冲突了。
另外一个比较容易忽略的点是分页插件依赖JSqlParser,不同MyBatisPlus版本内置的JSqlParser版本不一样。有些场景下,如果你业务SQL里写了JSqlParser解析不了的语法,比如某些复杂的OracleCONNECT BY、MySQLFOR UPDATE和递归CTE混用,插件可能直接抛JSQLParserException,此时你有两个选择:一是简化SQL,二是升级MyBatisPlus版本获取更高版本的JSqlParser支持。这属于比较边缘的兼容问题,遇到时报错信息通常很明确,不用慌。
3.2 注册拦截器Bean:两种写法的差异
网上配置分页插件有两种常见写法,一种是用@Configuration加@Bean,另一种是直接在启动类上配。我建议统一放在独立的配置类里,便于维护。
标准写法:
@Configuration public class MybatisPlusPageConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); PaginationInnerInterceptor paginationInnerInterceptor = new PaginationInnerInterceptor(DbType.MYSQL); // 单页最大条数限制,默认500条,超过则 paginationInnerInterceptor.setMaxLimit(1000L); // 溢出总页数后是否进行处理,true表示查询最后一页 paginationInnerInterceptor.setOverflow(true); interceptor.addInnerInterceptor(paginationInnerInterceptor); return interceptor; } }如果项目里还有其他内部拦截器,比如你要同时用乐观锁和防全表更新,要保证顺序合理。一般来说分页拦截器加在最前面,因为后面拦截器处理的SQL,最好是已经分页过的或者不经分页的。当然具体顺序要看你业务场景,比如你需要对分页结果做脱敏,那脱敏拦截器应该注册在分页拦截器后面。
注意,这里注册的MybatisPlusInterceptor是单例Bean,MyBatis在启动时会自动发现它并加入拦截器链。你不需要在mybatis-config.xml里手动配置<plugin>标签,也不要同时配置两遍,否则分页逻辑可能执行两次,出现总条数翻倍或SQL被改写了两次的异常现象。
3.3 yml里到底能不能配置分页参数
很多人在搜索“mybatisplus分页配置文件yml里如何配置”,其实application.yml里并没有专门的分页插件参数配置项。MyBatisPlus在yml里的配置前缀是mybatis-plus,它主要控制的是全局策略,比如:
mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0这些配置和分页参数没有直接关系。分页插件的maxLimit、overflow这些参数必须在Java代码里通过PaginationInnerInterceptor的setter方法设置,yml里没有对应的配置项。如果你在yml里写了类似mybatis-plus.pagination.max-limit这样的配置,它不会生效,也不会报错,容易给人“配了但没配对”的误导。
map-underscore-to-camel-case这个配置倒是和分页结果映射有关,它决定数据库字段create_time是否能自动映射到Java属性createTime。如果这个没开,分页查出来的数据里时间字段可能全是null,很多新手会误以为是分页插件问题,排查半天发现是驼峰映射没开。
3.4 一个最小可运行的示例
结合上面说的,给你一个最小可运行的分页查询例子:
// 实体 @Data @TableName("user") public class UserDO { private Long id; private String name; private Integer status; private LocalDateTime createTime; } // Mapper @Mapper public interface UserMapper extends BaseMapper<UserDO> { } // Service @Service public class UserService { @Resource private UserMapper userMapper; public PageResult<UserDO> pageUsers(int current, int size, Integer status) { Page<UserDO> page = new Page<>(current, size); LambdaQueryWrapper<UserDO> wrapper = Wrappers.<UserDO>lambdaQuery() .eq(status != null, UserDO::getStatus, status) .orderByDesc(UserDO::getCreateTime); userMapper.selectPage(page, wrapper); PageResult<UserDO> result = new PageResult<>(); result.setRecords(page.getRecords()); result.setTotal(page.getTotal()); result.setCurrent(page.getCurrent()); result.setSize(page.getSize()); return result; } }这个例子可以跑通最基础的分页场景。执行时控制台会打印两条SQL,一条是SELECT COUNT(*) ...,另一条是SELECT ... LIMIT ...。看到这两条SQL基本就说明插件生效了。
4. 单页500条限制:默认防线还是绊脚石
4.1 maxLimit是怎么生效的
搜索热词里有“接触mybatisplus单页500条限制”,这应该是很多人第一次遇到分页插件时的疑惑:为什么我一页明明传了size=1000,结果只返回500条?
原因就是PaginationInnerInterceptor内部默认设置了maxLimit = 500L。当Page对象的size(也就是每页条数)超过500时,插件不会让它直接去数据库执行LIMIT 1000,而是悄悄把size修正为500。
看一下PaginationInnerInterceptor源码里beforeQuery方法的大致逻辑:
public boolean beforeQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException { IPage<?> page = ParameterUtils.findPage(parameter).orElse(null); if (page == null) { return true; } // 处理size超限 if (page.getSize() < 0 || page.getSize() > maxLimit) { page.setSize(maxLimit); } // ... }这个默认值的意图是防止误操作。比如你写了个报表导出接口,本意是“一页全查出来”,结果size传了-1或者一个巨大值,如果插件不做限制,相当于一个没带分页条件的全表查询,数据库压力会瞬间拉满。500这个数字对大多数管理后台的分页场景是合理的。
但如果你真的需要一页返回超过500条,比如某些内部系统给前端下拉框一次性加载全部选项,500就不够用了。此时有两种改法:
第一种,通过setter调大:
PaginationInnerInterceptor paginationInnerInterceptor = new PaginationInnerInterceptor(); paginationInnerInterceptor.setMaxLimit(2000L);第二种,传入Long.MAX_VALUE直接放开限制:
paginationInnerInterceptor.setMaxLimit(Long.MAX_VALUE);我不建议为了图省事直接放开。500条限制本质上是一道保险,对绝大多数在线接口来说,一页返回500条已经很多了。如果有“一次拉全量”的需求,更合理的做法是单独设计导出接口或异步任务,而不是复用分页接口。
4.2 合理设置maxLimit而不是一味调大
说句实在话,我见过很多人的诉求是“我要导出10万条”,然后直接把maxLimit调成100000。这不是在解决问题,是在埋雷。
分页插件把maxLimit调大之后,单次查询确实能返回10万条,但这10万条数据在数据库端生成、网络传输、Java对象创建、JSON序列化这几个环节都会产生巨大的内存和IO开销。接口响应时间从几百毫秒变成十几秒,前端表格一次性渲染10万个DOM节点,浏览器直接卡死。
真正合理的做法是:在线分页查询,单页控制在10到100条之间;导出场景,使用流式查询或者异步分批查询,每次查几千条再写文件。maxLimit只是兜底,不能替代业务上的分页设计。
另外注意setOverflow(true)这个参数。默认overflow是false,当你请求第1000页但实际只有10页数据时,插件返回空列表。如果设置为true,插件会把页码修正为最后一页,返回最后一页的数据。它对maxLimit没有影响,两者是独立的控制维度。
5. 高频踩坑现场:count不准、JOIN翻车、Page对象复用
5.1 count查询与总条数的两个坑
第一个坑:count SQL里丢了条件。MyBatisPlus分页插件生成的count语句会尽量保留原SQL的WHERE条件,但如果你在Mapper方法上用了自定义SQL,比如:
<select id="selectUserPage" resultType="com.example.UserDO"> SELECT u.* FROM user u WHERE u.status = #{status} ORDER BY u.create_time DESC </select>这种写法在MyBatisPlus里配合Page参数使用时,插件能正确解析出count SQL。但如果SQL里带了动态标签<if>,并且某些条件下拼接的SQL语义不完整,解析器就可能生成错误的count语句。最典型的例子是动态ORDER BY后面拼接的条件不完整,导致count SQL解析失败,报JSQLParserException。
第二个坑:count SQL本身很慢。插件每执行一次分页查询都会额外执行一次SELECT COUNT(*),如果表数据量大、WHERE条件没有走索引,count查询可能比数据查询还慢。这种情况下你打开慢查询日志,会发现大量SELECT COUNT(*)语句占大头。
针对count很慢的情况,有两个解决方向:一是给WHERE条件涉及的字段建组合索引,这是最直接有效的手段;二是不太建议的改法是手动提供count语句——MyBatisPlus支持在Mapper里单独写一个selectCount方法并让插件跳过自动count,但这样又回到了手工维护SQL的老路,一般只在极特殊场景下采用。
5.2 JOIN分页的重复数据问题
分页插件处理多表JOIN查询时有一个经典问题:如果JOIN的右表是多行,比如一个用户关联了多张订单,那么SELECT u.* FROM user u LEFT JOIN orders o ON u.id = o.user_id查出来的数据里同一个用户会出现多行。此时分页的“总条数”统计的是结果集行数,而不是用户数,前端翻页时就会出现同一用户重复出现、总条数虚高的情况。
这个问题的根不在分页插件,而在SQL本身的JOIN语义。要解决它,通常有两种方案:
第一种,改用子查询或DISTINCT去重。比如先分页查出user_id,再用这些ID去查明细。
第二种,使用GROUP BY u.id保证同一用户只出现一行。MyBatisPlus分页插件在执行count时遇到GROUP BY会生成嵌套count:
SELECT COUNT(*) FROM (SELECT u.* FROM user u LEFT JOIN orders o ON u.id = o.user_id GROUP BY u.id) TOTAL这种写法能保证总条数正确,但性能不一定好,尤其在大表JOIN时。所以在设计分页接口时,如果涉及多表查询,我建议优先考虑“先查主表ID,再联查明细”的两段式查询,这也是后面讲性能优化时要展开的思路。
5.3 Page对象复用与线程安全问题
Page对象不是线程安全的,但更常见的坑是对象复用导致的总条数串数据。有些同学会把Page定义成类成员变量或者从请求上下文里取,第二次查询时发现total还是上次的值。
原因是Page对象里的total、current、size都是可变字段,分页插件在执行时会把结果直接写回这个对象。如果多个请求共用了同一个Page实例,后一个请求执行时total可能被覆盖成前一个请求的结果,并发场景下还会出现脏读。
规范做法是每次查询都new一个Page对象:
Page<UserDO> page = new Page<>(pageNum, pageSize);不要尝试复用,这个对象很轻量,创建成本可以忽略。很多性能优化方向搞错了,在这种地方省内存得不偿失。
5.4 逻辑删除对分页的影响
MyBatisPlus的逻辑删除是在SQL解析层面做的,当你配置了logic-delete-field后,插件会自动在查询SQL里追加AND deleted = 0。分页插件执行count时,这个条件同样会出现在count SQL里,所以分页总条数是“未删除数据”的条数,这个行为是正确的。
但有一点要注意:如果你在Mapper XML里手写SQL,且没有继承BaseMapper的通用方法,逻辑删除的自动拼接可能不生效。此时如果不手动在XML里加AND deleted = 0,分页查出来的数据会包含已删除的记录,总条数也会偏差。排查这类问题时,先把控制台打印的SQL打开,看count语句里有没有逻辑删除条件,往往一眼就能定位。
6. 分页查询性能优化:从优化SQL到Redis缓存
6.1 先分清是慢在count还是慢在data
分页查询慢,不要急着上缓存。先用数据库慢查询日志或者MyBatisPlus的SQL日志,把一次分页请求实际执行的两条SQL耗时分别打出来。一般性能瓶颈有三种情况:
第一种,count慢。前面说过,解决方向是索引优化。比如WHERE status = 1的status字段区分度不高,单独建索引效果有限,需要和排序字段组合设计索引。
第二种,data查询慢。分页SQL执行慢,很多时候不是LIMIT 10慢,而是大偏移量下的深分页慢。比如LIMIT 1000000, 10,数据库需要扫描前100万行再丢弃,才能取出最后10行。这个问题的本质是偏移量累积开销,不是简单加索引能解决的。
第三种,数据传输慢。一页有几百个字段,每行几KB,查50行也有几百KB数据,网络传输时间占比高。这种情况下做列裁剪、避免SELECT *是更有效的优化。
6.2 深分页优化:游标分页与ID缓存
处理深分页,业界常见的方案是“游标分页”。所谓游标,就是不用LIMIT offset, size,而是记住上一页最后一条记录的某个唯一字段,下一页用WHERE id > lastId来取。
MySQL示例:
SELECT * FROM user WHERE id > 1000000 ORDER BY id LIMIT 10;这种写法无论翻到第几页,扫描的数据量都只有10行,性能非常稳定。MyBatisPlus本身并没有内置游标分页对象,但你可以自己实现:在Page对象之外增加一个lastId参数,查询条件里带上id > lastId,最后再把本页最大ID返回给前端作为下一页的游标。
游标分页的代价是不能再依赖total做自由跳页,前端页码变成了“下一页”按钮。如果你的业务允许这种交互(信息流、聊天记录、操作日志),游标分页是非常好的选择。
如果业务一定要支持自由跳页,可以考虑“ID缓存”方案:提前把符合条件的ID列表按顺序缓存下来,比如存到Redis的ZSET里,score用排序字段值或者ID本身。分页时先查Redis拿到当前页的ID集合,再回数据库用WHERE id IN (...)取出数据。这样数据库端不再执行深偏移的LIMIT,只是按主键批量查数据,性能也能大幅提升。
6.3 Redis缓存分页结果的使用边界
“分页查询慢怎么用Redis优化”这个搜索词热度一直很高,但我必须泼一盆冷水:不是所有分页都适合加Redis缓存。
适合缓存的分页查询有几个特征:数据不频繁变更;量级在几千到几万条;查询条件组合固定;对数据实时性要求不高。典型场景是行政区域列表、产品分类列表、配置项列表。
不适合缓存的分页查询:条件组合非常多的搜索场景,比如用户在不同筛选条件下分页,每个条件组合都缓存一份会带来巨大的key膨胀和缓存一致性难题。这类场景更适合先把筛选后的ID列表降到最小,再用上面的深度分页优化手段。
如果你决定用Redis缓存分页结果,一个简单可用的思路是:以“条件哈希 + 页码 + 页大小”为key,缓存这一页的records,同时缓存count结果。但要设置合理的过期时间,并且在数据变更时主动清理相关key。
// 伪代码 String key = "user:page:" + hash(queryCondition) + ":" + current + ":" + size; List<UserDO> cached = redisTemplate.opsForValue().get(key); if (cached != null) { return cached; } Page<UserDO> page = userMapper.selectPage(new Page<>(current, size), wrapper); redisTemplate.opsForValue().set(key, page.getRecords(), 5, TimeUnit.MINUTES);这里有个细节:缓存里存records而不是整个Page对象。因为Page对象里有total,而total可能因为缓存过期被重新计算,和records放一起缓存容易不一致。我通常在总条数上也用一个独立key缓存,但更新频率会比records更低,因为它只在数据增删时变化。
最后再分享一个排查分页问题时的小技巧:如果怀疑是分页插件的问题,先把mybatis-plus.configuration.log-impl设为org.apache.ibatis.logging.stdout.StdOutImpl,然后在控制台观察插件实际执行的两条SQL。SQL一打出来,大多数问题的原因就浮出水面了。分页插件本身是个很稳的组件,绝大多数线上事故都出在“错误的使用方式”上,而不是插件本身的逻辑。理解了它的拦截原理和边界,你就能把它用得既顺手又安全。