
做Java后端这些年MyBatisPlus几乎是每个新项目的标配最近团队在重建内部框架模块正好轮到06号Module借着这个机会我把MyBatisPlus里最容易被问爆的两个问题——分页失效、单页条数限制——重新撸了一遍。这篇内容不是官方文档的复读是我们在实际项目中踩过坑、翻过源码、最后沉淀下来的一套可落地方案希望能帮到正在被这两个问题折磨的同学。1. 为什么项目里绕不开MyBatisPlus1.1 框架定位决定了它的价值MyBatisPlus的口号是“只做增强不做改变”这句话听起来温和实际用起来非常关键。它没有颠覆MyBatis而是在MyBatis基础上提供了一堆现成的能力单表CRUD不用写SQL、Wrapper条件构造器解决动态查询、分页插件自动拼LIMIT、逻辑删除注解一键生效、自动填充处理创建时间更新时间。这些能力对应的恰恰是后端开发中最枯燥、最重复、最容易出bug的日常编码场景。我见过不少团队纠结要不要引入MyBatisPlus担心“框架绑架业务”。这里我的观点很直接如果你的项目以单表操作和简单关联查询为主MyBatisPlus能把开发效率提升一个档次如果你的业务以大宽表Join、复杂子查询为主那MyBatisPlus也不拦着你写XML它不会像重ORM那样强迫你改变SQL习惯。这种“用得深能省事、用得浅不碍事”的弹性是我推荐它的核心原因。1.2 版本差异是第一个坑很多人把MyBatisPlus的依赖坐标背下来就完事了但版本不同API差异会让你一脸懵。3.4.0之后分页插件统一收拢到了MybatisPlusInterceptor里老版本的PaginationInterceptor已经废弃到了3.5.x拦截器顺序管理更严格注册方式也变了。还有Spring Boot 3的用户只能引入mybatis-plus-spring-boot3-starter用旧坐标会直接启动报错。我们框架模块选型时定的是3.5.x分支原因很务实官方对3.5.x的维护力度强社区反馈问题修复快而且分页插件的maxLimit参数在3.4.3.4之后实现更稳定。如果你是老项目升级要注意新版拦截器的配置方式这个稍后会有完整示例。1.3 常用能力清单先拉一遍理解一个框架最快的方式是把它的能力清单对应到具体业务场景。我在团队内部培训时常画这样一张对照表能力解决什么场景需要额外配置BaseMapper通用CRUD单表增删改查不用写XML无Wrapper/QueryWrapper动态条件查询多条件组拼注意SQL注入风险分页插件列表接口自动分页必须注册拦截器乐观锁防止并发更新覆盖实体加Version注解逻辑删除不物理删除保留历史数据实体加TableLogic自动填充统一处理创建时间/更新时间/操作人自定义MetaObjectHandler代码生成器快速生成实体、Mapper、Service需单独引入生成器依赖表格看完你会发现分页是其中唯一一个“必须主动注册插件才会生效”的能力。这个特性直接引出了后文最常见的坑为什么我的分页没效果因为你根本还没把分页插件注册进去。2. 分页插件原理与分页失效的根源2.1 分页插件是怎么偷偷改SQL的理解分页失效先得知道分页插件做了什么。MyBatisPlus的分页能力不是靠你在Mapper里写LIMIT实现的而是依赖MyBatis的拦截器机制在SQL执行前动态改写SQL。核心类是PaginationInnerInterceptor它挂在MybatisPlusInterceptor这个总拦截器上。执行查询时拦截器拿到原始的BoundSql做两件事第一把原始SQL去掉ORDER BY外层包装生成一条SELECT COUNT(1) FROM (原SQL) total统计总数的查询第二根据数据库方言生成带LIMIT的分页SQLMySQL就是LIMIT offset, sizePostgreSQL就是LIMIT size OFFSET offsetOracle就是ROWNUM那套逻辑。执行完之后把结果塞进Page对象的records、total、pages字段。这段机制说明一个关键点只有你传入了Page对象拦截器才会触发改写。你如果只是写了一个普通查询方法返回一个List分页插件是爱莫能助的。2.2 分页失效的八种典型场景我把实际业务中遇到过的分页失效场景汇总排序按出现频率从高到低场景一压根没注册分页插件。这是最常见的情况没有之一。你依赖引了、实体建了、Mapper写好了一查发现selectPage返回的数据是全量total也是0或者不正确。原因就是MybatisPlusInterceptor这个Bean没有配置到Spring容器里。解决方案很简单注册Bean即可后文会有完整代码。场景二自定义SQL方法里Page对象不是第一个参数。自定义分页查询有个铁律Page必须放在Mapper方法参数列表的第一个位置不能被Param包装否则拦截器无法识别分页参数。很多人习惯性写selectUserPage(Param(name) String name, Param(page) Page page)结果分页完全失效。正确写法是selectUserPage(Page page, Param(name) String name)。场景三Mapper方法返回类型不是IPage。分页插件的SQL改写条件是方法返回值为IPage或Page如果你把返回类型写成了ListUser那SQL改写时虽然可能生成了LIMIT但结果没法回填到Page对象里。最典型的写法应该是IPageUserVO selectUserPage(Page? page, ...).场景四自定义SQL里手动加了LIMIT。这个坑特别隐蔽。有些同学写XML时习惯性带一句LIMIT #{offset}, #{size}本意是双保险结果分页插件识别到SQL里已经有LIMIT要么报错要么拼接后语法不对要么数据错乱。记住用了MyBatisPlus分页插件XML里就不要再写任何分页语句把分页完全交给拦截器。场景五多数据源场景下插件只配置了一个。微服务里常配多个数据源有人只在主数据源里注册了分页插件切到从库查询时发现分页失效/分页不生效。排查时先确认分页插件配置在哪个数据源如果用的是dynamic-datasource这种动态数据源框架分页插件的注册方式和单数据源又有区别。场景六SQL太复杂拦截器改写错位。当你使用了UNION、多层嵌套子查询、或者Window函数时PaginationInnerInterceptor的SQL改写逻辑可能无法正确识别主查询边界生成的COUNT SQL和LIMIT SQL不符合预期。常见的表现是total统计不对或者LIMIT加在了内层子查询上。这不是什么Bug是因为框架对复杂SQL的解析能力有限。处理方案有两个要么把复杂SQL拆成简单查询在应用层做二次处理要么手动提供COUNT SQL通过Page的optimizeCountSql和XML里的count标签自定义。场景七多个拦截器执行顺序不对。MyBatisPlus官方建议拦截器的顺序是多租户、动态表名 → 分页 → 乐观锁 → 防全表更新。如果你把分页插件放到了多租户插件的前面SQL改写顺序异常会导致分页参数和租户条件错位。特别是老版本里拦截器之间没有强制顺序控制必须自己保证注入顺序。场景八MyBatis版本和MyBatisPlus不兼容。这个属于版本管理问题。MyBatisPlus对MyBatis版本有对应关系表版本差距过大会出现拦截器不执行或执行报错。建议直接用mybatis-plus-boot-starter传递下来的MyBatis版本不要手动覆盖除非你真的知道自己在做什么。2.3 分页失效问题定位的五步法遇到分页不生效别上来就怀疑框架Bug按我下面这个顺序排查90%能在两分钟内定位问题。第一步开SQL日志看控制台输出的SQL语句末尾有没有LIMIT。如果LIMIT字样压根没出现说明拦截器没生效直接跳到第二步如果出现LIMIT但数据不对问题在SQL改写逻辑看原SQL是否太复杂。第二步检查分页插件有没有注册。全局搜一下PaginationInnerInterceptor关键词没有的话就是注册缺失。第三步检查Mapper方法定义确认第一个参数是不是Page、返回类型是不是IPage。第四步检查是不是多数据源场景分页插件是否只配置在一个数据源上。第五步关闭其他自定义拦截器看分页是否恢复用来验证拦截器顺序问题。这套排查流程在我们团队已经固化成一份文档前端接口一反馈“分页没效果”后端同学先走一遍流程基本挡掉了八成问题工单。3. 单页500条限制的真相与应对3.1 500这个数字到底是谁定的“MyBatisPlus单页500条限制”是个流传很广的说法但严格讲MyBatisPlus框架本身并没有硬编码“一页最多500条”。这个数字来自项目里的三层设置很多人把它们混为一谈了。第一层是PaginationInnerInterceptor的maxLimit参数。这是MyBatisPlus官方提供的拦截器级限制创建分页插件时如果调用了setMaxLimit(500)那么当请求的单页size超过500时分页插件会直接拦截拦截不同版本行为略有差异有的是抛异常提示超过上限有的是按最大值执行。这算最接近“MyBatisPlus单页500条限制”的技术根源。第二层是业务代码里的参数校验。不少团队会在PageQuery对象里写一个Max(value 500)注解或者手动判断page.getSize() 500时抛业务异常。第三层是网关/前端组件层的默认约束。管理后台的表格组件如el-pagination默认每页10条/20条/50条下拉里最大选项可能就是500。用户如果手动改URL参数传了1000后端没限制前端也不一定会展示那1000条。所以遇到“单页500条限制”先查是哪个环节挡住了。我见过最典型的误会后端同学压根没配maxLimit但前端表格组件的分页大小下拉最多只有500前端产品经理就一口咬定“后端限制500条”——其实是前端组件限制死了。3.2 maxLimit参数的正确玩法如果你的项目确实需要在拦截器层面设置单页上限代码很简单Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor paginationInterceptor new PaginationInnerInterceptor(DbType.MYSQL); paginationInterceptor.setMaxLimit(500L); interceptor.addInnerInterceptor(paginationInterceptor); return interceptor; } }这段配置生效后当业务代码请求new Page(1, 1000)时分页插件会识别到size1000超过maxLimit500按版本不同要么抛异常要么将单页条数压回500。这个限制的本质是保护数据库防止接口被人恶意循环调用大pageSize拖垮数据库也可以防止一次查询返回几十万行把内存打满。但我要特别提醒maxLimit不是用来限制导出功能的。很多同学遇到导出报表需要查10万条数据为了绕过500条限制直接把maxLimit改成了Long.MAX_VALUE。这等于拆了保险丝。导出类场景的正确出路不是走分页查询而是走异步任务 流式查询/分批查询。3.3 大数据量查询的正确姿势被“单页500条限制”挡住高频场景有两个一个是前端列表要一次看全量数据一个是导出功能需要全量结果。前端列表要全量数据这是伪需求真正该做的是筛选条件和排序而不是一次渲染5000行。和产品经理聊清楚交互边界限制单页条数是合理的产品逻辑。导出功能需要全量数据这里给一个安全的做法用分页循环把数据分批捞出来每批不要贪多1000条以内比较稳妥。public void exportAllUsers(UserQuery query) { long batchSize 1000L; long current 1L; PageUser page; do { PageUser pageParam new Page(current, batchSize); page userMapper.selectPage(pageParam, buildWrapper(query)); // 每批数据写入Excel/CSV writeToFile(page.getRecords()); current; } while (current page.getPages()); }这段代码巧妙地绕开了条数限制同时每批数据占用内存有限适合几十万级别导出的场景。如果单表几百万行就得考虑游标分页或直接用数据库流式读取。4. Spring Boot整合MyBatisPlus分页的完整实操4.1 环境准备与依赖配置我以Spring Boot 2.7 MyBatisPlus 3.5.3.1为例先把pom依赖列出来dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependencySpring Boot 3项目把starter换掉dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-spring-boot3-starter/artifactId version3.5.3.1/version /dependency配置文件的重点是打开SQL日志方便排查分页问题。application.yml里加上mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl map-underscore-to-camel-case: true global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0map-underscore-to-camel-case保证数据库下划线字段自动映射成驼峰属性这是MyBatis的老配置很多人会忘。logic-delete配置项是按需的有逻辑删除字段才需要。4.2 注册分页插件的两种写法当前主流写法是Java配置类Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor paginationInterceptor new PaginationInnerInterceptor(DbType.MYSQL); paginationInterceptor.setMaxLimit(500L); interceptor.addInnerInterceptor(paginationInterceptor); return interceptor; } }老项目如果还在用mybatis-config.xml也可以把拦截器配进XML里但Spring Boot项目不推荐配置类和代码放在一起更容易维护。这里有个细节值得说明DbType.MYSQL要和你实际的数据库类型一致。如果你用的PostgreSQL却配了MYSQL分页SQL会按MySQL语法拼接LIMIT轻则语法报错重则数据错乱。多环境部署时这块建议从配置中心读取。4.3 Service层标准分页写法实体类和Mapper基类的写法就不赘述了直接看最常用的两种分页查询。第一种BaseMapper自带的selectPage适合单表条件分页public IPageUserVO pageUsers(UserQuery query) { PageUser page new Page(query.getCurrent(), query.getSize()); LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.eq(StringUtils.hasText(query.getStatus()), User::getStatus, query.getStatus()) .like(StringUtils.hasText(query.getName()), User::getName, query.getName()) .orderByDesc(User::getCreateTime); IPageUser result userMapper.selectPage(page, wrapper); // 实体转VO的逻辑在这里组装全局返回结构 return result.convert(user - convertToVO(user)); }第二种自定义SQL分页适合带Join的多表查询。先定义Mapper接口方法public interface UserMapper extends BaseMapperUser { IPageUserDeptVO selectUserWithDeptPage(Page? page, Param(query) UserQuery query); }再写XMLselect idselectUserWithDeptPage resultTypecom.example.vo.UserDeptVO SELECT u.id, u.name, u.status, d.dept_name FROM user u LEFT JOIN dept d ON u.dept_id d.id where if testquery.name ! null and query.name ! AND u.name LIKE CONCAT(%, #{query.name}, %) /if if testquery.status ! null AND u.status #{query.status} /if /where ORDER BY u.create_time DESC /select这块有个重点必须反复强调XML里不要写LIMIT。这条SQL不会自己分页会分页的是外部传入的Page参数。只要第一个参数是Page、返回类型是IPage分页插件就会自动把这条SQL改写成带LIMIT的查询并自动执行COUNT统计。4.4 分页执行流程与参数计算逻辑把执行流程掰开揉碎讲一遍你以后看日志就能心里有数。假设请求参数current2、size10业务代码执行selectUserWithDeptPage(page, query)。第一步分页插件识别到Page对象先计算偏移量offset (current - 1) * size 10。第二步执行COUNT查询生成SELECT COUNT(1) FROM user u LEFT JOIN dept d ON u.dept_id d.id WHERE ...拿到total。第三步改写主查询为SELECT ... LIMIT 10, 10执行后把数据封装到Page的records里。第四步把total、current、size回填如果之前没算过pages会按pages (total size - 1) / size向上取整处理。日志里你会看到两条SQL前后执行第一条是COUNT第二条是带LIMIT的查询。如果你发现只有一条查询SQL没有COUNT那说明searchCount被关了或者分页插件没生效。补充一个性能细节MySQL的LIMIT 100000, 10虽然也能执行但MySQL需要先扫描前面100010行再丢掉页数越深越慢。因此分页查询在数据量大时必须要限制深度这块下一节展开讲。5. 常见问题速查表与团队避坑经验5.1 分页常见问题速查表把我在一线答疑上遇到过的问题整理成一张速查表方便你遇到现象时快速对照现象可能原因解决方案查询结果没有分页全部返回分页插件未注册注册MybatisPlusInterceptor和PaginationInnerInterceptortotal始终为0COUNT SQL执行失败或searchCountfalse检查SQL日志中的COUNT语句确认分页插件已生效total值不对复杂SQL的COUNT改写错误拆SQL或手动指定COUNT传了Page但返回ListMapper返回类型不是IPage换成IPage/Page返回类型翻到第2页数据还是第1页Page参数放错位置或被Param包装Page放第一个参数不套ParamLIMIT语句拼接报错SQL里手写了LIMIT删掉XML里的LIMIT交给插件处理单页超过500被拦截PaginationInnerInterceptor设置了maxLimit调整maxLimit或走分批查询逻辑多数据源其中一分页失效分页插件没同步到所有数据源在对应的SqlSessionFactory上补配拦截器分页在某个事务方法里失效内部调用绕过代理/拦截器确保SQL走的是MyBatis的Executor链路这张表适合打印出来贴在工位。5.2 深分页问题的替代方案数据量超过百万后LIMIT offset, size的性能问题会浮出水面。就算不涉及MyBatisPlus本身这也是所有分页方案的共性难题。除了团队里硬性限制页数之外更优雅的做法是游标分页。游标分页的核心思想是不翻页只翻“下一页”以上一次查询到的最后一条记录ID作为下一次查询的起点public IPageUser pageUsersByCursor(Long lastId, int size) { PageUser page new Page(1, size); LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.gt(User::getId, lastId) // 只查比上次最大ID大的记录 .orderByAsc(User::getId) .last(LIMIT size); return userMapper.selectPage(page, wrapper); }注意这里的last(LIMIT size)和分页插件是冲突的如果用了这段思路就不适合再套用分页插件而是把Page当成普通的参数载体。项目里两种方式混用时务必想清楚当前接口走的是哪条链路。实践下来游标分页在“下拉加载更多”类的Feed流场景非常好用但在传统后台表格分页场景不适用因为用户可能想直接跳到第50页。5.3 团队落地时的规范建议最后分享几条我们团队经历过阵痛后沉淀的分页规范不是理论全是教训。分页参数要统一封装成PageQuery对象放在Controller层解析Service层只接收标准对象禁止散装传current和size参数这样能保证参数校验比如size上限只写一次。所有自定义分页SQL的Mapper方法第一个参数必须是Page返回类型必须是IPage把这个约定写进团队代码规范检查项里CR的时候重点看。COUNT查询很慢的大表可以适当使用Page对象的searchCount开关在明确不需要total的接口里关掉自动统计能省掉一次全表COUNT的开销。还有一个点很容易被忽略分页插件里的maxLimit不要随意放宽。如果业务真的需要单页500条以上先和前端聊清楚交互是不是筛选条件没做全再和后端确认查询性能EXPLAIN一下SQL语句看看扫描行数。多数情况下500这个阈值对于人类阅读已经是极限了放宽限制只是在给数据库埋雷。根据我个人经验在项目里落地这套规范后分页相关的线上问题工单至少降了八成剩下的两成基本是业务需求变动导致的。MyBatisPlus本身不难难的是把规范融入团队的日常开发习惯这篇文字如果能让你的团队少走几个弯路就没白写。