☰
MyBatis vs MyBatis-Plus:底层原理、缓存机制与项目选型全解析
2026/10/9 9:14:02 网站建设 项目流程
  • MyBatis 和 MyBatis-Plus 是 Java 后端开发里绕不开的两个名字,尤其做 Spring Boot 项目时,几乎天天跟它们打交道。很多人面试被问“两者区别”时,能说出“MyBatis-Plus 是增强工具、不用写 SQL”,但真到项目选型、排查缓存问题、看源码时就懵了。这篇文章从底层定位、CRUD 写法、缓存机制、Spring Boot 集成、源码原理到面试问答,把两者的真实差异一次性讲透,适合准备面试的开发者、做项目选型的技术负责人,以及想搞明白 MP 到底“增强”在哪里的朋友。
  • 先说结论放这儿:MyBatis 是“持久层框架”,MyBatis-Plus 是“基于 MyBatis 的增强工具”,它没改 MyBatis 的底层执行逻辑,也没换掉 SQL 映射机制,只是在上面套了一层自动 CRUD、条件构造器、分页插件这些“生产力工具”。这个定位决定了你该怎么选、怎么用、出了问题怎么排查。

1. 定位与底层关系:一个框架,一个增强工具

1.1 MyBatis 到底做了什么

MyBatis 的核心价值是“把 Java 方法和 SQL 映射起来”。你用接口定义方法,在 XML 或注解里写 SQL,MyBatis 在运行时通过动态代理把方法调用转成对 SqlSession 的调用,再交给 JDBC 执行。它解决了两个痛点:一是把 JDBC 的样板代码(Connection、PreparedStatement、ResultSet 遍历)全部隐藏掉;二是让 SQL 和 Java 代码分离,SQL 想怎么调优就怎么调优,DBA 同事接手也方便。

但 MyBatis 有个“薛定谔的便利”:单表 CRUD 你也要写 SQL。比如一个 User 表,查列表、按 ID 查、按条件查、分页查、统计数量,每一样都得在 XML 里写<select>、<insert>、<update>、<delete>,哪怕字段就五六个,这些基础 SQL 也要重复写五六个。项目一多,大量时间耗在“没有技术含量”的样板 SQL 上,而且字段一变化,所有 SQL 都要同步改。我当时维护一个老项目,40 多张表,光 Mapper XML 文件就有快两万行,其中至少三分之一是这种“Ctrl+C/V”的基础 CRUD。

1.2 MyBatis-Plus 是“增强”而非“替代”

MyBatis-Plus(以下简称 MP)的官方定位就一句话:MyBatis 的增强工具,在 MyBatis 的基础上只做增强不做改变。它没有重新实现一套 ORM,也没有改变 MyBatis 的 SQL 执行流程。它的做法是你继承一个BaseMapper<T>接口,里面预置了insert、deleteById、selectById、selectList、selectPage等十几个方法,这些方法的 SQL 是 MP 在项目启动时根据实体类注解动态生成的,然后注册到 MyBatis 的 MappedStatement 里,本质还是走 MyBatis 那套 SqlSession 执行链路。

这里有个关键认知:MP 不是把 MyBatis 替换掉,而是在 MyBatis 之上做“预置方案”。你照样可以写 XML,照样可以用注解,照样可以自定义 SQL。MP 的增强体现在“在你不想写 SQL 的场景,帮你把 SQL 写好了;在你仍需要精细控制 SQL 的场景,它完全让位给你”。

1.3 一张总览表看清定位差异

对比维度MyBatisMyBatis-Plus
框架性质独立持久层框架MyBatis 增强工具,依赖 MyBatis
单表 CRUD手写 SQL继承 BaseMapper 自动生成
条件构造XML/注解拼 SQLQueryWrapper/LambdaQueryWrapper
分页手动拼 LIMIT 或用 PageHelper内置分页插件,物理分页
主键策略手动配置或写 SQL 序列内置 ASSIGN_ID、ASSIGN_UUID 等
逻辑删除自己写WHERE deleted = 0@TableLogic 注解,自动拼接条件
字段自动填充无MetaObjectHandler 统一处理 create_time 等
代码生成器无官方生成器,靠第三方MyBatis-Plus-Generator 一键生成
复杂 SQL 场景完全手动控制,灵活度满分自定义 SQL 仍需手写,但可以结合 Wrapper
学习成本中等,需要理解 SQL 映射低,基础 CRUD 几乎零 SQL

