跳到正文

MyBatis 核心原理 ​

一句话:MyBatis 用 JDK 动态代理把 Mapper 接口调用翻译成 JDBC 执行,把 SQL 控制权留给开发者;换来可精确优化的 SQL,代价是映射、缓存、延迟加载的边界都要自己管。

适用版本:MyBatis 3.5.x,示例用 H2 2.x / JDK 17。不同数据库的驱动行为可能不同,以官方文档为准。

为什么需要 ​

JDBC 的样板代码很长:拿连接、建 PreparedStatement、设参数、遍历 ResultSet、处理异常、关资源。全自动 ORM 能消掉样板,但在复杂查询上容易生成不可控的 SQL。

MyBatis 选择中间路线:保留手写 SQL,用 Mapper 接口 + 动态代理消掉 DAO 实现类,用 ResultMap / TypeHandler 做结果映射,用插件在四大组件上插入分页、审计、脱敏等逻辑。

核心机制 ​

执行链路 ​

组件职责关键点
Executor执行入口,维护一级/二级缓存分页、读写分离插件通常在这里
StatementHandler封装 JDBC StatementSQL 改写、预编译、批处理
ParameterHandlerJava 参数 → JDBC 参数TypeHandler 在这里工作
ResultSetHandlerResultSet → Java 对象嵌套映射、延迟加载在这里发生

MapperProxy 缓存 Method → MapperMethod,MapperMethod 保存 statement id、参数映射和返回类型。Mapper 接口没有实现类,运行时由 MapperProxyFactory 生成代理。

#{} 与 ${}:参数和 SQL 结构分阶段处理 ​

维度#{}${}
处理方式生成 ? 占位符,走 PreparedStatement直接把值拼进 SQL 文本
注入风险参数作为数据传入,不改变 SQL 结构用户输入可改变 SQL 语义
适用条件值、插入值、更新值表名、列名、ORDER BY 等无法预编译的结构
执行计划可能被数据库/驱动缓存复用每次文本不同,通常无法复用

#{} 的防护不是“转义特殊字符”,而是 SQL 结构先编译、参数值后绑定:数据库看到的是 where name = ?,输入 ' OR '1'='1 只会被当成一个字符串值。

两个容易说过头的地方:预编译语句不等于执行计划一定复用,是否复用由数据库和驱动决定;${} 也不是完全不能用,动态表名、列名、排序字段只能拼接,但必须做白名单校验。

Mapper 代理与延迟加载 ​

  • SqlSession.getMapper() 返回 MapperProxy,它持有 SqlSession 引用;原生 MyBatis 中 SqlSession 不是线程安全的,不要跨线程共享同一个 Mapper 代理。
  • Spring 整合后 Mapper 代理持有 SqlSessionTemplate,每次调用从事务上下文取或新建 SqlSession,所以可以注入单例,详见 04 篇。

延迟加载配置:

xml
<settings>
    <setting name="lazyLoadingEnabled" value="true"/>       <!-- 默认 false -->
    <setting name="aggressiveLazyLoading" value="false"/>   <!-- 默认 false(3.4.1+) -->
    <setting name="lazyLoadTriggerMethods"
             value="equals,clone,hashCode,toString"/>
</settings>
xml
<resultMap id="userMap" type="User">
    <id property="id" column="id"/>
    <association property="dept" column="dept_id"
                 select="selectDeptById" fetchType="lazy"/>
</resultMap>

MyBatis 为关联对象生成 Javassist(默认)或 CGLIB 代理。首次访问 getter 触发查询,结果写入代理内部,后续访问直接返回。它不会把外层对象里的代理字段替换成真实对象;把代理序列化或跨 session 传递,仍会碰到代理和 session 的问题。

异常名要分清:LazyInitializationException 是 Hibernate/JPA 的异常,不是 MyBatis 的。MyBatis 在 SqlSession/Executor 关闭后再触发延迟加载,常见的是 ExecutorException: Executor was closed.;在序列化或代理替换路径上也可能看到 PersistentObjectException 或代理对象失效。解决方向是在 session 存活期内把需要的数据取完(DTO 投影、JOIN、批量查询),而不是把异常名当成配置问题。

