资讯详情

MyBatis-Plus核心原理与企业级最佳实践:从CRUD到生产优化

📅 2026/10/6 13:40:15 | 华诺云谱 👁 阅读
MyBatis-Plus核心原理与企业级最佳实践:从CRUD到生产优化
大概两年前我接手了一个遗留系统DAO 层全是手写的 JDBC 模板和 XML SQL一个订单查询能拼接出十几行动态条件Service 层一大半代码在做数据搬运。后来换到新团队发现新项目里几乎没人再手写单表 CRUD 了MyBatis-Plus 已经把这类工作压到了零 SQL的程度。这篇文章不聊官网文档里已经写烂的 API而是把我这些年从会用到用对再到用出价值的过程做一个系统性的拆解包括它的核心原理、生产级配置、性能优化方向以及那些排查到半夜的高危坑位。无论你是刚开始接触 MP 的初中级开发还是已经在项目里使用但觉得也就那样的资深工程师这篇文章都会有一些参考价值。MyBatis-Plus 核心原理与企业级最佳实践从 CRUD 到生产级优化全指南1. 从繁琐 CRUD 到声明式数据访问MyBatis-Plus 的价值到底在哪1.1 传统 MyBatis 开发的三个老毛病先聊聊 MyBatis-Plus 出现之前我们用原生 MyBatis 写业务时的真实体感。第一个痛点是 SQL 维护量大。一个普通的用户模块起码要写 insert、deleteById、updateById、selectById、selectList、selectCount 这六条基础 SQL每个实体来一套换成二十张表就是一百二十条几乎一模一样的 SQL。大部分时间我们在做的都是复制、粘贴、改表名、改字段名这种机械劳动不会带来任何成就感反而容易出错。第二个痛点是结果映射的重复劳动。MyBatis 的resultMap写起来又臭又长表字段是下划线风格、实体字段是驼峰风格时每一张表都要在mapUnderscoreToCamelCase之外反复声明映射关系。一旦表结构调整resultMap可能比 SQL 本身还难改。第三个痛点是分页。原生 MyBatis 分页要自己算LIMIT参数或者引入 PageHelper 插件。PageHelper 本身挺好但它通过 ThreadLocal 传递分页参数在异步场景、多数据源切换场景下经常出现分页串了页码丢失之类的诡异问题排查起来特别费劲。这三类痛点叠加在一起导致单表 CRUD 这种本该无脑的代码成为项目里 bug 率最高、最消耗精力的部分。MyBatis-Plus 正是冲着这三个问题来的内建通用 SQL、统一映射策略、内置分页插件把基础数据访问变成了一种声明式的操作——你只需要告诉它我要查什么剩下的 SQL 生成和参数绑定它自己搞定。1.2 单表 CRUD 的质变BaseMapper 与条件构造器MyBatis-Plus 的核心体验可以从一个最小的例子说起。定义一个实体类TableName(t_user) public class User { TableId(type IdType.ASSIGN_ID) private Long id; private String name; private Integer age; private String email; }再定义一个 Mapper 接口public interface UserMapper extends BaseMapperUser { }到这里UserMapper就已经拥有了 insert、deleteById、updateById、selectById、selectList、selectCount 等十几个方法。加上条件构造器Wrapper查询逻辑可以几乎完全用 Java 代码表达ListUser users userMapper.selectList( new LambdaQueryWrapperUser() .eq(User::getAge, 25) .orderByDesc(User::getId) .last(LIMIT 10) );这一小段代码背后发生的事情值得一提LambdaQueryWrapper利用 lambda 的方法引用通过SerializedLambda反推出实体字段名age、id再在 SQL 注入阶段拼接成WHERE age ? ORDER BY id DESC LIMIT 10。整个过程是类型安全的字段名写错了在编译期就会报错而不是运行时才发现 Unknown column。相比手写 XML代码即 SQL 的方式还有一个隐形的好处改动字段名时反射式的 SQL 替换会同步更新不太容易出现实体改了、XML 忘了改导致的运行时炸裂。1.3 什么场景适合 MyBatis-Plus什么场景建议绕开MyBatis-Plus 不是银弹这点必须说清楚。它最擅长的是单表 CRUD以及简单多表场景下的主表查询 从表拼装。比如用户管理、订单管理、配置管理、埋点数据查询这类业务一张表对应一个实体查询条件无非就是等值、范围、模糊、排序MP 基本能覆盖 80% 以上的访问需求。但以下三类场景我建议你重新评估复杂多表 JOIN。MP 虽然提供了TableName里的 join 写法其实是个伪 join以及apply拼接自定义 SQL 的手段但本质上是把 JOIN 塞进 Wrapper 里拼字符串可读性和维护性都很差。这种场景不如老老实实写 XML还能做 SQL 审查。报表统计类 SQL。多级 GROUP BY、窗口函数、case when 嵌套这些用 Wrapper 表达会非常痛苦。遇到这类需求直接 XML 写原生 SQL 是更务实的选择。千万级以上的大表全量更新。MP 默认的 updateById 会带乐观锁判断如果配置了并且每次都会把整行字段 set 进去性能上不如手写UPDATE ... SET的精简语句。大表批量更新时要么自定义 SQL要么用专门的批量能力后面会展开讲。所以我的建议是让 MyBatis-Plus 负责 80% 的无脑 CRUD把确实需要手写 SQL 的场景留给 XML 和注解 SQL两边各管一摊而不是要么全用、要么全不用。2. 核心原理拆解为什么 BaseMapper 能零 SQL 完成增删改查很多开发者用了很长时间 MP但仍然不知道BaseMapper里的方法是怎么来的。这一章我会把原理链路拆开你会发现在不知不觉中框架替你做了很多事。2.1 从 Mapper 接口到代理对象的完整链路先说结论BaseMapper里的方法并不是 MyBatis 运行时动态生成字节码去实现的而是通过SQL 注入器SqlInjector在 Mapper 接口加载阶段就把对应的 SQL 语句解析、注册进了 MyBatis 的配置对象中然后再走 MyBatis 标准的 MapperProxy 代理机制执行。具体链路大致是这样启动时MapperScan扫描到UserMapper接口通过MapperFactoryBean注册到 Spring 容器。在解析 Mapper 的过程中MP 的MybatisMapperAnnotationBuilder会额外调用SqlInjector.inspectInject遍历BaseMapper中定义的抽象方法。针对每个内建方法比如selectByIdAbstractMethod子类会生成一段 SQL 脚本比如SELECT id,name,age,email FROM t_user WHERE id#{param1}构建成MappedStatement注册到 MyBatis 的Configuration中。业务代码调用userMapper.selectById(1L)时走的仍然是 MyBatis 标准的MapperProxy动态代理根据方法名找到已注册的MappedStatement执行 SQL 并完成结果映射。所以用户感知是我什么都没写实际上是框架在启动阶段帮你把 SQL 全部生成并注册好了。这也解释了为什么BaseMapper里有的方法如selectByMap需要传特定的参数结构因为它们对应的是预先定义好的 SQL 模板。2.2 SqlInjector 与 AbstractMethod内建 SQL 的铸造车间MP 的 SQL 注入器接口是ISqlInjector默认实现是DefaultSqlInjector。它内部维护了一个AbstractMethod列表每个AbstractMethod负责一个方法论级别的 SQL 生成。拿我们最常用的selectById来说它对应的类是SelectById核心逻辑是通过TableInfo拿到表名和所有字段的映射关系拼出SELECT 全部字段 FROM 表名 WHERE 主键 #{param1}把拼好的SqlSource包装成MappedStatement。TableInfo是 MP 中一个非常关键的概念。它把TableName、TableId、TableField等注解的信息以及字段和列名的映射、主键策略、逻辑删除标记等全部解析成一个结构化的元数据模型。后面所有 SQL 生成、结果映射、条件构造都依赖它。理解了这个机制你就能明白 MP 那些高级功能为什么能生效了逻辑删除为什么对所有查询方法都自动生效因为逻辑删除字段在TableInfo里被标记了selectList、selectById的 SQL 模板生成时会自动拼接AND deleted 0自动填充为什么能在 insert 时补充字段因为Insert方法的 SQL 模板里预留了填充字段的位置执行前由MetaObjectHandler把值塞进去。这就是原理的价值——一旦框架出问题你能顺着链路猜到是哪个环节断了而不是对着报错信息瞎试。2.3 Wrapper 条件构造器从 Lambda 到 WHERE 子句的魔法Wrapper尤其是LambdaQueryWrapper可能是 MP 最令人喜欢的部分但它也是新手最容易误用的部分。我拆一下它的内部逻辑。LambdaQueryWrapper继承自AbstractWrapper。当你调用wrapper.eq(User::getAge, 25) .gt(User::getId, 100) .orderByAsc(User::getCreateTime);实际发生的事情是User::getAge这个方法引用通过LambdaUtils.extract被转换成一个SerializedLambda对象再解析出属性名ageTableInfo把属性名转换成数据库列名age如果有TableField注解或驼峰转换配置这里会做映射条件信息被存入QueryWrapper内部的expression链表节点中同时记录参数占位符和参数值最终执行时getSqlSegment()把整个条件链拼接成WHERE age #{ew.paramNameValuePairs.MPGENVAL1} AND id #{...} ORDER BY create_time ASC。整个过程中你看到的是方法调用实际上是一种内嵌式 DSL 构建器。用 DSL 这个词是因为它的条件表达能力和原生 SQL 基本一一对应eq/ne/gt/ge/lt/le/like/in/between对应各种 SQL 操作符and/or/nested对应逻辑组合apply甚至可以自定义 SQL 片段。我在团队内做过一个约定任何 mapper 方法如果 Wrapper 的条件维护超过四个就必须封装到专门的查询对象Query 对象里不允许在 Service 层散落一大串 Wrapper 调用。理由很简单Wrapper 虽然灵活但它是链式拼接条件多了之后阅读起来像一团毛线以后维护的人很可能是三个月后的你自己会头大。2.4 无状态通用 CRUD 服务基于 DB 工具类的工程化设计前面讲的是 Mapper 层但在真正的企业项目里代码规范往往要求 Service 层不要直接暴露 Mapper而是通过 Service 接口 ServiceImpl 走。MP 提供了IService和ServiceImpl基类我们项目里大量使用。关于无状态通用 CRUD 服务我多说一点。很多团队会封装一个类似这样的静态工具类public class DbUtil { public static T T getById(ClassT clazz, Serializable id) { return SqlHelper.table(clazz).getMapper().selectById(id); } public static T boolean save(T entity) { return SqlHelper.table(entity.getClass()).getMapper().insert(entity) 0; } public static T ListT list(ClassT clazz, WrapperT queryWrapper) { return SqlHelper.table(clazz).getMapper().selectList(queryWrapper); } }这种静态方法 实体类 Wrapper的风格确实非常简洁Controller 里一行代码就能完成 CRUD省去了 Service/Mapper 的传递过程。我在一些轻量级内部管理系统里确实这么干过开发效率极高毕竟那些系统不需要复杂的业务校验和事务编排。但这类设计有两个前提你要心里有数事务边界不好处理。静态方法直接拿 Mapper脱离了 Spring 管理的 Service Bean如果调用方没有事务注解跨表操作很难保证一致性。我的做法是只在查询场景用静态工具类所有写操作必须走 Service 层带Transactional的方法。业务逻辑容易被绕过去。如果团队规范不严大家都图省事直接在 Controller 调静态 CRUD那么 Service 层的校验、日志、权限控制就形同虚设。所以这个模式适合小团队快速交付的独立模块不适合多人协作的核心业务域。3. 从能用到好用生产级 CRUD 的默认配置与扩展在这个阶段项目里 CRUD 已经跑起来了但能跑和扛得住生产压力、经得起代码 review之间还有不小的距离。这一章列出我在配置 MP 时一定会做的几件事每件都是踩过坑之后才总结出来的。3.1 分页插件PaginationInnerInterceptor 的正确打开方式分页插件是 MP 配置里最不能省的一项。它的核心拦截器是PaginationInnerInterceptor需要指定数据库类型Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(500L); interceptor.addInnerInterceptor(pagination); return interceptor; }两个容易被忽略的细节一是MybatisPlusInterceptor的插件顺序。MP 3.4 之后的版本统一用MybatisPlusInterceptor作为容器多个插件通过addInnerInterceptor按顺序添加。顺序会影响执行链建议分页插件放前面乐观锁插件放后面因为分页要改写 SQL乐观锁要处理版本号执行顺序错了可能出现分页 SQL 没生效、乐观锁没拦截到的问题。二是maxLimit 的兜底。如果不设置maxLimit一旦前端恶意传一个pageSize999999SQL 性能瞬间崩盘。我通常会把maxLimit设置成业务允许的最大分页条数比如 200 或 500配合全局异常处理超过就报参数错误。这是一种防御性编程在接第三方接口时必须做。分页插件的原理也不复杂它通过拦截Executor的 query 方法拿到原始的 SQL改写成分页 SQLMySQL 是LIMIT ?再执行 count 查询获取总数最后把结果封装成Page对象。这里有一个性能细节MP 默认的 count 查询会生成SELECT COUNT(*) FROM (原SQL) TOTAL的子查询形式在复杂查询下可能不是最优的。如果你能确定某个查询不需要 count比如下拉加载更多的场景可以用Page构造时传入false来跳过 countPageUser page new Page(current, size, false);3.2 逻辑删除与唯一索引的冲突处理逻辑删除在生产环境几乎是标配——业务数据不允许物理删除删了要能追溯。MP 配置很简单mybatis-plus: global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0但这个方案有个隐藏炸弹逻辑删除字段和数据库唯一索引冲突。比如t_user表的email字段有唯一索引用户删除后这条记录还在表里只是deleted1。此时再注册一个相同邮箱的用户数据库唯一索引直接报 Duplicate entry。这是逻辑删除最常见的翻车点。我的处理方案有两种如果业务允许把唯一索引改成组合唯一索引把deleted字段纳入进去比如UNIQUE(email, deleted)这样同一邮箱最多只有一条deleted0的记录逻辑删除后重复注册不会冲突。如果不方便改索引那就在删除时同步改写业务唯一字段比如email拼一个删除时间戳后缀userexample.com_deleted_1699999999。不过这个方案字段长度会膨胀需要提前规划好。另外逻辑删除还有一个容易被忽略的连带影响MyBatis-Plus 官方默认的delete语句会被改写为 UPDATE底层 SQL 不是 DELETE。如果你有监听 binlog 做数据同步的机制要确认同步组件是否认识这种 UPDATE 删除标记否则另一边可能会漏删。3.3 乐观锁插件版本号机制的业务落地并发更新是生产环境绕不开的问题。MP 的乐观锁插件用得非常普遍配置方式是注册OptimisticLockerInnerInterceptor然后在实体类加Version字段Version private Integer version;它的执行逻辑是执行updateById时生成的 SQL 自动带上WHERE version 旧版本号 AND id ?更新成功后version自动加 1。如果更新过程中别人改了这条记录version不匹配影响行数为 0业务层判断到这个结果就知道更新冲突了可以重试或提示用户。实操中的几个注意点Version字段不能用wrapper方式做条件更新。其实update(entity, wrapper)时MP 的乐观锁不会自动拼版本条件因为有了 Wrapper 之后拦截器不知道主键在哪里。想用乐观锁保证并发安全务必走updateById(entity)。version字段的初始化问题。新插入的数据必须给version一个初始值比如 1否则selectById查出来是 nullupdateById时version null会导致条件永远不成立。我一般会在插入前统一entity.setVersion(0)或者用数据库默认值 自动填充兜底。不要把乐观锁当成万能的并发解决方案。乐观锁适合冲突概率低、更新频繁的场景比如用户修改个人资料。如果冲突概率很高比如热点商品库存扣减乐观锁会导致大量重试这种情况下要么用数据库悲观锁SELECT ... FOR UPDATE要么用 Redis 分布式锁做前置排队。3.4 自动填充MetaObjectHandler 的统一审计字段管理每个业务表几乎都有create_time、update_time、create_by、update_by这类审计字段。手工 set 容易遗漏数据库默认值又不能处理操作人这种业务字段。MP 的自动填充功能正好解决这个问题Component public class MyMetaObjectHandler implements MetaObjectHandler { Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, createTime, LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, updateTime, LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, createBy, String.class, UserContext.getUserId()); } Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, updateTime, LocalDateTime.class, LocalDateTime.now()); this.strictUpdateFill(metaObject, updateBy, String.class, UserContext.getUserId()); } }配合实体类上的注解TableField(fill FieldFill.INSERT) private LocalDateTime createTime; TableField(fill FieldFill.INSERT_UPDATE) private LocalDateTime updateTime;有几个细节值得提醒。第一strictInsertFill这种带strict前缀的方法是有值就不覆盖的。如果调用方手动 set 了字段自动填充不会覆盖它。这一点特别重要比如做数据迁移时历史记录的create_time需要保留原始值调用方只要在插入前手动 setcreateTime填充逻辑就会跳过。如果你用的是setFieldValByName这种老 API它没有这个判断可能直接把手动 set 的值覆盖掉这就是一个坑。第二自动填充要在MyBatis-Plus 的 Insert/Update 方法上才会生效。如果你走了自定义 XML SQL或者用了delete的物理删除填充逻辑不会执行。所以在团队规范里我一般约定涉及审计字段的业务一律走 MP 的insert或updateById自定义 SQL 里不碰这些字段避免两条路带来的不一致。第三updateById默认的字段策略是 NOT_NULL这意味着只更新非空字段。如果你某次业务需要把一个字段置为 nullupdateById默认做不到。要么配置global-config.db-config.update-strategyignored不推荐影响面太大要么用LambdaUpdateWrapper的.set(User::getRemark, null)显式置空。这个特性在清空备注、取消关联这类需求里特别容易踩雷。4. 性能优化从慢 SQL 到批量写入的改造实录CRUD 写多了必然会遇到性能问题。这一章我会讲几个我在生产环境做过的真实优化案例涉及批量写入、分页性能、深翻页、以及 SQL 自动生成带来的隐性问题。4.1 批量插入的真相为什么你在循环里 insert 会慢到怀疑人生新手最常见的操作是在 Service 层写一个for循环逐个调用userMapper.insert(entity)。数据量小还好一旦上千条每一条都要走一次网络往返 SQL 解析耗时直线上升。MP 的IService提供了一个saveBatch方法很多人以为它是一次性插入 N 条实际上它内部是分批执行Override Transactional(rollbackFor Exception.class) public boolean saveBatch(CollectionT entityList, int batchSize) { String sql SqlHelper.getInsertBatchSql(entities); // 实际执行时使用 SqlSessionTemplate batch 模式分批提交 }saveBatch的实现细节取决于版本。在 3.1.2 之前它甚至不是真正的高效批量而是循环调用了批量 SqlSession。3.1.2 之后引入了insertBatchSomeColumn但它不在BaseMapper的默认方法列表里需要自己通过AbstractMethod扩展注入。这也是很多同学发现MyBatis-Plus 的批量插入也就那样的原因——默认的批量能力其实是批量会话模式SQL 仍然是一条条发只是开启了 JDBC 的 rewriteBatchedStatements效率和真正的多值 INSERT 还是有差距。我实测下来针对 5000 条数据做对比方式耗时MySQL 本地说明for 循环单条 insert约 3200ms每条都有独立事务提交最慢saveBatch batchSize1000约 850ms批量 SqlSession依赖 JDBC 驱动批处理自定义 insertBatchSomeColumn多值插入 500 条/次 SQL约 210ms一次 SQL 插入最多 500 组值如果业务有大批量写入诉求比如导入、初始化、日志回放我建议直接扩展一个批量插入方法。做法是继承AbstractMethodpublic class InsertBatchSomeColumn extends AbstractMethod { Override public MappedStatement injectMappedStatement(Class? mapperClass, Class? modelClass, TableInfo tableInfo) { // 构造 INSERT INTO table (col1,col2,...) VALUES (,,,),(,,,)... // 字段过滤掉主键、逻辑删除字段等 } }然后在自定义的SqlInjector里注册它。这个过程不算简单但解决的是从能跑到高效的质变问题。如果你的项目没有特别大的批量写入需求用saveBatch 设置合理的batchSize就够了没必要过度设计。4.2 分页查询的性能隐患COUNT 查询与深翻页问题分页这个功能数据量小的时候无感数据量上来了就容易出问题。第一个隐患是count 查询性能。MP 默认会对原 SQL 做SELECT COUNT(*) FROM (原 SQL) TOTAL的包裹如果原 SQL 里有多表 JOIN 或者复杂的子查询这个 count 可能比真正的数据查询还慢。我在一个报表页面就遇到过数据查询 200mscount 却要 1.2s。优化手段是把 count 语句简化掉比如查一张大表的分页count 完全可以只COUNT(*) FROM 主表 WHERE 条件不需要带 JOIN。在不方便改 SQL 的情况下也可以在业务层做降级——比如列表页只需要有没有下一页可以用page.setSize(size1)拿 N1 条数据返回hasNext靠条数是否大于 size判断完全跳过 count。大部分用户列表页真的不需要精确的总数这种方案能省掉一半的成本。第二个隐患是深翻页。LIMIT 10000, 20这种写法MySQL 需要扫描并丢弃前 10000 行翻页越深越慢。我的建议是结合业务场景选择方案如果是后台管理系统的表格通常只翻前 50 页深翻页问题不明显如果是 C 端列表如消息记录、订单流水不要用页码翻页改用游标/键集分页。简单说就是用上次返回的最后一条记录的id作为下次查询的条件WHERE id ? ORDER BY id DESC LIMIT 20。这样即使翻到第 1000 页MySQL 也能直接走索引定位性能非常稳定。MP 中没有内置的键集分页 API但用LambdaQueryWrapper的.lt(User::getId, lastId)配合orderByDesc完全可以自己实现。4.3 查询字段过多select 子句也要节流MP 默认的selectList会 SELECT 实体映射的全部字段。有些表有几十个字段其中可能有content这种 text 类型的大字段。如果列表页只需要展示几条摘要信息把大字段全量查回来纯粹浪费 IO 和网络带宽。优化方式有两种一种是用select()指定所需列ListUser list userMapper.selectList( new LambdaQueryWrapperUser() .select(User::getId, User::getName, User::getCreateTime) );另一种是把列表查询用的实体单独定义成一个精简的 VO 类Mapper 方法返回ListUserVO。我实践下来后者更干净——VO和表实体分离语义清楚也给后续扩展字段留了空间。但要注意MP 的select映射是按实体类字段名来的VO 类里不要加表里不存在的字段比如计算列否则映射会报错。4.4 SQL 监控与慢查询定位让 MP 的 SQL 裸奔在日志里生产环境最怕不知道 SQL 长什么样。MP 默认打印 SQL 的方式是mybatis-plus.configuration.log-impl: org.apache.ibatis.logging.stdout.StdOutImpl但这个输出太啰嗦而且不带耗时统计。我更推荐在项目里集成 p6spy 这样的 SQL 日志框架专门输出真实 SQL 和执行耗时。这样做的好处是可以看到 Wrapper 最终生成的完整 SQL包括参数替换后的值排查问题时一目了然能看到每条 SQL 的执行耗时慢查询筛选起来方便可以配置只打印超过阈值的慢 SQL避免日志噪音。另外一个硬性要求生产环境的 SQL 日志必须有开关且默认关闭。不然大促高峰期SQL 日志的 IO 就能把磁盘打满别问我是怎么知道的。5. 企业级踩坑实录这些诡异问题排查半天才意识到是 MP 的锅这一章写几个我亲身踩过、或者在团队里帮别人擦过屁股的坑。每一个都是真实案例排查过程多多少少都能给你节省几小时的 debug 时间。5.1 字段映射问题为什么查出来的字段全是 null新同学最常见的一个问题建立了一张t_order表字段是order_no、customer_id实体类里定义成orderNo、customerId然后selectList查出来全是 null。排查思路确认map-underscore-to-camel-case: true是否配置。MP 4.0 之后如果使用 Spring Boot starter这个配置默认是 true但如果你手动配置 SqlSessionFactory很可能没开。确认表字段有没有带下划线。order_no和orderNo的映射依赖下划线转驼峰如果你的表字段本身就叫orderno没有下划线MP 是没办法把它转成orderNo的需要加TableField(orderno)显式声明。确认实体字段是不是被static或final修饰了。MP 在解析TableInfo时默认会忽略静态字段和 final 字段这些字段不会参与映射查出来自然全是 null。这个坑很隐蔽通常发生在拿实体类顺手加了常量的场景。5.2 逻辑删除字段上了唯一索引重复数据插入被拒这个坑我在 3.2 节提到过但值得再强调一次。我们有一个支付回调表trade_no字段有唯一索引用来防止重复回调。后来引入逻辑删除后用户取消订单会把这笔记录标记为逻辑删除。此时同一笔订单再次发起支付插入新记录时trade_no还是同一个唯一索引直接炸了。如果你确定需要逻辑删除 唯一约束共存建议在业务上做额外处理。比较常见的是把唯一索引调整为(trade_no, deleted)的联合唯一索引或者插入前先查询一下是否存在deleted1的同 trade_no 记录存在则把旧记录物理清理或把 trade_no 改掉再插新的。没有一劳永逸的银弹只能结合业务取舍。5.3 条件构造器的线程安全问题Wrapper 能不能作为静态常量复用这是一个设计层面的坑。有人觉得LambdaQueryWrapper就是一个描述对象想着可以像常量一样定义在类里复用节省实例化开销。实际上Wrapper 在生成 SQL 的过程中会维护内部状态参数值列表、条件片段等它不是线程安全的。如果多个线程共用同一个 Wrapper 实例会产生条件拼接串了、参数值错位等问题。而且 SQL 一旦生成Wrapper 里的状态改动已经发生复用确实没有意义。我见过一个生产事故有人把LambdaQueryWrapper定义成static final然后在高并发接口里直接使用结果like的字段随机变成别的字段查出来的数据偶尔错乱。排查了半天才找到原因。所以统一规范Wrapper 永远在方法内部创建用完即弃。它的构建成本很低没必要做复用。5.4 updateById 自动填充与并发更新时间不一致开启自动填充后updateById会自动 setupdate_time now()。这是多数情况下的期望行为但有一个细节在并发场景下如果有两个请求几乎同时更新同一条记录它们的update_time都是自己线程启动的那一刻而数据库最终写入的值取决于最后提交的事务。对于需要精确记录每次更新时间的审计场景比如留存、审批流这个字段的准确性可能不够。更稳妥的做法是让数据库字段本身也配上DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP这样即使 MP 这层没有填充数据库层也会维护一个权威的修改时间。MP 的自动填充更适合填充业务操作人这种数据库字段无法表达的信息。5.5 自定义 SQL 与 MP 特性冲突为什么你的 XML 里没走逻辑删除这也是个高频问题。项目里某个复杂查询写了 XML SQLselect idselectUserWithOrders resultTypemap SELECT * FROM t_user u LEFT JOIN t_order o ON u.id o.user_id /select结果删掉的用户也被查出来了。原因很简单MP 的逻辑删除、自动填充、乐观锁拦截只对框架自身注入的 CRUD 方法生效不会去改写你自己写的 XML SQL。要想在自定义 SQL 里也带上逻辑删除条件你必须自己在 WHERE 里加u.deleted 0或者用 MyBatis 的 SQL 片段来统一拼接。这个点特别重要。不要在心理上默认MP 已经全自动了它不是。框架管的边界就是BaseMapper和IService那套方法出了这个圈责任就在你身上。6. 再聊几个工程化习惯让 MP 项目真正好维护到了最后一个部分我分享几个我们自己团队沉淀下来的工程习惯不一定适合所有人但有参考价值。6.1 开启代码生成器但不要过度依赖它MyBatis-Plus 的代码生成器以及 MyBatisX 插件能根据数据库表结构生成实体、Mapper、Service、Controller这在项目启动阶段能省很多时间。但我对团队有个强制要求生成出来的代码只能作为起步骨架业务逻辑必须人工维护不允许把生成器的产物当成圣旨反复重新生成覆盖。原因很简单一旦你在生成的实体类上加了自己的业务注解比如乐观锁Version、自填充TableField(fill ...)下一次重新生成这些手写配置可能被冲掉。生成器适合做一次性脚手架不适合做反复同步工具。6.2 统一结果封装与异常处理别让 CRUD 裸奔在 Controller 里MP 本身不关心返回值格式但企业级项目一定要统一。我们的做法是全局用ResultT包装Controller 只负责接收参数和调用 ServiceMapper 返回的Page、List在 Service 层转换为对应的 VO/DTO再塞进Result。同时全局异常处理器要把 MP 抛出的一些异常做转换比如DataIntegrityViolationException转为数据已存在或违反唯一约束的业务提示OptimisticLockException转为数据已被他人修改请刷新后重试;MyBatisSystemException转为数据访问异常的统一日志记录。这样 CRUD 虽然基础但出错时用户看到的是清晰的可操作提示而不是一堆异常堆栈。6.3 为 AI 应用场景留一条数据访问快速通道最后说一句跟热词里的 AI 场景相关的题外话。这两年我接触过不少基于大模型做的应用项目它们的共同特点是会话记录、上下文存储、用户偏好配置、知识库元信息等绝大多数都是典型的单表无状态增删改查。这类需求的响应时延要求不高但开发周期被压缩得很紧MyBatis-Plus 这种声明式数据访问模式非常合适。我曾经在一个 AI 对话类应用里用 MP 的IService在不到一小时内把会话存储模块的增删改查全部跑通后面的时间都花在向量数据检索和 Prompt 调优上了。对于 AI 应用这类数据库访问不是核心难点、业务迭代速度才是关键的项目把数据访问层做得足够薄反而能让你把精力集中在真正有价值的地方。6.4 最后分享一个压箱底的小技巧如果你在用 Spring Boot 3.x MyBatis-Plus 3.5.3 以上版本并且某个查询需要同时走分页和多租户插件务必注意TenantLineInnerInterceptor和PaginationInnerInterceptor的注册顺序。多租户需要改写 SQL WHERE 条件分页需要改写 SQL LIMIT顺序错了生成的 SQL 可能只带了租户条件而丢了分页或者分页正常但租户隔离失效。我的固定写法是多租户插件在前分页插件在后。这类隐形顺序问题一旦上线出数据泄漏就不是排查几小时的问题了。还有很多细枝末节没有展开比如TableLogic的全局配置与局部配置优先级、DbType方言差异、id-type雪花 ID 与数据库自增的取舍每一条单独拿出来都值得写一篇。这里说一个我的总判断MyBatis-Plus 本质上是一个把单表数据访问的复杂度封装进框架里的工程化工具它的上限取决于你对 SQL 和 MyBatis 底层机制的理解程度。当你真正理解了它的原理它就从一个黑盒变成一个高度定制化的基座那时候你的项目才算真正进入了企业级使用阶段。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