你写的 Mapper 接口,MyBatis 到底是怎么把它变成 SQL 的? 你写的 Mapper 接口MyBatis 到底是怎么把它变成 SQL 的只写了一个接口没有实现类MyBatis 却能在运行时帮你执行 SQL——它是怎么做到的这篇从 SqlSession 工厂、Mapper 代理、SQL 解析到参数绑定把 MyBatis 的执行链路一次拆透。一、从最原始的用法说起大多数人第一次用 MyBatis都是先写一个 XML再写一个接口!-- UserMapper.xml --mappernamespacecom.example.mapper.UserMapperselectidselectByIdresultTypecom.example.entity.UserSELECT * FROM user WHERE id #{id}/select/mapper// UserMapper.java —— 只声明了接口没有实现类publicinterfaceUserMapper{UserselectById(Param(id)Longid);}然后神奇的事情发生了UserMappermappersqlSession.getMapper(UserMapper.class);Userusermapper.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{privatefinalMapClass?,MapperProxyFactory?knownMappersnewHashMap();publicTTgetMapper(ClassTtype,SqlSessionsqlSession){// 1. 从 knownMappers 拿代理工厂MapperProxyFactoryTmapperProxyFactory(MapperProxyFactoryT)knownMappers.get(type);// 2. 用 JDK 动态代理创建实例returnmapperProxyFactory.newInstance(sqlSession);}}// MapperProxyFactorypublicclassMapperProxyFactoryT{publicTnewInstance(SqlSessionsqlSession){MapperProxyTmapperProxynewMapperProxy(sqlSession,mapperInterface,methodCache);return(T)Proxy.newProxyInstance(mapperInterface.getClassLoader(),newClass[]{mapperInterface},// 代理的接口mapperProxy);// InvocationHandler}}当你调用mapper.selectById(1L)时真正执行的是MapperProxy.invoke()publicclassMapperProxyTimplementsInvocationHandler{OverridepublicObjectinvoke(Objectproxy,Methodmethod,Object[]args)throwsThrowable{// Object 自带的方法toString/hashCode 等不走 MyBatisif(Object.class.equals(method.getDeclaringClass())){returnmethod.invoke(this,args);}// 核心找到这个接口方法对应的 MapperMethod然后执行MapperMethodmapperMethodcachedMapperMethod(method);returnmapperMethod.execute(sqlSession,args);}}一句话总结MyBatis 用 JDK 动态代理伪造了接口的实现每个接口方法都对应一条已解析好的 SQLMappedStatement。四、MappedStatement接口方法 ↔ SQL 的绑定问题来了MapperProxy怎么知道selectById对应哪条 SQL答案是MappedStatement。在解析阶段MyBatis 把 XML 里的每个select/insert/update/delete解析成一个MappedStatement注册到Configuration// XMLMapperBuilder 解析 XML 时的核心逻辑简版publicvoidparse(){// 1. 解析 mapper namespace...Stringnamespaceparser.getStringAttribute(namespace);// 2. 逐个解析 select/insert/... 标签for(XNodestatementNode:parser.evalNodes(select|insert|update|delete)){// 生成 MappedStatementid namespace . 语句 idMappedStatementstatementbuildStatementFromContext(statementNode);configuration.addMappedStatement(statement);}}MapperProxy.invoke()里其实就是一条 lookup// MapperMethod 初始化时publicMapperMethod(Class?mapperInterface,Methodmethod,Configurationconfig){// key com.example.mapper.UserMapper.selectByIdStringstatementIdmapperInterface.getName().method.getName();// 从 Configuration 里找到对应 MappedStatementMappedStatementmsconfig.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}SqlSourceBuildersqlSourceParsernewSqlSourceBuilder(configuration);// 2. 替换成 ? 占位符 ParameterMapping 列表SqlSourcesqlSourcesqlSourceParser.parse(sql,parameterType,newHashMap());this.sqlSourcesqlSource;}}解析结果SELECT * FROM user WHERE id #{id} ↓ 解析 SELECT * FROM user WHERE id ? ↓ ParameterMapping [ParameterMapping{propertyid, jdbcTypenull, ...}]注意#{}是预编译占位符安全${}是字符串拼接有 SQL 注入风险。${}的场景是表名、排序字段等动态部分。五、ExecutorSQL 执行的三层过滤器Executor接口有 3 个默认实现实现类行为适用场景SimpleExecutor每次执行都新建 Statement默认通用ReuseExecutor复用 Statement同 SQL 复用 PreparedStatement循环执行同 SQLBatchExecutor批量执行攒一批再提交批量插入/更新Executor内部有两级缓存publicabstractclassBaseExecutorimplementsExecutor{// 一级缓存SqlSession 级默认开启protectedPerpetualCachelocalCache;OverridepublicEListEquery(MappedStatementms,Objectparameter,...){// 1. 先查一级缓存if(queryStack0ms.isFlushCacheRequired()){clearLocalCache();}// 2. 组装 CacheKey含 SQL、参数、分页信息CacheKeykeycreateCacheKey(ms,parameter,...);// 3. 查缓存命中直接返回ListElistlocalCache.getObject(key);if(listnull){listqueryFromDatabase(ms,parameter,rowBounds,key);// 查库}returnlist;}}// 二级缓存Mapper 级需配置 cache/// 入口CachingExecutor装饰器模式包在 Executor 外层publicclassCachingExecutorimplementsExecutor{privatefinalExecutordelegate;// 真正的执行器OverridepublicEListEquery(MappedStatementms,Objectparameter,...){// 1. 查二级缓存命名空间级跨 SqlSession 共享if(ms.isUseCache()){Cachecachems.getCache();if(cache!null){ListElistcache.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(inti0;iparameterMappings.size();i){ParameterMappingpmparameterMappings.get(i);// 从参数对象里取出对应属性值ObjectvaluevalueFromParameter(parameterObject,pm.getProperty());// 设置 jdbcType 等元数据TypeHandlertypeHandlerpm.getTypeHandler();JdbcTypejdbcTypepm.getJdbcType();// 核心给 PreparedStatement 的第 i 个 ? 赋值typeHandler.setParameter(ps,i1,value,jdbcType);}}}绑定过程mapper.selectById(1L);// ↓ MapperProxy 把参数包成 ParamMap// ↓ Executor 把参数传给 StatementHandler// ↓ ParameterHandler.setParameters()SELECT*FROMuserWHEREid?←PreparedStatement.setLong(1,1L)七、结果映射ResultSet → Java 对象查出来的结果怎么变成User对象这是ResultSetHandler的活publicclassDefaultResultSetHandlerimplementsResultSetHandler{OverridepublicObjecthandleResultSets(Statementstmt){ListObjectmultipleResultsnewArrayList();ResultSetWrapperrswnewResultSetWrapper(stmt.getResultSet());// 获取 resultMap或自动映射ResultMapresultMapgetResultMap(rsw);handleResultSet(rsw,resultMap,multipleResults);returncollapseSingleResultList(multipleResults);// 单条时解包}privatevoidhandleRowValues(...){// 核心反射/构造器创建目标对象ObjectmappedObjectcreateResultObject(rsw,resultMap,columnPrefix);// 按列名映射到属性applyAutomaticMappings(rsw,resultMap,metaObject,columnPrefix);}}自动映射过程user_name→ 去掉下划线转驼峰 →userName→ 反射setUserName()。这就是mapUnderscoreToCamelCase配置的底层逻辑。八、完整链路串联一次 selectById 的旅程调用 UserMapper.selectById(1L) │ ▼ ① MapperProxyJDK 动态代理 └─ 找到 MappedStatement: com.example.mapper.UserMapper.selectById │ ▼ ② SqlSessionTemplate └─ 获取当前事务的 ExecutorCachingExecutor 包装 │ ▼ ③ 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.idnamespace . 方法名后注册的覆盖先注册的第二个方法执行必然报错。这也是 MyBatis 设计上的一个限制。9.2 一级缓存失效的几种情况场景原因SqlSession 关闭/新建一级缓存绑定 SqlSession 生命周期手动 clearCache()主动清空两次查询中间有增删改增删改会 flushCache默认查询参数不同CacheKey 不同配置了 useLocalCachefalse全局关闭9.3 #{id} 和 ${id} 的区别对比项#{}${}处理方式PreparedStatement 占位符字符串拼接SQL 注入安全有风险适用场景值参数表名、排序字段等动态结构性能可复用预编译每次重新拼接9.4 为什么 MyBatis 查不出数据时会脏读到上一次的结果缓存穿透隐患同一个 SqlSession 内先查询出结果再执行一次返回空结果的查询如果参数和 SQL 完全一致CacheKey 相同会命中一级缓存返回旧数据。实践中批量处理大查询时要留意必要时调clearCache()或配置flushCache。十、总结MyBatis 的执行链路一句话概括代理MapperProxy 伪造接口实现→ MappedStatement接口方法 ↔ SQL 绑定→ Executor缓存 执行→ StatementHandler参数绑定→ ResultSetHandler结果映射记住这张图遇到接口方法没实现却能跑“SQL 怎么被替换成占位符”为什么查了缓存还慢这类问题就能直接定位到是哪个环节。框架并不神秘神秘的是你不去读它的源码。