
1. 事件复盘一个“看着很干练”的替换是怎么把项目拖进坑里的事情发生在三周前。组里来了个新同事履历漂亮干活麻利入职第二天就开始翻代码。他翻完我们那个维护了两年的订单中心项目之后提了一个建议把项目里的 MyBatis 换成 MyBatis-Plus理由是现在 XML 写得太累单表 CRUD 还要手动维护一堆 SQL效率太低用 MyBatis-Plus 的 BaseMapper 可以“消灭百分之六十的 XML”。他说得很有感染力组长当时没有明确反对只说“先评估一下”。后面的事情大家都猜到了。小伙子花了一个周末把十几个 Mapper 接口全部改掉实体类加上TableName、TableId、TableField注解删除了一百多个 XML 文件里的单表 SQL换成BaseMapper加LambdaQueryWrapper然后提了一个巨大的 PR。上线那天晚上监控群里炸了锅。先是几条核心查询的响应时间从 80ms 涨到了 1200ms紧接着另一个同事发现订单列表页的数据时对时错最后连写入也开始变慢。组长冲到会议室电脑一摔当着全组的面把小团伙案桌上的代码评审报告拍得啪啪响“你告诉我这叫优化线上是给你练手的地方吗”我们复盘的时候其实问题不是 MyBatis-Plus 本身而是这次替换踩到了三个典型雷区第一替换动作只做了“接口等价”没有做“行为等价”验证第二把原本依赖 SQL 细节实现的功能比如缓存、批量写、高级动态查询简单粗暴地套上了框架层封装第三完全没有评估团队现有的开发规范和技术栈边界。这篇文章我不站队纯粹从后端开发和团队协作的角度把我看到的全过程和技术细节拆一遍给想换骨架或者已经在换骨架的团队做个参考。2. MyBatis-Plus 确实让人上头但“方便”是要付出代价的先说公道话。那个小伙子的出发点不是错的MyBatis-Plus 在单表 CRUD、分页、逻辑删除这些高频场景下开发效率确实比手写 XML 高出一截。这也是为什么它能火。但你得先弄清楚它到底是怎么“变快”的快在哪一步慢在哪一步。2.1 BaseMapper 的通用 CRUD效率来自“预生成 SQL”不是“魔法”MyBatis-Plus 的核心是BaseMapperT里面预定义了 insert、deleteById、selectById、updateById、selectList 这些通用方法。你只需要让 Mapper 接口继承它再配上实体类的注解框架启动时就会自动生成一堆 MappedStatement这些 SQL 的雏形本质上还是 MyBatis 那一套只不过 SQL 字符串是由框架按照实体字段拼出来的。你看这小段代码public interface OrderMapper extends BaseMapperOrder { }然后你就能写了Order order orderMapper.selectById(10086L);换成原生 MyBatis你得写一条select * from t_order where id #{id}还要维护 resultMap。对于一张几十个字段的大表省下来的工作量肉眼可见。而且 MP 的updateById默认只更新非 null 字段这意味着你不用为“只更新某几个列”单独写动态 SQL它会在运行时帮你拼set片段。但你要注意一个细节框架只是把 SQL 的“写法”简化了没有把 SQL 的“行为”简化。updateById自动过滤 null 字段在大多数时候是好功能但如果你本身就指望把某个字段更新为 null用它就会莫名其妙地失败。我们的小伙子替换时就遇到了这种场景订单表里有几条数据需要把remark清空原来 XML 里写着update t_order set remark null换成 MP 的updateById之后remark 纹丝不动。他一开始还以为是缓存问题查了半天才发现是框架的默认行为。2.2 LambdaQueryWrapper动态条件省了字符串却把复杂度转移了条件构造器是 MP 另一个“用了就回不去”的功能。比如你要实现一个查询接口入参可能有五六个每个都可空原生 MyBatis 你需要写一坨if test...的 XMLMP 则可以在 Java 里直接拼LambdaQueryWrapperOrder wrapper Wrappers.lambdaQuery(); wrapper.eq(StringUtils.isNotBlank(orderNo), Order::getOrderNo, orderNo) .ge(query.getStartTime() ! null, Order::getCreateTime, query.getStartTime()) .le(query.getEndTime() ! null, Order::getCreateTime, query.getEndTime()) .orderByDesc(Order::getCreateTime); ListOrder list orderMapper.selectList(wrapper);这段代码确实比 XML 直观而且 lambda 引用不会出现字段名拼错的问题。但请注意它把“SQL 可控性”交出去了一半。像上面的条件可能还好一旦遇到and和or混合、子查询、exists、group by having这类复杂逻辑QueryWrapper 写出来的东西读起来非常痛苦。更麻烦的是因为你无法直接看到 SQL改完条件之后必须跑到控制台去看日志才能确认最终执行的语句长什么样。放在小项目里这不是问题但对于订单这种加了大量索引、数据量大、线上 SQL 要做 Review 的项目这层封装会变成一个“黑盒”。2.3 分页、乐观锁、逻辑删除MP 的“全家桶”真的都适配你的场景吗MP 另外三个名声在外的能力是分页插件、乐观锁插件和逻辑删除。分页插件做得很聪明你写selectPage它前面会拼接一个 count 查询后面拼上limit对于大部分列表页来说体验很好。乐观锁也需要一个插件然后在实体上放个Version字段更新时自动带上version version 1。逻辑删除则是给实体加TableLogic然后所有查询自动追加deleted 0条件。听着很完美对吧但上线事故告诉我们MP 的逻辑删除在统计类和关联查询时是个大坑。我们项目里有一类报表需要 join 多张表做聚合原生 SQL 会显式写死where o.deleted 0。换到 MP 之后selectList、selectPage这些方法会帮你自动加条件但如果你用自定义注解 SQL 或者 XML 里的 join 查询关联了加了TableLogic的表MP 不会自动改写你的 SQL于是同一个字段有的查询加了deleted0有的查询没加最后统计数字差了好幾千条。这种“隐身级别的数据错误”比报错难查一百倍。2.4 为什么“效率提升”在上线后反而成了隐患我再补一刀。MP 提效的前提是你的业务足够规矩实体和表结构一一对应SQL 都是单表才能顺手。而我们项目里大量存在报表、复杂关联、历史数据归档、跨库查询这些是 XML 原生 SQL 才能表达清楚的东西。小伙子的替换只覆盖了“看起来像 CRUD”的部分——也就是他理解的百分之六十剩下的百分之四十他为了“统一风格”也硬生生用框架 API 重写了一个看起来很诡异的版本。这导致那些原本能用索引命中的查询被框架生成的 SQL 打偏了全表扫描加文件排序慢查询日志里刷了一整页。说白了任何 ORM 增强框架做的都是“常见场景的约定优于配置”一旦你的场景不常见它节省的时间会连本带利还回去。3. 上线后真正翻车的四个技术点缓存、SQL 注入、批量写、TypeHandler接下来是最干货的部分。我按照排查顺序把这次事故里真正伤筋动骨的四个点逐个拆开。这些坑不只出现在替换场景哪怕你新项目直接用 MyBatis-Plus一样可能踩到。3.1 一级缓存与二级缓存被 MP 的默认配置改变搞出的脏读第一个事故现场是订单详情数据错乱。某个接口的响应一会儿是新数据、一会儿是老数据刷新页面居然会来回跳。我跟小伙子首先排查的是数据库主从延迟后来发现不对因为直接查库永远是最新值只有通过应用查才会乱。最终定位到了 MyBatis 的缓存机制。原生 MyBatis 里一级缓存是 SqlSession 级别二级缓存是 namespace 级别需要你在 XML 里显式开启cache/。我们原来的项目是开了二级缓存的而且在一些低频查询的 Mapper 上做了自定义Cache实现把热点缓存放到 Redis。MP 的很多默认行为会打破这套缓存协调机制比如 MP 的selectById走的是它预定义好的 MappedStatement如果你原来的 XML 里定义了同样的 ID两个 statement 的二级缓存 key 可能错开导致缓存里存了两份不同的数据。再加上 MP 的 BaseMapper 默认不继承你手动写的cache/配置或者 cache-ref 引用的关系在替换时被连带删掉了缓存直接集体失效或发生部分命中看起来就是“数据时而新时而旧”。这件事给我一个深刻的教训替换框架之前必须把缓存链路画出来搞清楚哪些 Mapper 开了二级缓存、缓存 key 的生成规则是什么、替换后是否仍然一致。MP 并不禁止你用二级缓存但它的默认行为和你手写 XML 的默认行为是有差异的不同版本差异还很大一定不要想当然。3.2 条件构造器的 SQL 可读性与注入边界review 阶段扛不住的“黑盒 SQL”第二个问题出现在代码评审阶段但真正爆发是上线后。我们有一条核心查询业务上要对主订单和子订单做递归拼接再按某个聚合维度过滤。原生实现用了exists子查询加unionSQL 很长但每一步都能看懂执行计划也经过多年调优。小伙子把这一段改成了 QueryWrapper 的嵌套玩法大概像这样wrapper.and(w - w.exists(select 1 from t_order_item i where i.parent_id t.id) .or().eq(Order::getStatus, CANCELED))这段代码跑起来没问题但上线后执行计划变了。因为框架拼接出来的exists子查询没有带着我们原先手动写的他在from里的表别名作用域优化器选择了一个完全不同的 join 顺序直接绕过了主键索引。当天大批线上订单查询从几十毫秒变成秒级。这里我要强调一个很多人忽略的点QueryWrapper 的字符串参数比如exists、apply、last是不做字段名校验的也就是存在 SQL 注入风险。虽然LambdaQueryWrapper对普通列名做了安全处理但只要你有类似需求仍会忍不住用inSql、apply、last(limit 1)这种原始 API这些一旦拼接用户输入攻击路径就有了。所以团队里如果要用 MP必须规定Wrapper 里不允许出现任何字符串拼接的用户输入复杂的 SQL 一律进 XML不许用 apply/last 硬凹。3.3 批量操作里的“隐形循环”和 SQL 超长rewriteBatchedStatements 被架空了第三个问题是写入变慢。排查慢查询日志发现数据库里的批量插入变成了大量单条 insert。原因很简单MP 的ServiceImpl.saveBatch内部实现其实是 for 循环里执行 N 次单条 insert只不过外层套了个批量 SQL 的壳。具体来说它也不是完全逐条而是根据maxBatchSize分组成批执行但默认的ExecutorType.SIMPLE并不会触发 JDBC 驱动层的rewriteBatchedStatements优化。什么是rewriteBatchedStatements这是 MySQL JDBC 连接串上的一个参数设置为 true 后像ps.addBatch()提交的 N 条同构 insert 会被驱动重写成一条insert into table values (...),(...)网络往返次数从 N 次降为 1 次。但 MP 的saveBatch并不是ExecutorType.BATCH除非你在 yml 里额外配置默认执行器类型否则它就是一条一条执行。我们这个项目每天凌晨有定时任务要入库几万条订单明细替换之前手写 XML 利用 SQLSession 批处理插入一般在几秒内完成替换之后同样数据量跑了将近一分钟直接拖垮了下面的统计任务。另外还有一个坑MP 的saveBatch在极端情况下会把生成的 SQL 拼得非常长超过数据库max_allowed_packet后直接报错。我们那晚还遇到过 MySQL 的PacketTooBigException排查过程非常狼狈。如果你确定要用 MP 的批量接口建议先压测并且确认连接串上把rewriteBatchedStatementstrue打开同时调小maxBatchSize别用默认 1000。3.4 TypeHandler 与自定义类型映射越“省事”的自动映射越容易失控第四个问题很隐蔽甚至大部分开发者都不知道自己踩过。我们订单状态字段存的是 int代码里用枚举OrderStatusEnum接收原来 XML 里为它配了一个自定义 TypeHandler完成了从 int 到枚举的转换。MP 替换后小伙子觉得这个 TypeHandler 太麻烦直接把实体字段类型改成枚举想靠 MP 默认的枚举映射来处理。MP 对枚举是有约定的可以实现IEnum或者在application.yml里配置default-enum-type-handler。这两种方式行为和原生 XML 的 TypeHandler 并不完全一致尤其是处理EnumTypeHandler的name()与ordinal()的取法以及反序列化时的 null 值策略。结果就是某些历史数据里相当一部分枚举值是新增的没有注册到 MP 的枚举缓存里读出来直接变成了 null导致订单状态显示成“未知”下游对账全面错乱。再往深一层说MP 对 JSON 字段有JacksonTypeHandler你可以在字段上标TableName(autoResultMap true)加TableField(typeHandler JacksonTypeHandler.class)实现自动把数据库里的 JSON 字符串映射成对象。这个能力确实好用但它会把映射细节和字段绑死在实体里。如果有一天你想换序列化框架或者某个接口需要不同的 JSON 结构你就得动实体类牵连面极大。所以我一直建议对于复杂类型映射保留显式的 XML TypeHandler 更可控。4. 线上排查的完整链路从慢日志一路挖到框架行为差异我想把这部分单独拿出来写是因为排查过程中体现出来的思路比事故本身更值钱。如果你以后也遇到“框架替换后什么都变了”可以照这个链路走一遍。4.1 第一现场监控看板和慢 SQL 日志里的异常信号事故当晚监控看板先报警的是 P99 延迟。我们后端用的监控系统能直接拉出每个接口的 trace第一眼看到的是订单详情接口突然出现大斜率上涨同时数据库慢查询日志里刷出来几条执行了五六秒的 SQL。SQL 语句看着似曾相识就是订单列表带状态的查询但 select 出来的列比之前多了不少。这里有个细节原始 XML 里面 select 了 12 个字段MP 的selectById和selectList会默认 select 实体的全部字段包括后来加的冗余大字段和 text 类型字段。你以为接口没变实际上框架默认帮你多查了三个大字段传输量直接翻了倍。所以慢的原因首先是数据量不只是执行计划。4.2 逐条对比替换前后的 SQL从 explain 结果找差距拿到 SQL 后我让小伙子把每个 Mapper 改之前的 XML 和改之后的 SQL 打印出来一条条做 explain。这一步是最枯燥也最有效的。比如订单详情那条原来 XML 对订单号字段加了强制索引force index(idx_order_no)因为历史原因这个表上有两个索引偶尔会被优化器选错。MP 生成的 SQL 不可能认识你的force index它只能靠优化器猜恰好在数据分布变化后选了一个错的。这就是“框架默认行为覆盖了你多年的 SQL 调优经验”的典型案例。两边的type、key、rows一对比哪些查询出现了全表扫描哪些排序用了 filesort立刻就摆在那里了。这里不需要什么高级工具就是你把 SQL 捞出来EXPLAIN一下用数据说话。4.3 深入框架源码自动填充、逻辑删除、乐观锁到底替你加了什么到这一步还只是定位到了现象没定位到机制。真正挖到根子上的是翻了 MP 的源码。比如逻辑删除我们知道它是在条件里追加deleted 0但你知道它是在哪里追加的吗MP 在BaseMapper的方法上做了拦截通过TableInfo把实体里TableLogic字段内置成 SQL 片段。你自定义的 XML SQL 和selectById是两个完全不同的生成路径所以忽略它太正常了。还有一个我们忽略的点自动填充。项目里订单有created_at和updated_at字段原来 XML 里在 insert 语句中显式写now()update 语句显式写updated_at now()。小伙子看到 MP 有TableField(fill FieldFill.INSERT_UPDATE)自动填充就把 SQL 里的时间赋值删了改成 Java 层填充。看着没问题结果上线后发现凌晨的定时任务批量回刷数据时因为走的是updateById或者 wrapper 更新的旁路自动填充没有被正确触发所有回刷数据的更新时间变成了默认值。后来查代码才发现MP 的自动填充要生效必须有MetaObjectHandler的实现而且只对通过框架方法执行的更新生效并不保证覆盖自定义 SQL。4.4 修复方案的灰度抉择哪些 Mapper 回退 XML哪些保留 MP排查清楚之后当天晚上的修复策略很简单凡是被监控点名的慢 SQL Mapper原样回退到 XML崩溃的缓存链路全部回到原来的自定义 Cache批量写入也恢复成原生 SqlSession 批处理。剩下那些确实没问题的单表简单查询我们保留了 MP。这个过程一定要克制。我发现很多团队一被坑就“全面回滚”把 MP 整个抽掉这又有点浪费。正确做法是给每个 Mapper 打标签写操作多还是读操作多有无特殊调优有无自定义类型映射有没有 join有没有大字段凡是碰了这些的留在 XML纯粹selectById、selectPage、状态更新用 MP 没问题。灰度发布时先只放量一个接口看监控曲线稳了再放第二个千万别搞一键全量。5. 共存不是妥协而是把工具放在它该在的位置事故处理完后组里开会做了一个决定MyBatis 和 MyBatis-Plus 可以共存但要立规矩。这个决定不是和稀泥而是基于一个非常实际的理由——工具使人高效的前提是人和工具都清楚边界在哪里。5.1 技术选型要先回答三个问题而不是看谁嗓门大有人在群里争执“MP 到底行不行”我后来用三个问题终结了争论这里也分享给你这个查询是单表操作还是多表关联单表优先 MP多表关联、统计报表、递归查询原生 XML 优先。这个 SQL 是否经过人工调优force index、hint、特定 join order如果是别动它让 MP 去碰一条被调优过的 SQL 就是灾难。团队成员的 MyBatis 熟练度怎么样如果多数人只写过 MP 的 Boilerplate出了事根本看不懂 SQL那就不是 MP 适配项目的问题是技能问题换什么工具都白搭。这三个问题直接决定了一个文件该用哪种风格。另一个很重要的是不要为了“统一”去消灭 XML。项目里技术栈“统一”不是目的线上不出事才是目的。一个一百个 XML 文件但每个都经过调优的项目比一个全是 MP 但动不动全表扫描的项目健康得多。5.2 共存的落地配置一个 SqlSessionFactory 同时认两种 Mapper有朋友肯定会问一个项目里 MyBatis 和 MyBatis-Plus 能共存吗当然能而且官方也没说不让你混用。核心是别建两套 SqlSessionFactory一个 Spring Boot 项目里只需要一个 SqlSessionFactoryMyBatis-Plus 的MybatisPlusAutoConfiguration会自动拾取所有 Mapper 接口包括你 XML 里定义的。实用的配置长这样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: assign_id logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0注意mapper-locations一定要指向你 XML 所在的目录因为 MP 的启动器继承了 MyBatis 的配置方式只是把一些自动化做掉了不会默认扫描你的 XML。拦截器方面如果你用了 MyBatis 的分页插件或者其他自定义拦截器要小心 MP 的MybatisPlusInterceptor会插一脚。比如我们项目原本有个 SQL 审计拦截器会在 Executor 层记录全量 SQLMP 的拦截器链和它的执行顺序如果不匹配可能导致你自定义拦截器拿不到最终 SQL。解决方法是实现Interceptor时注意Intercepts注解签名和 MP 的MybatisPlusInterceptor分开处理不同签名避免冲突。5.3 防止“下次再有人偷偷替换”从 PR 规范和基线检查入手事故之后我在团队的 PR 模板里加了几个检查项是否变更了 ORM 依赖是否删除了 XML是否调整了缓存配置凡是涉及这三项的 PR必须附带一份替换前后的 SQL diff 和 explain 对比。听起来很死板但真能拦住很多“看着很干练”的骚操作。更狠一点的团队已经在 CI 里做了 SQL Baseline 检查就是把核心接口执行过的一组 SQL 抓成基线框架升级后自动 diffSQL 如果发生变化就立刻在流水线上标红。这个思路成本略高但对订单、支付这种核心系统非常值。我们暂时只是定期人工抽检因为 CI 方案还在排期。5.4 给所有想动老项目的新人一句真心话最后说一句可能不太好听但很实在的话老项目里那些“啰嗦”的 XML不是因为它老而落后而是因为它每一行都踩过线上真实的坑。你想提升效率、想用更现代的方式重构这本身值得鼓励但前提是先把现状里每个“多余”的配置都弄清楚为什么存在。你可以在新模块大胆尝试新工具但不要着一张刚入职的地图去挖别人跑了多年的隧道。我现在回头看如果小伙子上线前先拉一个表——左边是替换前的 SQL右边是替换后的 SQL再找组长花十分钟对一遍那次事故也许根本不会发生。工具本无对错错的是我们默认“等价替换”等同于“行为一致”而实际上框架帮你省掉的每一行代码都在背后悄悄改写了你的 SQL 语义。