恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
MyBatis映射器模块源码解析:从Mapper接口到SQL执行全链路
首页
资讯中心
/
MyBatis映射器模块源码解析:从Mapper接口到SQL执行全链路
MyBatis映射器模块源码解析:从Mapper接口到SQL执行全链路
发布时间:2026/10/2 14:55:29
这几年带过不少新人也面试过一些候选人我发现一个很有意思的现象很多人CRUD写得飞起MyBatis用了好几年配置背得滚瓜烂熟可一旦被问到“映射器模块到底是怎么工作的”立刻就卡壳了。Mapper接口为什么不能直接newXML里的语句怎么就能自动对应到接口方法TypeHandler是如何介入参数和结果集处理的这些平时被框架隐藏起来的细节恰恰是面试里高频出现的问题也是真正排查线上问题时最需要具备的知识。这篇文章就围绕MyBatis的映射器模块从源码层面拆一拆它的结构、初始化链路和运行时机制。文章不会把源码逐行贴一遍而是讲清楚核心类之间的协作关系再结合Spring Boot整合场景和常见报错让有基础的读者能直接用来应付面试和排障也让刚接触MyBatis的读者能建立一个完整的骨架认知。1. 映射器模块到底由哪几块组成——先给这件事定个调1.1 三个必需却不常被说清的要素很多人提起映射器第一反应是“一个Mapper接口加一个XML文件”。这种理解不算错但少了最关键的一环——注册中心。严格来说映射器模块在MyBatis内部由三部分组成映射器接口也就是我们自己在代码里定义的XxxMapper它的作用更像一张“契约”声明有哪些方法、方法参数和返回值是什么。XML映射文件或注解承载SQL文本、参数映射、结果映射、动态SQL片段等具体配置。MapperRegistryMyBatis内部的一张注册表负责把接口Class和对应的代理工厂关联起来并提供按需加载的能力。三者缺一不可。接口负责定义“做什么”XML负责描述“怎么做”Registry负责把两者“绑在一起”。学习映射器模块时如果只盯着XML里的SQL就永远只看到了表面只有把三者的关系理清才能明白为什么MyBatis可以做到“面向接口编程而实现细节全在框架内部”。1.2 MapperRegistry一张“接口到代理工厂”的通讯录MapperRegistry是Configuration类里一个非常重要的字段你可以把它理解为一张Mappublic class MapperRegistry { // 每个接口对应一个创建代理对象的工厂 private final MapClass?, MapperProxyFactory? knownMappers new HashMap(); private final Configuration config; }它主要暴露三个方法addMapper(Class type)把接口注册进knownMappers同时解析注解或绑定XML配置。getMapper(Class type, SqlSession sqlSession)根据接口Class拿到对应的代理对象。hasMapper(Class type)判断某个接口是否已经注册。之所以能通过getMapper拿到代理对象靠的就是knownMappers里存放的MapperProxyFactory。这个Factory专门负责使用JDK动态代理生成接口的实现类。换句话说MyBatis从来就没有真正“实现”过Mapper接口它只是在我们调用接口方法时动态地创建了一个代理对象把方法调用转交给框架内部的执行器。1.3 为什么Mapper接口不能直接new这是面试里几乎必问的一道题。你打开Mapper接口源码会发现里面只有方法声明没有任何实现。你尝试直接new一个接口编译器都会直接报错——接口不能被实例化。那为什么Autowired一个Mapper接口却能在Spring容器里正常工作因为Spring容器里注册的根本不是这个接口本身而是通过MapperRegistry.getMapper生成的JDK动态代理对象。从行为上看它实现了这个接口从本质上讲它是一个拦截器把接口方法调用“劫持”到MyBatis的执行链路上。这里要注意一个细节正因为用的是JDK动态代理所以映射器接口必须满足代理的基本约束——不能被类继承方式实现也尽量不要有默认方法之外的复杂逻辑。JDK代理只针对接口方法生效如果接口里定义了静态方法或默认方法它们的执行路径与普通接口方法不同框架对它们的处理方式也存在区别。2. MapperProxy与MapperMethod一次接口调用如何演变成SQL执行2.1 MapperProxyFactory的创建与代理生成流程当你调用Configuration.addMapper时MyBatis会创建一个MapperProxyFactory实例存入knownMappers。这个工厂的代码非常简洁public class MapperProxyFactoryT { private final ClassT mapperInterface; private final MapMethod, MapperMethod methodCache new ConcurrentHashMap(); // 生成代理对象 protected T newInstance(MapperProxyT mapperProxy) { return (T) Proxy.newProxyInstance( mapperInterface.getClassLoader(), new Class[]{mapperInterface}, mapperProxy); } }核心动作发生在MapperProxy.invoke方法里。当外部代码调用某个Mapper接口方法时动态代理会调用MapperProxy的invokepublic Object invoke(Object proxy, Method method, Object[] args) throws Throwable { // Object类的方法直接放行 if (Object.class.equals(method.getDeclaringClass())) { return method.invoke(this, args); } // 关键为每个方法找到对应的MapperMethod然后执行 MapperMethod mapperMethod cachedMapperMethod(method); return mapperMethod.execute(sqlSession, args); }方法第一次被调用时MyBatis会从methodCache里取MapperMethod没有就创建并缓存。从此以后同一个接口方法的所有调用都会复用同一个MapperMethod对象这既提升了性能也保证了方法级状态的一致性。2.2 MapperMethod的双环节设计MapperMethod是映射器模块里承上启下的核心类它内部封装了两个子组件SqlCommand负责记录SQL命令的类型和执行语句的唯一标识。它保存了MappedStatement的id也就是接口全限定名.方法名同时标记这是SELECT、INSERT、UPDATE还是DELETE。MethodSignature负责解析方法签名。包括参数名、参数位置、返回值类型、是否返回Map或Cursor等关键元信息。为什么要拆成两个部分原因很简单SQL命令是XML里配置的方法签名是接口里定义的两者之间需要有一个“销对”的过程。SqlCommand负责确认“要执行哪条SQL”MethodSignature负责确认“参数怎么传、结果怎么收”。分工清晰各自只做一件事。2.3 execute方法里的四种分支MapperMethod.execute是整个调用链的出口public Object execute(SqlSession sqlSession, Object[] args) { Object result; switch (command.getType()) { case INSERT: { Object param method.convertArgsToSqlCommandParam(args); result rowCountResult(sqlSession.insert(command.getName(), param)); break; } case UPDATE: { Object param method.convertArgsToSqlCommandParam(args); result rowCountResult(sqlSession.update(command.getName(), param)); break; } case DELETE: { Object param method.convertArgsToSqlCommandParam(args); result rowCountResult(sqlSession.delete(command.getName(), param)); break; } case SELECT: // 根据返回类型选择不同查询方式 break; default: throw new BindingException(Unknown execution method for: command.getName()); } return result; }这里有几个值得注意的点参数转换统一走convertArgsToSqlCommandParam它解决了“多参数时如何打包成一个ParamMap”的问题。新增、修改、删除的返回值统一转成影响行数如果方法返回int或long就返回行数如果方法的返回值是void则会判断执行结果是否为0为0就直接抛出异常这也是很多人写insert后不判断返回值但框架却会报错的原因。SELECT分支会根据MethodSignature里的返回类型再次分流普通集合走selectList单对象走selectOneMap走selectMapCursor走selectCursor。看到这里你应该已经能回答“接口方法调用是怎么执行的”这个问题了接口 - 代理对象 - MapperProxy - MapperMethod - SqlSession。整个过程本质上是一层一层的“转发”而SQL真正执行的起点在SqlSession。3. XML映射解析全链路从XMLConfigBuilder到MappedStatement3.1 三个解析器的职责分工映射器模块的初始化核心是几个名字很接近、职责很不同的解析器。面试里常出现“XMLConfigBuilder的工作流程”这类题其实考察的就是这三个类之间的协作XMLConfigBuilder解析mybatis-config.xml主配置文件。当它解析到mappers标签时会读取每个mapperResource或mapper接口的包路径然后触发后续解析。XMLMapperBuilder解析一个具体的Mapper XML文件。它负责处理resultMap、cache、select|insert|update|delete等标签。XMLStatementBuilder在XMLMapperBuilder解析到具体SQL语句标签时被创建专门负责把一个statement标签转换成MappedStatement。这条链路的启动顺序是XMLConfigBuilder - XMLMapperBuilder - XMLStatementBuilder。用一句话概括就是主配置解析器负责“发现”映射文件映射文件解析器负责“拆解”文件内容语句解析器负责“构建”运行时结构。3.2 SQL语句是怎么变成MappedStatement的MappedStatement是MyBatis中极其重要的一个运行时对象。每条SQL语句在初始化阶段都会被封装成一个MappedStatement并注册到Configuration的mappedStatements集合里。它的key是statementId默认值就是“Mapper接口全限定名方法名”。XMLStatementBuilder.parseStatementNode的构建过程可以分成几步读取statement的id、parameterType、resultType、resultMap、statementType等属性。使用LanguageDriver创建SqlSource。根据resultType或resultMap构建ResultMaps。把所有信息打包成MappedStatement对象存入Configuration。其中第二点值得展开。SqlSource的创建直接决定了一条SQL是否能被正确解析。MyBatis默认使用XMLLanguageDriver它的核心逻辑是读取SQL标签里的动态SQL节点如if、where、foreach用XMLScriptBuilder构建成SqlNode树。如果SQL中包含#{}占位符再用SqlSourceBuilder解析占位符构建ParameterMapping列表。3.3 SqlSource与BoundSql占位符是如何被翻译的很多人分不清SqlSource和BoundSql的区别其实可以这样理解SqlSource是“静态的、可复用的SQL模板”它保存了解析后的SQL文本和SqlNode树。BoundSql是“动态的、针对某次请求生成的SQL对象”它是在每次执行时根据参数值把动态SQL节点拼接完毕后产生的里面包含最终的SQL文本、参数映射列表和额外参数。举个例子一个包含if标签的SQL在SqlSource阶段只是把分支逻辑保存成节点真正执行时SqlSource会根据传入的参数对象判断条件是否成立生成对应的SQL片段落入BoundSql。这也是为什么动态SQL在性能上会有微弱损耗——每次执行都需要重新判断和拼接。在设计层面SqlSourceBuilder把#{xxx}替换成?同时记录每个占位符对应的属性名嵌套关系比如#{user.name}表示取参数对象的name属性。这些ParameterMapping最终传递给ParameterHandler使用。到这里XML解析链路的整体图景就清晰了配置解析器发现并载入XML语句解析器构建MappedStatementSqlSource保存模板BoundSql保存某次执行的实例。后面所有的参数绑定、SQL执行、结果映射都要依赖这些初始化好的元数据。4. TypeHandler工作流程Java类型与JDBC类型之间的翻译4.1 TypeHandler在映射器模块里的位置TypeHandler并不是映射器独有的组件但它和映射器关系极为密切。每条SQL语句的参数绑定和结果集映射都要靠TypeHandler完成Java类型与JDBC类型之间的互相转换。可以把TypeHandler理解成一个“翻译官”往数据库写数据时它把Java对象的属性值转换成JDBC PreparedStatement能识别的类型从数据库读数据时它把ResultSet里取到的值转换成Java对象的属性类型。默认情况下MyBatis通过TypeHandlerRegistry根据Java类型自动匹配对应的TypeHandler。比如String类型用StringTypeHandlerInteger类型用IntegerTypeHandler日期类型根据JdbcType选择DateTypeHandler或TimestampTypeHandler。4.2 参数绑定阶段的调用链setParameter先看写入方向。当SqlSession执行update或select时最终会走到SimpleExecutor的doUpdate或doQuery核心执行类PreparedStatementHandler会被创建。它内部有一个关键对象ParameterHandler。DefaultParameterHandler的parameterize方法就是遍历BoundSql里的ParameterMapping对每个参数调用typeHandler.setParameterpublic void setParameters(PreparedStatement ps) { // 遍历所有参数映射 for (int i 0; i parameterMappings.size(); i) { // 计算参数值支持嵌套属性、null值判断 Object value ...; JdbcType jdbcType parameterMapping.getJdbcType(); // 核心交给TypeHandler写入PreparedStatement typeHandler.setParameter(ps, i 1, value, jdbcType); } }如果你自定义参数值想比较清楚地观察这段链路可以在日志里打印SQL和Parameters。打印出来的Parameters本质上就是在TypeHandler处理之后写入PreparedStatement的值。4.3 结果集映射阶段的调用链getResult再看读取方向。查询执行完毕后ResultSetHandlerDefaultResultSetHandler开始处理结果集。它根据ResultMap的定义逐行读取数据如果使用resultType自动映射框架会根据列名和属性名构建自动映射关系再对每个列调用typeHandler.getResult。如果使用resultMap则会根据column与property的配置配合嵌套结果映射来处理。无论哪种方式底层都是Object columnValue typeHandler.getResult(rs, columnName); metaObject.setValue(propertyName, columnValue);这里有个企业开发中常见的问题数据库中某些列值是NULL或者类型不匹配容易在结果映射阶段暴露。例如数据库的char类型在某些驱动里会返回带空格的字符串如果你没用对应的TypeHandler就可能出现“看起来一样其实不相等”的坑。4.4 哪些场景值得自定义TypeHandler枚举类型与数据库整数的互转默认的EnumTypeHandler存的是枚举名字符串但很多老表结构存的是int值。自定义一个StatusTypeHandler实现枚举到整数的转换是非常典型的场景。JSON字符串与Java对象的互转比如数据库Text字段存JSON实体对象里是List。自定义JSONTypeHandler可以在读写时自动序列化和反序列化。时间类型兼容当你使用JSR310LocalDate/LocalDateTime配合旧驱动时可能默认映射不上需要自定义或配置对应的TypeHandler。自定义TypeHandler的步骤也不复杂继承BaseTypeHandler实现setNonNullParameter、getNullableResult三个重载方法然后在mybatis-config.xml或Mapper XML里注册即可。注意一定要处理好null的场景否则数据库字段为NULL时getNullableResult方法直接抛异常很常见。5. 映射器和缓存机制一级缓存、二级缓存与常见坑5.1 一级缓存SqlSession级别的隐式缓存一级缓存是MyBatis默认开启的作用范围是SqlSession。同一个SqlSession内使用相同的statementId和相同的参数去查询第二次会直接命中缓存不再访问数据库。一级缓存的实现类是PerpetualCache本质上就是一个HashMap。缓存的key由statementId、参数对象、RowBounds以及MySQL分页信息共同构成。需要注意的是一级缓存的清理时机非常微妙。除了显式commit/rollback外任何INSERT、UPDATE、DELETE操作都会触发缓存清空因为框架无法判断数据是否发生变化干脆清空保证一致性。这个特性非常容易在生产环境踩坑。举个例子两个查询方法在同一个事务里执行第一次查询结果被缓存中间如果某段逻辑通过SqlSession做了写入即使这条写入跟查询无关下次同名查询就会重新查库。反之如果你使用同一个SqlSession连续查询完全相同的条件第二次拿到的其实是缓存这在“必须读最新值”的场景下会出问题。5.2 二级缓存namespace级别的共享缓存二级缓存的作用范围是Mapper namespace级别跨SqlSession共享。它默认不开启需要在Mapper XML里配置cache evictionLRU flushInterval60000 size512 readOnlytrue/二级缓存的实际执行者是CachingExecutor。当二级缓存开启时SqlSession在创建Executor时会用CachingExecutor包装真正的BaseExecutor。这样执行查询时会先查二级缓存再查一级缓存最后才访问数据库。在映射器模块中二级缓存和MappedStatement是关联的每个MappedStatement持有一个Cache对象就是cache标签构建出的二级缓存并且通过namespace关联所有同命名空间下的语句。这里的底层是Cache对象被Configuration的cacheMap持有每个命名空间对应一个Cache实例。一个很容易被忽略的点二级缓存只对配置了cache的namespace生效不同Mapper之间的缓存互不影响。如果你希望多个Mapper共享同一个缓存区就要用到cache-ref namespace.../。5.3 为什么“缓存不生效”是高频问题面试热词里“mybatis二级缓存实现”、“一级缓存不生效”出现频率极高。但现实里最常见的“缓存不生效”其实往往是以下几种情况查询走了Spring托管的SqlSessionTemplate每个方法可能拿到不同的SqlSession一级缓存天然无法跨方法生效。你需要确认是否在同一个事务里才会命中一级缓存。查询语句本身带flushCachetrue每个查询都要求清空缓存。更新操作执行后立即执行相同查询一级缓存被清空显得好像永远没缓存。查询参数对象不同哪怕内容相同也可能产生不同的缓存key。二级缓存没有配置或者namespace不对导致连Mapper之间都各自为政。理解了缓存的作用范围和清理时机很多问题其实都不用查日志就能判断出来。6. 实战复盘Spring Boot MyBatis做最简注册功能时映射器模块做了什么6.1 一个注册功能背后完整的映射器调用链把概念落到具体功能上最容易理解。假设我们要做一个最简单的注册功能前端传来用户名和密码后端保存到user表。这个场景下的映射器链路是这样的Spring启动阶段执行MapperScan(com.example.mapper)Spring容器会扫描指定包下所有Mapper接口注册BeanDefinition。每个Mapper接口被注册到MapperRegistry。如果是XML配置方式XMLMapperBuilder会被触发加载对应的UserMapper.xml。UserMapper.xml中的insert语句被解析成MappedStatement注册到Configuration。当Controller调用userMapper.insert(user)时Spring容器注入的是代理对象调用进入MapperProxy.invoke。MapperMethod.execute中INSERT分支调用sqlSession.insert(com.example.mapper.UserMapper.insert, param)。SqlSession进入Executor执行DefaultParameterHandler遍历参数映射用TypeHandler把User对象的username属性写入PreparedStatement。执行完成后返回影响行数事务提交一级缓存被清空。这条链路看似长但每一步都是解耦的。接口只知道调用方法代理负责拦截MapperMethod负责匹配SQL命令和参数SqlSession负责调度Executor负责执行细节。6.2 与Spring MVC结合时最容易出错的两个环节第一事务问题。如果你写了Transactional事务是在Spring管理的SqlSession上开启的。Spring会把一个SqlSession绑定到当前线程通过ThreadLocal的方式确保同一事务内复用同一个SqlSession。如果同一事务内先查询后更新前面提到的一级缓存清空行为就会显现。第二Mapper扫描路径冲突。MapperScan扫描包时MapperRegistry会把接口注册进去但如果XML没有放在对应的resource路径下或者Mapper接口与XML的namespace不匹配启动时不会立即报错运行时第一次调用就会抛BindingException。这种错最坑的地方在于项目能正常启动看起来一切正常一访问接口就报“Invalid bound statement (not found)”。6.3 一个新的视角从映射器反推MyBatis初始化工作流程有读者可能不太清楚“基于XML配置的初始化工作原理”和映射器模块的关系。其实可以这么理解MyBatis的初始化本质上就是在做“读取配置 - 构建Configuration - 注册映射器 - 准备Executor”这几件事。而映射器模块的初始化是整个初始化流程的后半段也是最复杂的部分。初始化工作流程一句话总结SqlSessionFactoryBuilder解析全局配置生成XMLConfigBuilderXMLConfigBuilder解析mappers加载每个Mapper接口和XMLXMLMapperBuilder解析ResultMap和StatementXMLStatementBuilder把SQL语句包装成MappedStatement存进ConfigurationConfiguration再通过MapperRegistry生成代理。至此从配置到可执行对象的转换全部完成。7. 我踩过的几个映射器相关的真实坑条件不生效、参数绑定失败、属性映射缺失7.1 条件不生效的三种典型原因“MyBatis条件不生效”是热词里非常实务的一条。我排查过不少案例统计下来三种原因占了大头。第一if test条件里写了Integer类型的比较if teststatus ! and status ! null AND status #{status} /if当status是Integer类型时status ! 会把Integer和空字符串做比较结果往往不符合预期有时候条件恒成立有时候恒不成立。正确的写法是if teststatus ! null AND status #{status} /if第二动态SQL标签拼写错误。比如where标签后面误用了AND开头或者trim的prefix和suffixOverrides配置不对。这类问题不会直接报错但生成的SQL会多出多余的AND或缺少逗号。排查时一定要打开SQL日志看最终执行的SQL长什么样。第三参数名和test表达式不一致。比如接口方法里用的是Param(userName)XML里却写了testname ! null。MyBatis在解析动态SQL时不会校验参数是否存在运行时才可能报错或直接不执行条件分支。7.2 参数绑定失败的经典排查链路如果你遇到“Parameter xxx not found. Available parameters are [...]”这个错思路应该是固定的确认接口方法是不是多参数。多参数且没有Param注解时MyBatis会把参数包装成param1、param2这样的名字XML里如果写#{name}就找不到。有Param注解但XML里的#{}名称与注解不一致也会报参数找不到。如果你用的是POJO作为参数那XML里的#{}应该直接对应POJO属性名不要出现param1之类的引用。检查是否开启了useActualParamName。在Spring Boot的自动化配置下通常为true但如果是Java编译未加入-parameters参数方法名可能无法被反射获取。这条链路走完绝大部分参数绑定错误都能定位。我自己调试时习惯优先看异常信息里的“Available parameters are [...]”这一行已经把框架知道的参数名全列出来了。7.3 属性映射缺失为什么查出了数据但对象是null还有一种极常见的问题SQL执行成功数据库里也有数据但返回的对象属性全是null。这通常不是数据的问题而是结果映射没配上。数据库字段是user_name实体属性是userName且全局没有开启mapUnderscoreToCamelCase那么自动映射无法完成。如果你用了resultMap但漏配了某些字段的column属性这些字段会被静默忽略。如果你要的是嵌套对象映射比如订单里包含用户信息但association配置不正确也会出现外部对象正常、嵌套对象全是null的情况。解决方案就两条要么开启驼峰映射并在列名上遵循下划线到驼峰的约定要么把resultMap配置完整。最忌讳的是两种方案混用结果某个字段“以为映射了其实没有”。最后分享一点我的个人体会写这篇文章的时候我一直在想一个问题为什么映射器模块在面试里如此高频后来想明白了因为它像一座桥。一头连着开发者的接口定义一头连着数据库的SQL执行中间还有动态代理、解析器、TypeHandler、缓存这些机制。面试官随便往桥上戳一个点都能问出候选人对框架的底层认知到底有多深。如果你刚接触MyBatis源码我的建议是不要一上来就死磕XMLConfigBuilder的几百行代码。先把它拆成三条线一条线看接口如何变成代理一条线看XML如何变成MappedStatement一条线看参数和结果如何通过TypeHandler流转。每条线不用看全把链路里的关键类名和职责记住再遇到问题就能快速定位到具体环节。最后分享一个排障的小技巧遇到任何Mepper相关的诡异问题时先把Configuration、MappedStatement、BoundSql这三层的信息打出来。Configuration告诉你这个接口有没有被加载MappedStatement告诉你SQL模板长什么样BoundSql告诉你这次执行真的进了什么SQL。这三层看完80%的问题都能有个明确方向。