实战场景 ​

一、order by ${sort}:唯一必须白名单的地方 ​

现象:安全团队扫描后台列表接口,报告 sort 参数存在 SQL 注入;复现方式是 sort=id;select+sleep(5) 让响应延迟 5 秒。接口是内部运营系统,但同一个 Mapper 写法被复制到了 12 个列表页。

排查:判定证据只看 SQL 日志一行。

定位层取证据判定
是否走预编译看 ==> Preparing: 里有没有拼进来的值出现 order by id desc 而不是 ? → 用了 ${}
全仓范围搜 ${ 在 XML 与注解 SQL 里的出现位置逐个分类:结构片段可留,值必须改
参数来源该参数是否直接来自请求体/前端列名可控即视为注入点
数据库侧后果是否拼进 where、limit、子查询拼进 where 可改写语义,最严重

#{} 的防护来自“结构先编译、值后绑定”,排序字段属于 SQL 结构,数据库不接受 order by ? 作为列名,所以只能拼接——这就是它必须白名单的原因,不是写法偏好。

对症做法:

java
// 1. 前端传“列别名”,服务端映射到固定 SQL 片段(唯一可靠做法)
private static final Map<String, String> SORTABLE = Map.of(
        "id", "t.id", "createTime", "t.create_time", "name", "t.user_name");
String orderBy = SORTABLE.getOrDefault(req.getSort(), "t.id");   // 非法值落回默认,不报错也不拼接
boolean desc = Boolean.TRUE.equals(req.getDesc());

// 2. XML 只接收映射结果,不接受自由文本
//    order by ${@com.xxx.SafeSort@of('id')}   ← 不要用工具类反射绕过白名单
java
// 3. 加一条断言测试,把“非法输入不改变 SQL”固化
@Test void illegalSortFallsBackToId() {
    String sql = SqlCapturer.capture(() -> mapper.page(reqWith(sort="id;drop table users")));
    assertThat(sql).contains("order by t.id").doesNotContain("drop table");
}

limit、分页偏移同理:MyBatis 的分页插件会把数值算进 SQL,业务代码里的 limit ${size} 一样要改成 #{} 或在服务层做区间校验。

复盘要点:注入判定要留下可复查的证据——Preparing 行截图进工单。规则可以写成一句:值一律 #{},结构一律白名单;把这条做成静态检查(${ 出现即 CI 失败,除少数标记过的文件),比 code review 稳定。

二、一级缓存在事务里给出旧值 ​

现象:库存扣减后立刻统计,同一个事务里两次查询返回同一个数字,但库里实际已经变了;接口对外表现为“扣完还是显示原库存”,每天约 10 余笔工单。

排查:一级缓存命中的直接证据是“第二次调用没有 SQL”。

定位层取证据判定
是否命中缓存打开 StdOutImpl 或 logging.level.<mapper包>=DEBUG,数 Preparing 条数两次查询只有一条 SQL → 命中一级缓存
缓存作用域localCacheScope 配置(默认 SESSION)SESSION 下同 SqlSession 内一直复用
session 边界Spring 下是否在同一事务内事务内通常共用一个 SqlSession,一级缓存生命周期等于事务
是否有旁路写期间是否有别的连接/服务/定时任务改过同一行有 → 缓存不可能感知外部变化

关键点在于一级缓存失效只发生在同一 SqlSession 内执行了 update/insert/delete 时;另一个事务、另一个服务甚至另一条连接写的值,不会触发失效。所以它不是“请求级缓存”,也不保证事务内读到数据库最新值。

对症做法:

xml
<!-- 方案 1:统计类查询每次跳出缓存(代价是同事务内重复查询会回表) -->
<settings><setting name="localCacheScope" value="STATEMENT"/></settings>
java
// 方案 2:只在需要的 statement 上禁用,保留其余缓存收益
@Options(flushCache = Options.FlushCachePolicy.TRUE)
@Select("select qty from stock where sku = #{sku}")
int qtyForSku(String sku);

// 方案 3(推荐):写后读不要依赖缓存语义,直接用数据库的返回值
int updated = mapper.deduct(sku, n);       // update 影响行数
if (updated == 0) throw new SoldOutException(sku);   // 以数据库判定,不查回来再判断

mybatis-spring 下若需要“读己之写”,最稳的做法是让判断依赖受影响行数或唯一键冲突,而不是“写完再查一次”。

复盘要点:把这条边界写进规范——一级缓存是 SqlSession 内的重复查询优化,不是并发正确性保障。需要跨请求缓存时才考虑二级缓存或 Redis,见 MyBatis 缓存机制;统计口径类问题优先用一次聚合 SQL 在服务层算清,别在缓存语义上绕。

三、Executor was closed. 与 N+1:同一个配置引发两种故障 ​

现象:部门树接口一次返回 200 个用户,P99 从 30ms 涨到 1.2 秒;同一天另一台机器报 ExecutorException: Executor was closed.。两处故障来自同一次改动:为了“减少查询”打开了 lazyLoadingEnabled=true。

排查:

定位层取证据判定
SQL 条数一次请求的日志里 Preparing 计数200 条按 dept_id 的单行查询 → N+1,形状是 1 + N
触发时机是否在 session 关闭后才访问 user.getDept()事务外访问 → Executor was closed.
触发方法lazyLoadTriggerMethods 默认含 equals,clone,hashCode,toString只打日志调 toString() 也会触发全量加载
代理是否外泄序列化返回值 / 把对象放进缓存 / 放进 MQ代理字段仍是代理,不会被换成真实对象

异常名不要混用:LazyInitializationException 属于 Hibernate/JPA;MyBatis 在 session 关闭后触发延迟加载看到的是 ExecutorException: Executor was closed.。

对症做法:

xml
<!-- 1. 改 JOIN:一次取完,映射到嵌套 resultMap -->
<select id="listWithDept" resultMap="userMap">
  select u.id, u.name, d.id as d_id, d.name as d_name
  from users u left join dept d on d.id = u.dept_id
  where u.dept_id in <foreach collection="ids" item="i" open="(" separator="," close=")">#{i}</foreach>
</select>
java
// 2. 需要“按批取关联数据”时,用二次批量查询替代逐行 lazy
List<User> users = userMapper.list(ids);
Map<Long, Dept> deptMap = deptMapper.listByIds(collectDeptIds(users))
        .stream().collect(toMap(Dept::getId, identity()));
users.forEach(u -> u.setDept(deptMap.get(u.getDeptId())));   // 2 次查询,可预测
java
// 3. 对外一律返回投影,不把带代理的实体放进 JSON / 缓存 / MQ
public record UserView(Long id, String name, String deptName) {}

生产配置保持 lazyLoadingEnabled=false、aggressiveLazyLoading=false(默认值),把“取全数据”的责任放在查询设计上,而不是代理语义上。

复盘要点:延迟加载换来的是“没用到就不查”,代价是查询次数和数据访问时机都变得不可见。判据要能量化:给核心列表接口加 SQL 条数指标(每请求 Preparing 数),超过阈值(例如 5 条,经验值,需校准)直接告警;这比压测时才发现 P99 尖刺早得多。

最小上手 ​

一个可运行的 MyBatis 3.5.x + H2 工程,验证 Mapper 代理与参数绑定:

xml
<!-- pom.xml:mybatis 3.5.16 + h2 2.2.224 -->
<dependency><groupId>org.mybatis</groupId><artifactId>mybatis</artifactId><version>3.5.16</version></dependency>
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><version>2.2.224</version></dependency>
java
import java.util.Map;
import org.apache.ibatis.annotations.*;
import org.apache.ibatis.datasource.pooled.PooledDataSource;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.session.*;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
public class Main {
    public interface UserMapper {
        @Select("select id, name from users where id = #{id}")
        Map<String, Object> selectById(long id);
        @Insert("insert into users(id, name) values (#{id}, #{name})")
        int insert(Map<String, Object> user);
    }
    public static void main(String[] args) throws Exception {
        var cfg = new Configuration();
        cfg.setMapUnderscoreToCamelCase(true);
        cfg.setLogImpl(org.apache.ibatis.logging.stdout.StdOutImpl.class);
        cfg.setEnvironment(new Environment("dev", new JdbcTransactionFactory(),
            new PooledDataSource("org.h2.Driver",
                "jdbc:h2:mem:demo;DB_CLOSE_DELAY=-1", "sa", "")));
        cfg.addMapper(UserMapper.class);
        var factory = new SqlSessionFactoryBuilder().build(cfg);
        try (var session = factory.openSession(true)) {
            session.getConnection().createStatement().execute(
                "create table users(id bigint primary key, name varchar(20))");
            var mapper = session.getMapper(UserMapper.class);
            mapper.insert(Map.of("id", 1L, "name", "Ada"));
            System.out.println(mapper.selectById(1L).get("NAME"));   // Ada
        }
    }
}

日志里应出现:

text
==>  Preparing: select id, name from users where id = ?
==> Parameters: 1(Long)
<==      Total: 1

Preparing 行是 ? 而不是拼接后的值,这就是 #{} 的直接证据。

生产动作 ​

  • lazyLoadingEnabled=false(默认 false):默认关闭,避免 session 生命周期问题;确需开启时配合 DTO/JOIN,不要依赖 OpenSessionInView。
  • mapUnderscoreToCamelCase=false(默认 false):user_name → userName 需显式开启;开启后仍要检查别名冲突。
  • localCacheScope=SESSION(默认):一级缓存作用域;设为 STATEMENT 可让每次查询后清空,代价是同一事务内重复查询也回表。
  • jdbcTypeForNull=OTHER(默认):Oracle 等数据库对 null 参数类型敏感,插入 null 报错时按字段显式指定 jdbcType。
  • 排障:BindingException: Invalid bound statement (not found) → 查 Mapper 接口全限定名与 XML namespace、方法名与 statement id、XML 是否在 mappers/mapper-locations 中。
  • 排障:参数为 null 或类型不对 → 看 SQL 日志的 Parameters: → 查 #{} 属性名、@Param、TypeHandler、驼峰映射。
  • 排障:Executor was closed. → 查是否在事务外访问延迟加载属性 → 在 session 内取完或改 DTO 投影。

边界 ​

  • ${} 只用于结构片段:表名、列名、排序字段必须白名单;值一律 #{}。
  • 延迟加载依赖 session:session 关闭后再访问代理必然失败;OpenSessionInView 只是延长连接持有时间,不解决 N+1 和连接池压力。
  • MyBatis 不是全自动 ORM:没有 JPA 的持久化上下文、脏检查、自动级联;对象状态和 SQL 都要自己管理。
  • 一级缓存不是“请求级缓存”:生命周期跟 SqlSession 走;Spring 下通常等于事务范围,不是整个 HTTP 请求。

讲清楚 ​

MyBatis 的核心是“把 SQL 的控制权留在开发者手里,同时用代理消掉 DAO 样板”。Mapper 接口没有实现类,调用被 MapperProxy 拦截,转成 MapperMethod,再由 Executor → StatementHandler → ParameterHandler → ResultSetHandler 完成执行和映射。

#{} 和 ${} 的区别不是“安全/不安全”这么简单:#{} 把参数作为 ? 绑定,SQL 结构先编译、值后传入;${} 直接拼文本,只能用于表名、列名这类无法预编译的结构,并且必须白名单。

延迟加载常被误解成“第一次访问后把代理换成真实对象”。实际是代理内部缓存了加载结果,外层引用仍是代理;session 一关,再触发就会报 Executor was closed. 之类的错误。生产上优先用 DTO/JOIN 在 session 内取完,而不是延长 session。

把一门技术讲透到既懂也会用