
周一早上刚坐到工位群里就有人甩了个截图分页接口返回的 total 永远是 0可 List 里明明有数据。下一秒又有人补了一句订单表建了唯一索引逻辑删除过的单据再插入直接 Duplicate entry 卡死。老实说MyBatisPlus 用好了是效率神器用不好就是连环坑。这两年我接手过好几个 SpringBoot MyBatisPlus 项目几乎每个项目都会在同样的地方翻车分页拦截器没生效、selectCount 查出来是 0、字段名撞了数据库关键字、批量插入慢得离谱。这篇文章就把我在生产环境里排查过、处理过的问题系统整理一遍重点讲配置层的细节、坑在哪里以及每个坑背后的原理顺便给出一套可以直接抄作业的配置方案。写这篇文章之前我也习惯性地翻了一下最近的搜索趋势发现大量开发者都在搜“mybatisplus 单页500条限制”“mybatisplus selectcount 为0”“mybatisplus分页失效”“mybatisplus关键字”这类问题另外还有一堆是 JDK、Maven、MySQL 安装配置。确实很多项目第一关就卡在环境搭建但真正让团队头疼的从来不是环境而是这些配置细节和隐性问题。所以这篇文章主要面向正在用 MyBatisPlus 做业务系统、尤其是准备上生产环境的团队也适合刚把 MyBatis 项目迁到 MyBatisPlus 的开发者。我会把配置项掰开揉碎讲清楚并且把网上那些搜不到答案的“为什么”一并补上。1. MyBatisPlus 生产就绪先要把定位和版本搞明白1.1 先看清 MyBatisPlus 的定位增强工具不是新型框架我遇到过不少新人上来就问MyBatisPlus 是不是要把 MyBatis 替换掉其实不是。MyBatisPlus 是在 MyBatis 之上做增强的底层仍然走 MyBatis 的 SqlSession 机制SQL 解析、会话管理、事务这些核心能力一点都没变。它做的只是把单表 CRUD、分页、逻辑删除、自动填充、乐观锁这些高频重复操作封装成了现成能力开发者不需要再为每个实体写一套 XML。这也是生产就绪的第一个判断标准你的团队是主要做单表操作还是大量复杂多表 JOIN。如果是前者MyBatisPlus 能省下大量样板代码如果是后者最好还是老老实实手写 XMLMyBatisPlus 的自定义 SQL 能力跟 MyBatis 完全一致不存在冲突两者是配合关系不是二选一。实际项目中我见过最舒服的分工是单表查询、简单的列表分页、字典表操作全走 MyBatisPlus 的 BaseMapper报表类、多表关联、复杂条件动态 SQL 走自定义 XML。这样既保证了开发速度也不至于让 XML 文件膨胀到没法维护。1.2 版本选型和生产依赖配置版本问题看起来不起眼但踩坑率极高。我用过一个项目SpringBoot 2.7 配了 MyBatisPlus 3.5.2分页和乐观锁都能正常工作。但另一个项目升级到 SpringBoot 3.2 后发现原来那套 mybatis-plus-boot-starter 直接启动报错原因是 SpringBoot 3 改用了 Jakarta EE旧版本的 MP 初始化逻辑无法兼容。后来换成mybatis-plus-spring-boot3-starter才解决。如果你是全新项目建议直接用一个相对新的稳定版本比如 MyBatisPlus 3.5.3 以上SpringBoot 2.x 用mybatis-plus-boot-starterSpringBoot 3.x 用mybatis-plus-spring-boot3-starter。注意不要自己随意混搭版本MP 的拦截器机制和 MyBatis 核心版本耦合很深换版本前先看官方版本兼容矩阵。生产环境里我一般还会关闭 MP 启动时打印的 Banner说实话那东西除了占日志空间没有任何用处mybatis-plus: global-config: banner: false1.3 一份可以直接复制的基础配置下面这份是我在多个生产项目里验证过的核心配置逐行解释一下作用mybatis-plus: mapper-locations: classpath*:mapper/**/*.xml type-aliases-package: com.example.demo.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl global-config: banner: false db-config: id-type: assign_id logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0mapper-locations指定 XML 文件位置如果你有自定义 SQL一定要把路径配对否则启动后调用自定义 Mapper 方法会报Invalid bound statement。type-aliases-package是实体类扫描路径配了之后 XML 里写 resultType 可以直接用类名不用带全限定名。map-underscore-to-camel-case是驼峰映射开关数据库字段create_time会自动映射到实体属性createTime这个强烈建议开启否则每次查询都要手动写TableField。id-type我一般选assign_id也就是 MP 默认的雪花 ID。生产环境强烈不建议用数据库自增主键做分布式系统的主键ID 冲突和数据迁移都是大麻烦。雪花 ID 虽然看着长但全局唯一、有序性也够用。2. 配置环节最容易埋雷的几个点2.1 分页拦截器配置所谓“500 条限制”到底是怎么回事先回答很多人在搜的那个问题——MyBatisPlus 单页 500 条限制。其实 MyBatisPlus 本身并没有内置一个写死的 500 条上限这个数字通常是两种情况造成的一是分页插件里有人主动设置了maxLimit500二是前端分页组件默认每页 500 条。但很多团队搜到这个说法后根本不知道还有maxLimit这个参数于是分页明明配好了查出来的数据却永远只有 500 条最后怪到框架头上。我建议所有生产项目必须显式设置maxLimit这是一种自我保护机制。想象一下如果有人写了个接口前端只传了一个巨大的pageSize比如 100000那这条 SQL 会直接把数据库内存打爆。设置一个上限超过就抛异常或者按上限返回这是最便宜的限流手段。分页拦截器还有一个隐藏参数叫overflow默认 false。含义是当请求页码超过总页数时是返回空数据还是自动修正到最后一页。真实场景里用户停留在列表第 10 页管理员删了几条数据用户再点下一页如果 overflowfalse就会看到空白页体验很差。我通常把 overflow 设成 true宁可多查一次也别让用户一脸懵。配置方式如下Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(500L); pagination.setOverflow(true); interceptor.addInnerInterceptor(pagination); return interceptor; } }注意这里的DbType一定要跟实际数据库匹配MySQL、PostgreSQL、Oracle 的分页语法完全不同MP 是根据 DbType 来生成对应方言的分页 SQL 的。配错了轻则分页结果不对重则直接 SQL 报错。2.2 selectCount 返回 0 的完整排查路径“列表有数据但 total 是 0”这个问题的出现频率高得惊人。MyBatisPlus 的selectCount方法在底层会自动生成SELECT COUNT(*)的 SQL并且在原有查询条件基础上追加逻辑删除条件。所以当你用selectCount查出来是 0第一步要做的不是看代码而是把实际执行的 SQL 打出来看。排查路径我一般按顺序走第一看 SQL 里的条件。打印出来的 count SQL 是不是多了deleted0如果这张表本身不需要逻辑删除但实体类继承了某个带TableLogic的基类就会多出这个条件把已删除的数据全部过滤掉计数自然少了。第二看条件构造器的拼接逻辑。很多人用LambdaQueryWrapper时会把eq、like、and、or混在一起最后生成的 SQL 条件优先级完全不对。比如eq(status, 1).or().eq(type, 2).eq(deleted, 0)生成出来的 where 条件可能把deleted0只作用在type2上。排查方法很简单打开 SQL 日志看括号位置。第三看字段映射。如果实体属性叫isDeleted数据库列叫is_deleted而项目又没开启驼峰映射那查询条件可能生成为is_deleted ?这种情况在 MySQL 里有时不会立刻报错但查出来的结果必然不对。第四检查事务隔离级别。如果是“刚插入数据就 count 查不到”那可能是你的 Service 方法没有加事务或者事务隔离级别是READ_COMMITTED另一个连接还没提交当前连接自然看不到。这种不属于 MyBatisPlus 问题但经常被误认为是 MP 的 bug我也见过好几次。2.3 关键字和保留字order、rank、desc 这些字段名的定时炸弹很多团队的数据库表字段喜欢用order、desc、level、rank、interval、year这类词MySQL 5.7 时代还能勉强跑升级到 MySQL 8.0 后就会开始报 SQLSyntaxErrorException因为 8.0 对保留字的检查更严格了。比如有个需求要给订单排序字段名直接叫orderMyBatisPlus 生成的 SQL 是SELECT id, order FROM t_orderMySQL 8.0 直接报语法错误。解决办法有三种第一种单字段转义在实体上用TableField指引用符号包起来TableField(order) private String order;第二种全局配置column-format让 MP 生成 SQL 时为所有列名都加上反引号mybatis-plus: global-config: db-config: column-format: %s但这招我不推荐因为开了之后所有字段都被包上反引号日志阅读性变差而且在 PostgreSQL、SQLServer 上反引号语法不通用换库就完蛋。第三种最推荐数据库设计阶段就约定好不用保留字已经上线的表就老老实实改字段名。我知道改字段名有风险但这是长远最省事的方案。如果实在不能改那就给那少数几个字段单独加TableField转义不要全局开启。2.4 驼峰映射与字段命名规范map-underscore-to-camel-case是我见过配置率最高、但讨论最少的一个开关。开启之后MP 会自动把user_name映射为userName省掉大量TableField注解。但这里有个隐蔽的坑实体属性如果叫uName数据库列叫user_name驼峰映射规则是“去下划线后首字母小写”它生成的是userName不是uName结果就是查询能查出列但映射不到实体属性上最后属性全是 null。另一个容易忽略的是数据库列本身就有大写的情况比如userId这种驼峰式列名映射规则也容易出问题。我一般直接约定数据库列一律小写下划线实体属性一律小驼峰从源头避免这类问题。如果你用了 MyBatis 原生的Results或 XML 里的resultMap要注意这些映射的优先级高于全局驼峰配置。XML 里一旦手写了 resultMapMP 的自动驼峰映射就不会发挥作用必须自己把每个字段对应关系写全否则又是 null。3. 生产环境高频故障实录分页失效、逻辑删除、乐观锁3.1 分页失效的三种典型场景分页失效是最经典的“搜不到答案”系列。配置看着没问题但一执行MP 还是查出了全表数据。根据我的排查经验最常见的场景有三个。第一个场景压根没注册分页拦截器。很多人以为引入mybatis-plus-boot-starter就自带分页能力了其实不是。分页插件必须通过MybatisPlusInterceptor手动注册否则Page参数传给 MapperMP 会把它当成普通参数忽略掉SQL 执行时页面大小完全不生效返回所有数据。这个坑几乎每个新项目都会踩一次。第二个场景分页的IPage对象没有作为 Mapper 方法第一个参数。MP 的分页拦截器是靠识别 Mapper 方法参数里的IPage来工作的如果方法签名长这样ListUser selectUserList(User user, PageUser page);理论上也能生效因为 MP 会扫描所有参数找到 IPage。但我见过有人把 Page 放在自定义对象里比如req.setPage(new Page(1,10))这种就无法被识别结果自然全量返回。规范做法是把 Page 作为 Mapper 方法的第一个参数简洁又保险。第三个场景多表 JOIN 查询时 count 生成错误。分页拦截器在拦截到分页查询后会自动把原 SQL 改写为 count 查询。单表没问题但多表 JOIN、带DISTINCT、带GROUP BY的 SQL改写后的 count 语句经常在语法层面就出错或者生成的 count 对不上。因为 JOIN 场景下如果没有明确分页主表拦截器生成的 count 会对整个 JOIN 结果做统计一旦 JOIN 产生笛卡尔积count 数值就是错的。这时候最靠谱的方案是手动指定 count SQL使用Page.setCountId或者给这个 Mapper 方法单独写一个 count 方法让分页插件去调用你精确计算过的 SQL。另外分页 SQL 中如果有ORDER BYMySQL 5.7 和 8.0 在执行 count 优化时的行为还不太一样这也是为什么线上 MySQL 版本升级后分页反馈异常的常见原因之一。3.2 TableLogic 的连带效应唯一索引、count、自定义 XML逻辑删除是 MyBatisPlus 最受欢迎的功能之一也是生产环境最容易出问题的功能。它帮你做的只是在delete操作时把DELETE语句改成UPDATE deleted1在查询时自动追加AND deleted0。问题来了如果你在业务表上建了唯一索引比如订单表用order_no建唯一索引那么逻辑删除一条订单后这条记录还留在表里order_no还被占用着。下次新增同号订单直接 Duplicate entry。这是我在一个日单量不小的系统里真实遇到过的场景当时上线才一周就有用户反馈重复下单失败。解决方案无非两个一是把唯一索引改成联合索引把deleted字段也放进去这样已删除的记录deleted1新插入的记录deleted0唯一性就能区分开二是如果业务上确实允许复用被删的编号那就需要物理删除或者用不带逻辑删除的 Mapper 方法。还有一个隐蔽点你在 XML 里手写 SQL 时如果写的是SELECT * FROM t_order WHERE user_id #{userId}MP 的自动逻辑删除条件不会生效因为拦截器只处理 MP 生成的通用方法不处理自定义 XML。也就是说同一个实体用 BaseMapper 自带方法查出来的数据是过滤掉已删除的但手写 XML 查出来的却含已删除数据。这个不一致很容易在生产环境里造成账单类数据重复排查时一定要记得检查自定义 SQL 里有没有手动补上and deleted 0。3.3 自动填充字段create_time、update_time 的坑MetaObjectHandler是 MP 用来做公共字段自动填充的入口比如创建时间、修改时间、创建人、更新人。很多团队的实现方式是在实体类上标注TableField(fill FieldFill.INSERT)和TableField(fill FieldFill.INSERT_UPDATE)然后写一个 Handler。这个方案本身没问题代码看着也清爽但生产环境里坑就藏在时间格式和时区里。我遇到过最典型的一个问题数据库字段是datetime实体属性是LocalDateTimeHandler 里填充的却是new Date()。看起来能存进去但 MP 映射时出了隐式转换问题最终查出来的时间和实际相差 8 小时或者部分记录的时间字段变成 null。排查到最后发现是 Handler 里统一用LocalDateTime.now()才对。另一个坑是FieldFill.UPDATE只对updateById生效对UpdateWrapper方式不生效。比如你写userService.update(new User(), new LambdaUpdateWrapperUser() .eq(User::getId, 1) .set(User::getName, test));这种写法基于update(entity, wrapper)Handler 不会自动填充 updateTime。正确做法是显式在set里把 updateTime 带上或者尽量用updateById。还有一个地方容易漏大字段更新时如果实体属性有TableField(updateStrategy FieldStrategy.NOT_NULL)这类的策略配置自动填充可能被覆盖或者跳过。遇到这类问题直接看执行 SQL 最直观别猜。3.4 乐观锁与批量写的性能问题乐观锁在 MP 里的实现方式很优雅实体类加一个Version注解的字段再注册OptimisticLockerInnerInterceptor。每次 update 时 MP 会自动在 SQL 里带上version 当前版本并且更新后version version 1。这个机制本身很成熟但现场问题也不少。最常见的是忘了注册乐观锁拦截器。有Version字段没有拦截器MP 不会帮助你做版本校验update 语句就跟普通 update 一样乐观锁形同虚设。这个问题隐蔽在代码看什么都是对的运行结果就是不对。另外乐观锁字段在实体类上是基本类型int时需要给默认值 1。如果是Integer且为 null第一次 update 时生成的 SQL 会变成where version is null不仅没起到乐观锁作用还可能更新错行。这个我在数据修复脚本里踩过一次教训深刻。再说性能。批量插入是很多团队迁到 MyBatisPlus 后最想开的功能saveBatch确实用起来很爽但如果你没在 JDBC URL 里加上rewriteBatchedStatementstrue这玩意儿就是伪批处理。MySQL 默认不会重写批量语句MP 的saveBatch生成的批量插入 SQL 到了 MySQL 驱动层可能被逐条执行性能提升约等于零。加上这个参数后MySQL 会把多条 insert 合并成一条多值 insert 执行单批插入几千条数据的耗时能从十几秒降到一两秒。JDBC URL 配置jdbc:mysql://localhost:3306/db?useUnicodetruecharacterEncodingutf8rewriteBatchedStatementstrue如果你一边用批处理、一边又在代码里循环调用save或insert那不管怎么调优都没用批处理和循环单条是两回事。要批量就一口气saveBatch要单条就单条别混着来。4. 生产环境配置建议连接池、SQL 监控与扩展点取舍4.1 Hikari 连接池参数怎么调才合理SpringBoot 2.x 默认集成 HikariCP这也是现在 Java 界综合性能最好的连接池。大部分团队用默认配置跑起来没问题但生产环境我建议至少明确设置几个参数别完全依赖默认值。maximum-pool-size默认是 10很多人以为越大越好直接调到 200结果数据库连接数被打满响应反而变慢。连接不是越多越好每个连接背后都有数据库端的内存和线程资源连接数过多会让数据库频繁切换上下文。经验法则一个 4 核 8G 的 MySQL 实例上应用连接池上限设在 20 到 50 之间比较稳妥具体要压测验证。connection-timeout默认 30000 毫秒警示意义大于实际意义。如果连接池耗尽请求要卡 30 秒才会抛异常。对在线接口来说这个时间太长了我一般会调到 3000 到 5000 毫秒宁可快速失败让上游感知也不要让用户傻等 30 秒。Hikari 还有几个隐蔽参数比如maximum-lifetime要小于数据库的wait_timeout否则连接会被数据库服务端回收但连接池不知道拿到手才发现连接已经失效。MySQL 默认wait_timeout是 8 小时Hikari 的maximum-lifetime默认 30 分钟所以一般不会撞上但如果数据库运维改了 wait_timeout连接池也对应调整。4.2 慢 SQL 和日志打印的定位技巧MyBatisPlus 提供了PerformanceInterceptor这类插件来打印执行耗时但我不建议生产环境长期开启它那个插件在高并发下会有额外的性能损耗和日志刷屏。更合理的做法是直接用 MySQL 官方的慢查询日志slow_query_logON long_query_time1 slow_query_log_file/var/log/mysql/slow.log然后把long_query_time设成 1 秒对线上区分度很高。再配合EXPLAIN看执行计划基本能定位绝大多数 SQL 性能问题。如果你在项目里想快速看 SQL 执行耗时又不想引入太多依赖可以在 Mybatis 配置里把日志级别调成 DEBUG但只对某个 mapper 包单独开避免全局刷屏。比如在application.yml里logging: level: com.example.demo.mapper: debug这样只打印 Mapper 接口包下的 SQL日常开发排错够用又不至于被日志淹没。4.3 多租户、逻辑删除、乐观锁这些内置扩展要不要全上MyBatisPlus 的扩展能力很强有TenantLineInnerInterceptor做多租户、BlockAttackInnerInterceptor防止全表更新删除、IllegalSQLInnerInterceptor做 SQL 规范性校验。看起来功能很全但我的建议是按需引入不搞大而全。多租户插件是这里面使用成本最高的一个。它会在你所有查询上强制追加租户条件如果某个查询需要跨租户或者查全量数据就得在代码里手动拼租户条件或写自定义 SQL 绕过。业务没想清楚之前贸然开启后面每个查询都要照顾这个隐藏条件非常痛苦。我见过一个项目因为多租户插件和分页插件顺序配错导致分页 count 查询带上重复的租户条件排查了整整一天。如果团队目前只是单租户模式真的不必提前引入这个复杂度。同样BlockAttackInterceptor这种安全插件可以配置但也要评估业务里是否有合法的全表更新需求否则上线后可能把内部定时任务给误杀。我的原则是生产环境默认只开分页和乐观锁两个拦截器逻辑删除按表按需配置其他扩展等业务真正需要时再加避免为未来可能用不到的复杂度买单。4.4 单元测试与回归保障MyBatisPlus 的 CRUD 方法封装度很高很多代码不需要写 XML但也正因为如此一些隐藏行为靠代码评审根本发现不了。我强烈建议给 Mapper 层建立一套轻量级的单元测试至少覆盖以下几个场景新增后能按主键查到updateById后修改字段生效逻辑删除后列表查询不再返回分页查询 total 值和列表数量一致自定义 XML 方法能正常返回结果。最简单的做法是用 H2 内存数据库配合 MP 的 schema 脚本做 Mapper 层测试不需要依赖真实 MySQL本地跑起来秒级完成。数据源可以在测试配置里覆盖spring: datasource: driver-class-name: org.h2.Driver url: jdbc:h2:mem:testdb;MODEMySQL;DATABASE_TO_LOWERTRUE这样每次提交代码前跑一遍很多看起来对但一跑就炸的问题能在测试阶段暴露而不是上线后让报警群来提醒你。5. 常见问题速查表与一套标准排查动作5.1 高频问题速查表我把这些年遇到的高频问题整理成一张表格方便你按图索骥。实际排查时先对号入座再看对应章节的详细说明。症状可能原因快速定位方向分页查询返回全部数据分页拦截器未注册检查 MybatisPlusInterceptor 是否有 PaginationInnerInterceptortotal 为 0 但列表有数据逻辑删除条件/BETWEEN 条件/字段映射打印实际执行的 count SQL分页数据始终只有 500 条分页插件设置了 maxLimit500检查 setMaxLimit 配置按业务调整查询报 Unknown column字段名是数据库关键字检查 order、desc、rank 等保留字字段逻辑删除后再插入报唯一键冲突唯一索引未包含 deleted 字段改成联合唯一索引或改为物理删除updateById 修改的新值没生效字段为空且策略为 NOT_NULL使用 UpdateWrapper.set 或者调整字段策略乐观锁总是更新失败拦截器未注册或版本字段未初始化检查 OptimisticLockerInnerInterceptor 和默认值saveBatch 批量插入很慢未开启 rewriteBatchedStatementsJDBC URL 拼接该参数查询列表有数据但字段全是 null驼峰映射未开启或 resultMap 手动覆盖开启 map-underscore-to-camel-case手写 XML 查出已逻辑删除的数据自定义 SQL 未追加 deleted0XML 条件里手动补逻辑删除条件查询条件覆盖了全表导致错更新LambdaUpdateWrapper 传了空值用三参 eq(condition, column, val) 防止空条件拼接5.2 一套标准排查动作清单遇到 MyBatisPlus 相关问题时我建议按下面的顺序排查不要一上来就怀疑框架有 bug八成是配置或者用法问题。第一步打开 SQL 日志把 MP 实际执行的 SQL 完整打出来。用logging.level.你的mapper包: debug的方式最直接地看到 MP 生成了什么 SQL很多问题看到 SQL 的一瞬间就明白了。第二步检查拦截器顺序。多个 InnerInterceptor 在MybatisPlusInterceptor中是有顺序的一般分页在前、乐观锁在后顺序配错可能导致某些拦截器不生效或者执行了异常逻辑。不确定的时候可以先把代码简化到只留分页拦截器再逐步加回来。第三步检查实体类注解与数据库表结构的对应关系。把每个实体的TableName、TableField、TableLogic、Version逐一对照表结构重点关注字段名是否撞保留字、逻辑删除字段是否有默认值、版本字段是否为空时会影响更新。第四步确认版本兼容性。包括 MyBatisPlus 和 SpringBoot 的版本、MyBatisPlus 和 MyBatis 的版本、JDBC 驱动的版本。这三个维度任何一个不匹配都可能引发诡异问题。第五步回归测试。修改完配置或者代码后至少把分页、新增、修改、删除、逻辑删除、乐观锁这几个场景各跑一遍别只验证报错的单点。在线上排查这类问题的时候我还习惯把责任边界划分清楚是 MyBatisPlus 生成的 SQL 不对还是业务代码条件构造不对还是数据库结构不对。三种情况的处理方式完全不同。先把 SQL 打出来一眼就能看出该找谁。这也是为什么我一直强调排查工具再多都不如一份清晰的 SQL 日志来得实在。最后说一个我个人的体会MyBatisPlus 这类框架给开发带来很大便利但它从来不是银弹。越是用得顺手越要弄清楚生成的 SQL 长什么样。很多生产事故不是因为框架能力不行而是开发人员把框架当成了黑盒出了问题才去翻日志。如果你能把 MP 的执行链路理解清楚把分页、逻辑删除、乐观锁这几个核心机制的原理吃透大多数所谓“坑”都能在设计阶段避开。真等到报警响起来再排查成本至少是写代码时的十倍。