这张表基本回答了“该学哪个”的问题:你要是刚入行,直接学 MP 能快速上手做项目,但必须补 MyBatis 的底层原理,否则遇到复杂 SQL、缓存问题、插件扩展时你会束手无策;你要是资深开发,MP 能帮你把大量重复劳动省下来,让你把精力投到业务建模和 SQL 优化上。

2. CRUD 写法的核心差异:从“写 SQL”到“写条件”

2.1 传统 MyBatis 的“三步走”

用 MyBatis 写一个基础查询,标准流程是这样的:

第一步,定义实体类 User:

public class User { private Long id; private String name; private Integer age; private String email; // 省略 getter/setter }

第二步,写 Mapper 接口和 XML:

public interface UserMapper { User selectById(Long id); List<User> selectListByAge(Integer age); }
<select id="selectById" resultType="com.example.entity.User"> SELECT id, name, age, email FROM user WHERE id = #{id} </select> <select id="selectListByAge" resultType="com.example.entity.User"> SELECT id, name, age, email FROM user WHERE age = #{age} </select>

第三步,在 Service 层调用:

@Resource private UserMapper userMapper; public User getUserById(Long id) { return userMapper.selectById(id); }

这就是一个简单的selectById,你得在三处地方(接口、XML、调用)留下代码痕迹。表一多、方法一多,重复劳动指数级上升。最让人头疼的是 SQL 里字段和实体类属性之间“天然的断层”——实体类加了字段,XML 里漏加;XML 里改了字段名,实体类忘了同步,这类问题只能靠运行时报错或结果集对不上才能暴露。

2.2 MP 的 BaseMapper:一行继承,十几张“免写卡”

MP 的写法是继承一个泛型接口:

@Mapper public interface UserMapper extends BaseMapper<User> { // 想写自定义方法也完全可以,比如下面这个 User selectByName(@Param("name") String name); }

然后就完事了。BaseMapper里预置的方法直接可用:

public User getUserById(Long id) { return userMapper.selectById(id); // SQL 由 MP 自动生成 } public List<User> getUsersByAge(Integer age) { LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(User::getAge, age); return userMapper.selectList(wrapper); }

selectById、selectList、insert、updateById、deleteById、selectCount这些高频方法全都不用写 SQL。最实用的还属selectBatchIds,以前要按 ID 集合查列表,XML 里得写<foreach>遍历,现在一行:

List<User> users = userMapper.selectBatchIds(Arrays.asList(1L, 2L, 3L));

2.3 条件构造器 QueryWrapper 和 LambdaQueryWrapper

MP 给人最大“爽感”的就是条件构造器。以前按多条件查询,你得用<if test>在 XML 里拼动态 SQL:

<select id="selectByCondition" resultType="com.example.entity.User"> SELECT * FROM user WHERE 1 = 1 <if test="name != null and name != ''"> AND name = #{name} </if> <if test="age != null"> AND age = #{age} </if> </select>

用 MP 的 LambdaQueryWrapper:

public List<User> search(String name, Integer age) { LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>(); wrapper.like(StringUtils.hasText(name), User::getName, name) .eq(age != null, User::getAge, age) .orderByDesc(User::getCreatedAt); return userMapper.selectList(wrapper); }

注意这段代码里的两个技巧:

  • 第一个参数是“是否拼接条件”的开关,比如age != null为 false 时,eq方法直接跳过,不会把失效条件拼进 SQL。
  • 用User::getName这种方法引用替代数据库字段名,编译期就能发现“字段名拼错”这种低级问题,重构实体类字段时也能自动联动。

对比下来你就能感受到:MyBatis 的动态 SQL 是靠 XML 标签描述“SQL 的形态变化”,MP 是用 Java 流畅 API 描述“过滤条件”,两者解构问题的粒度完全不同。MP 这种写法在 IDE 里有补全、有类型检查,新手上手几乎零门槛。

不过需要泼一盆冷水:条件构造器只适合所有条件通过AND连接的场景。你要是碰到需要OR嵌套、多组括号分组、子查询关联、UNION、动态排序字段等复杂情况,Wrapper 虽然能做但可读性很差。我自己不太建议在复杂场景硬用 Wrapper,比如(A = 1 OR B = 2) AND C = 3这种条件组合,用 Wrapper 写出来绕来绕去,远不如 XML 里直接写清楚来得维护。这也是 MP 和 MyBatis 那层“兼容与让位”的体现——复杂 SQL,它不抢。

2.4 复杂 SQL:MP 并没有“毁掉”XML 能力

MP 里写复杂 SQL 有两种方式,一种是直接在 Mapper 接口里加方法,然后在 XML 里写:

public interface UserMapper extends BaseMapper<User> { // 统计每个年龄段的用户数,联表查询,这类复杂 SQL 还是得手写 List<UserAgeGroupVO> countGroupByAge(); }
<select id="countGroupByAge" resultType="com.example.vo.UserAgeGroupVO"> SELECT CASE WHEN age < 18 THEN '未成年人' WHEN age BETWEEN 18 AND 60 THEN '成年人' ELSE '老年人' END AS ageGroup, COUNT(*) AS count FROM user GROUP BY ageGroup </select>

另一种是利用 MP 的Wrapper传入自定义 SQL,适合“前半段业务条件交给 Wrapper,后半段复杂查询自己控制”的折中场景。但我的经验是:不要让 MP 的 Wrapper 渗透到复杂的自定义 SQL 里,那会让 SQL 的可读性变得非常差。项目里的复杂查询要规规矩矩写在 XML 里,保持 DBA 能看懂、能优化、能审查的形态。这个原则,团队协作时极其重要。

3. Spring Boot 集成实操:从依赖到插件配置

3.1 Maven 依赖差异与兼容版本

用 MyBatis 时,Spring Boot 项目通常引入mybatis-spring-boot-starter:

<dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.2</version> </dependency>

同时还要自己加pagehelper做分页、自己加mybatis-generator-maven-plugin做代码生成,这些都是“搭积木”。

用 MP 时,引入mybatis-plus-boot-starter就行:

<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.5</version> </dependency>

这里有个容易踩的坑:MP 3.5.x 的 starter 里已经内置了对 MyBatis 和 Spring Boot 的版本适配,但如果你额外再引入mybatis-spring-boot-starter,两块可能会打架,出现 SqlSessionFactory 创建冲突。我见过同事在同一个项目里同时引了这两个 starter,启动时Invalid bound statement报错查了半天,最后发现是两个 starter 里的 SqlSessionFactoryBean 互相覆盖了。MP 项目里千万别画蛇添足再引 MyBatis starter。

3.2 实体类注解与主键策略

MP 通过注解把实体类和数据库表关联起来。最常见的三个注解:

@Data @TableName("user") // 指定表名,如果实体类名和表名一致可省略 public class User { @TableId(type = IdType.ASSIGN_ID) // 主键策略,雪花算法生成 private Long id; private String name; private Integer age; @TableField("email_addr") // 字段映射,处理数据库下划线和 Java 驼峰的差异 private String emailAddr; @TableLogic // 逻辑删除标记 private Integer deleted; @TableField(fill = FieldFill.INSERT) // 插入时自动填充 private Date createTime; @TableField(fill = FieldFill.INSERT_UPDATE) // 插入和更新时自动填充 private Date updateTime; }

主键策略IdType有几种选择:AUTO是数据库自增;ASSIGN_ID是 MP 内置的雪花算法生成 64 位 Long 型 ID;ASSIGN_UUID是生成去掉横线的 32 位字符串 ID;INPUT是用户手动传入。我建议新项目优先用ASSIGN_ID的雪花 ID——它不依赖数据库自增计数,分库分表时全局唯一,而且包含了时间戳信息,可以反推记录创建时间。但有分布式主键生成要求的场景,也可以用INPUT配合美团的 Leaf 或百度的 UidGenerator,MP 不会干预你传进来的 ID 值。

3.3 分页插件配置:内置 PaginationInnerInterceptor

MP 的分页插件是目前我觉得它最“香”的功能之一。配置一个配置类:

@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); PaginationInnerInterceptor paginationInterceptor = new PaginationInnerInterceptor(DbType.MYSQL); paginationInterceptor.setMaxLimit(500L); // 单页最大条数限制,防止恶意全表查询 paginationInterceptor.setOverflow(false); // 页码超出总页数时是否回退到第一页 interceptor.addInnerInterceptor(paginationInterceptor); return interceptor; } }

然后分页查询:

public IPage<User> pageUsers(int current, int size) { Page<User> page = new Page<>(current, size); LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>(); wrapper.orderByDesc(User::getCreateTime); return userMapper.selectPage(page, wrapper); }

注意一点:MP 的分页插件是“物理分页”,它会根据数据库方言自动改写 SQL,在 MySQL 底层拼上LIMIT,PageHelper 也是类似机制。但 PageHelper 有个经典的“只在紧跟其后的第一条查询生效”的线程安全问题,MP 的分页插件基于InnerInterceptor机制,直接拦截后面的selectPage方法,不会出现 PageHelper 那种“分页串页”的坑。这一点在实际项目里很重要,尤其是写多线程或异步任务时,用的工具越少“隐式状态”越安全。

3.4 字段自动填充、逻辑删除、乐观锁插件

这三个“插件式”增强,是 MP 把“重复劳动”收进框架里的典型代表。

字段自动填充先建一个处理器:

@Component public class MyMetaObjectHandler implements MetaObjectHandler { @Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, "createTime", Date.class, new Date()); this.strictInsertFill(metaObject, "updateTime", Date.class, new Date()); } @Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, "updateTime", Date.class, new Date()); } }

