ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

MyBatisPlus分页拦截器原理与优化:从count不准到深分页避坑指南

MyBatisPlus分页拦截器原理与优化:从count不准到深分页避坑指南 先抛个我踩过的场景项目从零搭起来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, sizeOracle要用ROWNUM或者12c以后的FETCH FIRST ? ROWS ONLYSQL 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内部维护了一个ListInnerInterceptor通过addInnerInterceptor方法按顺序追加。除了PaginationInnerInterceptor你还可以追加OptimisticLockerInnerInterceptor乐观锁、BlockAttackInnerInterceptor防全表更新删除等。它们按注册顺序依次处理SQL语句。日常开发里最常见的错误是把PaginationInnerInterceptor直接当成Bean注册// 错误示例 Bean public PaginationInnerInterceptor paginationInnerInterceptor() { return new PaginationInnerInterceptor(DbType.MYSQL); }这样写分页插件不会生效因为MyBatis的插件机制要求被拦截的目标必须是实现了Interceptor接口的类而PaginationInnerInterceptor本身没有实现MyBatis的拦截器接口它只是被MybatisPlusInterceptor调用的一个内部策略。这个坑我见过不少同事踩过配置完之后怎么查都是全量数据翻页毫无反应。2.2 一次分页查询的完整执行链路假设你在Service层写了这么一段代码PageUserDO page new Page(1, 10); LambdaQueryWrapperUserDO wrapper Wrappers.UserDOlambdaQuery() .eq(UserDO::getStatus, 1) .orderByDesc(UserDO::getCreateTime); userMapper.selectPage(page, wrapper); ListUserDO 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 BYcount语句通常没问题。但如果SQL里带了GROUP BYcount语句会变成SELECT COUNT(*) FROM (SELECT ... GROUP BY ...) TOTAL这种嵌套形式。第三步生成分页SQL。插件根据配置的数据库方言把原SQL改写成对应的分页形式。比如MySQL下会追加LIMIT 0, 10Oracle 12c以下会套一层ROWNUMPostgreSQL则追加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 groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.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是单例BeanMyBatis在启动时会自动发现它并加入拦截器链。你不需要在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 BaseMapperUserDO { } // Service Service public class UserService { Resource private UserMapper userMapper; public PageResultUserDO pageUsers(int current, int size, Integer status) { PageUserDO page new Page(current, size); LambdaQueryWrapperUserDO wrapper Wrappers.UserDOlambdaQuery() .eq(status ! null, UserDO::getStatus, status) .orderByDesc(UserDO::getCreateTime); userMapper.selectPage(page, wrapper); PageResultUserDO 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条限制”这应该是很多人第一次遇到分页插件时的疑惑为什么我一页明明传了size1000结果只返回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 idselectUserPage resultTypecom.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会生成嵌套countSELECT 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对象PageUserDO 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; ListUserDO cached redisTemplate.opsForValue().get(key); if (cached ! null) { return cached; } PageUserDO 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一打出来大多数问题的原因就浮出水面了。分页插件本身是个很稳的组件绝大多数线上事故都出在“错误的使用方式”上而不是插件本身的逻辑。理解了它的拦截原理和边界你就能把它用得既顺手又安全。
返回列表