用了这么多年的MyBatis,你大概已经熟练到能在Mapper接口里写一个方法,在XML里配一段SQL,剩下的事全交给框架。但真正的问题来了:当你调用一个Mapper方法时,MyBatis到底怎么把这条SQL送到数据库手上,又怎么把ResultSet变成你的POJO?这就是SQL执行模块干的事。这篇东西不打算讲MyBatis入门,而是直接钻进源码和核心链路里,把SQL执行模块的每一个关键环节拆开看,包括Executor如何调度、StatementHandler怎么和JDBC打交道、缓存为什么时灵时不灵、参数和结果映射有哪些隐藏的坑、以及如何通过拦截器把执行日志和慢SQL抓出来。不管你是准备面试、排查线上性能问题,还是想给项目定制点黑科技,这篇都值得你耐心读完。我尽量用大白话,把源码逻辑翻译成人话。
1. 执行模块全景:MyBatis到底怎么把SQL跑起来的
1.1 四个核心组件的角色图谱
讲执行模块,必须先建立整体认知。MyBatis的设计其实非常简单,核心就是四个组件:Executor(执行器)、StatementHandler(语句处理器)、ParameterHandler(参数处理器)、ResultSetHandler(结果集处理器)。再加上一个门面类SqlSession。你可以把SqlSession想象成前端接待,Executor是项目经理,StatementHandler是具体干活的工人,ParameterHandler和ResultSetHandler是辅助工人。用户接口层只跟SqlSession打交道,SqlSession把任务丢给Executor,Executor根据任务类型选择update或query,然后转给StatementHandler去准备PreparedStatement、填充参数、执行查询,最后用ResultSetHandler把结果集收拾干净。
这几个组件之间的关系和职责,用一张表格看得更清楚。
| 组件 | 类型 | 核心职责 | 关键实现类 |
|---|---|---|---|
| SqlSession | 门面 | 对外暴露API,转发命令 | DefaultSqlSession |
| Executor | 执行调度 | 管理缓存、事务、调度StatementHandler | BaseExecutor、SimpleExecutor、BatchExecutor、ReuseExecutor、CachingExecutor |
| StatementHandler | JDBC交互 | 创建PreparedStatement、绑定参数、执行SQL | PreparedStatementHandler、CallableStatementHandler |
| ParameterHandler | 参数处理 | 将Java入参转换成JDBC类型,设置到PreparedStatement上 | DefaultParameterHandler |
| ResultSetHandler | 结果映射 | 从ResultSet提取数据,映射成对象、列表、游标等 | DefaultResultSetHandler |
每种Executor对应不同的执行策略:SimpleExecutor每次执行都新建Statement,BatchExecutor批量执行,ReuseExecutor缓存Statement,CachingExecutor在二级缓存开启时作为装饰器包裹其他Executor。这本身就体现了设计模式里的策略模式和装饰器模式。你理解了这个骨架,后面再看源码就会轻松很多。很多mybatis面试题都喜欢问“Executor有哪几种实现”,其实就是考察你能否把这层关系说清楚。
1.2 包一层设计为什么能高性能
很多人疑惑,MyBatis搞这么多组件,性能不会比直接JDBC差吗?恰恰相反,MyBatis的核心优势恰恰在这套分层设计。Executor层做了很多JDBC没做的事,比如一级缓存自动命中、懒加载、事务提交自动清缓存。StatementHandler层把PreparedStatement的创建和参数绑定变成可复用流程,避免重复写那些枯燥的try-catch-resource代码。而且最关键的是,所有组件都可以被拦截器扩展,比如MyBatis-Plus的分页插件、通用Mapper的自动SQL,都是基于MyBatis提供的Interceptor机制实现的。这个机制在后面的拦截器章节我会详细展开,先记住一点:SQL执行模块的分层不是为了让你翻源码,而是为了让框架可插拔、可观测、可调优。
这也让我想到一个真实场景,很多Spring Boot项目都直接用mybatis-spring-boot-starter,然后在Service里反复调用Mapper方法。其实每次调用都会走一遍Executor的标准流程,而流程中任何一个环节的优化都可能带来倍数级的收益。光让人背“一级缓存二级缓存”,不如真正理解缓存到底挂在哪个环节。
2. 主链路拆解:从Mapper代理到ResultSet的一路流程
2.1 Mapper接口原来是个动态代理
首先解决第一个高频面试题:为什么Mapper接口没有实现类,调用方法时却能把SQL跑起来?原因在于MyBatis在启动时会扫描Mapper接口,然后用JDK动态代理生成一个代理实例。当我们调用mapper.selectByPrimaryKey(1001)时,实际上调用了MapperProxy的invoke方法,它会把方法解析成MapperMethod,然后执行execute方法,最终走向sqlSession.selectOne或sqlSession.selectList。这意味着,你写的每个Mapper方法,本质上都是一个路由动作,把Java方法调用转成SqlSession的命名空间调用。
源码里关键的一行是:
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { if (Object.class.equals(method.getDeclaringClass())) { return method.invoke(this, args); } return cachedInvoker(method).invoke(proxy, method, args, sqlSession); }也就是说,只有Object自带的方法会被直接透传,其他方法都会走到MapperMethod里面找对应的SqlCommand。SqlCommand会读取MappedStatement的id、SQL类型、以及ResultMaps等元数据。这个机制你理解后,就能明白为什么一个Mapper接口里不能有重载方法(参数不同,id相同会冲突)。如果你在面试中能把这个动态代理链路描述清楚,直接会加分。
2.2 Executor的query与update做了什么
拿到sqlSession后,就会执行对应的方法。以常用的selectList为例,DefaultSqlSession会拿到configuration中的Executor,然后调用executor.query。如果是update/insert/delete,则调用executor.update。这两个方法才是SQL执行模块的心脏。
在BaseExecutor中,query流程大概是:
public <E> List<E> query(...) throws SQLException { BoundSql boundSql = ms.getBoundSql(parameterObject); CacheKey key = createCacheKey(ms, parameterObject, rowBounds, boundSql); return query(ms, parameterObject, rowBounds, resultHandler, key, boundSql); }先获取BoundSql对象,再根据StatementId、参数、分页条件生成CacheKey。这个CacheKey是判断一级缓存是否命中的凭据。如果没有命中缓存,就会调用queryFromDatabase,最终交给StatementHandler去执行。而update方法也会做类似的事情,在真正执行SQL前会先清空局部缓存,避免脏读。
这里有一个细节:很多人以为MyBatis的一级缓存是“杠杠的”,其实恰恰相反,它在很多场景下反而会引发问题。比如同一个SqlSession里连续查询同一条数据两次,第二次虽然命中缓存,但如果数据库在两次查询之间被其他连接改了数据,你的程序拿到的就是旧数据。尤其在高并发、多写多读的电商系统里,这就会导致数据不一致。后面缓存章节还会深入讲。
2.3 StatementHandler:真正与JDBC打交道的人
Executor只负责调度,真正干苦力的是StatementHandler。它要做三件事:prepare、parameterize、query或update。以PreparedStatementHandler为例,prepare会调用connection.prepareStatement(sql),parameterize会调用ParameterHandler的setParameters方法,把Java参数一个个绑定到PreparedStatement上。最后query调用preparedStatement.executeQuery并交给ResultSetHandler处理;update调用executeUpdate返回影响行数。
@Override public <E> List<E> query(Statement statement, ResultHandler resultHandler) throws SQLException { PreparedStatement ps = (PreparedStatement) statement; ps.execute(); return resultSetHandler.handleResultSets(ps); }这段逻辑看起来平淡,却藏着不少性能优化的机会。比如PreparedStatement本身是支持预编译的,但如果你每次执行SQL时参数变了,预编译的优化就体现不出来。另一个跟连接池相关的坑是:如果连接池的maxPreparedStatementCount不够大,大量重复prepare会造成资源浪费。真正的项目里,我们可以通过配置合理大小,或者用ReuseExecutor缓存Statement来减轻这种压力。
2.4 从ResultSet到POJO:最后一公里的映射
当SQL执行完,ResultSetHandler就开始工作了。它会把ResultSet里的行数据转换成用户配置的结果类型。这一过程需要调用TypeHandler把JDBC类型的值转换为Java类型。比如数据库的TINYINT转成Java的Integer,VARCHAR转成String。转换规则由ResultMap定义,如果你没有显式配置resultMap,框架会按自动映射来处理:列名与属性名保持大小写不敏感匹配,开启驼峰映射后下划线转驼峰。
这里有个容易被忽略的坑:当查询结果中的列名在POJO里不存在时,MyBatis默认会忽略它,而不是报错。如果你使用select *,又开了自动映射,可能出现字段错位或类型转换异常。更常见的问题是:如果ResultSet中某一列是空值,映射到Java基本类型时会抛异常,因为基本类型不能是null。解决方法是尽量用包装类型,或者在配置里设置jdbcTypeForNull。我在项目中遇到过好多次,就是因为date字段在某个数据源里返回了null,POJO用的是java.util.Date还好,用的是java.sql.Date且是基本类型就当场炸了。这些细节在参数绑定与结果映射章节我会再展开说。
3. 缓存机制:为什么说它是SQL执行模块的“变速器”
3.1 一级缓存和二级缓存到底挂在哪一层
聊到SQL执行,缓存是绕不开的,因为缓存被设计在Executor环节,直接影响整个查询链路。一级缓存PerpetualCache是Executor内部的一个LocalCache,默认开启,生命周期跟SqlSession绑定。当你执行一个select,如果SqlSession没关闭,第二次执行相同的查询(条件完全相同)就会直接命中内存缓存。二级缓存则是在CachingExecutor这个装饰器上实现的,生命周期是整个namespace级别,跨SqlSession共享。打开二级缓存后,Executor会从SimpleExecutor等实现包装成CachingExecutor,先查二级缓存,再查一级缓存,最后才查数据库。
很多初学者分不清“缓存到底存的是什么”。答案很简单:缓存存的是查询结果对象列表的“引用”,而不是重新创建一个新列表。如果缓存中挂着同一个List,你在程序中修改了它,下次再查这个key,拿到的还是这同一个List,已经是被改过的。这就是一个经典事故,我在下文的问题排查里会提到。
3.2 为什么二次查询数据没变?缓存失效场景
先说结论:MyBatis的一级缓存在默认配置下对你通常没有太大好处,反而可能给你造成错觉。为什么会失效?原因有好几个。第一,SqlSession关闭后一级缓存自动清空,类似Spring整合后,每次Service方法的事务边界往往就是SqlSession的生命周期,方法一结束,缓存就没了。第二,任何update/insert/delete操作都会调用clearLocalCache,不管更新的是不是同一条数据,整个SqlSession的缓存全部失效。第三,查询参数、SQL语句、RowBounds不同,CacheKey不同,自然不命中。这对那些在同一个方法里先查再改再查的代码有影响。
再看二级缓存,失效的问题就更多了。二级缓存默认是PerpetualCache,只支持内存缓存,如果你想用Redis、EhCache,还得配置第三方缓存实现。而且二级缓存最让人头疼的问题是脏读。每个namespace的缓存是独立的,如果一个Mapper的查询关联了另一个Mapper的数据,两边的缓存无法感知对方修改,就很容易读脏数据。所以在多表联查、分布式场景下,建议直接关闭二级缓存,用Redis等其他机制做真正的缓存,而不是依赖MyBatis的二级缓存。
3.3 手动清缓存和CacheKey组成的秘密
有一个实用技巧是:如果你确实需要强一致性的查询,可以调用sqlSession.clearCache()手动清除一级缓存。但这个方法只是清除SqlSession的本地缓存,二级缓存还是原样。想清二级缓存就麻烦一些,你得通过commit或刷新相关namespace的缓存。还有一个冷知识是CacheKey由StatementId、SQL、参数值、RowBounds等组成,所以哪怕参数顺序不同,只要值不同,就会生成不同的键。因此在开发中要慎用多个参数拼接,尽量在SQL层面用绑定变量。
我自己在维护一个基于spring boot + mybatis的Java开源多商户跨境商城时,遇到过这样一个场景:商户端查询订单列表,因为事务边界很长,缓存一直有效,但此时后台修改了订单状态,导致商户端列表始终缓存旧数据。最终方案是把事务边界改小,同时给订单相关的更新添加了一个清除缓存的逻辑。这个案例值得大家参考,也印证了“缓存不太可靠,需要明确失效时机”这一经验。
4. 参数绑定与结果映射:最容易翻车的两个环节
4.1 参数绑定怎么从#{}变成PreparedStatement的?
在MyBatis的XML或注解里,我们写#{}和${}时,执行模块对它们的处理是完全不同的。#{}会被解析成PreparedStatement的占位符?,由ParameterHandler调用setObject方法绑定,可以防SQL注入。${}则是直接把字符串拼接到SQL里,适用于动态表名、排序字段等场景,但绝不能用在用户输入上。
具体到代码层面,与参数绑定密切相关的是ParameterHandler。DefaultParameterHandler会遍历ParameterMapping集合,拿到当前参数的值,然后根据JdbcType调用对应的TypeHandler的setParameter方法。比如参数是String时,用StringTypeHandler执行ps.setString。这里有一个常见坑:如果你传的是null,而没有指定jdbcType,某些数据库驱动会报错“无效的列类型”。解决方法是统一配置mybatis.configuration.jdbc-type-for-null=NULL,或者在注解里指定jdbcType。
另外,从MyBatis 3.4开始支持参数名自动推断,所以开发时使用@Param就可以把参数名带到SQL里。如果没有@Param,对于单参数直接用任意名字也能绑定,但多参数一定要用@Param,否则会报“Parameter 'xxx' not found”。这个报错我相信每个用MyBatis的人都见过,也是mybatis面试题里出镜率很高的一道。
4.2 结果映射的自动匹配与显式ResultMap抉择
ResultSetHandler在做结果映射时,如果不配置ResultMap,就会根据数据库列的“列标签”和POJO的属性名做自动匹配。默认下是大小写不敏感的,所以userId和userid都能匹配上。但是若开启mapUnderscoreToCamelCase,则会先把列名转成驼峰再匹配,此时user_name能映射到userName。如果不开启,你就要给列起别名。
自动映射虽然方便,但是一旦你的SQL里出现了多表联查,不同表有相同列名,比如id,就会导致你配置文件里的字段映射错乱。此时最好显式定义ResultMap,并通过column来指定每一列的映射来源。若处理嵌套对象和集合,比如一对一、一对多,还得用association和collection。这些配置背后其实是在往ResultMap里添加ResultMapping对象,映射过程就会更加可控。
千万不要把ResultSetHandler想得太复杂,它本质上就是:遍历ResultSet的每一行,创建一个目标对象,找到这个对象的元数据,然后调用合适的TypeHandler把列值读出来放入属性。如果你做了嵌套映射,它内部会递归处理子对象。理解了这一点,你就会明白,为什么结果映射慢时,可以尝试减少嵌套关联查询,或者开启懒加载。
4.3 常见映射异常与调试技巧
我遇到过最多的结果映射异常有几种:第一种,日期时间类型转换出错,通常是java.sql.Date和java.time.LocalDateTime混用。第二种,数值类型精度丢失,比如数据库的DECIMAL映射为Double导致精度损失,应该映射为BigDecimal。第三种,BOOLEAN在MySQL中其实映射为TINYINT,如果POJO是Boolean类型没问题,但若是Integer,有些驱动会把TINYINT映射为Integer。第四种,null值映射到基本类型,前面已经提到了。解决思路永远是通过TypeHandler、ResultMap、jdbcType配置来解决,而不是硬编码改SQL。
调试技巧方面,最实用的就是在MyBatis配置里打印SQL。在MyBatis 3.5之后,可以设置日志实现为StdOutImpl,或者在Spring Boot的application.yml里配置:
mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl这样执行时会把PreparedStatement的占位符和参数打印出来。但在生产环境不要开启,否则日志量会非常大。更好的方式是使用拦截器收集慢SQL,这个我放在最后一部分讲。
5. 动态SQL与BoundSql:让SQL执行灵活又可控
5.1 SqlSource的两兄弟:DynamicSqlSource与RawSqlSource
动态SQL是MyBatis被人叫“灵活”的关键,但很多人不知道动态SQL在执行模块中其实是分阶段的。XML里写的那段包含 、 、 的SQL,会被解析成一个DynamicSqlSource对象。它在运行时每次执行时,由SqlNode集合根据当前参数动态生成SQL片段,再拼成完整的SQL字符串。另一种是RawSqlSource,针对静态SQL,不需要每次判断。这个设计是为了性能:动态SQL需要每次执行时重新解析,而静态SQL可以只解析一次。
虽然我们平时写SQL的时候感觉没什么两样,但在高并发系统里,你如果大量使用动态SQL,每次执行时都会走一遍动态SQL解析流程。这个解析过程虽然很快,但积累起来也有一定开销。所以如果有一条SQL明明很固定,却因为习惯写成了动态标签,可以考虑改为固定SQL。当然,对于复杂查询,动态SQL带来的灵活性是值得这点开销的。
5.2 BoundSql是SQL执行模块的中转数据包
当执行器需要真正执行SQL时,会通过sqlSource.getBoundSql(parameterObject)生成一个BoundSql。这个对象封装了最终的SQL字符串、参数映射列表、以及额外参数。后续Executor、StatementHandler、ParameterHandler都用它来协同工作。例如,PreparedStatementHandler在prepare阶段调用boundSql.getSql()获得SQL语句,在parameterize阶段使用boundSql.getParameterMappings()来逐个绑定参数。很多插件比如PageHelper、MyBatis-Plus的分页插件,本质上也是先拿到BoundSql,然后修改SQL,比如拼接limit和count语句。
理解BoundSql对你排查动态SQL拼接错误非常有帮助。当你的SQL莫名其妙地报语法错误,而日志里显示的SQL又与你写的XML不一致时,十有八九是动态标签作用域问题。比如 标签在某些条件下没有拼上条件,或者 的collection属性配置成空列表时,生成的SQL变成SELECT ... WHERE id in (),这种属于MyBatis的经典坑。
5.3 SQL注入是怎么靠执行模块挡住的
再说回到安全。很多小白问“MyBatis是否绝对防注入”,答案是:分写法。如果全部使用#{},由于是预编译参数绑定,恶意输入再长也就是个字符串,无法改变SQL结构。但如果有人图方便在排序字段上用了${},又没用白名单过滤,那SQL注入就复活了。比如order by ${sortField},如果传入的是“id; drop table users”,预编译根本不认识什么分号,直接拼接,后果不堪设想。
所以如果你的代码里不得不使用${}(动态表名、排序字段、IN列表),一定要在业务层做好校验,比如用枚举白名单限定可传值。另外一个低风险做法是:对于IN列表,虽然MyBatis可以使用 动态生成多个#{}占位符,但列表很大时会导致SQL过长,最好分批或限制最大条数。
6. 拦截器扩展与线上排查:SQL执行模块的高阶玩法
6.1 Interceptor如何“劫持”四大组件方法
Executor、StatementHandler、ParameterHandler、ResultSetHandler都是MyBatis允许拦截的对象,拦截器机制通过动态代理增强,看起来就像给SQL执行模块装了后门。你可以任选四大组件的方法进行拦截,最常被拦截的就是Executor.query和Executor.update,以及StatementHandler.prepare。管控台可以做三件经典的事:打印完整SQL和参数、统计执行耗时、给慢SQL报警。
实现一个打印SQL的拦截器非常简单:
@Intercepts({ @Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class}) }) public class SqlPrintInterceptor implements Interceptor { @Override public Object intercept(Invocation invocation) throws Throwable { StatementHandler handler = (StatementHandler) invocation.getTarget(); BoundSql boundSql = handler.getBoundSql(); String sql = boundSql.getSql().replaceAll("\\s+", " "); System.out.println("SQL: " + sql); System.out.println("Params: " + parameterObjectToString(boundSql.getParameterObject())); return invocation.proceed(); } }需要注意的是,如果直接打印boundSql.getSql(),你看到的还是带?的预编译语句,而不是替换后的完整SQL。要打印占位符和参数,可以遍历parameterMappings,然后根据参数位置取值。在网上很多截图中看到的“实际SQL”,其实是框架内部拼接的,并不是真正传给数据库的预编译语句。明白这一点,你调试时不至于被误导。
6.2 慢SQL统计与性能优化实战
在实际项目中,我经常用拦截器做耗时统计。关键点是使用System.nanoTime()计算精确时间差,然后在超过阈值时输出日志。常见的线上慢SQL主要原因有:没用索引、数据量巨大、使用了select *导致大量IO、分页深度过大、MySQL连接数打满等。这时通过SQL执行模块打印的SQL,加上explain分析,基本能定位。
另一件容易被忽视的事:MyBatis默认查询会把所有结果一次性加载到内存里映射成List,如果你只是要遍历处理,用游标查询(Cursor)更省内存。Cursor本质上也是ResultSetHandler处理成只进前向迭代器,避免了OOM风险。比如批量导出数据时,我就喜欢用SqlSessionFactory.openSession并调用selectCursor,一边遍历一边释放内存,而不是一次性放到List。
6.3 Spring Boot整合时的高阶配置与多商户项目实践
说到Spring Boot整合,现在大家用的都是mybatis-spring-boot-starter,它自动注册了SqlSessionFactory和MapperScannerConfigurer。在多商户跨境商城这类项目中,通常会有多个数据源(主库、商品库、订单库),此时需要配置多个SqlSessionFactory和不同的事务管理器。复杂的查询可配合MyBatis-Plus优化,但如果是核心交易流水,我仍然建议手写SQL,因为MyBatis执行模块虽然强大,但它不负责SQL质量,最终决定效率的还是SQL本身。
如果你的项目里准备用Spring Boot 4(新版本)整合MyBatis,其实核心模块没变,变的更多是自动配置类和依赖版本。我们在升级时最需要注意的是版本兼容、配置项变化。比如mybatis-spring-boot-starter的版本与Spring Boot版本得对齐,不然会出现SqlSessionFactory找不到之类的低级错误。最好的方式是在升级前,先把当前项目的Mapper方法、XML、数据源配置全量梳理一遍,做好回归测试。
另外,在打印SQL方面,除了log-impl配置,我更喜欢使用p6spy来统一打印真实SQL和参数。p6spy本质上也是一个JDBC层代理,配合MyBatis的StatementHandler并不冲突,可以更直观地看到数据库收到的SQL。不过p6spy也会增加性能损耗,只适合开发和预发环境。
如果你只是会用MyBatis,那它对你来说就是一个“黑盒”。但当你真正理解SQL执行模块那一条链路之后,很多“玄学问题”突然就变成了源码里某一行逻辑的事。我自己这些年踩过最多的坑,集中在缓存和参数这两个位置。比如给用户导出大量数据,以为MyBatis一次查询很快,结果直接内存溢出;再比如觉得二级缓存好东西,结果上线后一堆脏数据。希望这篇文章可以让你少走这些弯路。最后再分享一个小技巧:遇到任何MyBatis执行问题,先去看日志里打印出来的SQL和参数是否正常,再去看命中了几级缓存,最后才考虑是不是结果映射的错误。换一句话说,先弄清楚“SQL执行模块”在干什么,你就成功了一半。