大概两年前,我接手了一个遗留系统,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 BaseMapper<User> { }到这里,UserMapper就已经拥有了 insert、deleteById、updateById、selectById、selectList、selectCount 等十几个方法。加上条件构造器Wrapper,查询逻辑可以几乎完全用 Java 代码表达:
List<User> users = userMapper.selectList( new LambdaQueryWrapper<User>() .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中定义的抽象方法。 - 针对每个内建方法(比如
selectById),AbstractMethod子类会生成一段 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对象,再解析出属性名age;TableInfo把属性名转换成数据库列名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(Class<T> 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> List<T> list(Class<T> clazz, Wrapper<T> 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,一旦前端恶意传一个pageSize=999999,SQL 性能瞬间崩盘。我通常会把maxLimit设置成业务允许的最大分页条数(比如 200 或 500),配合全局异常处理,超过就报参数错误。这是一种"防御性编程",在接第三方接口时必须做。
分页插件的原理也不复杂:它通过拦截Executor的 query 方法,拿到原始的 SQL,改写成分页 SQL(MySQL 是LIMIT ?),再执行 count 查询获取总数,最后把结果封装成Page对象。这里有一个性能细节:MP 默认的 count 查询会生成SELECT COUNT(*) FROM (原SQL) TOTAL的子查询形式,在复杂查询下可能不是最优的。如果你能确定某个查询不需要 count(比如下拉加载更多的场景),可以用Page构造时传入false来跳过 count:
Page<User> 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字段有唯一索引,用户删除后,这条记录还在表里(只是deleted=1)。此时再注册一个相同邮箱的用户,数据库唯一索引直接报 Duplicate entry。这是逻辑删除最常见的翻车点。
我的处理方案有两种:
- 如果业务允许,把唯一索引改成组合唯一索引,把
deleted字段纳入进去(比如UNIQUE(email, deleted)),这样同一邮箱最多只有一条deleted=0的记录,逻辑删除后重复注册不会冲突。 - 如果不方便改索引,那就在删除时同步改写业务唯一字段,比如
email拼一个删除时间戳后缀:user@example.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查出来是 null,updateById时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,这意味着只更新非空字段。如果你某次业务需要"把一个字段置为 null",updateById默认做不到。要么配置global-config.db-config.update-strategy=ignored(不推荐,影响面太大),要么用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(Collection<T> 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 batchSize=1000 | 约 850ms | 批量 SqlSession,依赖 JDBC 驱动批处理 |
| 自定义 insertBatchSomeColumn(多值插入 500 条/次 SQL) | 约 210ms | 一次 SQL 插入最多 500 组值 |
如果业务有大批量写入诉求(比如导入、初始化、日志回放),我建议直接扩展一个批量插入方法。做法是继承AbstractMethod:
public 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 可能比真正的数据查询还慢。我在一个报表页面就遇到过:数据查询 200ms,count 却要 1.2s。优化手段是:把 count 语句简化掉,比如查一张大表的分页,count 完全可以只COUNT(*) FROM 主表 WHERE 条件,不需要带 JOIN。
在不方便改 SQL 的情况下,也可以在业务层做降级——比如列表页只需要"有没有下一页",可以用page.setSize(size+1)拿 N+1 条数据,返回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()指定所需列:
List<User> list = userMapper.selectList( new LambdaQueryWrapper<User>() .select(User::getId, User::getName, User::getCreateTime) );另一种是把列表查询用的实体单独定义成一个精简的 VO 类,Mapper 方法返回List<UserVO>。我实践下来,后者更干净——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)的联合唯一索引,或者插入前先查询一下是否存在deleted=1的同 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 SQL:
<select id="selectUserWithOrders" resultType="map"> 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 本身不关心返回值格式,但企业级项目一定要统一。我们的做法是全局用Result<T>包装,Controller 只负责接收参数和调用 Service,Mapper 返回的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 底层机制的理解程度。当你真正理解了它的原理,它就从一个"黑盒"变成一个"高度定制化的基座",那时候你的项目才算真正进入了企业级使用阶段。