你写的 Mapper 接口,MyBatis 到底是怎么把它变成 SQL 的?
只写了一个接口,没有实现类,MyBatis 却能在运行时帮你执行 SQL——它是怎么做到的?这篇从 SqlSession 工厂、Mapper 代理、SQL 解析到参数绑定,把 MyBatis 的执行链路一次拆透。
一、从最原始的用法说起
大多数人第一次用 MyBatis,都是先写一个 XML,再写一个接口:
<!-- UserMapper.xml -->
<mapper namespace="com.example.mapper.UserMapper">
<select id="selectById" resultType="com.example.entity.User">
SELECT * FROM user WHERE id = #{id}
</select>
</mapper>
// UserMapper.java —— 只声明了接口,没有实现类
public interface UserMapper {
User selectById(@Param("id") Long id);
}
然后神奇的事情发生了:
UserMapper mapper = sqlSession.getMapper(UserMapper.class);
User user = mapper.selectById(1L);
接口没有实现类,代码却能跑通。 这就是 MyBatis 的核心魔法:JDK 动态代理。下面从全局视角拆这条链路。
二、全局架构:MyBatis 执行链路总览
┌──────────────────────────────────────────────────────────┐
│ 应用层 │
│ UserMapper.selectById() ← 你只写了接口 │
└──────────────────────────┬───────────────────────────────┘
▼
┌──────────────────────────────────────────────────────────┐
│ MapperProxy (JDK 动态代理) │
│ invoke() → 按方法找到 MappedStatement │
└──────────────────────────┬───────────────────────────────┘
▼
┌──────────────────────────────────────────────────────────┐
│ SqlSessionTemplate / DefaultSqlSession │
│ · 从 Configuration 拿 MappedStatement │
└──────────────────────────┬───────────────────────────────┘
▼
┌──────────────────────────────────────────────────────────┐
│ Executor(执行器) │
│ · SimpleExecutor / ReuseExecutor / BatchExecutor │
│ · 二级缓存 → 一级缓存 → 查库 │
└──────────────────────────┬───────────────────────────────┘
▼
┌──────────────────────────────────────────────────────────┐
│ StatementHandler(语句处理器) │
│ · ParameterHandler:#{id} 参数绑定 │
│ · ResultSetHandler:结果集 → 对象映射 │
└──────────────────────────────────────────────────────────┘
▼
数据库 JDBC
一条 SQL 从接口到数据库,经过 5 个环节:代理 → SqlSession → Executor → StatementHandler → 参数/结果处理。下面逐个拆。
三、Mapper 代理:接口是怎么被"实现"的?
SqlSession.getMapper() 的底层是 MapperRegistry:
// MapperRegistry 源码(核心逻辑)
public class MapperRegistry {
private final Map<Class<?>, MapperProxyFactory<?>> knownMappers = new HashMap<>();
public <T> T getMapper(Class<T> type, SqlSession sqlSession) {
// 1. 从 knownMappers 拿代理工厂
MapperProxyFactory<T> mapperProxyFactory = (MapperProxyFactory<T>) knownMappers.get(type);
// 2. 用 JDK 动态代理创建实例
return mapperProxyFactory.newInstance(sqlSession);
}
}
// MapperProxyFactory
public class MapperProxyFactory<T> {
public T newInstance(SqlSession sqlSession) {
MapperProxy<T> mapperProxy = new MapperProxy<>(sqlSession, mapperInterface, methodCache);
return (T) Proxy.newProxyInstance(
mapperInterface.getClassLoader(),
new Class[]{mapperInterface}, // 代理的接口
mapperProxy); // InvocationHandler
}
}
当你调用 mapper.selectById(1L) 时,真正执行的是 MapperProxy.invoke():
public class MapperProxy<T> implements InvocationHandler {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
// Object 自带的方法(toString/hashCode 等)不走 MyBatis
if (Object.class.equals(method.getDeclaringClass())) {
return method.invoke(this, args);
}
// 核心:找到这个接口方法对应的 MapperMethod,然后执行
MapperMethod mapperMethod = cachedMapperMethod(method);
return mapperMethod.execute(sqlSession, args);
}
}
一句话总结:MyBatis 用 JDK 动态代理"伪造"了接口的实现,每个接口方法都对应一条已解析好的 SQL(MappedStatement)。
四、MappedStatement:接口方法 ↔ SQL 的绑定
问题来了:MapperProxy 怎么知道 selectById 对应哪条 SQL?
答案是 MappedStatement。在解析阶段,MyBatis 把 XML 里的每个 <select>/<insert>/<update>/<delete> 解析成一个 MappedStatement,注册到 Configuration:
// XMLMapperBuilder 解析 XML 时的核心逻辑(简版)
public void parse() {
// 1. 解析 <mapper namespace="...">
String namespace = parser.getStringAttribute("namespace");
// 2. 逐个解析 <select>/<insert>/... 标签
for (XNode statementNode : parser.evalNodes("select|insert|update|delete")) {
// 生成 MappedStatement:id = namespace + "." + 语句 id
MappedStatement statement = buildStatementFromContext(statementNode);
configuration.addMappedStatement(statement);
}
}
MapperProxy.invoke() 里其实就是一条 lookup:
// MapperMethod 初始化时
public MapperMethod(Class<?> mapperInterface, Method method, Configuration config) {
// key = "com.example.mapper.UserMapper.selectById"
String statementId = mapperInterface.getName() + "." + method.getName();
// 从 Configuration 里找到对应 MappedStatement
MappedStatement ms = config.getMappedStatement(statementId);
}
这就解释了为什么 XML 里
id必须等于接口方法名、namespace必须等于接口全限定名——MyBatis 靠这两个拼接出唯一 ID 来绑定。
五、SQL 解析:从 #{id} 到 JDBC 的 ?
XML 里写的 #{id} 不是直接拼进 SQL 的,MyBatis 会先把它解析成 JDBC 的 ? 占位符:
// DynamicSqlSource / RawSqlSource 最终都会生成 BoundSql
public class RawSqlSource implements SqlSource {
private final SqlSource sqlSource;
public RawSqlSource(Configuration configuration, String sql, Class<?> parameterType) {
// 1. 用 SqlSourceBuilder 解析 #{xxx}
SqlSourceBuilder sqlSourceParser = new SqlSourceBuilder(configuration);
// 2. 替换成 ? 占位符 + ParameterMapping 列表
SqlSource sqlSource = sqlSourceParser.parse(sql, parameterType, new HashMap<>());
this.sqlSource = sqlSource;
}
}
解析结果:
SELECT * FROM user WHERE id = #{id}
↓ 解析
SELECT * FROM user WHERE id = ?
↓
ParameterMapping = [ParameterMapping{property='id', jdbcType=null, ...}]
注意:
#{}是预编译占位符(安全),${}是字符串拼接(有 SQL 注入风险)。${}的场景是表名、排序字段等动态部分。
五、Executor:SQL 执行的三层过滤器
Executor 接口有 3 个默认实现:
| 实现类 | 行为 | 适用场景 |
|---|---|---|
| SimpleExecutor | 每次执行都新建 Statement | 默认,通用 |
| ReuseExecutor | 复用 Statement(同 SQL 复用 PreparedStatement) | 循环执行同 SQL |
| BatchExecutor | 批量执行,攒一批再提交 | 批量插入/更新 |
Executor 内部有两级缓存:
public abstract class BaseExecutor implements Executor {
// 一级缓存(SqlSession 级,默认开启)
protected PerpetualCache localCache;
@Override
public <E> List<E> query(MappedStatement ms, Object parameter, ...) {
// 1. 先查一级缓存
if (queryStack == 0 && ms.isFlushCacheRequired()) {
clearLocalCache();
}
// 2. 组装 CacheKey(含 SQL、参数、分页信息)
CacheKey key = createCacheKey(ms, parameter, ...);
// 3. 查缓存,命中直接返回
List<E> list = localCache.getObject(key);
if (list == null) {
list = queryFromDatabase(ms, parameter, rowBounds, key); // 查库
}
return list;
}
}
// 二级缓存(Mapper 级,需配置 <cache/>)
// 入口:CachingExecutor(装饰器模式,包在 Executor 外层)
public class CachingExecutor implements Executor {
private final Executor delegate; // 真正的执行器
@Override
public <E> List<E> query(MappedStatement ms, Object parameter, ...) {
// 1. 查二级缓存(命名空间级,跨 SqlSession 共享)
if (ms.isUseCache()) {
Cache cache = ms.getCache();
if (cache != null) {
List<E> list = cache.getObject(key);
if (list != null) return list; // 命中二级缓存
}
}
// 2. 未命中,走 delegate(一级缓存 + 查库)
return delegate.query(ms, parameter, rowBounds, resultHandler, key, boundSql);
}
}
缓存层级:二级缓存(Mapper 级)→ 一级缓存(SqlSession 级)→ 数据库。二级缓存默认关闭,一查默认开启。
六、参数绑定:PreparedStatement 的赋值过程
Executor 最终通过 PreparedStatementHandler 创建 PreparedStatement,然后用 ParameterHandler 绑定参数:
public class DefaultParameterHandler implements ParameterHandler {
@Override
public void setParameters(PreparedStatement ps) {
// 遍历 MappedStatement 里解析好的 ParameterMapping 列表
for (int i = 0; i < parameterMappings.size(); i++) {
ParameterMapping pm = parameterMappings.get(i);
// 从参数对象里取出对应属性值
Object value = valueFromParameter(parameterObject, pm.getProperty());
// 设置 jdbcType 等元数据
TypeHandler typeHandler = pm.getTypeHandler();
JdbcType jdbcType = pm.getJdbcType();
// 核心:给 PreparedStatement 的第 i 个 ? 赋值
typeHandler.setParameter(ps, i + 1, value, jdbcType);
}
}
}
绑定过程:
mapper.selectById(1L);
// ↓ MapperProxy 把参数包成 ParamMap
// ↓ Executor 把参数传给 StatementHandler
// ↓ ParameterHandler.setParameters()
SELECT * FROM user WHERE id = ? ← PreparedStatement.setLong(1, 1L)
七、结果映射:ResultSet → Java 对象
查出来的结果怎么变成 User 对象?这是 ResultSetHandler 的活:
public class DefaultResultSetHandler implements ResultSetHandler {
@Override
public Object handleResultSets(Statement stmt) {
List<Object> multipleResults = new ArrayList<>();
ResultSetWrapper rsw = new ResultSetWrapper(stmt.getResultSet());
// 获取 resultMap(或自动映射)
ResultMap resultMap = getResultMap(rsw);
handleResultSet(rsw, resultMap, multipleResults);
return collapseSingleResultList(multipleResults); // 单条时解包
}
private void handleRowValues(...) {
// 核心:反射/构造器创建目标对象
Object mappedObject = createResultObject(rsw, resultMap, columnPrefix);
// 按列名映射到属性
applyAutomaticMappings(rsw, resultMap, metaObject, columnPrefix);
}
}
自动映射过程:user_name → 去掉下划线转驼峰 → userName → 反射 setUserName()。这就是 mapUnderscoreToCamelCase 配置的底层逻辑。
八、完整链路串联:一次 selectById 的旅程
调用 UserMapper.selectById(1L)
│
▼
① MapperProxy(JDK 动态代理)
└─ 找到 MappedStatement: "com.example.mapper.UserMapper.selectById"
│
▼
② SqlSessionTemplate
└─ 获取当前事务的 Executor(CachingExecutor 包装)
│
▼
③ CachingExecutor
├─ 查二级缓存 → 命中?直接返回
└─ 未命中 → 交给 BaseExecutor
│
▼
④ BaseExecutor
├─ 查一级缓存 → 命中?直接返回
└─ 未命中 → queryFromDatabase()
│
▼
⑤ SimpleExecutor
└─ StatementHandler.prepare() → 创建 PreparedStatement
│
▼
⑥ ParameterHandler
└─ setParameters() → PreparedStatement.setLong(1, 1L)
│
▼
⑦ JDBC executeQuery()
│
▼
⑧ ResultSetHandler
├─ createResultObject() → 反射创建 User
└─ 逐列 setter 赋值 → 返回 User 对象
│
▼
⑨ 回填一级缓存 → 返回结果
九、高频面试追问
9.1 为什么 MyBatis 的 Mapper 接口不能用方法重载?
接口里同名方法会生成相同的 MappedStatement.id(namespace + "." + 方法名),后注册的覆盖先注册的,第二个方法执行必然报错。这也是 MyBatis 设计上的一个限制。
9.2 一级缓存失效的几种情况?
| 场景 | 原因 |
|---|---|
| SqlSession 关闭/新建 | 一级缓存绑定 SqlSession 生命周期 |
| 手动 clearCache() | 主动清空 |
| 两次查询中间有增删改 | 增删改会 flushCache(默认) |
| 查询参数不同 | CacheKey 不同 |
| 配置了 useLocalCache=false | 全局关闭 |
9.3 #{id} 和 ${id} 的区别?
| 对比项 | #{} | ${} |
|---|---|---|
| 处理方式 | PreparedStatement 占位符 | 字符串拼接 |
| SQL 注入 | 安全 | 有风险 |
| 适用场景 | 值参数 | 表名、排序字段等动态结构 |
| 性能 | 可复用预编译 | 每次重新拼接 |
9.4 为什么 MyBatis 查不出数据时会"脏读"到上一次的结果?(缓存穿透隐患)
同一个 SqlSession 内先查询出结果,再执行一次返回空结果的查询,如果参数和 SQL 完全一致(CacheKey 相同),会命中一级缓存返回旧数据。实践中批量处理大查询时要留意,必要时调 clearCache() 或配置 flushCache。
十、总结
MyBatis 的执行链路一句话概括:
代理(MapperProxy 伪造接口实现)→ MappedStatement(接口方法 ↔ SQL 绑定)→ Executor(缓存 + 执行)→ StatementHandler(参数绑定)→ ResultSetHandler(结果映射)
记住这张图,遇到"接口方法没实现却能跑""SQL 怎么被替换成占位符""为什么查了缓存还慢"这类问题,就能直接定位到是哪个环节。框架并不神秘,神秘的是你不去读它的源码。