Skip to content

MyBatis 与 MyBatis-Plus

MyBatis 四大核心对象生命周期、#{} 与 ${} 防 SQL 注入、一二级缓存机制与脏读、MyBatis-Plus BaseMapper 注入原理、LambdaQueryWrapper、分页插件与拦截器、自动填充。

Updated View as Markdown
For humans

MyBatis 与 MyBatis-Plus

校招考察频度:☆ 中频(持久层常考·国内企业级事实标准) 一句话速记:MyBatis 靠四大核心对象与拦截器驱动 SQL 执行,#{} 走 PreparedStatement 预编译根治注入,一级缓存会话级、二级缓存跨会话易脏读生产慎用;MyBatis-Plus 启动注入 MappedStatement 赋能 BaseMapper,LambdaQueryWrapper 借助方法引用杜绝列名硬编码,分页插件拦截 StatementHandler 自动改写 COUNT 与方言 LIMIT。

在国内互联网与企事业单位中,MyBatis / MyBatis-Plus 相比 JPA / Hibernate 拥有不可动摇的霸主地位。国内业务强调复杂业务查询的高度可控性、慢 SQL 索引精准优化与动态拼接。面试官在考察持久层时,核心深挖:MyBatis 执行链路底层四大对象、预编译防注入原理、缓存设计与脏读缺陷、以及 MyBatis-Plus 在工程提效中的核心底层机制。


一、0 基础生动类比:数据库的同声传译官与全自动预制菜厨房

如果把与数据库(MySQL)的每一次交互比作一位中国主厨去海外农贸市场采购食材:

组件 / 概念生动生活化比喻现实技术作用
SqlSessionFactory大型跨国采购公司总部单例存在,负责解析所有 XML / 注解配置,为系统全局构建工厂。
SqlSession主厨手里的一张单次采购单与对接窗口线程不安全,生命周期对应一次数据库事务或请求,用完必须关闭。
Executor(执行器)总部委派的专业调度采购员负责调度维护一级缓存、二级缓存,决定是直接查缓存还是叫工人下地干活。
StatementHandler农贸市场的多语种专业同声翻译官将 Java 对象与方法真正翻译成底层数据库识别的 JDBC Statement 并与 MySQL 通信。
#{} 预编译参数密封严实、贴有防伪条码的标准化冷链箱内容物被严格隔离当成普通数据处理,无论里面装什么都不会改变冷链车(SQL 语法)的行驶路线。
${} 字符串拼接直接把小纸条塞进大巴引擎排气管恶意分子在纸条上写“立即炸毁大楼”(SQL 注入),引擎照单全收,引发灾难。
MyBatis-Plus现代化的智能预制菜流水线告别每一道家常菜(基础增删改查)都手写一份冗长菜谱(手写 Mapper XML),自动配好预制包开袋即用。

二、MyBatis 底层架构与四大核心对象生命周期

MyBatis 执行一条 SQL 的底层流水线环环相扣:

MyBatis 底层四大核心对象执行流水线

四大核心对象(四大金刚)职责与插件拦截点:

  1. Executor:真正执行 SQL 的调度中枢,负责一级缓存和二级缓存的管理,以及事务的提交与回滚。
  2. StatementHandler:与 JDBC Statement 直接打交道,负责 SQL 语句的编译、预编译与组装。PageHelper 与 MyBatis-Plus 分页插件主要就是通过动态代理拦截 StatementHandler 来改写 SQL 的!
  3. ParameterHandler:负责将用户传入的 Java 参数类型,转换为 JDBC 支持的预编译占位符参数。
  4. ResultSetHandler:负责将 JDBC 查询结果集(ResultSet)根据字段映射规则,自动转换包装为对应的 Java 实体对象列表。

三、安全核心:#{} 与 ${} 的本质区别与防注入推导

这是校招高频常考题,核心不仅在于记住结论,更在于讲清底层 JDBC 机制。

1. 对比总结表

