主题
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 Statement | SQL 改写、预编译、批处理 |
| ParameterHandler | Java 参数 → JDBC 参数 | TypeHandler 在这里工作 |
| ResultSetHandler | ResultSet → 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: 1Preparing 行是 ? 而不是拼接后的值,这就是 #{} 的直接证据。
生产动作
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 接口全限定名与 XMLnamespace、方法名与 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。