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 设计最巧妙的地方,也是高频追问。

  1. 扫描注册:启动时 @MapperScan 触发 MapperScannerConfigurer(或 Spring Boot 的 @MapperScan),扫描包下所有接口,为每个接口注册一个 MapperFactoryBean 到容器
  2. FactoryBean 生产代理:MapperFactoryBean 实现了 FactoryBean<T>,它的 getObject() 返回的是 JDK 动态代理对象(MapperProxy)
  3. 调用拦截:调用 Mapper 方法时进入 MapperProxy.invoke(),根据「接口全限定名 + 方法名」定位到对应的 MappedStatement
  4. 执行:交给 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 集成?事务怎么保证?

  1. SqlSessionFactoryBean:Spring 用它创建 SqlSessionFactory,注入数据源和 MyBatis 配置
  2. MapperScannerConfigurer / @MapperScan:扫描 Mapper 接口,注册成 MapperFactoryBean
  3. SqlSessionTemplate:线程安全的 SqlSession 代理。每次操作从 Spring 事务同步管理器拿当前线程绑定的 SqlSession
  4. 关键点: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 就是加分项
⚠️ 本页由人工整理,仍建议以官方文档和实际源码为准。