维度#{}(参数预编译)${}(字符串替换拼接)
底层机制使用 JDBC 的 PreparedStatement,将变量替换为 占位符 ?底层使用 JDBC 普通 Statement,直接进行纯文本字符串强行拼接
执行时机先将带 ? 的 SQL 模板发给数据库编译检查,随后再向 ? 填充参数数据在将 SQL 发给数据库前就完成了拼接,数据库拿到的是拼接完成的最终文本
安全性彻底杜绝 SQL 注入(数据被视为纯字面量,绝不可能改变语法树结构)极度不安全,极易引发 SQL 注入漏洞(如 ' OR '1'='1)
典型应用WHERE 子句的具体条件值(如 WHERE id = #{id})动态表名(FROM ${tableName})、动态排序列(ORDER BY ${columnName})

2. 为什么 #{} 能彻底杜绝 SQL 注入?

// 恶意攻击者传入用户名: admin' OR '1'='1
// 1. 若错误使用 ${} 拼接:
SELECT * FROM sys_user WHERE username = '${username}';
// 实际拼成的 SQL:
SELECT * FROM sys_user WHERE username = 'admin' OR '1'='1'; -- 语法树被篡改,恒真匹配,所有用户数据泄露!

// 2. 若正确使用 #{}:
SELECT * FROM sys_user WHERE username = #{username};
// 发给数据库进行语法树预编译的模板:
SELECT * FROM sys_user WHERE username = ?;
  • 核心原理:数据库在收到带有 ? 的 SQL 语句后,已经完成了词法分析、语法分析与执行计划优化(生成了固定的 AST 抽象语法树)。
  • 随后驱动程序将参数绑定进去,无论参数里包含了多少引号、分号还是关键字,数据库只会将其当成普通字符串内容,绝对不可能颠覆已经编译好的 SQL 语法结构,因此从根本上杜绝了 SQL 注入!

生产避坑:必须要用 ${} 动态传表名或排序列时怎么办?
必须在后端代码层结合强白名单校验(如判断传入的 columnName 是否属于枚举类的合法字段名),非白名单内容直接抛出异常拒绝拼接。


四、MyBatis 缓存机制与分布式并发脏读陷阱

MyBatis 设计了两级缓存,但两者的作用域与工业界落地完全不同:

1. 一级缓存(本地会话缓存)

  • 作用域:SqlSession 级别。在同一个 SqlSession 会话内,多次执行相同的 SQL 查询,后续查询直接从一级缓存的 HashMap 中读取,不打扰数据库。
  • 失效时机:
    • 两次查询之间执行了 insert / update / delete 并触发提交;
    • 手动调用了 sqlSession.clearCache() 或 sqlSession.close()。
  • 局限性:在微服务多实例环境下,不同的请求由不同的 SqlSession 或不同 Pod 处理,一级缓存范围极小。

2. 二级缓存(全局跨会话缓存)与生产致命缺陷

  • 作用域:Namespace(Mapper)级别。多个不同的 SqlSession 共享同一个 Mapper 的二级缓存。
  • 为什么互联网大厂生产环境通常显式禁用 MyBatis 自带二级缓存?
    • 严重的数据脏读问题(Dirty Read):二级缓存是基于单个 Mapper 的 Namespace 划分的。如果存在多表关联查询(如 UserMapper 联表查询了 Order 表数据并缓存在 User 下),当另一个事务在 OrderMapper 中更新了订单数据时,UserMapper 的二级缓存根本无法感知,用户查询将持续读取到过期的脏数据!
    • 无法天然适配多节点分布式集群:MyBatis 二级缓存默认存在单个应用进程的 JVM 内存中。节点 A 更新了数据,节点 B 的本地二级缓存依然是旧数据。
  • 工业界标准做法:完全关闭 MyBatis 二级缓存(cache-enabled: false),将缓存职责全部收归给专门的高性能分布式缓存中间件 Redis 统一管理与精准失效。

五、MyBatis-Plus 工业级增强核心原理

MyBatis-Plus(简称 MP)是针对 MyBatis 的开箱即用增强工具,在不改变 MyBatis 核心架构的前提下只做增强不做改变。

1. BaseMapper 通用 CRUD 自动注入原理

开发者只需编写一个空接口 public interface UserMapper extends BaseMapper<User>,无需写任何 XML 即可直接使用 insert, selectById, deleteList 等十几个常用方法。

  • 底层注入原理:
    • 在 Spring Boot 应用启动阶段,MyBatis-Plus 的 MybatisSqlSessionFactoryBean 会介入替代原生的工厂 Bean。
    • MP 提供的 ISqlInjector(SQL 自动注入器) 会扫描所有继承了 BaseMapper 的接口,通过 Java 反射提取其实体泛型类(如 User)。
    • 根据实体的属性名与数据库字段映射,动态拼装通用的 SQL 节点内容(包含动态判空 <if test="...">)。
    • 为每一个通用方法构造一个专属的 MappedStatement 对象,直接强行注册到全局唯一的 Configuration.mappedStatements 字典中。
    • 结论:虽然开发者没手写一行 XML,但在 MyBatis 底层眼里的结构与手写了庞大 XML 完全没有任何区别!

2. LambdaQueryWrapper 链式查询与类型安全

传统手写字符串查询极其容易因为拼写错误导致线上报错:

// 传统手写列名,极易手抖拼错 "user_name",编译期无法发现:
QueryWrapper<User> query = new QueryWrapper<>();
query.eq("user_name", "张三");

// ★ 现代最佳实践: LambdaQueryWrapper (编译期严格校验)
LambdaQueryWrapper<User> lqw = new LambdaQueryWrapper<>();
lqw.eq(User::getUsername, "张三")
   .ge(User::getAge, 18)
   .orderByDesc(User::getCreateTime);
List<User> list = userMapper.selectList(lqw);
  • 底层原理(如何通过 User::getUsername 获取数据库列名 user_name?):
    1. User::getUsername 是一个方法引用。在 Java 中,当它被赋值给一个实现了 Serializable 的函数式接口(MP 中的 SFunction)时,JVM 会在底层生成一个带有序列化信息的 SerializedLambda 实例。
    2. MP 通过反射调用该实例的 getImplMethodName() 方法,成功在运行时抽取出方法名字符串(例如 "getUsername")。
    3. 将 Getter 方法名去掉 get 前缀并将首字母小写得到实体字段名 "username"。
    4. 从之前启动时缓存的实体表元数据(TableInfo)中查找注解 @TableField("user_name") 或下划线命名映射,精准还原出数据库真正的列名 user_name!

3. 分页插件 PaginationInnerInterceptor 底层实现机制

MyBatis-Plus 的通用分页代码:

Page<User> page = new Page<>(1, 10); // 查询第 1 页,每页 10 条
IPage<User> userPage = userMapper.selectPage(page, lqw);
  • 底层拦截与 SQL 改写流程:
    1. 开发者调用 selectPage 时,传入 Page 对象。
    2. 插件基于 MyBatis 的拦截器机制,拦截了 StatementHandler.prepare() 方法。
    3. 第一步改写(自动统计总记录数):拦截器抽取原业务 SQL,使用 JSqlParser 语法树解析器自动改写生成一条 SELECT COUNT(1) FROM (原 SQL) 查询,先行发送给数据库获取 total 总记录数。若 total 为 0 则直接返回空页,避免后续无意义查询。
    4. 第二步改写(自动追加分页方言):根据当前配置的数据库类型(如 MySQL),直接在原 SQL 末尾智能拼接方言字符串:LIMIT 0, 10。
    5. 最终将执行后的数据集封装回 Page 对象中返回给前端。

4. 自动填充与高频工程插件

① 自动填充 MetaObjectHandler

杜绝在业务代码中手动反复 user.setCreateTime(new Date()):

// 实体类上配置
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;

@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;

// 统一元数据处理器组件
@Component
public class MyMetaObjectHandler implements MetaObjectHandler {
    @Override
    public void insertFill(MetaObject metaObject) {
        this.strictInsertFill(metaObject, "createTime", LocalDateTime::now, LocalDateTime.class);
        this.strictInsertFill(metaObject, "updateTime", LocalDateTime::now, LocalDateTime.class);
    }

    @Override
    public void updateFill(MetaObject metaObject) {
        this.strictUpdateFill(metaObject, "updateTime", LocalDateTime::now, LocalDateTime.class);
    }
}

② 逻辑删除与乐观锁防并发写

  • 逻辑删除(@TableLogic):字段标注 @TableLogic private Integer deleted;。执行 deleteById 时,底层自动改写为 UPDATE sys_user SET deleted = 1 WHERE id = ? AND deleted = 0;所有查询方法自动隐式追加 WHERE deleted = 0 过滤条件。
  • 乐观锁插件(@Version):在版本号字段上加 @Version private Integer version;。执行更新时,MP 自动拼接版本号匹配与自增:UPDATE sys_user SET age = 20, version = version + 1 WHERE id = 1 AND version = 1,如果返回影响行数为 0,说明并发冲突被其他事务先修改了,防止并发覆盖。

六、校招高频面试追问

  1. 既然有了 MyBatis-Plus 这么方便的 BaseMapper,为什么实际大厂项目依然少不了写 XML?
    • 答:
      • MP 的 BaseMapper 与 Wrapper 非常适合单表的通用基础增删改查与轻量条件过滤,大幅提升了 80% 的开发效率。
      • 但是在面对多表复杂 JOIN 关联查询、动态复杂的报表聚合统计、超大表深分页查询的延迟关联优化(基于覆盖索引自连接)、以及极端严格的 SQL 调优场景下,手写 XML 的可读性、维护性与执行计划掌控力远胜于将复杂逻辑硬塞进 Java 代码的 Wrapper 中。因此大厂标准规范为:“单表增删改查首选 MP Wrapper,跨表复杂高频慢 SQL 强制回归 XML 精雕细琢”。
  2. 在 MyBatis 中,如何获取自增主键的值?
    • 答:
      • 在 XML 映射文件的 <insert> 标签中配置 useGeneratedKeys="true" 和 keyProperty="id"(对应实体类的主键属性名)。
      • 底层基于 JDBC 的 getGeneratedKeys() 方法获取数据库自增生成的主键,在执行完插入后,MyBatis 会自动将生成的主键反射设置给传入的实体类对象主键属性。
  3. MyBatis 插件(Interceptor)能拦截哪些核心对象?其底层基于什么设计模式?
    • 答:
      • 能拦截的四大对象:Executor、StatementHandler、ParameterHandler、ResultSetHandler。
      • 底层设计模式:基于 JDK 动态代理与责任链模式(Chain of Responsibility)。MyBatis 在创建上述四大对象时,都会调用 interceptorChain.pluginAll(target),通过一层层动态代理拦截器链(Plugin 对象)对目标对象进行无侵入动态包装。
Navigation

Type to search…

↑↓ navigate↵ selectEsc close