
接手一个统计类后台项目的时候第一周我就被 MyBatisPlus 的三个问题轮番锤了一遍分页查询不生效、单页超过 500 条后数据被悄悄截断、新业务上线前建表脚本还得靠人工一条条写。三个问题表面上看毫无关联排查完才发现全都埋在这个 ORM 框架的细节里。这篇不是 MyBatisPlus 的完整教程而是把实际处理过的问题、看过的源码和最终落地的方案梳理出来给正在用 MyBatisPlus 被分页和建表 SQL 折磨的人做一个对照排查清单。MyBatisPlus 在 Java 圈子里早就是 MyBatis 之上的默认选择之一它把单表 CRUD、条件构造器、分页插件、逻辑删除这些高频功能收拢成了开箱即用的配置。日常开发里你几乎不用写 SQL 就能完成一张表的基础增删改查但恰恰是这种“太省事”带来的错觉最容易让人在遇到第一条自定义 SQL、第一次超过百万行数据时翻车。下面这些内容基本都是我在真实项目里踩过的坑有的甚至是排了一整天才定位到的根因。1. 先搞清楚 MyBatisPlus 到底把哪些懒人活干完了1.1 为什么你的 Mapper 可以空着不写 XML传统 MyBatis 项目里每张表基本都要配一个 Mapper 接口加一份 XML 文件一个简单的selectById都要写resultMap、写 SQL、写参数映射。如果表结构一变resultMap、SQL、Java 实体三处要同步改漏一处就等着半夜告警。MyBatisPlus 解决这个问题的核心是BaseMapperT。你只要定义一个接口继承它再配上实体类上的TableName、TableId、TableField注解框架就能在启动时通过反射把实体和表结构的映射关系缓存下来然后自动生成单表的增删改查 SQL。实际项目中我见过最少的一个 Mapper 长这样Mapper public interface UserMapper extends BaseMapperUser { }就这一行insert、deleteById、selectPage、updateById全部开箱可用。这里面的门道是TableInfoHelper它对实体类做了一次元数据解析把哪些字段对应哪些列、哪个字段是主键、哪个字段需要自动填充全部整理成了TableInfo对象。后续所有自动 SQL 都是基于这份元数据拼出来的这也是后面我们自己写建表 SQL 生成器时的关键线索。但注意BaseMapper只覆盖单表操作。一旦你的 SQL 里有join、子查询、多表聚合就得回到 XML 或注解 SQL。MyBatisPlus 并没有魔法它只是把单表套路化的工作做完了。1.2 条件构造器选型Lambda 优先QueryWrapper和LambdaQueryWrapper是 MyBatisPlus 最常用的条件构造入口。前者直接传字符串列名后者传方法引用// QueryWrapper 写法列名是硬编码字符串字段重命名后编译器无法提示 QueryWrapperUser wrapper new QueryWrapper(); wrapper.eq(user_name, zhangsan); // LambdaQueryWrapper 写法列名跟着实体字段走 LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.eq(User::getUserName, zhangsan);我的建议是项目里统一用 Lambda 写法。改字段名时 IDE 会直接标红全局搜索替换也不容易漏。更重要的是 Lambda 写法在分页、条件拼接、逻辑删除联动这些场景下列名解析是一致的不会出现字符串写错导致 SQL 注入习惯性隐患。ServiceImpl里的lambdaQuery()、lambdaUpdate()也是同样的思路配合IServiceT接口可以省掉大量重复的 Controller 到 Service 的胶水代码。但我必须提醒一句条件构造器看着好用千万别把一个复杂的多表查询强行拆成好几次单表查询再用 Java 内存拼接这种写法在数据量上来后性能会非常难看。2. 分页失效的完整排查链路2.1 第一步拦截器是否真的注册了分页失效的第一反应大概率是“插件没配置”。MyBatisPlus 分页不是 MyBatis 自带的它靠一个MybatisPlusInterceptor拦截器在 SQL 执行前改写语句自动追加LIMIT并在查询完成后把total回填到Page对象里。老版本用的是PaginationInterceptor3.4 之后废弃统一改为MybatisPlusInterceptor内部添加PaginationInnerInterceptor。正确的配置类似这样Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); // DbType 一定要填对MySQL 和 PostgreSQL 的 limit 语法完全不同 interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }这里最容易踩的坑是项目里存在多套配置类新写的MybatisPlusConfig和老的MybatisConfig同时生效或者有两个MybatisPlusInterceptorBean 互相覆盖。Spring 容器中同名 Bean 后加载的会覆盖先加载的一旦某个配置类没有被ConfigurationProperties扫描到分页插件就没进去。排查手段很朴素在PaginationInnerInterceptor关键方法上打断点看willDoQuery或beforeQuery是否被调用。如果一次都没断到基本就是 Bean 没注册或者被覆盖。顺带说一句如果你的项目引了mybatis-plus-spring-boot3-starter这类高版本依赖配置类扫描路径和自动装配逻辑有细微差别Bean 方法上的Bean名称冲突更容易发生。实在找不到原因时可以在启动日志里搜一下MybatisPlusInterceptor的初始化输出。2.2 第二步拦截器顺序带来的“幽灵覆盖”这是比“没注册”更隐蔽的一层。MybatisPlusInterceptor内部可以挂多个InnerInterceptor比如多租户插件TenantLineInnerInterceptor、乐观锁插件OptimisticLockerInnerInterceptor、分页插件PaginationInnerInterceptor。它们按addInnerInterceptor的顺序排队执行顺序不同最终 SQL 完全不一样。官方文档和源码里都强调了一个原则SQL 改写类插件尤其是多租户、动态表名这种会在语句里注入条件的必须放在分页插件之前。因为分页插件在最后阶段执行的逻辑是把改写完成的 SQL 包一层 count 语句再追加 limit如果你把分页插件放前面后面多租户插件又往里塞tenant_id ?条件count 语句很可能是错的。我见过一个实际案例分页没问题但 count 查出来的总数偏大因为多租户条件在 limit 生成之后才被注入导致前几条和后几条的租户隔离没生效。对这种问题不要靠猜直接看同一个拦截器里各InnerInterceptor的执行顺序核心优先级是多租户/动态表名等安全改写在前分页在最后乐观锁在分页前后都行但一般放在分页之后。2.3 第三步自定义 SQL 里 Page 参数的位置用BaseMapper.selectPage时失效概率很低因为方法签名是框架规定好的。但自定义 Mapper 方法里经常有人把Page参数放错位置导致分页插件拿不到分页参数。为什么说放错位置因为PaginationInnerInterceptor分页时需要从 Mapper 方法的参数列表里识别出IPage类型的对象。在多数版本中MP 对参数位置的解析是有限定的。稳妥的写法是让IPage参数排在第一位而不是夹在业务参数中间// 推荐的声明方式 IPageUser selectUserPage(PageUser page, Param(name) String name);如果写成这样IPageUser selectUserPage(Param(name) String name, PageUser page);在部分版本上分页插件根本不会识别page或者识别到了但返回的分页参数状态不正确表现就是查询结果确实执行了但你没有看到 LIMIT 被追加或者total始终是 0。老项目升级到新版本后尤其容易触发这个问题因为 3.4 之前的分页实现是基于Page在参数列表位置靠Interceptor内部反射扫描的行为更宽容。另外自定义方法的返回值必须是IPageT或PageT不要返回ListT。你想着“反正我有 Page 对象传进去返回 List 也行”插件检测到返回类型不是 IPage 时分页流程根本不会走。这个我实测过属于最常见的“数据查出来了但就是不分页”的情况。2.4 第四步total 为 0 时的回头检查分页数据有值但total 0前端只能翻一页这是另一种高频故障。数据都出来了说明 limit 已经生效问题出在 count 语句上。MyBatisPlus 做 total 统计时默认会对原 SQL 做一次优化去掉ORDER BY再包一层SELECT COUNT(*) FROM (...) total。这个优化在单表简单查询下很稳但在多表 join、带GROUP BY、带DISTINCT的复杂 SQL 上容易翻车。比如你查询里带left join且 join 条件引用了被外键约束的列count 子查询可能被优化成错误的语句或者 group by 字段没有被正确保留。此时最直接的修复是关闭 count 优化PageUser page new Page(1, 10); page.setOptimizeCountSql(false);关闭后 MP 会对原 SQL 直接包一层 count 子查询不再自作聪明去掉 order by代价是 count 性能略有下降。复杂报表查询里如果发现分页 total 不对我基本第一时间先关optimizeCountSql再回头审视 SQL 本身而不是在 Java 代码里反复调试。还有一点容易忽略如果自定义 Mapper 方法里自己设置了page.setTotal()后续插件回填时会覆盖你设置的值。某些老代码里为了“性能优化”手动设置 total结果分页插件执行完后把 total 又算了一遍两边不一致前端永远拿到错的页数。正确做法是交给插件统一回填不要手动干预。3. 单页 500 条限制的身份确认与破解姿势3.1 现象页大小设成 1000查出来只有 500有一天产品跑过来说报表翻页不对每页明明选了 1000 条但列表永远只有 500 条total 却是对的。前端翻到后面总页数按 500 一条计算数据页数变多了一倍给人感觉非常分裂。第一反应是 SQL 里写死了 limit排查后发现原生 SQL 没有。后来在控制台看到实际执行的 SQL 里 limit 变成了 500才知道不是没有 limit而是 limit 的 size 被替换成了 500。顺着这个线索找到了PaginationInnerInterceptor里的maxLimit参数它在某些 3.4.x 版本的默认值是 500。3.2 maxLimit 在源码里是怎么生效的看PaginationInnerInterceptor源码beforeQuery方法里有一段逻辑大意是判断当前 page 的 size 是否大于设定的 maxLimit如果大于就直接把 size 强制改写成 maxLimitif (null ! this.maxLimit this.maxLimit 0 page.getSize() this.maxLimit) { page.setSize(this.maxLimit); }这段代码执行时不会抛异常也不会打印明显的警告日志所以很多人根本感知不到自己的分页 size 被“静默降级”了。只有当你对比传入的 size 和实际返回的记录数时才会发现不对劲。检查方式很简单在 IDE 里打开PaginationInnerInterceptor.class搜索maxLimit字段看初始值是多少。不同版本差异很大有些版本默认是Long.MAX_VALUE也就是不限制但部分发行版默认就是 500。老项目在升级 MP 后突然遇到“单页只能查 500 条”十有八九就是新拦截器的默认值变了。3.3 解除限制的正确姿势如果你确实需要放开限制可以显式设置maxLimit。按照我上面的源码判断条件设置成-1L就不会进入限制分支PaginationInnerInterceptor pagination new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(-1L); interceptor.addInnerInterceptor(pagination);但我个人的建议是不要无脑放开。500 这个默认值背后是有道理的分页查询常常是面向用户列表的单页拉 5000 条甚至 10000 条会直接拖垮数据库。更合理的方案是按业务场景分档比如普通列表接口保持默认限制导出类接口单独设置更大的 maxLimit并且配合异步任务或流式查询而不是让前端一次性拿大数据集。另外要警惕前端可控的page.getSize()参数。如果没有 maxLimit 保护恶意请求可以传一个极大的 size 把数据库连接打满。我自己处理过线上一次慢查询事故就是某个导出接口没做分页上限控制用户传了个五万DB CPU 瞬间飙到百分百。所以破解 500 限制的同时一定要在业务层再补一道参数校验。4. 用 Java 实体类反推建表 SQL 的落地实现4.1 官方 Generator 的方向其实反了MyBatisPlus 官方配套的代码生成器mybatis-plus-generator是标准的方向数据库表 - Java 实体类。这在老项目迁移时很有用但有一种场景它解决不了产品模型先定义好Java 实体已经写完了表还没建建表脚本要你自己写。这时候手工维护一份CREATE TABLE脚本既有重复劳动又容易跟实体字段脱节。顺着这个痛点我研究了一套基于实体注解生成建表 SQL 的方案。核心思路不复杂实体类上的TableName、TableId、TableField已经把表名、列名、主键、字段策略都描述清楚了Java 类型也能明确映射到数据库类型反射扫描一遍就能拼出大部分 DDL。我为什么不直接用TableInfoHelper因为它的元数据初始化依赖 MP 的 Mapper 扫描流程在纯工具类、单元测试甚至启动早期可能拿不到完整信息。自己用标准 JDK 反射读取注解反而更可控、更通用。4.2 自己写一个基于注解的 DDL 生成器一个最简可用的生成器大概是这个思路public class TableDDLGenerator { public static String generate(Class? entity) { TableName tableName entity.getAnnotation(TableName.class); String table tableName ! null ? tableName.value() : camelToUnderline(entity.getSimpleName()); StringBuilder sql new StringBuilder(); sql.append(CREATE TABLE IF NOT EXISTS ).append(table).append( (\n); ListString columnDefs new ArrayList(); String primaryKey null; for (Field field : entity.getDeclaredFields()) { // 跳过静态字段和 serialVersionUID if (Modifier.isStatic(field.getModifiers())) continue; TableField tableField field.getAnnotation(TableField.class); if (tableField ! null !tableField.exist()) continue; String column camelToUnderline(field.getName()); TableId tableId field.getAnnotation(TableId.class); if (tableId ! null) { primaryKey column; columnDefs.add( column mapType(field.getType()) NOT NULL AUTO_INCREMENT); } else { columnDefs.add( column mapType(field.getType()) DEFAULT NULL); } } if (primaryKey ! null) { columnDefs.add( PRIMARY KEY ( primaryKey )); } sql.append(String.join(,\n, columnDefs)); sql.append(\n) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT;\n); return sql.toString(); } private static String mapType(Class? type) { if (type String.class) return VARCHAR(255); if (type Long.class || type long.class) return BIGINT; if (type Integer.class || type int.class) return INT; if (type BigDecimal.class) return DECIMAL(18, 2); if (type LocalDateTime.class) return DATETIME; if (type LocalDate.class) return DATE; if (type Boolean.class || type boolean.class) return TINYINT(1); return VARCHAR(255); } private static String camelToUnderline(String str) { return str.replaceAll(([a-z])([A-Z]), $1_$2).toLowerCase(); } }这个工具的核心点有两个。第一是类型映射表要贴合项目规范比如字符串统一VARCHAR(255)大金额统一DECIMAL(18,2)这些都可以用常量提取出来。第二是特殊注解的处理比如加了TableLogic的逻辑删除字段不应该生成DEFAULT NULL而是DEFAULT 0否则你查deleted 0时会漏掉那些 NULL 值记录。生成效果类似这样CREATE TABLE IF NOT EXISTS user ( id BIGINT NOT NULL AUTO_INCREMENT, user_name VARCHAR(255) DEFAULT NULL, age INT DEFAULT NULL, deleted TINYINT(1) DEFAULT 0, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT;这里没处理索引和外键因为索引策略往往依赖具体业务查询自动生成反而容易错。骨架生成完人工补几个关键索引是合理的操作节奏。4.3 生成器如何融入日常项目迭代有了生成器怎么用起来才是关键。我的落地方式是这样第一步把生成器写成一个独立的测试工具类放在src/test/java下或者单独一个tools模块。每次新增实体后手动跑一次把生成的 SQL 复制到项目的db/migration目录里配合 Flyway 做版本化迁移。第二步在实体上补充自定义注解或字段注释让生成器可以输出列注释。比如使用ApiModelProperty或者自建的ColumnComment注解生成器读取后拼到 DDL 里。这一步尝到甜头后你会越来越想把所有表结构的元信息都收拢到实体上。第三步注意生产环境不要用启动时自动建表。自动建表在本地开发、测试环境很香但生产环境的 DDL 变更必须经过评审和记录直接让代码在启动时改表结构出了事故很难追溯。所以我一直推荐“生成 SQL 文件 Flyway/Liquibase 管理”而非“运行时自动执行”。5. 分页之外的几个容易忽略的联动细节5.1 逻辑删除会悄悄改变 count 语句很多项目用逻辑删除代替物理删除实体字段上加一个TableLogic所有自动 SQL 都会自动追加deleted 0条件。这个特性对分页同样生效count 语句里也会带上过滤条件这是好事。但有个隐蔽问题如果某些历史数据没有这个字段的值比如 NULL那么deleted 0条件会把这些记录过滤掉导致总数对不上。所以逻辑删除字段在数据库里一定要建NOT NULL DEFAULT 0这也是前面 DDL 生成器里我特别强调默认值的原因。如果你是后来才加的TableLogic要同步做一次历史数据回填把 NULL 更新成 0。另外如果你在 XML 里手写 SQL 做多表 join逻辑删除条件不会自动拼接需要自己在 SQL 里加。这是很多“分页总数对了但明细里混进去已删除数据”的常见来源。5.2 乐观锁字段和分页的并发问题乐观锁插件OptimisticLockerInnerInterceptor是 MyBatisPlus 另一个高频配置它通过给 UPDATE 语句自动加WHERE version 旧值来避免并发覆盖。这个机制和分页本身不冲突但容易在业务逻辑上出问题分页查出来的对象带有 version页面上放着用户隔了几分钟才点提交此时数据库里的 version 已经变了更新直接失败。处理和分页配合时的常规思路是列表详情页展示的数据不直接作为更新依据进入编辑页时重新根据 id 查一次最新记录拿到当前 version再允许提交。不要把列表页缓存的对象一路传到更新接口。这个教训来自一次实际事故运营后台批量编辑用户状态并发稍微一高就报“更新失败”排查后才发现整个链路里 version 一直用的是列表查询出来的旧值。5.3 自动填充字段与数据库默认值冲突MetaObjectHandler可以在 insert/update 时自动填充create_time、update_time这类公共字段。这跟数据库默认值很容易撞车。我见过最典型的写法是实体字段createTime上加了TableField(fill FieldFill.INSERT)同时建表 SQL 又给create_time设置了DEFAULT CURRENT_TIMESTAMP。表面看两边都是自动的但当你用 MyBatisPlus 的insert插入时如果 MP 填充了值SQL 会带上这个字段如果某些特殊途径绕过了 MP 直接 SQL 插入数据库默认值会兜底。看起来双保险实际上很容易出现两边时区不一致、格式化不一致的问题。我现在的做法是二选一数据库统一用默认值控制create_timeJava 端只负责update_time的填充或者反过来Java 端全权负责两个时间字段数据库不设默认值。这样线上排查时间问题时只会有一个来源不用两边对账。分页失效也好500 条限制也好建表脚本生成也好它们都有一个共同点都在 MyBatisPlus 的默认约定之外藏着。MyBatisPlus 的好处是它把大量约定固化成了默认行为代价就是你要在关键节点知道这些默认值到底是什么。处理完这一圈之后我最大的体会是遇到诡异问题不要先怀疑是不是框架 bug先打开源码确认它做了什么假设。maxLimit默认 500 是假设Page参数必须放在第一位也是假设optimizeCountSql能正确优化你的复杂 SQL 同样是假设。所有的坑本质上都是因为我们不小心打破了某个它没明说的假设。