接手一个统计类后台项目的时候,第一周我就被 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 解决这个问题的核心是BaseMapper<T>。你只要定义一个接口继承它,再配上实体类上的@TableName、@TableId、@TableField注解,框架就能在启动时通过反射把实体和表结构的映射关系缓存下来,然后自动生成单表的增删改查 SQL。实际项目中我见过最少的一个 Mapper 长这样:
@Mapper public interface UserMapper extends BaseMapper<User> { }就这一行,insert、deleteById、selectPage、updateById全部开箱可用。这里面的门道是TableInfoHelper,它对实体类做了一次元数据解析,把哪些字段对应哪些列、哪个字段是主键、哪个字段需要自动填充,全部整理成了TableInfo对象。后续所有自动 SQL 都是基于这份元数据拼出来的,这也是后面我们自己写建表 SQL 生成器时的关键线索。
但注意,BaseMapper只覆盖单表操作。一旦你的 SQL 里有join、子查询、多表聚合,就得回到 XML 或注解 SQL。MyBatisPlus 并没有魔法,它只是把单表套路化的工作做完了。
1.2 条件构造器选型:Lambda 优先
QueryWrapper和LambdaQueryWrapper是 MyBatisPlus 最常用的条件构造入口。前者直接传字符串列名,后者传方法引用:
// QueryWrapper 写法,列名是硬编码字符串,字段重命名后编译器无法提示 QueryWrapper<User> wrapper = new QueryWrapper<>(); wrapper.eq("user_name", "zhangsan"); // LambdaQueryWrapper 写法,列名跟着实体字段走 LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(User::getUserName, "zhangsan");我的建议是项目里统一用 Lambda 写法。改字段名时 IDE 会直接标红,全局搜索替换也不容易漏。更重要的是 Lambda 写法在分页、条件拼接、逻辑删除联动这些场景下,列名解析是一致的,不会出现字符串写错导致 SQL 注入习惯性隐患。
ServiceImpl里的lambdaQuery()、lambdaUpdate()也是同样的思路,配合IService<T>接口可以省掉大量重复的 Controller 到 Service 的胶水代码。但我必须提醒一句:条件构造器看着好用,千万别把一个复杂的多表查询强行拆成好几次单表查询再用 Java 内存拼接,这种写法在数据量上来后性能会非常难看。
2. 分页失效的完整排查链路
2.1 第一步:拦截器是否真的注册了
分页失效的第一反应大概率是“插件没配置”。MyBatisPlus 分页不是 MyBatis 自带的,它靠一个MybatisPlusInterceptor拦截器在 SQL 执行前改写语句,自动追加LIMIT,并在查询完成后把total回填到Page对象里。
老版本用的是PaginationInterceptor,3.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参数排在第一位,而不是夹在业务参数中间:
// 推荐的声明方式 IPage<User> selectUserPage(Page<User> page, @Param("name") String name);如果写成这样:
IPage<User> selectUserPage(@Param("name") String name, Page<User> page);在部分版本上分页插件根本不会识别page,或者识别到了但返回的分页参数状态不正确,表现就是查询结果确实执行了,但你没有看到 LIMIT 被追加,或者total始终是 0。老项目升级到新版本后尤其容易触发这个问题,因为 3.4 之前的分页实现是基于Page在参数列表位置靠Interceptor内部反射扫描的,行为更宽容。
另外,自定义方法的返回值必须是IPage<T>或Page<T>,不要返回List<T>。你想着“反正我有 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 优化:
Page<User> 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 强制改写成 maxLimit:
if (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"); List<String> 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) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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_time,Java 端只负责update_time的填充;或者反过来,Java 端全权负责两个时间字段,数据库不设默认值。这样线上排查时间问题时只会有一个来源,不用两边对账。
分页失效也好,500 条限制也好,建表脚本生成也好,它们都有一个共同点:都在 MyBatisPlus 的默认约定之外藏着。MyBatisPlus 的好处是它把大量约定固化成了默认行为,代价就是你要在关键节点知道这些默认值到底是什么。处理完这一圈之后我最大的体会是,遇到诡异问题不要先怀疑是不是框架 bug,先打开源码确认它做了什么假设。maxLimit默认 500 是假设,Page参数必须放在第一位也是假设,optimizeCountSql能正确优化你的复杂 SQL 同样是假设。所有的坑,本质上都是因为我们不小心打破了某个它没明说的假设。