配上实体类里@TableField(fill = FieldFill.INSERT)的注解,插入时create_time、update_time自动赋值,再也不用 Service 层手动setCreateTime。很多公司也用它统一填充createBy、updateBy(操作人工号),把审计字段的维护彻底从业务代码里剥离。

逻辑删除更省事。实体类里deleted字段标上@TableLogic,MP 会自动把这些逻辑变了:你调用deleteById,它执行的是UPDATE user SET deleted = 1 WHERE id = ?;你调用selectList,它会自动在 SQL 尾巴上拼AND deleted = 0。以前手写逻辑删除,最怕的就是某条 SQL 漏了deleted = 0,查出了“已删除”的数据还浑然不觉。MP 的统一拦截在源头解决了这个隐患。

乐观锁使用@Version注解:

@Version private Integer version;

配一个 OptimisticLockerInnerInterceptor 插件。每次更新前,MP 会自动把version作为版本号代入WHERE条件,更新后把version加一;如果更新影响行数为 0,说明版本冲突,你可以捕获后做重试或提示操作者。这是处理并发更新的常用手段,尤其适合“最后写入覆盖”会出事故的资产类、余额类数据。

3.5 打印 SQL 日志:两个方案都要会

热词里有“mybatis配置打印”,这是排查问题的刚需。MyBatis 在 application.yml 里的配置是:

mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

MP 因为是基于 MyBatis 的,所以上面的配置同样有效。同时 MP 还提供了一个更干净的方案,只打印执行耗时:

mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl

我建议用标准 Slf4j 输出到日志文件,而不是用 StdOutImpl 打到控制台——生产环境控制台日志保留时间短,一旦现场排查问题,还是要从文件日志里捞 SQL 执行记录。你可以在 logback 配置里单独给mapper包设置 DEBUG 级别,这样 SQL 日志和业务日志能同时看到,定位问题更快。

4. 缓存机制对比与踩坑记录

4.1 一级缓存和二级缓存的底层机制

MyBatis 的缓存机制分两级,这两级缓存设计的初衷和范围完全不同。

一级缓存是SqlSession级别的,默认开启。一个 SqlSession 里执行两次完全相同的查询,第一次查完结果会存在本地 Map 里,第二次直接从缓存取,不再查数据库。因为一个 SqlSession 通常对应一次数据库会话,所以一级缓存的生效范围非常有限——你同一个方法里连续调两次selectById(1),会有缓存命中;但跨方法、跨事务,缓存基本不生效。

二级缓存是namespace(Mapper)级别的,跨 SqlSession 共享,默认关闭。需要手动在 Mapper XML 里加<cache/>标签开启。配置之后,查询结果会缓存到一个公共区域,多个 SqlSession 之间共享。二级缓存设计的目标是“让同一个 Mapper 的查询结果在不同会话间复用”,但对于写操作,MyBatis 会触发该 namespace 下缓存的清空,防止脏读。

从缓存架构上说,MyBatis 的二级缓存是装饰器模式,通过CachingExecutor包装真正的Executor,每次查询先去缓存里查找 key 对应的 value。这里的一个大坑是:默认二级缓存序列化的是对象引用,还是序列化之后的对象拷贝?默认情况下,二级缓存存的是反序列化出来的对象,如果查询结果没有实现Serializable,开启二级缓存会直接报错。所以官方建议二级缓存的 POJO 类要实现Serializable接口。

