资讯详情

MybatisPlus分页失效与自动建表实战:规避单页500条限制

📅 2026/10/8 21:36:59 | 华诺云谱 👁 阅读
MybatisPlus分页失效与自动建表实战:规避单页500条限制
先说个背景。我之前维护过一个老项目数据层是原生 MyBatis几十个Mapper接口加同量级的XML里面清一色的selectById、insert、updateById结构几乎一样只是表和字段不同。加一个简单的单表查询要先写接口方法、写XML、写ResultMap再处理驼峰映射重复劳动非常重。后来新项目切到 MybatisPlus这些基础CRUD全部由BaseMapper继承过来新表接入只用写实体类和一个接口效率提升非常明显。这篇博文就把我在实际项目中用 MybatisPlus 的经验整理出来重点覆盖三块网上高频踩坑的内容分页查询为什么会失效、怎样根据Java实体类自动生成建表SQL、以及所谓的“单页500条限制”到底是怎么来的。适合正在做Spring Boot MyBatis项目、想换或正在用 MybatisPlus 的读者参考。1. 从原生MyBatis到MybatisPlus我为什么愿意交出mapper层的控制权1.1 原生MyBatis的重复劳动比想象中更消耗精力原生 MyBatis 的优势是灵活SQL 完全可控但代价是基础增删改查需要重复书写。一个Order表你要写OrderMapper接口、OrderMapper.xml、五个基础SQL、一个ResultMap还要处理数据库下划线字段和Java驼峰属性之间的映射。表一多这些工作量是线性增长的而且没有任何技术含量纯粹是体力活。更麻烦的是多人协作时每个人写的ResultMap风格都不一样有人用autoMapping有人result逐个配review代码时大量时间花在这些没有业务价值的地方。MybatisPlus 通过BaseMapper接口把单表CRUD一次性做好insert、deleteById、selectById、updateById、selectList这些方法直接用用户只需要继承接口不用写XML甚至不用写SQL。这一点对中后台管理类项目来说非常合适因为这类项目的核心诉求就是快速出活。1.2 MybatisPlus不是替换MyBatis而是补全单表操作很多团队的顾虑是用了 MybatisPlus 是不是就没法写复杂SQL了。这个其实是误解。MybatisPlus 底层仍然是 MyBatis自定义SQL、多表JOIN、存储过程照样可以使用只不过这部分需要你在XML或注解里显式写出来。它真正接管的是“单表操作”这个场景而单表操作恰好占了一个业务系统里三分之二以上的数据访问量。我们在项目中摸索出的打法分三层单表简单查询直接继承BaseMapper一行代码都不写单表复杂条件查询用QueryWrapper或LambdaQueryWrapper构造动态条件避免XML里堆if标签多表关联查询回归XML写原生SQL必要时手工指定分页的count语句这个分层方式是我最推荐的。既拿到了效率又没有丢失灵活性。后面所有内容都围绕这套打法展开。2. BaseMapper和Wrapper的正确打开方式多数人只用到了20%的API2.1 BaseMapper继承之后先搞清楚每个方法的边界BaseMapperT里提供的方法常见的有insert、deleteById、delete、updateById、update、selectById、selectOne、selectList、selectPage等。其中两个方法需要特别留意边界selectOne如果数据库里匹配到多条记录会直接抛TooManyResultsException。业务上有“唯一”要求时建议配合数据库唯一索引使用不要只依赖这个方法兜底update和delete这两个方法带Wrapper参数不带Wrapper的重载早已废弃。它们的影响行数取决于Wrapper构造出来的条件所以调用前务必确认条件有效性防止全表更新我见过不少事故就是因为update(entity, null)这种写法直接把整张表的某个字段改成了同一个值。MybatisPlus 提供了blockAttackInnerInterceptor来拦截不带条件的全表更新/删除这个我后面会详细讲。2.2 条件构造器LambdaQueryWrapper是日常最顺手的工具Wrapper体系里我日常使用频率最高的是LambdaQueryWrapper。它最大的好处是类型安全字段名通过Order::getStatus这种形式引用不会出现手写字符串拼错的情况而且重构字段名时IDE能自动感知。一个典型的动态条件查询可以这样写LambdaQueryWrapperOrder wrapper Wrappers.lambdaQuery(); wrapper.eq(StringUtils.hasText(status), Order::getStatus, status) .ge(Order::getCreateTime, startTime) .le(Order::getCreateTime, endTime) .orderByDesc(Order::getId); ListOrder list orderMapper.selectList(wrapper);eq的第一个参数是boolean为true时才拼接这个条件这样就把if判断收敛到Wrapper构造过程中代码比一堆if标签干净很多。ge、le、like、in、between、isNull这些方法的命名也足够直观基本不需要翻文档。LambdaUpdateWrapper更适合做“只更新几个字段”的操作不需要先把整个实体查出来再整体覆盖LambdaUpdateWrapperOrder wrapper Wrappers.lambdaUpdate(); wrapper.eq(Order::getId, 10086) .set(Order::getStatus, 2) .set(Order::getRemark, 已发货); orderMapper.update(null, wrapper);这里第一个参数传null意味着更新内容全部由Wrapper的set方法指定。这种方式生成的SQL只包含必要的字段在大字段多的表上性能优势非常明显。2.3 自定义SQL在Mapper里把分页和Wrapper搭配起来有的查询即便走了自定义SQL也仍然希望用上QueryWrapper的能力。MybatisPlus 支持在Mapper方法中直接接收Wrapper参数并在XML通过${ew.customSqlSegment}引用。假设有一张订单需要关联商品明细表按状态和商品名过滤可以这样定义IPageOrderDetailVO selectOrderDetailPage(IPageOrderDetailVO page, Param(Constants.WRAPPER) WrapperOrder wrapper);对应XMLselect idselectOrderDetailPage resultTypeOrderDetailVO SELECT o.*, g.goods_name FROM order o LEFT JOIN goods g ON o.goods_id g.id ${ew.customSqlSegment} /select${ew.customSqlSegment}会把Wrapper里构造好的WHERE和ORDER BY片段拼接进SQL而且因为是在XML中拼接Wrapper字段名需要是数据库列名的下划线格式用QueryWrapper时尤其注意这一点。如果用LambdaQueryWrapper字段名会正确映射但底层拼接时仍然依赖实体的映射关系所以遇到动态条件较多的自定义SQL我建议优先使用LambdaQueryWrapperParam(Constants.WRAPPER)的组合。3. 分页查询失效的完整排查链路从版本差异到count优化失灵3.1 最经典的开局分页插件没注册MybatisPlus 分页不是把传入的Page对象组装成LIMIT就完事它依赖PaginationInnerInterceptor在SQL执行阶段改写原SQL生成带LIMIT的语句和COUNT查询。凡是“分页没效果”“查出来是全部数据”“total一直是0”这类问题第一个要检查的就是拦截器是否生效。3.4.0版本前后的配置方式差异很大这是大多数踩坑案例的根源。旧版常用PaginationInterceptorBean public PaginationInterceptor paginationInterceptor() { return new PaginationInterceptor(); }3.4.0之后官方推荐统一的MybatisPlusInterceptor按插件类型分别添加Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination new PaginationInnerInterceptor(DbType.MYSQL); interceptor.addInnerInterceptor(pagination); return interceptor; }如果项目里同时存在旧版PaginationInterceptor和新版MybatisPlusInterceptor或者两个MybatisPlusInterceptor重复注册插件就会互相干扰表现出来就是方法不生效或SQL被重复改写。排查时先用spring.factories和Configuration的加载顺序核对项目里定义的Bean。实际上很多分页失效问题的根因只是在配置这一步就断了。3.2 一步步排查从方法签名到返回类型即使插件配置正确仍然有几种情况会静默失效。我把排查顺序固定成一套遇到问题直接按这个链路走确认Mapper接口方法第一个参数是IPage类型且Page对象是泛型化后的实例。MybatisPlus 分页拦截器依赖第一个参数定位分页对象换成普通参数就识别不到确认返回类型是IPageT或PageT。返回ListT时分页插件不会改写SQL依然查全表确认Page没有被业务代码重新new。有的同事为了拿total在Service层里又new了一个Page覆盖了原对象导致参数丢失确认调用时没有对Page做setSearchCount(false)。这个开关默认是true如果手动关掉了count查询total就是0但数据仍是分页的检查SQL里是否有多行结果集与Page类型不匹配。自定义VO类没有无参构造、字段没有赋值方法时插件在反射创建返回对象时可能异常表现也是分页“失效”以上链路排查完八成的问题能定位。剩下两成集中在复杂SQL的count优化上。3.3 JOIN复杂SQL的count优化为什么经常失灵分页插件对SELECT COUNT(*)的生成有优化逻辑能直接去掉ORDER BY、替换查询列为COUNT(*)的会自动简化。但遇到DISTINCT、GROUP BY、多表JOIN且带FOR UPDATE这类特殊情况时自动生成的count语句很可能不是你要的结果甚至执行报错。一个真实的例子订单列表页面需要按用户分组统计SQL里有GROUP BY分页插件自动生成的count语句在部分MySQL版本下会执行超时因为聚合查询本身在全表扫描。这种情况不要指望自动优化直接手工指定count查询PageOrderGroupVO page new Page(current, size); // 关闭自动count手工查总数 page.setSearchCount(false);再通过一个selectCountByCondition方法先查出满足条件的总数手动setTotal。或者更干脆一点在SQL层面做一次查询总数和一次分页数据查询。另外要提醒一句分页插件对单表的ORDER BY优化很激进但SQL中如果出现了MySQL 8.0之前的LIMIT与ORDER BY字段不一致的排序场景优化的count语句可能会引起逻辑错误导致total不对。遇到这种边界我一般会先EXPLAIN看执行计划再决定是否手工介入count。4. 没有官方支持也要能做从Java实体类反推建表SQL的工程化方案4.1 为什么会有这个需求正常开发流程是先设计表结构再用 MybatisPlus 代码生成器生成实体类。但实际工作中经常遇到反过来的情况业务侧已经定义了Java实体字段、注释、类型都齐全然后希望快速生成一份可执行的建表DDL。比如快速验证一个流程、给临时报表表建结构、或者把核心模型的字段结构同步到演示环境。MybatisPlus 官方并没有直接提供“实体类生成建表SQL”的功能。它的代码生成器AutoGenerator是反向的从数据库表生成实体类、Mapper、Service。所以这个需求属于典型的“官方没做但团队真的需要”的痛点。4.2 方案对比自研工具、IDEA插件、切JPA解决这个需求团队里有三种常见路径我对比一下实际效果方案优点缺点IDEA插件如JPA Buddy可视化操作支持常见类型映射只能在个人IDE里使用无法融入CI临时引入JPA的ddl-auto配置简单能自动建表引入额外依赖类型映射策略和线上DDL规范不一致风险大自研DDL生成工具类可控、映射规则可定制、能进自动化流程需要写一定代码前期有一点成本自研方案看起来成本高但一旦做成整个团队的实体类都能统一走这套规则出来的DDL风格完全一致还能按项目规范自动加上ENGINE、CHARSET、字段注释。我比较推荐这个方案。4.3 自研工具的核心代码拆解实现思路不复杂反射读取实体类字段读取TableName、TableId、TableField注解根据Java类型映射到MySQL列类型最后拼成CREATE TABLE语句。先做一个Java类型到MySQL类型的映射private static String toMySqlType(Class? javaType, TableField fieldAnno) { if (fieldAnno ! null StringUtils.hasText(fieldAnno.type())) { return fieldAnno.type(); // 注解里显式指定了列类型优先使用 } if (javaType Long.class || javaType long.class) return BIGINT; if (javaType Integer.class || javaType int.class) return INT; if (javaType String.class) return VARCHAR(255); if (javaType BigDecimal.class) return DECIMAL(20,4); if (javaType LocalDateTime.class) return DATETIME; if (javaType LocalDate.class) return DATE; if (javaType Boolean.class || javaType boolean.class) return TINYINT(1); return VARCHAR(255); }注意TableField注解里有type属性就是为解决这种场景准备的某个字段需要精确到DECIMAL(10,2)或VARCHAR(64)时不需要改映射代码直接在实体上写清楚即可。核心方法public static String generateCreateTableSql(Class? entityClass) { TableName tableName entityClass.getAnnotation(TableName.class); String table tableName ! null ? tableName.value() : camelToUnderline(entityClass.getSimpleName()); StringBuilder sql new StringBuilder(); sql.append(CREATE TABLE IF NOT EXISTS ).append(table).append( (\n); ListField fields Arrays.asList(entityClass.getDeclaredFields()); boolean hasPrimaryKey false; ListString primaryKeys new ArrayList(); for (Field field : fields) { TableId tableId field.getAnnotation(TableId.class); TableField tableField field.getAnnotation(TableField.class); if (tableField ! null !tableField.exist()) { continue; // 非表字段跳过 } String column camelToUnderline(field.getName()); String type toMySqlType(field.getType(), tableField); if (tableId ! null) { if (tableId.type() IdType.AUTO) { sql.append( ).append(column).append( ).append(type) .append( NOT NULL AUTO_INCREMENT COMMENT 主键,\n); } else { sql.append( ).append(column).append( ).append(type) .append( NOT NULL COMMENT 主键,\n); } primaryKeys.add(column); hasPrimaryKey true; } else { sql.append( ).append(column).append( ).append(type) .append( DEFAULT NULL COMMENT ).append(column).append(,\n); } } if (hasPrimaryKey) { sql.append( PRIMARY KEY ().append(String.join(, , primaryKeys)).append()\n); } sql.append() ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT).append(table).append(); return sql.toString(); }camelToUnderline的逻辑就是把驼峰字段拆成下划线命名createTime变create_time这部分不难注意不要重复处理已经是下划线格式的字段。4.4 实操建议把生成结果纳入版本管理工具类写好后我的建议是不要每次运行时动态生成建表SQL而是把生成结果作为一个基线DDL文件提交到项目里后续表结构变更仍然走标准的ALTER TABLE流程。原因很现实自动生成的DDL只解决“从0到1”的问题线上的字段类型调整、索引优化、默认值变更实体类注释是承载不了的。另外一个容易被忽略的点生成的SQL最好在本地执行一次EXPLAIN和SHOW CREATE TABLE对照检查特别关注VARCHAR长度和索引设计。实体类上的TableField只影响ORM映射不影响数据库索引。真正需要索引的字段仍然要在DDL里手工补充。5. “单页最多500条”不是Mock分页插件maxLimit的真相与取舍5.1 限制不在框架在配置网上关于“MybatisPlus 单页最多500条”的说法满天飞很多人以为这是框架内置的硬性限制。实际上 MybatisPlus 默认的PaginationInnerInterceptor并没有这个阈值maxLimit默认值是Long.MAX_VALUE。你之所以遇到“单页最多500条”几乎都是项目里有人显式配置了这个值PaginationInnerInterceptor pagination new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(500L);设置了maxLimit后当请求的size超过500时插件不会报错而是把Page的size静默修改成500。从调用方看就是“不管我怎么传返回都是500条”。这个配置不是安全漏洞反而是很多团队刻意加的“保险丝”。前端页面如果被人恶意传了一个size1000000的请求数据库的查询代价会非常大甚至拖垮实例。把单页上限卡在500配合索引能在绝大多数场景下保护数据库。所以它不是框架限制是一个主动的运维决策。5.2 业务上确实需要一页500条以上时怎么办先说结论大部分业务都不应该出现单页超过500条的需求。用户一次性看500行数据本身就不是一个合理的产品交互。但有些场景确实绕不开比如大数据量的批量核对、运营后台的列表导出。如果是管理后台查询500条以内用普通分页即可。如果确实要拉取超过500条我一般分三种处理方式提高maxLimit阈值。适合内部系统且查询条件清晰、索引有力的场景比如setMaxLimit(5000L)改为异步导出。适合导出型需求先按条件把数据写入临时表或文件再通知用户下载不占用接口分页额度游标式翻页。适合数据量极大的连续翻页用WHERE id ? ORDER BY id DESC LIMIT n代替LIMIT offset, n。offset大了之后MySQL 的深分页性能会急剧下降游标方式可以稳定保持查询效率很多团队在实际落地时接口层会再做一层maxPageSize校验配置中心的开关动态控制允许的单页上限。这比单纯依赖 MybatisPlus 的maxLimit更灵活因为你能针对不同接口分别设置不同阈值。5.3 和分页相关的另一个保险防全表更新与删除和maxLimit经常一起设置的还有BlockAttackInnerInterceptor。它的作用是拦截没有带WHERE条件的update和delete语句防止手滑全表更新。两者配置顺序有讲究。在MybatisPlusInterceptor中多个InnerInterceptor的执行顺序与添加顺序相关。我建议把BlockAttackInnerInterceptor放在PaginationInnerInterceptor之前interceptor.addInnerInterceptor(new BlockAttackInnerInterceptor()); interceptor.addInnerInterceptor(pagination);原因很简单先挡住危险的更新删除再做分页改写避免攻击拦截器在SQL改写后失效。这个顺序在实际项目中踩过坑顺序反了之后分页SQL里带出的条件会被攻击拦截器误判导致更新被拦截排查了半天才发现是拦截器先后问题。6. 项目落地时的全局配置和N条避坑清单6.1 一份实用的mybatis-plus配置模板新项目接入 MybatisPlus我通常会在application.yml里放这样一份基础配置mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: banner: false db-config: id-type: assign_id logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 mapper-locations: classpath*:/mapper/**/*.xml几个关键项逐个说明。map-underscore-to-camel-case开启后数据库create_time字段能自动映射到实体的createTime属性不需要每个字段都写TableField。id-type: assign_id让默认主键生成策略走雪花算法适合分布式环境如果项目主键是自增则改成auto。logic-delete-field配置后deleteById方法会自动变成UPDATE ... SET deleted 1 WHERE id ?注意这只是逻辑标记查询时MybatisPlus会自动追加deleted 0条件但自定义SQL不会自动追加这是初级用户最容易遗漏的点。mapper-locations最好显式指定XML路径。默认值classpath*:/mapper/**/*.xml已经覆盖大部分场景但如果你把Mapper接口和XML放在同一个包需要额外确认构建工具是否把XML拷贝到classpath否则启动会报Invalid bound statement。6.2 代码生成器的正确打开方式MybatisPlus 官方代码生成器mybatis-plus-generator可以从数据库表生成实体类、Mapper接口、Service、ServiceImpl、Controller。注意它是“表驱动”所以前提是你已经建好了表结构。我一般不会直接用默认模板而是做两处调整第一关闭Controller层生成项目里Controller往往是业务定制区域自动生成的风格不合团队规范第二实体类模板里设置superEntityClass和公共字段把id、createTime、updateTime这些基础字段收敛到基类避免每个实体都重复定义。这一步看着不起眼但能让后续实体类维护量下降不少。6.3 一条条踩出来的避坑清单把项目里的真实问题整理成清单按重要性排序不要在多处配置MybatisPlusInterceptor。全局只保留一个配置类否则插件会被重复执行SQL改写异常逻辑删除字段不要参与唯一索引。逻辑删除只是标记字段值只有0和1如果唯一索引包含这个字段第二次插入同一条数据会因为唯一约束冲突而失败updateById会更新所有非null字段。实体里如果有大字段建议用LambdaUpdateWrapper.set明确指定更新列否则大文本字段每次都参与更新数据库压力成倍增长TableField(fill FieldFill.INSERT)配合MetaObjectHandler实现自动填充createTime、updateTime。如果你用了全局的时间字段自动填充记得在MetaObjectHandler里正确处理insertFill和updateFill两个时机自定义SQL如果用到Wrapper注意Param(Constants.WRAPPER)必须写否则ew.customSqlSegment取不到值分页时current从1开始部分前端习惯从0开始接口层记得做转换。这个看似简单的坑曾让一个列表页的“第二页”直接查空数据在事务里使用selectPage事务隔离级别会影响count结果必要时用select加锁或调整隔离级别不要试图在一个Page对象上做多次setRecords。有的同学先page.setRecords(list1)又跑了一次查询再setRecords(list2)最终页面上出现的是最后一次的结果而total还是第一次的数据对不上时排查起来很隐蔽6.4 在“自动”和“可控”之间找平衡MybatisPlus 给了很高的开发效率但也容易让人产生“所有SQL都不用写了”的错觉。我的经验是两句话单表操作放心交给BaseMapper多表JOIN老老实实写XML。凡是涉及性能敏感的查询、复杂的权限过滤、动态表名我都会把SQL显露出来通过EXPLAIN看执行计划而不是躲在一层又一层的Wrapper里。还有一点值得提团队协作时强烈建议约定Wrapper的使用边界。Wrapper是查询条件构造器非常灵活但也是最容易写出不可读代码的地方。有人喜欢把所有过滤条件都塞进一个超长的QueryWrapper链式调用里业务逻辑完全不透明。后来我们强制要求超过三个条件的查询必须写成带注释的自定义SQL或者拆分Wrapper为多个短链保证Review时能看懂意图。在技术选型这件事上没有银弹。MybatisPlus 帮你省下的时间应该花在业务设计和SQL优化上而不是变成新的技术债。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