你写的 Mapper 接口,MyBatis 到底是怎么把它变成 SQL 的?

28 阅读6分钟

你写的 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 怎么被替换成占位符""为什么查了缓存还慢"这类问题,就能直接定位到是哪个环节。框架并不神秘,神秘的是你不去读它的源码。