4.2 一级缓存失效的三种情况

哪怕一级缓存默认开着,我实际排查问题时发现它会失效的情况非常频繁:

第一,不同的 SqlSession。Spring 整合 MyBatis 后,每次通过 Mapper 接口调用方法,如果没有事务包裹,SqlSession 是“用完即关”的,一级缓存随之销毁。同一个方法里调两次selectById,理论上不该是同一个 SqlSession,缓存也没机会命中。只有在事务方法里(@Transactional 包裹),两次查询才可能共享一个 SqlSession。

第二,查询参数或 SQL 不同。缓存 key 由 statementId、SQL 语句、参数、RowBounds 等组成,参数一变,缓存 key 就变。

第三,中间执行了任何增删改操作。MyBatis 只要检测到INSERT、UPDATE、DELETE语句,就会清空当前 SqlSession 的一级缓存。哪怕更新操作影响的不是你在缓存里的那行数据,也会整个清空。这个机制很“粗暴”,但保障了缓存与数据的一致性。

4.3 MP 有没有动缓存的“奶酪”

直接说结论:MP 完全没有改动 MyBatis 的缓存机制。一级缓存、二级缓存的开关、生效条件、失效策略,在 MP 里和原生 MyBatis 一模一样。它没有把你selectList的结果自动放到 Redis,也没有帮你调整二缓存的命中策略,更不会比原生 MyBatis 多一个“透明缓存层”。

这个点其实挺重要的,因为很多初学者会误以为“用了 MP 就等于开了缓存”。真相是:MP 在启动时用 SqlInjector 把预置方法生成为 MappedStatement,这些 MappedStatement 的flushCache和useCache属性与手写的 Mapper 方法没有本质区别,默认情况下select不进二级缓存(除非开启),update/delete/insert会刷新缓存。

所以在 MP 项目里,你照样需要遵循 MyBatis 的缓存规则来做优化。比如一个配置表的查询,Mapper 的@CacheNamespace或 XML 的<cache/>开着,查询结果才能被二级缓存共享;把高频配置类查询放到本地缓存或 Redis,自己用 Spring Cache 做业务级缓存,这是比依赖 MyBatis 二级缓存更可控的方案。我见过项目里把 MyBatis 二级缓存开到@CacheNamespaceRef做跨 namespace 缓存复用,结果数据更新后缓存没有及时刷新,最后干脆全关掉用 Caffeine 自己做缓存,更简单也不容易出“数据不一致”的事故。

4.4 自定义二级缓存的注意事项

如果还是想用 MyBatis 二级缓存,有几点经验供参考:

  • 实体类务必实现Serializable,否则反序列化直接报NotSerializableException。
  • 读写比例高的配置表,适合用二级缓存;频繁更新的流水表,完全不建议,因为每次更新都会清空缓存,命中率极低,反而增加了“缓存击穿”的风险。
  • 如果项目里有多个数据源或分库分表,二级缓存的 key 设计要带上库表标识,避免不同库的同名 Mapper 数据互相串。
  • 严格评估“更新操作触发缓存清空”的粒度:MyBatis 二级缓存的清空是按 namespace 为单位的,同 namespace 下任何更新都会清空所有缓存,意味着“少数更新 + 大量查询”的模型下缓存会被频繁清空,收益极低。

你能在热词里看到“mybatis二级缓存实现”也说明这是一个大家普遍关心但容易踩坑的点。我的建议是:项目初期不要依赖 MyBatis 的二级缓存,先把 Redis 或多级缓存架构设计好,收益更直接、更好控制;二级缓存可以作为单机应用的“最后一道缓存防线”,但别把它当成分布式缓存来用。

5. 源码层面的“本质差异”和性能分析

5.1 MP 的 SqlInjector 到底注入了什么

很多面试官喜欢问“MP 是怎么做到不写 SQL 就能 CRUD 的”,答案就在SqlInjector。MP 启动时,MybatisPlusAutoConfiguration会注册一个MybatisSqlSessionFactoryBean,它在构建 SqlSessionFactory 时遍历所有继承了BaseMapper的 Mapper 接口,通过SqlInjector调用inspectInject方法,把BaseMapper里预置的每个方法(selectById、selectList、insert 等)解析成 MappedStatement,注册到 MyBatis 的 configuration 里。

