你写的 Mapper 接口,MyBatis 到底是怎么把它变成 SQL 的?
只写了一个接口,没有实现类,MyBatis 却能在运行时帮你执行 SQL——它是怎么做到的?这篇从 SqlSession 工厂、Mapper 代理、SQL 解析到参数绑定,把 MyBatis 的执行链路一次拆透。
一、从最原始的用法说起
大多数人第一次用 MyBatis,都是先写一个 XML,再写一个接口:
<!-- UserMapper.xml --><mappernamespace="com.example.mapper.UserMapper"><selectid="selectById"resultType="com.example.entity.User">SELECT * FROM user WHERE id = #{id}</select></mapper>// UserMapper.java —— 只声明了接口,没有实现类publicinterfaceUserMapper{UserselectById(@Param("id")Longid);}然后神奇的事情发生了:
UserMappermapper=sqlSession.getMapper(UserMapper.class);Useruser=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 源码(核心逻辑)publicclassMapperRegistry{privatefinalMap<Class<?>,MapperProxyFactory<?>>knownMappers=newHashMap<>();public<T>TgetMapper(Class<T>type,SqlSessionsqlSession){// 1. 从 knownMappers 拿代理工厂MapperProxyFactory<T>mapperProxyFactory=(MapperProxyFactory<T>)knownMappers.get(type);// 2. 用 JDK 动态代理创建实例returnmapperProxyFactory.newInstance(sqlSession);}}// MapperProxyFactorypublicclassMapperProxyFactory<T>{publicTnewInstance(SqlSessionsqlSession){MapperProxy<T>mapperProxy=newMapperProxy<>(sqlSession,mapperInterface,methodCache);return(T)Proxy.newProxyInstance(mapperInterface.getClassLoader(),newClass[]{mapperInterface},// 代理的接口mapperProxy);// InvocationHandler}}当你调用mapper.selectById(1L)时,真正执行的是MapperProxy.invoke():
publicclassMapperProxy<T>implementsInvocationHandler{@OverridepublicObjectinvoke(Objectproxy,Methodmethod,Object[]args)throwsThrowable{// Object 自带的方法(toString/hashCode 等)不走 MyBatisif(Object.class.equals(method.getDeclaringClass())){returnmethod.invoke(this,args);}// 核心:找到这个接口方法对应的 MapperMethod,然后执行MapperMethodmapperMethod=cachedMapperMethod(method);returnmapperMethod.execute(sqlSession,args);}}一句话总结:MyBatis 用 JDK 动态代理"伪造"了接口的实现,每个接口方法都对应一条已解析好的 SQL(MappedStatement)。
四、MappedStatement:接口方法 ↔ SQL 的绑定
问题来了:MapperProxy怎么知道selectById对应哪条 SQL?
答案是MappedStatement。在解析阶段,MyBatis 把 XML 里的每个<select>/<insert>/<update>/<delete>解析成一个MappedStatement,注册到Configuration:
// XMLMapperBuilder 解析 XML 时的核心逻辑(简版)publicvoidparse(){// 1. 解析 <mapper namespace="...">Stringnamespace=parser.getStringAttribute("namespace");// 2. 逐个解析 <select>/<insert>/... 标签for(XNodestatementNode:parser.evalNodes("select|insert|update|delete")){// 生成 MappedStatement:id = namespace + "." + 语句 idMappedStatementstatement=buildStatementFromContext(statementNode);configuration.addMappedStatement(statement);}}MapperProxy.invoke()里其实就是一条 lookup:
// MapperMethod 初始化时publicMapperMethod(Class<?>mapperInterface,Methodmethod,Configurationconfig){// key = "com.example.mapper.UserMapper.selectById"StringstatementId=mapperInterface.getName()+"."+method.getName();// 从 Configuration 里找到对应 MappedStatementMappedStatementms=config.getMappedStatement(statementId);}这就解释了为什么 XML 里
id必须等于接口方法名、namespace必须等于接口全限定名——MyBatis 靠这两个拼接出唯一 ID 来绑定。
五、SQL 解析:从 #{id} 到 JDBC 的 ?
XML 里写的#{id}不是直接拼进 SQL 的,MyBatis 会先把它解析成 JDBC 的?占位符:
// DynamicSqlSource / RawSqlSource 最终都会生成 BoundSqlpublicclassRawSqlSourceimplementsSqlSource{privatefinalSqlSourcesqlSource;publicRawSqlSource(Configurationconfiguration,Stringsql,Class<?>parameterType){// 1. 用 SqlSourceBuilder 解析 #{xxx}SqlSourceBuildersqlSourceParser=newSqlSourceBuilder(configuration);// 2. 替换成 ? 占位符 + ParameterMapping 列表SqlSourcesqlSource=sqlSourceParser.parse(sql,parameterType,newHashMap<>());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内部有两级缓存:
publicabstractclassBaseExecutorimplementsExecutor{// 一级缓存(SqlSession 级,默认开启)protectedPerpetualCachelocalCache;@Overridepublic<E>List<E>query(MappedStatementms,Objectparameter,...){// 1. 先查一级缓存if(queryStack==0&&ms.isFlushCacheRequired()){clearLocalCache();}// 2. 组装 CacheKey(含 SQL、参数、分页信息)CacheKeykey=createCacheKey(ms,parameter,...);// 3. 查缓存,命中直接返回List<E>list=localCache.getObject(key);if(list==null){list=queryFromDatabase(ms,parameter,rowBounds,key);// 查库}returnlist;}}// 二级缓存(Mapper 级,需配置 <cache/>)// 入口:CachingExecutor(装饰器模式,包在 Executor 外层)publicclassCachingExecutorimplementsExecutor{privatefinalExecutordelegate;// 真正的执行器@Overridepublic<E>List<E>query(MappedStatementms,Objectparameter,...){// 1. 查二级缓存(命名空间级,跨 SqlSession 共享)if(ms.isUseCache()){Cachecache=ms.getCache();if(cache!=null){List<E>list=cache.getObject(key);if(list!=null)returnlist;// 命中二级缓存}}// 2. 未命中,走 delegate(一级缓存 + 查库)returndelegate.query(ms,parameter,rowBounds,resultHandler,key,boundSql);}}缓存层级:二级缓存(Mapper 级)→ 一级缓存(SqlSession 级)→ 数据库。二级缓存默认关闭,一查默认开启。
六、参数绑定:PreparedStatement 的赋值过程
Executor最终通过PreparedStatementHandler创建PreparedStatement,然后用ParameterHandler绑定参数:
publicclassDefaultParameterHandlerimplementsParameterHandler{@OverridepublicvoidsetParameters(PreparedStatementps){// 遍历 MappedStatement 里解析好的 ParameterMapping 列表for(inti=0;i<parameterMappings.size();i++){ParameterMappingpm=parameterMappings.get(i);// 从参数对象里取出对应属性值Objectvalue=valueFromParameter(parameterObject,pm.getProperty());// 设置 jdbcType 等元数据TypeHandlertypeHandler=pm.getTypeHandler();JdbcTypejdbcType=pm.getJdbcType();// 核心:给 PreparedStatement 的第 i 个 ? 赋值typeHandler.setParameter(ps,i+1,value,jdbcType);}}}绑定过程:
mapper.selectById(1L);// ↓ MapperProxy 把参数包成 ParamMap// ↓ Executor 把参数传给 StatementHandler// ↓ ParameterHandler.setParameters()SELECT*FROMuserWHEREid=?←PreparedStatement.setLong(1,1L)七、结果映射:ResultSet → Java 对象
查出来的结果怎么变成User对象?这是ResultSetHandler的活:
publicclassDefaultResultSetHandlerimplementsResultSetHandler{@OverridepublicObjecthandleResultSets(Statementstmt){List<Object>multipleResults=newArrayList<>();ResultSetWrapperrsw=newResultSetWrapper(stmt.getResultSet());// 获取 resultMap(或自动映射)ResultMapresultMap=getResultMap(rsw);handleResultSet(rsw,resultMap,multipleResults);returncollapseSingleResultList(multipleResults);// 单条时解包}privatevoidhandleRowValues(...){// 核心:反射/构造器创建目标对象ObjectmappedObject=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 怎么被替换成占位符”"为什么查了缓存还慢"这类问题,就能直接定位到是哪个环节。框架并不神秘,神秘的是你不去读它的源码。