从进公司的第二周开始,我就被Service层那几百行反复式的 CRUD 代码折磨得够呛。每来一张新表,就要照着旧代码复制粘贴,改个表名、改几个字段,insertUser、updateUser、selectById、deleteById,一写就是一整天,全是体力活。后来项目组引入 MyBatis-Plus,两个星期内数据访问层代码砍掉将近六成,那是我第一次意识到:单表增删改查这件事,真的不该让程序员反复造轮子。这篇文章我会从框架的核心原理开始讲,帮你理解它为什么能自动生成 SQL,然后落地到一套能直接抄进生产项目的企业级实践,包括通用 CRUD 服务、条件构造器、批量插入、分页优化和常见的坑。适合正在用 MyBatis 但想摆脱重复样板代码的 Java 后端,也适合那些准备把 MyBatis-Plus 推进到生产级项目但还没摸清边界的人。
1. 项目概述:MyBatis-Plus到底解决了什么问题
1.1 从反复粘贴到“无状态”的CRUD时代
先明确一点,MyBatis-Plus 不是要替代 MyBatis,它是在 MyBatis 之上做增强,官方原话是“只做增强不做改变”。引入它之后,你现有的 XML 映射、自定义 Mapper 方法、SqlSession 机制都照常工作,改变的只是基础的单表操作方式。
很多团队之所以选它,核心原因是省事。以前写一个用户表的增删改查,你要写 Mapper 接口、写 XML、写 Service、写 ServiceImpl,四层文件缺一不可,改一个字段要四层联动。引入 MyBatis-Plus 之后,Mapper 接口继承一个 BaseMapper,Service 继承一个 IService,方法直接就有,不需要 XML,不需要自己拼 SQL。
近几个版本还提供了 Db 工具类,也就是无状态的通用 CRUD 服务。你甚至不用定义 Service 和 ServiceImpl,直接 Db.save(user)、Db.lambdaQuery(User.class).eq(User::getStatus, 1).list(),就能完成常规操作。这种风格非常适合模板方法、批量任务和无状态的工具类场景,代码写起来非常干净,这也是企业里越来越多人愿意把它当成数据访问层默认底座的原因。
1.2 核心能力全景:一张表究竟能省多少代码
为了让你直观感受差异,我们拿一张 user 表举例,假设字段有 id、name、status、age、create_time。用原生 MyBatis 写一套单表 CRUD,大概要创建 5 个文件,写 20 条左右的 SQL;如果用 MyBatis-Plus,你只需要定义实体和继承 BaseMapper 的接口,剩下的方法全是现成的。
| 操作类型 | 原生 MyBatis 需要做的事 | MyBatis-Plus 的做法 |
|---|---|---|
| 插入单条 | 写 insert 语句、动态判断 null | BaseMapper.insert(entity) |
| 按 ID 查询 | 写 selectById | BaseMapper.selectById(id) |
| 条件列表查询 | 写 selectList + where 动态拼接 | LambdaQueryWrapper 一行解决 |
| 按条件更新 | 写 update 语句,逐字段判断 | LambdaUpdateWrapper.set(...).eq(...) |
| 分页查询 | 手写 limit、count 两条 SQL | Page + 分页插件自动解析 |
| 逻辑删除 | 每次 SQL 都写 deleted=0 | @TableLogic 自动追加条件 |
| 乐观锁 | 手动用 version 字段拼接 update | @Version + 乐观锁插件 |
| 批量保存 | 手写 foreach 插入 | saveBatch、Db.saveBatch |
| 自动填充创建时间、更新时间 | 两个字段每次都要手动 set | MetaObjectHandler 全局处理 |
这只是最基本的 BaseMapper 能力。再加上条件构造器 Wrapper、SQL 注入器、多租户插件、防全表更新插件,生产环境里绝大多数单表场景都可以覆盖。
1.3 适用场景与真实边界
也得说说它的边界。MyBatis-Plus 的舒适区是单表 CRUD、单表条件查询、轻量级分页,一旦遇到多表 join、复杂子查询、报表汇总这种场景,你还是得写 XML。这个不是缺陷,而是设计哲学,它把简单的事做到极致,把复杂的事交还给 SQL,所以团队里如果有人指望一个框架解决所有查询,最好趁早打消这个念头。
实际落地时我建议把它当“单表 CRUD 的终结者”,而非整个数据访问层的替代品。复杂的统计报表、跨表业务模型,老老实实写在 XML 里,用 @Select、ResultMap 明确控制结果映射。框架和原生 SQL 混用,才是生产项目里最常见的姿态。
2. 核心原理拆解:从Mapper接口到SQL执行的这条链路
2.1 表结构元数据:TableInfo是怎么建立起来的
先说清楚一个关键点:MyBatis-Plus 之所以能自动生成 SQL,是因为它在启动阶段就把实体类解析成了自己的一套表结构元数据,这套元数据叫 TableInfo。
每个实体类启动时都会被扫描,扫描的内容包括表名、主键、字段列表、字段属性、逻辑删除字段、乐观锁字段、自动填充字段等。比如你用 @TableName("t_user") 指定了表名,用 @TableId(type = IdType.ASSIGN_ID) 指定了主键策略,用 @TableField("nick_name") 指定了字段映射,这些信息最终都会进入 TableInfo 的缓存结构,后续所有自动 SQL 都从这里取数据。
这个地方很容易踩坑的是 ID 生成策略,它直接影响插入行为。IdType.AUTO 是数据库自增,插入后会用 Jdbc3KeyGenerator 回填主键;IdType.ASSIGN_ID 是分布式雪花 ID,插入前由框架生成雪花值再 set 到实体里;IdType.ASSIGN_UUID 则是生成去掉横线的 32 位字符串;IdType.INPUT 就是完全交给业务层自己设置主键。生产环境多实例部署,我一般建议用 ASSIGN_ID,避免依赖数据库自增导致的性能瓶颈与主键冲突风险。
字段解析也不是只有驼峰转下划线那么简单。实体里用 @TableField 显式指定列名时,以注解优先;没有注解时,才走 map-underscore-to-camel-case 的驼峰映射;再往下还有字段类型处理器 field-handler 的注册。这些规则全部拼起来,最终形成一个完整的 TableInfo。
2.2 BaseMapper方法为什么不需要写SQL
如果你追过源码,会看到 Mapper 接口上的 BaseMapper 只是一个接口定义,真正的实现逻辑是通过 SQL 注入器把一系列预置方法绑定到 MapperProxy 上。
MyBatis 本身的 Mapper 是通过 JDK 动态代理生成的代理类,执行时通过 MappedStatement 找到对应的 SQL。MyBatis-Plus 做了一件巧妙的增强:启动时扫描所有自定义的 Mapper 接口,发现有继承 BaseMapper 的,就把那些预置的 CRUD 方法构建成 MappedStatement 并注册进去,每个方法背后都有一条自动拼接好的 SQL 模板。
以 insert 为例,遍历 TableInfo 里的字段列表,过滤掉主键和自动填充字段,按 INSERT INTO 表名 (列名...) VALUES (#{属性名...}) 的形式拼出 SQL。以 selectById 为例,拼出 SELECT 列 FROM 表 WHERE 主键 = ?。这些 SQL 模板并不会缓存死,因为它们要根据 Wrapper 动态变化,所以大多数查询走的都是 DynamicSqlSource,每次执行时再根据参数最终绑定 SQL。
理解了这套机制,你就明白为什么 BaseMapper 里那些方法号称“无需 SQL”,表面看是魔法,底层仍然是 MyBatis 的 MappedStatement 体系,只是把原本需要人肉完成的 SQL 模板,改成了框架按元数据自动生成。
2.3 条件构造器:Lambda表达式是怎么变成WHERE条件的
条件构造器 Wrapper 是 MyBatis-Plus 最有特色的部分,LambdaQueryWrapper 允许你写 userMapper.selectList(new LambdaQueryWrapper ().eq(User::getStatus, 1).like(User::getName, "张")),完全不用接触列名,列名跟着实体走。它的原理是 Lambda 表达式被编译成了 SerializedLambda,框架可以从 SerializedLambda 的 implMethodName 中反解出方法名,比如 getName 对应 Getter 方法,再按照 Java Bean 规范去掉 get、首字母小写,得到属性名,然后通过 TableInfo 查到这个属性对应的数据库列名。
为什么要用 Lambda 而不是字符串?好处是编译期就检查了属性是否存在,重构字段时不会出现 SQL 里列名忘了改的情况,IDE 跳转和搜索也都方便,所以我在团队规范里直接要求:能用 Lambda 写就别用字符串写。掌握 Wrapper 的几个核心方法就够用了,eq、ne、gt、ge、lt、le、like、in、isNull、orderByAsc、orderByDesc、last、groupBy,还有 and 与 or 的嵌套组合。
嵌套条件是最容易写错的地方,比如“状态为1且(名称包含张或备注包含技术)”这种括号表达式。很多人第一反应是 eq(status,1).and(w -> w.like(name, "张").or().like(remark, "技术")),这样写就是对的,关键是 or 要包在 and 子句里,让括号结构符合业务意图。条件构造器本质上会把这些条件解析成一段动态 SQL 片段,最后拼接到主 SQL 之后,理解成“它会帮你拼 WHERE 后的部分”就够了。
3. 企业级实操:从CRUD到通用服务的完整落地
3.1 环境准备:依赖、配置与分页插件注册
实操第一步是引入依赖。生产项目基于 Spring Boot 的场景,我一般用 mybatis-plus-boot-starter,版本就看分支,3.5.7 之后仍然是这个坐标,配合 Spring Boot 3 时要注意引入的是 mybatis-plus-spring-boot3-starter,这是很多人升级时会翻车的地方。
<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.12</version> </dependency>然后是 application.yml 的基础配置,逐个解释关键项:
mybatis-plus: mapper-locations: classpath*:/mapper/**/*.xml 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: 0mapper-locations 是给自定义 XML 用的;map-underscore-to-camel-case 让数据库列 sid 自动映射到实体 sid;id-type 设置全局主键策略,实体里被 @TableId 覆盖时以实体注解优先;logic-delete-field 指定全局逻辑删除字段,可以让所有实体自动拥有逻辑删除能力。log-impl 在本地开发时设为 StdOutImpl 可以打印 SQL,生产环境请一定关掉,否则日志量会非常可怕。
分页是 MyBatis-Plus 最常见的插件能力,但它不是默认生效的,必须在配置类里注册 MybatisPlusInterceptor 并添加 PaginationInnerInterceptor:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination = new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(500L); pagination.setOverflow(false); interceptor.addInnerInterceptor(pagination); return interceptor; } }dbType 尽量显式指定,不要靠框架自动推断,因为生产环境连接池里的连接可能包含不同数据库方言,写死更稳。setMaxLimit(500) 是保护机制,正常业务明细查询很难超过 500 条,超过时直接报错,防止有人写漏条件把全表拉出来。setOverflow(false) 表示页码超出总页数时不再回退到第一页,避免分页跳页带来的数据重复和业务事故。
3.2 实体与Mapper的规范写法
实体是 MyBatis-Plus 的核心映射对象,我给出一个生产环境可用的模板:
@Data @TableName("t_user") public class User { @TableId(type = IdType.ASSIGN_ID) private Long id; private String name; private Integer status; private Integer age; @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; @TableLogic private Integer deleted; @Version private Integer version; }几个设计点:主键用 ASSIGN_ID,避免数据库自增;createTime 和 updateTime 交给自动填充,不靠数据库时间,因为数据库时间和应用时间可能有偏差;deleted 和 version 也很典型,一个逻辑删除、一个乐观锁,字段本身仍然存在数据库里,查询时框架会自动处理。
Mapper 接口则非常薄:
public interface UserMapper extends BaseMapper<User> { }如果后续需要自定义查询方法,直接在接口里加方法,然后在 mapper 目录写 XML,或者直接加 @Select 注解,都不影响现有能力。这个组合的好处是开箱即用,也保留扩展点。
3.3 用Db工具类实现真正的无状态增删改查
接下来是重头戏:通用 CRUD 服务。如果你已经注册好了实体和 Mapper,MyBatis-Plus 3.5.x 提供的 Db 工具类可以直接开工,它是对 Mapper 的进一步封装,免掉了定义 Service、ServiceImpl 的环节,真正做到了无状态增删改查。
// 插入单条 User user = new User(); user.setName("张三"); user.setStatus(1); Db.save(user); // 批量插入 List<User> list = buildUserList(); Db.saveBatch(list); // 按主键删除 Db.removeById(User.class, 1L); // 按条件删除 Db.lambdaUpdate(User.class) .eq(User::getStatus, 0) .remove(); // 查询单条 User one = Db.lambdaQuery(User.class) .eq(User::getId, 1L) .one(); // 条件列表查询 List<User> users = Db.lambdaQuery(User.class) .eq(User::getStatus, 1) .like(User::getName, "张") .orderByDesc(User::getCreateTime) .page(new Page<>(1, 10)) .getRecords();代码读起来几乎就是人话,“查一下 User 表,条件是这个、这个,再按时间倒序分页”。这一点对可维护性提升很大,新来的同事看代码成本极低。
但我得提醒一句:Db 工具类适合 Controller、Job、定时任务和临时数据处理脚本,这些场景不需要 Service 层的缓存和事务编排;如果业务本身有复杂的别名校验、状态机流转、多表操作事务边界,那就老老实实定义 Service 和 ServiceImpl,把事务注解放在 Service 方法上,让 Db 工具类只做简单的数据访问。
3.4 Wrapper条件构造器实战:多个常见场景一次看懂
条件构造器的最大价值是“动态”,也就是当查询条件不确定时,你不需要手动拼 SQL 和判断空值。比如一个用户筛选接口,入参可能只有名字、可能只有状态、也可能全都有:
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(StringUtils.hasText(name), User::getName, name) .eq(status != null, User::getStatus, status) .orderByDesc(User::getCreateTime); page(userMapper.selectPage(new Page<>(pageNum, pageSize), wrapper));注意 eq 第一个参数是 boolean 条件,为 false 时该条件自动忽略,这个机制比手动 if 判断干净得多。
更新场景同样适用:
Db.lambdaUpdate(User.class) .eq(User::getId, 1001L) .set(User::getStatus, 2) .set(User::getName, "李四") .update();这样生成的 SQL 是 UPDATE t_user SET status=?, name=? WHERE id=?,只更新你 set 过的字段,不会动其它字段。这是它比先查出来再 update 整个实体更安全的地方,也避免了并发场景下的“查改不一致”。
嵌套条件再举一个:查所有有效用户,条件里 (name like 张 或 age > 30) 并且 status=1:
Db.lambdaQuery(User.class) .eq(User::getStatus, 1) .and(w -> w.like(User::getName, "张") .or() .gt(User::getAge, 30)) .list();生成的 SQL 是 WHERE status=1 AND (name LIKE ? OR age > ?)。这种写法建议在团队里统一起来,括号逻辑可读性比手动拼接字符串强太多。
4. 生产级优化:性能、安全与维护性的平衡
4.1 批量插入从“百条”到“万条”:三个可落地的方案
生产环境里最常见的一个性能问题是批量插入。BaseMapper 的 insert 一次只能插入一条数据,系统初始化数据、批量导入、上报明细这种场景,几千条数据如果循环调用 insert,会产生几千次数据库往返,速度感人。实测数据可以给个参考:本地数据库 500 条数据循环 insert 大约耗时 3 到 5 秒,换成批量插入方案直接降到 500 毫秒以内,差距非常明显。
第一个方案是使用 MyBatis-Plus 内置的 saveBatch。ServiceImpl.saveBatch 或 Db.saveBatch 会把集合拆成每 1000 条一批,通过 SqlSessionTemplate 在同一个 SqlSession 里连续执行多条 insert,减少了会话切换开销。但注意它本质上仍然是一条一条 insert,只是省了会话和批次处理成本。
第二个方案是自定义 SQL 注入器,使用 INSERT INTO ... VALUES (...), (...), (...) 这种多值批量插入。实现思路是先定义一个自定义方法,然后继承 DefaultSqlInjector 注册并注入到 Mapper 里。操作稍微繁琐但收益直接。如果不想引入太复杂的注入器,直接在 XML 里写 foreach 也能达到目标:
<insert id="batchInsert"> INSERT INTO t_user (id, name, status, age, create_time, update_time) VALUES <foreach collection="list" item="item" separator=","> (#{item.id}, #{item.name}, #{item.status}, #{item.age}, #{item.createTime}, #{item.updateTime}) </foreach> </insert>第三种最容易忽略:MySQL 的连接 URL 加上 rewriteBatchedStatements=true。这个参数会告诉 JDBC 驱动把多条 INSERT 批量重写成多值 VALUES 下发,实测批量性能提升超过 8 倍。很多团队的批量还是慢,不是 SQL 的问题,而是驱动默认没开这个参数。配置类似 jdbc:mysql://localhost:3306/app?rewriteBatchedStatements=true。我实际测试时,加了它以后同样一批 5000 条数据的插入耗时从约 1600ms 降到了约 450ms。
4.2 分页深翻页与count慢的治理
分页插件用起来方便,但生产环境深翻页是大坑。LIMIT 100000, 20 这种写法,MySQL 要先把前十万条数据扫描出来再丢掉,越往后翻越慢。很多时候接口慢得离谱,不是索引没用,而是 MySQL 驻留在回表和排序上的代价太高。
方案一,限制深度分页。超过某个页数直接报错或要求用户走条件筛选,比如 maxLimit 已经能挡住单页过大,但没挡住 offset 很大,所以业务上最好限制最大页码。方案二,使用游标分页或 keyset 分页,也就是“上一次查询最后一条记录的主键/时间戳,下一次查询从它之后开始取”。MyBatis-Plus 没有原生游标分页,但是支持利用 Wrapper 条件自己拼:WHERE id > ? ORDER BY id ASC LIMIT 20。这个方案适合信息流类产品,稳定性和性能都好。
另一个容易忽略的是 count 查询性能。PaginationInnerInterceptor 会自动生成 count SQL 来统计总条数,但它的自动生成基于 SQL 解析,处理 order by 有时不够智能,ORDER BY 在页面上其实没必要参与 count,尤其排序字段没有索引时,count 子句里带着 ORDER BY 会拖慢速度。可以看生成的 count SQL,如果还带着 order by,直接自定义 count 覆盖。我在项目里会全局配置禁止 count 时出现不必要的排序,用覆盖 SQL 或精简字段来优化。
还有一条经验是,列表查询尽量避免 SELECT 全字段,用 Wrapper.select(User::getId, User::getName) 裁剪列,减少回表数据量。尤其列表页只需要展示用到的列时,这种裁剪效果立竿见影。
4.3 逻辑删除、乐观锁、多租户的企业级正确姿势
逻辑删除在单表查询上确实优雅,框架会自动把所有查询加上 deleted = 0 条件,删除变成 update deleted=1。但难点在于被关联表查询、唯一索引和统计汇总时。
一个经典场景是唯一索引。手机号在 user 表上建了唯一索引,用户删了一次再注册同手机号,第二次插入会因为 deleted 是 0 和唯一索引冲突而插不进去。常规解法是让唯一索引带上 deleted 字段,比如 UNIQUE KEY uk_mobile (mobile, deleted),然后把删除时的 deleted 改成主键 ID,这样每个删除记录都有唯一的一个 deleted 值,不会和正常记录冲突。逻辑删除字段就不能只用 0/1 了,MyBatis-Plus 的逻辑删除配置支持把逻辑删除值配成 DELETED,通过 setLogicDeleteValue 等配置实现。
乐观锁是解决并发更新的标准姿势。实体加了 @Version 字段后注册 OptimisticLockerInnerInterceptor,更新时自动拼接 WHERE version = ?,更新成功后 version 自增。如果两条线程同时读同一条数据,后更新的线程会因为 version 不匹配而更新失败,业务层需要收到影响行数 0 后做重试或提示。注意乐观锁字段不能用数据库触发器或手写 SQL 去改,它必须跟着业务更新走,否则框架判断版本号会失效。还有一个常见误用:version 字段是 Integer 类型时,初始值如果是 null,更新时拼进 SQL 可能出现 null 值导致条件永远不成立,这种情况要确保插入时 version 有默认值 0。
多租户插件是 SaaS 系统的刚需。TenantLineInnerInterceptor 可以让你所有 SQL 自动追加 tenant_id = 当前租户条件,不需要在每个查询里手动写。使用时要特别注意,多租户插件要注册在分页插件之前,MybatisPlusInterceptor 内部是按添加顺序执行的,顺序错了可能分页查不到 tenant 条件。
4.4 慢SQL日志与运行时安全拦截
生产环境的 SQL 日志一定要关掉 StdOutImpl,改成运行时只记录慢 SQL。MyBatis-Plus 自带的插件机制支持自定义 InnerInterceptor,你可以编写一个统计 SQL 执行耗时超过阈值的拦截器,超过 500ms 就写 warn 日志并带上完整 SQL。这样既不影响大量正常语句,又能在高峰期快速定位慢查询接口。
@Component public class SlowSqlInterceptor implements InnerInterceptor { @Override public void beforeQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) { // 可以在这里记录开始时间到 ThreadLocal } @Override public void afterQuery(...) { // 计算耗时并判断阈值 } }还有一个生产必备的安全插件:防全表更新与删除。注册 BlockAttackInnerInterceptor 后,如果出现没有 WHERE 条件的 update 或 delete,框架直接抛异常,从根本上挡住了手滑把全表数据清空的惨案。这个插件我强烈建议每个生产项目都加上,成本极低,但能拦住一次事故就值回票价。
5. 避坑指南:开发中绕不开的“深水区”
5.1 字段映射与空值更新的坑
第一个高频坑是驼峰映射失效。实体里写了 private Long userNo,数据库列是 user_no,框架默认能映射上,但如果列名是 userNo 这种非标准命名,或者用了奇怪的缩写,就会查询结果集映射不上,字段一直是 null。排查时优先看生成的 SQL 和查询结果,再看全局配置 map-underscore-to-camel-case 是否打开,最后用 @TableField("user_no") 显式指定列名兜底。我个人的习惯是,数据库设计一旦出现不合规列名,直接显式写 @TableField,别赌默认映射。
第二个坑是 updateById 传了 null 字段。很多人写 userMapper.updateById(user),发现实体的某个字段明明是 null,结果数据库里对应列没有被清空。原因是 MyBatis-Plus 默认的字段策略是 NOT_NULL,也就是值为 null 的字段不参与更新。想更新 null 字段有几种办法:局部用 LambdaUpdateWrapper 的 set 方法强制更新;或者实体字段上加 @TableField(updateStrategy = FieldStrategy.ALWAYS);或者在 updateById 前手动 set 字段。建议按业务场景选择,不要全局放开,因为全局放开会导致一些“只想改个别字段”的更新把别列为 null。
5.2 逻辑删除与唯一索引冲突
前面提过唯一索引加 deleted 的做法,我再展开具体操作。假设表里有一个唯一索引是 phone,逻辑删除字段是 deleted,那么建表索引要改成联合索引 UNIQUE KEY uk_phone_deleted (phone, deleted)。删除时不能只把 deleted 设成 1,要设成主键值,保证每条删除记录的唯一性。这样新插入的记录 deleted 为 0,不会历史删除记录冲突。框架侧如果开了逻辑删除,普通查询会自动带 deleted=0,但联合查询或者自定义 SQL 里不会自动带,你需要在 XML 里手动加条件,否则可能出现删掉的用户还被关联表查到的情况。
5.3 乐观锁失效的三种现场
乐观锁失效是我排查过最多的并发问题。第一种是拦截器没注册,只加了 @Version 注解却没在 MybatisPlusInterceptor 里加 OptimisticLockerInnerInterceptor,更新语句根本不会带 version 条件。第二种是更新走向了自定义 SQL 或 XML 方法,乐观锁插件只对注入的通用方法生效,自己写的 update SQL 要手动把 version 条件拼上。第三种是实体中 version 字段没有参与更新,因为字段策略 NOT_NULL 导致 version 传入时被过滤,结果更新语句里既没有 version 条件也没有 version 自增。测试乐观锁时,最直接的办法是看日志里 update 语句是否带了 WHERE version = ? 片段,没带就是配置有问题。
5.4 LambdaWrapper的泛型擦除与继承陷阱
条件构造器在使用 Lambda 时有个隐藏陷阱:如果在父类泛型里写 LambdaQueryWrapper ,框架反解析 SerializedLambda 时需要知道当前方法的 OwnerClass。如果泛型被子类继承但泛型实参没有在方法签名里体现,可能出现反序列化失败,报类似 “can't find lambda cache” 的错。解决办法是在继承场景下给 wrapper 直接显式指定泛型,不要依赖父类泛型推导。另外,实体里如果有 boolean 类型的 getter,命名不规范时也会导致反解析出错的列名异常,所以实体字段名、getter 方法名务必规范。
5.5 分页插件与其他插件顺序干扰
MybatisPlusInterceptor 内部通过 List 保存多个 InnerInterceptor,执行顺序就是添加顺序。多租户插件必须放在分页插件前面,先追加 tenant_id 条件再分页,否则 count SQL 解析时可能漏掉租户条件;防全表更新 BlockAttackInterceptor 通常放在最后。插件的顺序问题在日志里很隐蔽,现象是某条 SQL 没有租户条件或 count 与列表数据不一致,排查时要回到顺序配置上,先把顺序调整成多租户 -> 分页 -> 乐观锁 -> 防全表更新再测一遍。
这条链路系统跑稳之后,我最大的体会是把 MyBatis-Plus 当成数据访问层的高效工具,而不是银弹。单表 CRUD 用框架自动完成,复杂报表和跨表查询老老实实写 XML,两类场景各就各位,才能让团队在业务需求里腾出手来优化真正的瓶颈。最后分享一个我在新项目里养成的实用小习惯:每新建一张表,先用 Db.lambdaQuery(XX.class).list() 跑一条空条件验证,看实体映射和 SQL 输出是否正常,这套一分钟的验证,能帮你在上线前拦掉大部分字段映射和配置错误。