SqlInjector内部有一个AbstractMethod的抽象体系,每种方法对应一个具体类:

  • SelectById生成SELECT * FROM user WHERE id = ?
  • SelectList生成SELECT * FROM user WHERE (条件),配合 wrapper 动态拼接
  • Insert生成INSERT INTO user (字段) VALUES (值),自动剔除 null 字段
  • UpdateById生成UPDATE user SET ... WHERE id = ?

这些生成 SQL 的动作,本质上是“字符串拼接 + 参数映射处理”。所以从源码层面看,MP 并没有绕开 MyBatis 的 SQL 执行链路,它只是用动态代理和注解解析在“方法调用”和“SQL 生成”之间架了一座桥。你调用userMapper.selectById(1),MP 生成好 SQL,再把这 SQL 交给 MyBatis 底层的SqlSessionTemplate去执行——这跟原生 MyBatis 里调用一个 XML 中手工定义的selectById在底层执行路径上没区别。

5.2 MP 会比 MyBatis 慢吗

这个问题的答案分两层。从单次 SQL 执行的角度看,MP 多了“Wrapper 解析成 SQL 条件片段”的过程,这个解析是 Java 代码拼接,跟 MyBatis 解析 XML 里<where>、<if>标签其实处于同一量级的开销。你执行一条selectById,MP 的开销是生成SELECT * FROM user WHERE id=?的字符串拼接,这个 SQL 还会被 MyBatis 的SqlSource缓存起来,所以第二次执行的时候几乎零额外开销。

从整体性能看,两者差异可以忽略不计。真正的性能差距来自 SQL 本身的优劣——MP 生成的 CRUD 方法全是全字段SELECT *,如果你的表字段非常多、查询并发高,这种“取多余字段”的浪费可能比 MP 框架本身的开销更显著。所以做性能优化时,要么实体类上把不需要的字段排除掉,要么这种高频接口就别走 MP 的通用方法,直接在 XML 里写“精准字段查询”的 SQL,两者结合起来用才是正解。

5.3 源码分析带来的“排查问题能力提升”

搞懂 MP 的本质是 MyBatis 增强插件之后,你在排查问题时就多了一个“底层视角”。比如启动时报Invalid bound statement (not found),你能立刻想到:这个错误可能出现在 MyBatis XML 里没有对应方法的 SQL,也可能出现在 MP 项目里扫包配置不对、@MapperScan没配、或者 Mapper 接口没继承BaseMapper。再比如遇到“用了 @TableField 但 SQL 还是查了错误的列”,你就能想到:MP 生成 SQL 靠的是实体类映射,检查一下实体类字段有没有被@TableField正确标注,或者有没有用@TableField(exist = false)排除非表字段。

源码阅读对排查“预置方法不够用”的场景也有帮助。我知道BaseMapper里没有“批量插入”的高性能方法(只有一个循环插入的insert),也知道一旦遇到“多表联查 + 复杂聚合”的场景,MP 的预置方法就无能为力了。这两点直接决定了你必须在项目里“保留 XML 能力”——MP 负责 80% 的简单 CRUD,MyBatis 负责 20% 的复杂 SQL,这个分工是当前很多商用项目的最佳实践。

6. 面试问答速查表和项目选型建议

6.1 高频面试题整理(附简洁参考答案)

面试题简洁回答要点
MyBatis 和 MyBatis-Plus 有什么区别MyBatis 是持久层框架,负责 SQL 映射;MP 是基于 MyBatis 的增强工具,只增强不改变,提供 BaseMapper 自动 CRUD、Wrapper 条件构造器、分页插件等
MP 是怎么做到不用写 SQL 的继承 BaseMapper 后,启动时通过 SqlInjector 把预置方法解析成 MappedStatement,本质还是走 MyBatis 的 SQL 执行链路
MP 是否完全替代 MyBatis不能,复杂 SQL 仍然需要手写 XML;MP 的设计理念就是增强 MyBatis 而不是替换
分页插件是物理分页还是逻辑分页物理分页,会针对数据库方言改写 SQL(MySQL 拼 LIMIT),和 PageHelper 类似但插件机制更安全
MP 的缓存和 MyBatis 一样吗一样,MP 不改变 MyBatis 缓存机制,一二级缓存规则完全相同
逻辑删除怎么实现@TableLogic 注解,MP 自动把 delete 改为 update 并拼接 deleted=0 条件
乐观锁插件怎么用@Version 字段 + OptimisticLockerInnerInterceptor 插件,版本号冲突则更新影响行数为 0
主键策略怎么选单库用 AUTO 或 ASSIGN_ID;分库分表用 ASSIGN_ID(雪花算法)保证全局唯一
MP 会影响性能吗单次执行的解析开销与 MyBatis XML 解析同量级,且 SQL 可缓存,影响可忽略;真正的性能瓶颈是 SQL 本身

