🗃️ MyBatis
#{} 与 ${} · Mapper 动态代理 · 一级/二级缓存 · 插件四大接口 · Spring 集成
1. MyBatis 是什么?和 Hibernate / JPA 有什么区别?
MyBatis 是持久层框架(SQL 映射框架),官方定位是「半自动 ORM」:SQL 由你写,它负责把 SQL 和 Java 方法绑定、把结果集自动映射成对象。
| 对比项 | MyBatis(半自动) | Hibernate / JPA(全自动) |
|---|---|---|
| SQL 控制 | 手写 SQL,完全可控 | 框架生成 SQL,复杂查询难优化 |
| 学习成本 | 低(会 SQL 就行) | 高(要懂 HQL/JPQL、缓存、懒加载) |
| 性能调优 | 直接改 SQL,配合 EXPLAIN | 要理解生成规则,难精细控制 |
| 数据库移植 | 换库要改 SQL | 方言自动适配,移植性好 |
| 国内使用 | 主流(互联网公司几乎都用) | 较少(部分传统企业/Spring Data JPA) |
🎯 一句话回答
- MyBatis = SQL 映射框架:把「SQL 写在 XML/注解里」和「Java 方法」绑定起来,结果自动变对象,省掉 JDBC 那套 ResultSet 手工封装
- 它不是全自动 ORM——不替你生成 SQL,所以性能可控,这也是它流行的原因
2. #{} 和 ${} 的区别?(必考)
#{} | ${} | |
|---|---|---|
| 底层 | PreparedStatement 占位符 ? | 字符串直接拼接 |
| SQL 注入 | 安全(参数被当值处理) | 有注入风险 |
| 预编译 | 会预编译,可复用执行计划 | 每次都是新 SQL,无法复用 |
| 适用场景 | 几乎所有参数传值 | 只能用于动态表名/列名/order by 字段 |
对比示例
<!-- ✅ 安全:编译成 SELECT * FROM user WHERE name = ? -->
SELECT * FROM user WHERE name = #{name}
<!-- ❌ 危险:假如 name 传 ' OR 1=1 -- ,SQL 变成 -->
<!-- SELECT * FROM user WHERE name = '' OR 1=1 -- ' → 全表泄露 -->
SELECT * FROM user WHERE name = ${name}
<!-- ✅ ${} 的正确用法:只能拼接「结构」,不能拼接「值」 -->
SELECT * FROM ${tableName} ORDER BY ${sortColumn}
🎯 面试要点
#{}是预编译占位符(推荐,默认);${}是字符串拼接(危险)- ${} 必须用白名单校验:例如
order by的字段名要在允许列表里比对,否则依然是注入漏洞 - 追问:「为什么 #{orderBy} 不能用在 order by 上?」——因为预编译占位符会被当成值(字符串常量),变成
ORDER BY 'create_time',排序失效
3. Mapper 接口没有实现类,为什么能调用?(动态代理)
这是 MyBatis 设计最巧妙的地方,也是高频追问。
- 扫描注册:启动时
@MapperScan触发MapperScannerConfigurer(或 Spring Boot 的@MapperScan),扫描包下所有接口,为每个接口注册一个MapperFactoryBean到容器 - FactoryBean 生产代理:
MapperFactoryBean实现了FactoryBean<T>,它的getObject()返回的是 JDK 动态代理对象(MapperProxy) - 调用拦截:调用 Mapper 方法时进入
MapperProxy.invoke(),根据「接口全限定名 + 方法名」定位到对应的MappedStatement - 执行:交给
SqlSession执行,最终走 JDBC
MapperProxy 的核心逻辑(简化)
public Object invoke(Object proxy, Method method, Object[] args) {
if (Object.class.equals(method.getDeclaringClass())) {
return method.invoke(this, args); // toString/hashCode 等直接放行
}
// 关键:接口全限定名 + 方法名 = statementId
return execute(method, args);
}
🎯 面试要点
- 为什么用 JDK 动态代理而不是 CGLIB?——因为 Mapper 本来就是接口,JDK 动态代理正是为接口设计的(CGLIB 是给类生成子类)
- @Mapper 和 @MapperScan 的区别?——
@Mapper标在单个接口上;@MapperScan标在启动类上,批量扫描整个包(更常用) - Mapper 接口能重载方法吗?——不能。因为 statementId 是「接口名 + 方法名」,重载会产生相同 id 而报错
4. 一级缓存和二级缓存?(必考)
| 一级缓存(本地缓存) | 二级缓存(全局缓存) | |
|---|---|---|
| 作用域 | SqlSession 级别 | namespace(Mapper)级别 |
| 默认 | 开启,无法关闭(只能改作用域) | 关闭,需手动开启 |
| 存储 | 内存中的 PerpetualCache(HashMap) | 可配置(内存/Redis/Ehcache) |
| 失效时机 | SqlSession 关闭/提交/回滚;执行了 update/insert/delete;手动 clearCache() | 同一 namespace 有写操作;或配置了 flushCache |
开启二级缓存
<!-- ① mapper.xml 里加一行 -->
<cache eviction="LRU" size="1024" flushInterval="60000"/>
<!-- ② 实体类要实现 Serializable(缓存可能被序列化到磁盘) -->
public class User implements Serializable { ... }
<!-- ③ 或在注解/配置里设置 -->
mybatis.configuration.cache-enabled=true
🎯 面试要点
- 一级缓存为什么在 Spring 里经常「失效」?——因为 Spring 整合后,每次查询可能拿到不同的 SqlSession(SqlSessionTemplate 每次操作后关闭会话),所以一级缓存基本用不上。这也是很多人说「一级缓存没效果」的原因
- 二级缓存的坑——① 跨 namespace 会脏读(两张表关联查询写在一个 namespace 里,另一个 namespace 更新了数据,这边不知道);② 多表操作慎用;③ 分布式环境下本地二级缓存不一致,要用 Redis 做二级缓存
- 实践建议——生产环境一般关掉 MyBatis 二级缓存,统一用 Redis 做应用级缓存,可控性更好
5. MyBatis 插件(拦截器)原理?
MyBatis 插件本质是责任链 + 动态代理:它允许你在执行 SQL 的关键节点插入自定义逻辑(分页、SQL 监控、多租户、字段加解密)。
- 能拦截的四个接口(记住这四个就够):
Executor(执行器,最常用)、StatementHandler(SQL 语法构建)、ParameterHandler(参数处理)、ResultSetHandler(结果集处理) - 实现步骤——实现
Interceptor接口 → 加@Intercepts注解声明要拦截的签名 → 在intercept()里写逻辑 → 用@Bean注册到容器
一个统计 SQL 耗时的插件
@Intercepts({@Signature(type = Executor.class,
method = "query",
args = {MappedStatement.class, Object.class,
RowBounds.class, ResultHandler.class})})
public class SqlCostInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
long start = System.currentTimeMillis();
try {
return invocation.proceed(); // 放行到下一个插件/真实方法
} finally {
long cost = System.currentTimeMillis() - start;
if (cost > 500) {
MappedStatement ms = (MappedStatement) invocation.getArgs()[0];
System.out.println("慢 SQL: " + ms.getId() + " 耗时 " + cost + "ms");
}
}
}
}
🎯 面试要点
- 分页插件 PageHelper 就是这个原理——拦截
Executor.query,先查 count 再改写 SQL 加 limit - 多个插件怎么排序?——MyBatis 会按插件注册顺序层层代理,执行时后注册的先执行(类似栈)
- 为什么要用
Plugin.wrap()?——它判断被代理对象是否实现了目标接口,是则返回Plugin代理,否则原样返回(避免无谓代理)
6. MyBatis 怎么和 Spring 集成?事务怎么保证?
- SqlSessionFactoryBean:Spring 用它创建
SqlSessionFactory,注入数据源和 MyBatis 配置 - MapperScannerConfigurer / @MapperScan:扫描 Mapper 接口,注册成
MapperFactoryBean - SqlSessionTemplate:线程安全的 SqlSession 代理。每次操作从 Spring 事务同步管理器拿当前线程绑定的 SqlSession
- 关键点:SqlSession 也被放进
TransactionSynchronizationManager的 ThreadLocal 里,和数据库连接绑定
事务为什么能生效(源码级理解)
// 事务开始:连接绑定到当前线程的 ThreadLocal
TransactionSynchronizationManager.bindResource(dataSource, connectionHolder);
// Mapper 执行:SqlSessionTemplate 从同一个 ThreadLocal 拿 SqlSession
// → 拿到的自然也是同一个 Connection
// → 所以多条 SQL 在同一个事务里
// 如果不用同一个连接:每条 SQL 各自 autocommit,事务就失效了
🎯 面试要点
- 「Spring 事务 + MyBatis」的核心——AOP 开事务 + ThreadLocal 绑定连接,两个机制缺一不可。SqlSession 必须参与这个绑定,否则事务无效
- 为什么 SqlSessionTemplate 是线程安全的?——它本身不持有连接状态,每次都从 ThreadLocal 取当前会话,用完就还
- Mapper 能直接 @Autowired 吗?——可以,因为容器里注册的是
MapperFactoryBean生产的代理对象,注入时拿到的是代理
7. 常用技巧:ResultMap、动态 SQL、批量操作、分页
ResultMap——解决「数据库字段下划线 vs Java 驼峰」和复杂关联映射:
ResultMap 与一对多
<!-- 开启驼峰自动映射(最常用,省掉大部分 ResultMap) -->
mybatis.configuration.map-underscore-to-camel-case=true
<!-- 复杂映射:一个订单关联多个订单项 -->
<resultMap id="orderMap" type="Order">
<id column="id" property="id"/>
<result column="user_id" property="userId"/>
<collection property="items" ofType="OrderItem">
<id column="item_id" property="id"/>
<result column="product_name" property="productName"/>
</collection>
</resultMap>
动态 SQL:if / choose / foreach
<select id="search" resultType="User">
SELECT * FROM user
<where>
<if test="name != null and name != ''"> AND name LIKE CONCAT('%', #{name}, '%') </if>
<if test="minAge != null"> AND age >= #{minAge} </if>
</where>
</select>
<!-- 批量插入:foreach 拼 VALUES,注意 #{item.name} -->
<insert id="batchInsert">
INSERT INTO user (name, age) VALUES
<foreach collection="list" item="item" separator=",">
(#{item.name}, #{item.age})
</foreach>
</insert>
<!-- 批量 IN 查询:注意 or 也要用 foreach 包起来避免语句错误 -->
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
- 分页——物理分页用
PageHelper(拦截器改写 SQL 加 limit);逻辑分页(RowBounds)会把全表查出来再内存分页,大表会 OOM,不要用 - #{} 的常见坑——
LIKE要用CONCAT('%', #{name}, '%'),不能写%#{name}% - 批量操作注意——
foreach拼的 SQL 太长会超max_allowed_packet,要分批(如每 500 条一批)
🎯 面试要点
<where>标签会自动去掉开头的 AND/OR;<set>会自动去掉末尾逗号<choose><when><otherwise>相当于 switch-case- 动态 SQL 的坑——
test里判断字符串要用!= null and != '',只判 null 会遇到空串
8. 高频追问汇总(10 条)
- #{} 和 ${} 的区别?——前者预编译占位符(安全),后者字符串拼接(注入风险,只用于表名/列名且要白名单校验)
- Mapper 为什么不用写实现类?——
MapperFactoryBean用 JDK 动态代理生成MapperProxy,调用时按「接口名 + 方法名」找到MappedStatement - 一级缓存和二级缓存的区别?——一级是 SqlSession 级(默认开,Spring 下基本失效);二级是 namespace 级(默认关,有跨 namespace 脏读风险)
- 为什么生产环境常关掉二级缓存?——跨 namespace 脏读 + 分布式下本地缓存不一致;统一用 Redis 更可控
- 插件的四个可拦截接口?——Executor、StatementHandler、ParameterHandler、ResultSetHandler
- MyBatis 怎么保证事务?——靠 Spring:AOP 开事务 + ThreadLocal 绑定 Connection,SqlSession 参与同一绑定
- Mapper 方法能重载吗?——不能,statementId 会冲突
- 怎么防止全表更新/删除?——可以用插件拦截,检测 update/delete 没有 where 条件时直接抛异常
- 延迟加载是什么?——关联对象用到时才查(
fetchType="lazy"/lazyLoadingEnabled=true)。缺点:容易触发 N+1 查询,要么用 join 一次查完,要么用fetchType="eager" - MyBatis 和 MyBatis-Plus 的区别?——MP 在 MyBatis 上封装了 通用 CRUD(BaseMapper)、条件构造器(QueryWrapper)、分页插件、逻辑删除、自动填充。本质还是 MyBatis,复杂 SQL 仍要自己写
🎯 最后的提醒
- MyBatis 面试的三个核心点:①
#{}vs${};② Mapper 动态代理;③ 一二级缓存。把这三个讲透,基本够用 - 追问往深处走就是「和 Spring 的事务绑定」,能讲到 ThreadLocal 就是加分项