ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MyBatis迁移MyBatis-Plus避坑指南:从SQL生成到分页拦截的实战教训

MyBatis迁移MyBatis-Plus避坑指南:从SQL生成到分页拦截的实战教训 看到这个标题我真以为是个段子直到自己团队里也出了一模一样的事。新来的同事干活确实利索集成MyBatis-Plus只用了半天接口上线当天生产环境就出了一个不大不小的SQL事故组长当着全组的面把责任归到了“无脑换框架”上。说实话问题不在MyBatis-Plus这个框架本身而在于替换MyBatis时根本没有全面评估两个框架在底层执行链路上的差异。这篇不是来判对错的而是把整个替换过程里最容易踩的坑、最容易忽略的隐性问题拆开来讲。如果你正在准备做MyBatis到MyBatis-Plus的迁移或者团队里有“能干但很冲”的同事想动这块建议把这篇文章看完至少能少走一圈弯路。1. 事故还原上线当天到底发生了什么1.1 为什么一个老项目会被盯上替换那个老项目是典型的Spring Boot 2.x MyBatis 3.5.x结构Mapper.xml里堆了百来条手写SQL很多还是老员工从历史系统里直接搬过来的。代码里有个很突出的痛点每加一个单表查询都要新建XML方法、写resultMap、配parameterType一个简单的selectById都要几层跳转。新来的小伙熟悉MyBatis-Plus那套通用CRUD第一天就提出“这个项目可以直接换成MyBatis-Plus能砍掉一半Mapper文件开发效率翻倍”。这个提议在立项评审时几乎没人反对因为单表操作确实是MyBatis-Plus最擅长的场景把那些简单的selectByPrimaryKey、insert、updateByPrimaryKey换掉没有任何难度。大家默认的结论是既然单表没问题整个项目替换也不会出大问题。1.2 上线故障的具体现场上线当晚第一个故障是列表查询超时。旧的列表SQL用了三层嵌套子查询外部用PageHelper分页每页20条历史数据量大概380万行。替换时因为要“统一风格”把PageHelper去掉改成MyBatis-Plus内置的分页插件。第一次执行线上查询数据库CPU直接飙到90%以上页面上等了三十秒还在转圈。日志里打出来的SQL明显不对MyBatis-Plus的count语句把最外层嵌套子查询完整包了一层导致count执行扫了整个中间结果集。更麻烦的是那个子查询里有自定义的GROUP BYMyBatis-Plus默认的count优化没有命中正确的主键标识于是退化成“SELECT COUNT(1) FROM (整段子查询)”。这种换框架直接换SQL生成方式的坑不压测根本看不出来。第二天又爆了一个问题。项目里有一处“批量更新用户状态”的逻辑原来的XML里是foreach循环拼接每次都传一个List。替换时有人顺手改成了MyBatis-Plus的updateBatchById()结果上线后发现只有第一次更新成功后面几条数据状态全部没变。日志里update的SQL都是好的也没有报错但事务回滚时静默失败了。查到最后是因为该实体类配置了乐观锁Version字段而旧代码更新时自己控制版本号新代码走MyBatis-Plus的乐观锁拦截器两套机制混在一起导致更新的行数判定异常。组长怒怼的不是“换框架”而是整个替换流程里没有任何对比测试方案上线前只跑通了功能路径没有跑数据路径。2. MyBatis和MyBatis-Plus在底层实现上的核心差异2.1 一个生成SQL一个是解析已有SQLMyBatis本质是一个半自动ORM框架SQL由你自己写在Mapper.xml或注解里框架只负责参数绑定、结果映射和执行。它的强项是SQL的可控性一个复杂查询怎么写、怎么优化、是否走到索引开发者心里一清二楚。MyBatis-Plus则在MyBatis之上做了大量封装。BaseMapper提供了selectList、selectPage、insert、updateById等方法核心逻辑是通过反射读取实体类上的TableName、TableField等注解在运行时动态生成SQL。这意味着同样的selectById在MyBatis-Plus里执行的是自动生成的SELECT id,name,status FROM user WHERE id?替换时如果实体类注释漏了一个SQL就会和原来的XML版本差一个字段。最典型的例子是逻辑删除如果你在实体类里加了TableLogicMyBatis-Plus会在你手写的自定义deleteSQL上自动拼接AND deleted0甚至会把原来的DELETE FROM table WHERE id?整段改写成UPDATE table SET deleted1 WHERE id? AND deleted0。这个行为是透明的、不可关闭的除非你在自定义SQL里特别处理。老项目里如果有人在XML里写了物理删除替换后会直接变成逻辑删除语义数据删不掉还是小事严重时会在统计类SQL里把已删除数据查出来。MyBatis-Plus生成的SQL质量在单表场景下是稳定的但到了多表join、子查询、动态行列转换这些场景自动生成就不够用了。官方文档也明确说复杂SQL建议还是写在XML里。也就是说替换MyBatis-Plus后项目大概率会变成“混合模式”简单CRUD走BaseMapper复杂查询继续走XML。这个混合模式本身没问题但很多人忽略了一个关键点项目里Mapper.xml里的namespace路径、方法名、返回值类型是否和MyBatis-Plus的BaseMapper接口兼容。如果一开始的Mapper接口没有继承BaseMapper那替换后所有注入的Mapper都只是半成品很多通用方法根本找不到运行期会报Invalid bound statement。2.2 拦截器链路的差异决定了排查方向完全不同MyBatis的插件机制是基于JDK动态代理的Interceptor通过拦截Executor、StatementHandler、ParameterHandler、ResultSetHandler四个核心对象来实现扩展。PageHelper就是一个典型插件它在Executor层面拦截SQL改写成分页格式。MyBatis-Plus把分页、乐观锁、多租户、动态表名、非法SQL拦截都做成了内置拦截器统一交给MybatisPlusInterceptor管理每个内部拦截器在同一个链路里按顺序执行。替换后最大的问题就是两个框架的拦截器不能同时并存。如果在引入MyBatis-Plus的同时还保留PageHelper依赖并且两个分页插件都生效那么一条分页SQL会被改写两次数据量和总页数都会错乱。更隐蔽的是顺序问题MyBatis-Plus的分页插件必须被加在MybatisPlusInterceptor的最后一个位置而且必须先设置DbType否则分页SQL会被当成其他数据库方言处理。很多人在本地用的MySQL测试时没暴露问题一到生产切了Oracle或PostgreSQL分页直接变成LIMIT语法当场报错。还有一种情况是项目自定义了Interceptor比如用来做数据权限拦截的。MyBatis-Plus的拦截器会先执行内部再调用自定义拦截器。如果你在自定义拦截器里通过BoundSql.getSql()拿到SQL并尝试修改会发现拿到的可能是已经被MyBatis-Plus改写过的SQL。这个顺序问题非常难排查因为本地环境单线程跑没问题线上并发一高就偶发出现数据越权或者多查了几条记录。2.3 内置功能看着省事实际上是一套新的状态模型MyBatis-Plus的内置功能里最容易被忽视的是MetaObjectHandler自动填充和Version乐观锁。自动填充听起来很美好插入时自动设置createTime、updateTime更新时自动设置updateTime。但在老项目里很多表的创建时间字段是数据库默认值实体类里根本没这个字段。替换后如果给实体类补上了字段并启用了自动填充那么每次insert都会多带一个create_time列而原来的SQL是让数据库自己生成的。这两个行为在绝大多数情况下结果一致但一旦数据库表字段有变动或者某些历史数据的时间值是NULL就会暴露出差异。乐观锁更是个大坑。老项目如果用update ... set status#{status} where id#{id}这种裸SQL是不检查版本号的。换到MyBatis-Plus后如果实体增加了Version字段那么所有通过updateById和updateBatchById执行的更新都会自动带上WHERE version?但手写在XML里的更新不会走这个逻辑。于是同一个实体、两种更新方式行为不一致。项目里如果两条链路共存数据一致性会在并发写入时直接被破坏。更坑的是MyBatis-Plus的乐观锁插件默认要求版本字段不能为NULL如果历史数据version为空update会直接失败返回更新行数为0。所以替换MyBatis-Plus不只是在改代码风格它等于引入了一套新的状态管理模型。你用它的通用CRUD就要接受它的逻辑删除、乐观锁、自动填充这些默认行为你继续用手写XML就要时刻留意这些行为是否还会影响你。两套模型交叉的时候排查成本翻倍。3. 迁移前必须做的五件事一个都不能省3.1 梳理全量Mapper接口与XML的对应关系替换前第一件事不是改代码而是把项目里所有Mapper接口的继承关系列出来。旧项目里Mapper接口通常直接继承com.baomidou.mybatisplus.core.mapper.BaseMapper的情况比较少更多的还是裸接口方法全是自己声明的。不要想当然地把所有接口改成继承BaseMapper因为一旦继承MyBatis-Plus的所有内置方法都会暴露出来而原来的XML里并没有这些方法对应的statement运行时就会抛异常。正确的做法是分两步第一步把所有只做单表CRUD的Mapper接口改成继承BaseMapper并把XML里对应的方法全部删除。第二步保留那些有复杂自定义SQL的Mapper接口原样不动只在需要时注入BaseMapper。替换后项目里会同时存在两种Mapper风格这是正常状态不要为了统一而强行把XML删光。梳理过程中还得做一张映射表记录每个方法的SQL来源是“XML自动生成”还是“MyBatis-Plus注解生成”。我建议直接在项目里跑一次SqlSessionFactory的初始化日志把每个Mapper方法对应的MappedStatement打印出来对比替换前后生成的SQL差异。这个日志在替换前一个月就应该开始留档否则上线后才对比根本不知道改了什么。3.2 确认MyBatis-Plus版本与Spring Boot版本的兼容矩阵MyBatis-Plus版本和Spring Boot版本之间是有兼容要求的。简单说老项目如果有自己的全局配置比如sqlSessionFactory、transactionManager这类自定义Bean替换MyBatis-Plus后这些Bean要重新对齐。MyBatis-Plus的MybatisSqlSessionFactoryBean在初始化时会把很多默认配置覆盖掉尤其是configuration.mapUnderscoreToCamelCase这个参数。原MyBatis配置里如果开了驼峰映射替换后忘了在MyBatis-Plus里配置那么查询结果里所有下划线字段都会映射失败返回的实体类里全是NULL而代码本身不报错。版本问题上强烈建议用MyBatis-Plus的Spring Boot 3版本对应关系比如Spring Boot 2.x要用mybatis-plus-boot-starter的3.5.x分支Spring Boot 3.x则要引入mybatis-plus-spring-boot3-starter。如果你的项目用了mybatis-plus-extension和mybatis-plus-core版本号要完全一致不然运行期会出现ClassNotFoundException或NoSuchMethodError这类错误都是启动时能发现的但很多人只改了starter依赖没改extension依赖导致启动时看着正常一调用通用方法就崩。3.3 分页插件替换要写成独立版本验证案例老项目用PageHelper的替换成MyBatis-Plus分页插件后必须单独写一个验证项目把旧项目里所有出现分页的SQL全部跑一遍。要验证的不是“页数对不对”而是“count语句对不对”。MyBatis-Plus分页插件对count的优化逻辑是帮你自动生成SELECT COUNT(*)但它取决于第一条SQL的写法。如果你的SQL里有DISTINCT、GROUP BY、UNION这类关键字建议手动设置分页插件的optimizeCountSqlfalse让count语句走最原始的包一层方式。性能差点但结果准确。这里有个特别实际的建议把每一条涉及分页的老SQL的explain结果导出存档替换后再导一次对比索引命中和扫描行数。因为自动生成的count SQL一旦把外层包上MySQL的优化器很可能漏掉原来子查询里生效的索引线上数据量一大这个差异就变成秒级延迟。3.4 全面排查字段类型和自动填充的隐藏绑定实体类里的TableField注解有个exist属性默认是true。如果老项目里的SQL是SELECT *实体类里有些非表字段比如临时计算用的totalCount替换前没标existfalse那么MyBatis-Plus生成SQL时会把这个字段当成真实列insert和select都会报“Unknown column”错。相反表里有字段但实体类里没声明MyBatis-Plus自动生成的SQL就不会带这个字段如果该字段又没默认值insert直接失败。业务字段的填充逻辑也要清一遍。像创建时间、更新时间、操作人这类的字段如果以前是数据库端设置的替换后不要启用MetaObjectHandler否则两套时间源会打架。如果以前是应用层设置的那就把TableField(fill FieldFill.INSERT)和FieldFill.INSERT_UPDATE标注好同时实现MetaObjectHandler统一填充。这个工作必须在迁移前做完因为很多历史数据的时间字段格式是混的有DATETIME也有TIMESTAMP自动填充一旦介入排序结果可能直接变化。3.5 提前约定“自调用不走代理”的规则MyBatis-Plus的IService接口提供了saveBatch、updateBatchById这些批量方法它们内部是通过SqlHelper获取当前代理对象来执行SQL的。这里有个特别容易踩的坑如果你的业务类继承ServiceImpl然后在同一个类内部直接调用this.saveBatch()那这个调用不会经过MyBatis-Plus批量优化逻辑而是走普通Mapper的逐条执行。原因是Spring AOP代理链路在自调用时失效。如果你用模板方法或者事务边界调用批量方法一定要在Spring容器里注入Service的代理对象来调而不是在类内部this.xxx()。批量操作还有一个隐患是默认批次大小。MyBatis-Plus的saveBatch默认批量大小为1000不要以为所有批量调用都能一次提交如果传入的List是1万条它会分批执行。这些分批操作如果在一个事务里那没问题如果不在事务里中途某批失败前面的批次已经提交数据变成部分成功状态。替换后批量方法的事务边界和以前完全不同必须仔细核对原来XML里的批量SQL是不是在同一事务里。4. 迁移实操从搭建对比环境到灰度验证4.1 搭建一个最小对比环境先把SQL日志拉平替换前强烈建议搭一个独立的迁移分支用同一份数据库快照跑两套代码一套旧MyBatis一套新MyBatis-Plus。配置文件里都打开SQL日志用p6spy或者MyBatis自带的log-impl: org.apache.ibatis.logging.stdout.StdOutImpl输出完整SQL。然后把核心接口挨个调一遍把SQL和执行时间都记录下来。对比时不要只对比“是否返回相同结果”还要对比“SQL执行计划是否发生变化”。这里有三个重点第一原来用WHERE条件走索引的自动生成的SQL是否还能走到那个索引第二原来自定义resultMap处理的一对多映射换成MyBatis-Plus的TableField(existfalse)后是否出现N1查询第三原来XML里where标签自动拼接的AND/OR逻辑换成Wrapper后是否产生多余的空条件导致索引失效。我在实际迁移时还会额外做一次慢日志采集把旧环境两周内的慢SQL拉出来替换后同样的SQL再跑一遍看有没有变成新的慢SQL。这个动作虽然费时间但比上线后手工排查省事一百倍。4.2 配置文件的差异处理最容易忽略的就是这些MyBatis-Plus的配置项散落在application.yml和JavaConfig里需要注意的不只是mapUnderscoreToCamelCase还有log-impl、type-aliases-package、configuration。很多老项目没有显式配置type-aliases-package而是靠MapperScan包扫描自动注册。替换后如果MybatisPlusInterceptor配置类的位置不对分页插件和乐观锁插件不会被加载代码却能正常启动直到调用分页方法时才报错。这里贴一个实际可用的配置示例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.stdout.StdOutImpl global-config: db-config: id-type: auto logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0注意logic-delete-field这里配的是全局字段名如果每个实体类的前缀后缀不统一全局配置会把你手写XML里的deleted字段也改掉。更稳妥的做法是在实体类上用TableLogic单独指定不要开全局配置否则你手写的WHERE deleted0会被自动改成别的字段排查起来极其痛苦。JavaConfig里分页插件配法如下Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(500L); interceptor.addInnerInterceptor(pagination); return interceptor; } }这个配置里有几个重点第一DbType必须和你数据库类型对应不要用OTHER第二setMaxLimit(500L)这个限制看业务需求旧系统如果有一页取几万条的导出逻辑一定要调整否则替换后导出功能直接报分页溢出第三如果你的项目同时有多个数据库源这个Interceptor要按数据源分别注册不能共用一个Bean。4.3 灰度上线怎么设计避免一上来就全量覆盖替换框架的灰度策略和普通功能上线完全不同。别想着“替换后所有接口都验证一遍再全量发”这种项目根本做不到因为你还有很多老SQL没跑过。稳妥的方式是按读接口和写接口分开灰度。第一批灰度只放开那些单表查询、主键查询、简单列表分页接口观察SQL日志和慢查询。第二批再放开单表insert、update、delete接口重点看字段填充和逻辑删除是否正确。第三批才放开复杂查询和批量写入接口。每一批灰度的前置条件都是日志对比结果无差异。如果上线窗口比较短还有一个更保守的做法把MyBatis-Plus当成一个新Mapper包的补充而不是替换旧的Mapper。也就是说新增的接口用MyBatis-Plus老的接口原封不动。等项目跑稳定一个版本后再把老接口逐个往新风格迁移。这样的好处是风险完全可控坏处是项目里存在两套风格需要团队约定好边界。我个人在真实项目里更倾向这种做法因为“替换”这件事的本质不是框架升级而是技术债清理技术债清理最忌讳的就是一次性推倒重来。5. 高频故障实录与排查方法5.1 分页查出来总条数对但数据错乱这种现象大概率是慢字段映射问题而不是分页SQL问题。MyBatis-Plus分页插件对查询的数据部分使用的是Page对象分装结果但如果你原来在XML里写了resultType是Map而实体类里没有对应的TableField注解分页结果里的字段顺序和旧版本不一致前端渲染就会错乱。排查方法很简单把分页插件生成的SQL和原始SQL各跑一次对比到LIMIT 0,20的列顺序。如果不一致需要在XML里显式指定resultType或给实体类补全TableField。另一个隐藏点在于分页插件默认会帮你加一个ORDER BY吗不会它是按原SQL顺序执行。如果原SQL没有order by只是靠自增主键排了一下数据库数据变更后分页就会出现重复和遗漏。这不是MyBatis-Plus的问题但很多人替换前没发现老的SQL本身就缺order by上线后就把锅甩给分页插件。5.2 Wrapper条件拼错导致的全表更新QueryWrapper和LambdaQueryWrapper看着好用但有一个非常致命的点如果你传入的条件字段在实体类里没有对应列MyBatis-Plus构建SQL时会直接忽略这个条件而不是报错。比如你写LambdaQueryWrapperUser().eq(User::getStatus, null)MyBatis-Plus默认会把null条件过滤掉于是最终SQL变成UPDATE user SET name?没有任何WHERE条件。这在批量更新时直接变成全表更新。规避方法是在构建Wrapper前做统一参数校验或者直接开启MyBatis-Plus的block-attack-sql配置mybatis-plus: global-config: block-attack-sql: true这个配置开启后遇到update和delete语句没有WHERE条件时会直接抛异常能挡住最严重的那类事故。但仍然不要依赖这个开关Wrapper拼接条件时一定要用eq(条件不为空, 字段, 值)这种带boolean参数的重载方法。5.3 IService自调用方法导致的事务边界失效ServiceImpl的自调用问题前面提过这里给一个具体排查方向。如果你调了saveBatch但发现真实执行的SQL是一条一条insert而且日志里没有出现Preparing: INSERT INTO table VALUES ...这种批量SQL基本就是自调用导致的。原因在于IService接口的实现类是在代理对象上执行批量操作的自调用没有走代理底层退化了。解决办法有两种第一种把批量操作提到Controller层通过Spring注入的Service代理来调用第二种在Service内部通过SqlHelper.execute获取当前代理对象再调用。更推荐第一种因为代码可读性好。如果项目里大量使用this.getBaseMapper().updateBatchById()这种写法一定要重构否则批量更新操作在并发场景下很容易出现部分成功到时候排查事务问题的时间和返工成本远大于当初写代理调用的成本。5.4 自定义SQL里deleted字段被偷偷改掉这个问题最隐蔽坑过很多人。如果你在实体类里定义了TableLogic注解的字段MyBatis-Plus会对所有SQL生效包括你自己手写在XML里的SQL。比如你原本在XML里写update idupdateUserStatus UPDATE user SET status #{status} WHERE id #{id} /update如果实体类里deleted字段标了TableLogic这个SQL会被改写成UPDATE user SET status #{status} WHERE id #{id} AND deleted 0这看起来是好事但如果你在另一个接口里要清理已删除数据比如定时任务里物理删除所有deleted1的过期数据而你直接用自定义XML去执行deleted1这个条件正好把逻辑删除的数据漏掉数据永远清理不掉。所以我现在的习惯是手写的自定义SQL一律不依赖MyBatis-Plus的逻辑删除改写自己写deleted0或deleted1条件实体类上的TableLogic只留给通用方法。如果项目里逻辑删除字段不是全局统一的建议完全不要用MyBatis-Plus的logic-delete配置直接用SQL条件控制反而简单可控。5.5 主键策略不一致导致插入失败或自增ID错乱老项目数据库主键如果是自增的替换时一定要在实体类主键上标TableId(type IdType.AUTO)。如果漏标MyBatis-Plus默认策略是ASSIGN_ID会生成一个雪花ID插入和你数据库自增主键直接冲突。更麻烦的是如果你把生成的雪花ID设置到实体对象里再通过这个ID去查数据查出来的是完全另一条记录。这个问题出现在很多“看起来没毛病”的迁移里因为本地库数据量小插入后回显主键偶尔碰巧能用一上生产加上并发就乱了套。如果项目里有的表是自增主键、有的是业务主键那就在每个实体类上单独标注不要用全局配置一刀切。全局配置适合新项目老项目里历史表千奇百怪一个全局策略根本覆盖不了。6. 几点个人经验送给想动手替换的人框架替换不丢人但替换前不评估、替换中不对比、替换后不回归那才是真正的坑。经历过这次事故后我给自己定了几条规矩第一任何框架升级都必须有独立的对比测试环境不能光靠线上验证第二SQL日志在切换窗口内要全量留痕方便事后回溯第三老代码里所有自定义XML方法在替换初期一律保留不允许为了“统一风格”提前删除第四批量操作和分页操作这类跟执行计划强相关的功能必须压测到线上数据量级才能上线。我还有一个非常实用的技巧替换前先在旧项目里把MyBatis的SQL日志用某个固定前缀打满一个月比如LEGACY_SQL:替换后把新日志打上NEW_SQL:然后用脚本做一次字符串比对。两条SQL只要有一处不同就自动标红。这个方法不需要什么高级工具一条grep加diff就够了但当月标红的内容能直接告诉你哪些SQL被框架悄悄改写很多潜在风险在上线前就能被发现。最后再说一个容易被忽略的小细节替换框架后不要急着删掉旧版本的依赖和注释代码至少要保留一个发布周期。这不是让你两边都加载而是保留一份可回滚的基线。真正上线时如果发现问题回滚一个依赖版本比重新改回几十个Mapper接口要快得多。很多人被组长怒怼其实不是技术不行而是没有给自己留后路。技术债清理这种事稳永远比快重要。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进