面试官如果继续深挖“MP 是怎么解析 Wrapper 的”,你可以补充说:LambdaQueryWrapper里面的eq、like方法实际上是在内部拼接一个SqlSegment链表,selectList时由Wrapper的getSqlSegment方法生成 WHERE 子句,然后无缝拼接到 MP 预生成的SELECT * FROM user后面。整个过程在BaseMapper.selectList的SqlSource中完成,之后再交给 MyBatis 执行。

6.2 什么场景选 MyBatis,什么场景选 MP

选型不能只看“谁更高级”,要看团队构成和项目性质。

先说什么场景我建议“纯 MyBatis”:团队里全是喜欢手写 SQL 的“老炮儿”,对 XML 的掌控有洁癖;或者项目里有大量存储过程调用、复杂动态报表 SQL、多表联查占比超过一半;又或者项目已经成型多年,几十万行 XML 迁移成本极高——这种就别折腾着换 MP 了,引入它只会造成“两套风格并存”的维护困惑。

再说什么场景果断上 MP:新项目从零开始、团队里有较多初级开发、业务系统以单表 CRUD 为主、开发节奏紧张——这种项目用 MP 能显著提速。实测下来,一个 5 张表的用户中心模块,用原生 MyBatis 从写 XML 到联调至少小半天,用 MP 半小时内完成 Mapper、Service、Controller,还不容易出低级 SQL 错误。

最后说混用策略:坦白讲这就是我目前最推荐的方式。所有单表 CRUD 用 MP,所有复杂统计、多表查询、报表联查用 XML 里的自定义方法。既享受 MP 的效率,又不牺牲复杂 SQL 的可控性。唯一要注意的是约定:哪些场景必须走 XML、哪些场景可以走 Wrapper,把这个约定写进团队开发规范,比技术选型本身还重要。

6.3 从 MyBatis 迁移到 MP 的经验

如果你手里有个 MyBatis 老项目想逐步迁移到 MP,我给出一个低风险路径:

第一步,引入mybatis-plus-boot-starter,保留全部现有 Mapper XML 和注解不删,MP 完全兼容原生 MyBatis 项目。

第二步,新写的 Mapper 接口改成继承BaseMapper<T>。老接口不动,不强制迁移。这样新旧接口可以共存,MP 注入了新接口的预置方法,老接口继续按原样工作,彼此不干扰。

第三步,逐步把高频、基础的单表查询方法从 XML 迁移到 BaseMapper/Wrapper 上。每迁移一个,跑一遍回归测试,保证结果集一致。

最后一步,把 XML 里不再使用的冗余 SQL 删除或注释,并统一字段映射规则(map-underscore-to-camel-case)和主键 ID 策略,保证“迁移后行为完全一致”。

我实际做一个 20 张表老项目迁移时,一周内完成 80% 的单表 CRUD 迁移,剩下的复杂查询保持在 XML 里,整体风险非常可控。前提是项目里原有 SQL 必须规范命名、参数必须清晰,迁移时才发现原来老项目里“同名方法不同 SQL”的问题不少,这类历史债没法靠 MP 解决,只能靠重构消除了。

7. 多商户商城类项目里的实际使用场景

热词里出现“spring boot + mybatis 的 java 开源多商户跨境商城源码”,这其实是 MP 大显身手的一类典型项目。商城系统的后台管理端有大量单表 CRUD:商户表、商品表、订单表、用户表、支付流水表——每一类都有“按 ID 操作”“分页查询”“按状态筛选”“逻辑删除”的需求。这种场景如果全用 XML 手写,开发速度会非常低,而且随着表结构演进,需要同步修改的 XML 数量会滚雪球。

我在维护一个多商户商城项目时总结了一套打法:基础数据表(商户、商品类目、物流公司)全部继承BaseMapper,靠 LambdaQueryWrapper 做多条件筛选。订单这种复杂查询走 XML 自定义 SQL,因为订单要联表查买家信息、支付信息、物流信息,条件还涉及时间范围、状态组合、金额比较——这种查询用 Wrapper 硬拼会非常痛苦。

另外,商城项目非常依赖分页。后台列表页动辄就是“商户列表 + 入驻时间 + 状态筛选”的组合搜索,MP 的分页插件让每一页数据的返回格式统一化(IPage<T>自带 total、current、size、records),前端拿到total就能直接渲染分页器,不用后端再包装一层。

商城项目里 MP 的代码生成器也值得用起来。用Mybatis-Plus-Generator一键根据数据库表生成实体类、Mapper、Service、ServiceImpl、Controller、DTO、VO 骨架,再手工调整业务逻辑。一张订单表的 CRUD 模块,生成 + 调整半天内能完成,如果手写整个模块至少一天半。而且生成的实体类带好了@TableField和@TableId注解,字段映射再也不用担心下划线和驼峰的“翻译”问题了。

8. 项目里的“隐藏坑”和我的实操体会

最后分享几个我在项目里真实踩过、或者在培训和评审中反复提醒同事注意的坑。

第一,BaseMapper里的updateById默认忽略 null 字段。这意味着你把一个只设置部分字段的对象传进去,它会生成UPDATE user SET name=?, age=? WHERE id=?,没有 set 的字段不会被更新,这是 MP 的默认行为。不过如果你把对象里某个字段显式设为 null,updateById会直接跳过它。这就造成一个隐患:想把某字段更新为 null,直接用updateById是做不到的,必须用LambdaUpdateWrapper的.set(User::getEmail, null)来强制设置。我见过生产事故,运营把用户邮箱清空想设置为 null,结果 SQL 压根没处理这一列,数据一直没“清”掉。

第二,逻辑删除和唯一索引有冲突。你给deleted字段加了@TableLogic,逻辑删除后的数据还在表里占用着唯一索引。比如用户表为了防重复注册,给phone字段加了唯一索引,一个用户逻辑删除后再注册同手机号,数据库里其实存在两行“deleted=1 和 deleted=0”的记录,前者占了唯一索引,后者插入直接冲突。这种场景的解法一般是:唯一索引里把deleted字段也带上,比如UNIQUE KEY uk_phone(phone, deleted),但这样只能容忍一次逻辑删除;另一种方案是物理删除某些域的业务数据或用额外状态位处理。逻辑删除不是银弹,设计阶段就要想清楚。

第三,MetaObjectHandler的自动填充字段,在工厂式或反序列化生成对象时可能失效。自动填充是 MP 在“实体类参数传入方法时”动态填充的,这依赖 MP 的内部拦截器。如果你在非 MP 环境(如直接 new 出实体类后做 JSON 反序列化或通过构造函数生成)拿到对象,再传到updateById,updateTime 能不能自动填上,取决于你是否已经注册了MetaObjectHandler。实测下来注册后是能填上的,但如果你在代码里绕过了 Service 层直接用自定义 SQL,那些字段就不会被“魔法填充”了。越依赖框架“隐形能力”,越要提前约定好编码规范。

第四,MP 的@TableField(exist = false)忘了加也会出问题。实体类里加了个非表字段(比如一个联查出来的临时字段),忘了加exist=false,MP 生成 SQL 时会把这个字段当成表字段拼进去,结果要么 SQL 报错说“字段不存在”,要么更隐蔽——刚好表里有同名字段,被错误更新。我们团队把“实体类里的非表字段必须加 exist=false”写进了代码检查规范,尽量在 Code Review 阶段拦下来。

我自己在实际项目中用 MP 的体会是:它是一个“效率放大器”,能把后端开发从繁琐的单表 CRUD 中解放出来,把精力留给真正复杂的业务和 SQL 优化。但它毕竟只是增强工具,底层的 MyBatis 原理、SQL 执行流程、缓存机制、事务边界这些问题,你必须懂,否则遇到边界情况就会“知其然而不知其所以然”,排查问题全靠猜。最好的姿势是:用 MP 的便捷,持 MyBatis 的清醒,在业务里找到二者的平衡点。这也是我对所有新入行的 Java 开发同事的建议——先把 MyBatis 的 XML 映射、动态 SQL、缓存体系弄扎实,再上手 MP,你会发现它就是一个“装好了轮子”的 MyBatis,真正需要你发挥价值的是对 SQL 和业务的理解力,而不是框架本身有多“黑科技”。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询