从一条selectOne调用开始,MyBatis 内部要经历多少层包装、多少个对象的协作,最终才能变成一个数据库真正认得的PreparedStatement?很多人写 MyBatis 用了几年,出了缓存问题、参数问题、结果集映射问题,第一反应都是百度报错,却不知道问题出在链路里哪一环。
我本身也是从“面向百度编程”走过来的,直到有一次排查一个“查询不走缓存”的诡异问题,被迫把 MyBatis 的源码从头到尾跟了一遍,才真正理解SqlSession、Executor、StatementHandler、ParameterHandler、ResultSetHandler这条链条到底在做什么。这篇文章就按“一次查询的完整生命周期”为主线,把从SqlSession到 JDBCStatement的每一层拆开来讲,适合已经会写 MyBatis 映射文件、但想搞懂内部机制的开发者。看完之后,你会明白缓存为什么失效、参数为什么会绑定失败、结果集映射为什么会乱,以及遇到这类问题时,该去源码里找哪一段。
1. 从 SqlSession 说起:一次查询的一生
1.1 SqlSession 为什么被设计成“门面”
MyBatis 里最容易被忽略的设计,就是SqlSession这个接口。很多新手把它当成“数据库连接”的替代品,但实际上它根本不直接操作数据库,它是一个门面模式的外观接口,把后面复杂的组件全部封装起来,对外只暴露简单的selectOne、selectList、insert、update、delete这些方法。
对比 JDBC 原生写法,你要手动Connection、PreparedStatement、ResultSet,然后还要自己处理SQLException、关闭资源。MyBatis 把这一套全部收进SqlSession背后。你调用session.selectOne("com.example.UserMapper.findById", 1),接下来发生的是一连串的委托调用:DefaultSqlSession会把请求转发给Executor,Executor再交给StatementHandler,StatementHandler内部分工给ParameterHandler、ResultSetHandler,最后由 JDBC 层的Connection和Statement真正干活。
用生活化类比来说,SqlSession像是餐厅的前台服务员,你只需要跟他说“来一份红烧肉”,他不需要在你面前展示后厨到底有几个厨师、开几个灶、怎么摆盘。服务员负责下单和送菜,后厨才是真正做菜的。同样的,SqlSession负责接收你的 Mapper 方法调用,然后把任务派发给后厨Executor。
理解这一层有什么实际价值?当你排查问题时,一旦发现是SqlSession层面的异常,比如“SqlSession was not registered for synchronization”,你第一步就要去看是不是@Transactional或 Spring 事务管理器没有正确绑定SqlSession,而不是去翻 SQL 语句本身。
1.2 拿到 SqlSession 之后,代码里到底发生了什么
大多数项目里,你并不会直接session.selectOne,而是通过 Mapper 接口,例如:
UserMapper mapper = session.getMapper(UserMapper.class); User user = mapper.findById(1L);这里有两层魔法。第一层:session.getMapper通过MapperRegistry从配置里找到对应的MapperProxyFactory,生成一个 JDK 动态代理对象。第二层:调用mapper.findById(1L)时,代理对象把方法调用交给MapperProxy,MapperProxy内部使用MapperMethod来解析这次调用。
MapperMethod会做两件事。解析方法签名里的@Param注解并组装成参数 map,以及确定这条 SQL 是 SELECT 还是 UPDATE 类型,然后据此调用SqlSession的对应方法。findById最终会走到sqlSession.selectOne。
selectOne和selectList的关系也值得注意。在DefaultSqlSession的实现里,selectOne本质就是调selectList,返回结果是List后判断数量:
public <T> T selectOne(String statement, Object parameter) { List<T> list = this.selectList(statement, parameter); if (list.size() == 1) { return list.get(0); } else if (list.size() > 1) { throw new TooManyResultsException(...); } else { return null; } }细节在于:它允许返回 0 条,返回 null;返回超过 1 条时直接抛TooManyResultsException。很多人在数据库里查到两条数据时,报错信息只看到Expected one result (or null) to be returned by selectOne(), but found: 2,却没意识到selectOne不是“只要一条”,而是“最多且仅能一条”,超过一条不会帮你取第一个,而是直接失败。
顺着调用链往深处走,DefaultSqlSession会调用executor.query。这一步开始进入真正的执行中枢。
2. Executor:真正的执行中枢
2.1 Executor 家族:模板方法模式的经典运用
Executor是 MyBatis 执行 SQL 的核心接口,它的实现类是整个执行流程的骨架。默认情况下,DefaultSqlSession持有的Executor并不是直接执行 JDBC 的那个执行器,而是CachingExecutor包装了某个BaseExecutor子类的组合体。
BaseExecutor是个抽象类,它用模板方法模式定义了查询的规范流程。子类只需要实现doQuery、doUpdate、doQueryCursor、doFlushStatements这些真正操作 JDBC 的方法。MyBatis 提供了三种核心子类:
SimpleExecutor:每次执行都会创建新的Statement,用完即关。ReuseExecutor:用Map缓存已创建的Statement,同一个 SQL 的相同语句复用同一个Statement,减少重复 prepare。BatchExecutor:批量执行 SQL,将多次更新攒在一起,通过addBatch+executeBatch提交,适合大批量插入场景。
BaseExecutor还维护了查询缓存最重要的数据结构——一级缓存localCache,以及记录缓存更新状态和事务状态的一系列字段。
拿ReuseExecutor来说,它内部有一个Map<String, Statement>,key 是由 SQL 和参数模式计算出来的。相同BoundSql的语句第二次执行就不走prepareStatement,直接复用之前的Statement,这对重复执行同一条 SQL 的应用场景很有价值。但它也有坑:如果 SQL 中包含临时表、会话级变量、动态变化的 schema 等,复用的Statement可能拿到过期的上下文。生产环境里我遇到过一个问题:连接池采用ReuseExecutor,某条 SQL 第一次执行时创建临时表,第二次执行时报“临时表已存在”,就是因为Statement复用导致重复执行了同样的CREATE TEMPORARY TABLE,这其实是设计预期的行为,解决方案是禁用复用或者把该 SQL 移出复用范围。
2.2 缓存逻辑优先:一级缓存和二级缓存的执行顺序
Executor这一层最大的干预者是缓存。MyBatis 默认开启一级缓存,也就是SqlSession级别缓存,可以通过localCacheScope配置为SESSION(默认)或STATEMENT。二级缓存是 Mapper 级别的,需要手动开启,开启后CachingExecutor会作为装饰器包裹真正的Executor。
执行顺序必须从 (CachingExecutor.query) 开始讲。它一进来先根据MappedStatement的 id、参数值、分页信息、SQL 语句等生成CacheKey,然后去查二级缓存:
- 如果二级缓存命中,直接返回结果,后面的
BaseExecutor、StatementHandler都没有机会参与。 - 如果二级缓存未命中,则把请求转发给底层
BaseExecutor。 - 底层
BaseExecutor.query先查一级缓存localCache:命中则直接返回;未命中则调用doQuery真正查询数据库。 - 数据库查询完成后,结果存入一级缓存,同时由于
CachingExecutor在外层包装,它还会把结果放入二级缓存。
这里有一个非常重要的细节:BaseExecutor.query在查询之前会先调用clearLocalCache的时机是有限的,但update操作会触发clearLocalCache。也就是说,同一个SqlSession内执行 insert/update/delete 后,一级缓存会被清空,这是为了保证数据一致性。
一级缓存失效的另一个时机是跨SqlSession查询:一级缓存是SqlSession私有的,另一个SqlSession修改了数据,当前SqlSession的一级缓存不会被通知,只能等它自己commit或close。这就是 MyBatis 一级缓存经常被诟病“脏数据”的根源。生产环境通常建议把localCacheScope设为STATEMENT,或者干脆关闭一级缓存,避免数据不一致问题。很多人说 MyBatis 关闭一级缓存是做不到的,其实可以:mybatis-config.xml里<settings>加<setting name="localCacheScope" value="STATEMENT"/>,并把cacheEnabled保持默认 true(这个控制的是二级缓存),这样每次查询结束一级缓存就被清掉,相当于只保留下一次查询缓存,近似关闭。
二级缓存的序列化问题也在这条链路上体现。CachingExecutor把结果存入二级缓存时,要求查询结果对象必须实现Serializable接口,因为二级缓存默认基于PerpetualCache的内存实现,但可能被第三方缓存扩展如 Redis 缓存序列化。如果你开启了二级缓存但实体没实现Serializable,首次查询没问题,第二次再查就可能报NotSerializableException。这正是很多团队“开启二级缓存后出现偶发报错”的一个常见原因。
2.3 核心四步:从缓存查到 doQuery
BaseExecutor.query的简化流程是:
- 解析
BoundSql。 - 创建
CacheKey。 - 查一级缓存。
- 未命中则执行
doQuery。
doQuery在SimpleExecutor里的实现,是创建一个StatementHandler,然后执行四个步骤:
public <E> List<E> doQuery(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException { Statement stmt = null; try { Configuration configuration = ms.getConfiguration(); StatementHandler handler = configuration.newStatementHandler(wrapper, ms, parameter, rowBounds, resultHandler, boundSql); stmt = prepareStatement(handler, ms.getStatementLog()); return handler.query(stmt, resultHandler); } finally { closeStatement(stmt); } }这里出现了一个新组件:StatementHandler。它才是真正和 JDBCStatement打交道的人。prepareStatement内部做了两件事:从Connection获取Statement,然后给Statement设置超时时间和 fetchSize。
这一步也被很多开发忽略:MyBatis 的defaultFetchSize设置会在这层生效。如果你查询超大结果集,不设置fetchSize,驱动默认可能一次拉回所有数据导致内存溢出;设置了合理的 fetchSize,配合ResultSet.TYPE_FORWARD_ONLY才能实现流式查询。常见的“MyBatis 查询大数据量导致 OOM”问题,很大程度上就出在这个prepareStatement到handler.query之间的设置细节上。
3. StatementHandler:JDBC Statement 的诞生与参数装配
3.1 RoutingStatementHandler 和 PreparedStatementHandler
StatementHandler也是接口,MyBatis 提供了RoutingStatementHandler、PreparedStatementHandler、SimpleStatementHandler、CallableStatementHandler等实现。RoutingStatementHandler本身不干活,它根据MappedStatement的statementType路由到具体的StatementHandler:
STATEMENT对应SimpleStatementHandler,走 JDBC 的Statement,不能防止 SQL 注入,一般不用。PREPARED对应PreparedStatementHandler,默认方式,使用预编译,支持?占位符。CALLABLE对应CallableStatementHandler,存储过程调用。
绝大多数使用场景都在PreparedStatementHandler。它内部会创建一个 JDBC 的PreparedStatement,然后调用ParameterHandler设置参数,执行execute()得到ResultSet,再交给ResultSetHandler处理。
这里有一个常见的 SQL 注入误区。有人觉得 MyBatis 映射文件里的${}不能乱用,#{}安全,这其实只对了一半。#{}会被编译成?占位符,值通过setObject传给驱动,驱动一般会做转义,安全。但${}是直接做字符串替换,拼进 SQL 后仍会经过StatementHandler,但此时已经是拼接完整的 SQL,JDBC 完全把它当 SQL 语法解析,危险。所以在ORDER BY字段、动态表名、批量列名等不能用#{}的场景下,必须自己白名单校验。
3.2 BoundSql 与动态 SQL 解析之后的样子
在StatementHandler真正处理之前,SQL 早就被解析成了BoundSql。BoundSql是一个核心数据对象,里面含有三个关键部分:最终可执行 SQL、参数映射列表parameterMappings、参数对象。
动态 SQL(比如<if>、<where>、<foreach>)是在哪一步完成拼接的?答案是SqlSource。MyBatis 解析 MappedStatement 时,会根据<select>标签的内容创建一个DynamicSqlSource或RawSqlSource。DynamicSqlSource用 OGNL 表达式处理${}和<if>、<choose>等标签,每次执行都动态解析,生成静态 SQL 片段;RawSqlSource则只处理一次#{}占位符,将其替换为?,但不做动态判断。
理解这一点,就能明白为什么#{}和${}的解析时机不同:${}在DynamicSqlSource.getBoundSql阶段就会被拼接进 SQL,值直接进入 SQL 文本;#{}则保留?占位符,并在ParameterHandler阶段通过参数映射列表逐位 set。所以BoundSql里看到的 SQL,已经是把动态标签迭代完成、${}已替换、#{}已变?的“最终形态”。排查 SQL 语法问题时,可以在日志里打印BoundSql.getSql(),看到的就是即将发给数据库的真实 SQL,比看映射文件里的原始 SQL 更接近问题现场。
parameterMappings列表里每一项包含属性名、jdbcType、typeHandler 等。#{}里指定的jdbcType、typeHandler、jdbcTypeName都会在这里体现。这也是为什么你写#{createTime,jdbcType=TIMESTAMP}时,MyBatis 能精确知道用什么 JDBC 类型去 set。
3.3 ParameterHandler 和 TypeHandler:Java 类型到 JDBC 类型的最后一公里
ParameterHandler的默认实现是DefaultParameterHandler,核心逻辑就一个setParameters(PreparedStatement ps)方法:
- 拿到
BoundSql.getParameterMappings()。 - 如果是空参数映射,直接返回。
- 遍历每个
ParameterMapping,获取属性值。 - 根据属性值的类型和
ParameterMapping里配置的TypeHandler,调用ps.setXxx(i, value)。
TypeHandler就是负责 Java 类型和 JDBC 类型转换的类型处理器。MyBatis 内置了很多TypeHandler,比如IntegerTypeHandler对应 JDBC 的INTEGER,StringTypeHandler对应VARCHAR。DateTypeHandler会比较特殊,默认只处理java.util.Date,如果你用java.time.LocalDateTime,在 MyBatis 3.4 之前需要额外注册LocalDateTimeTypeHandler,之后内置了才不报错。但老项目迁移时,经常遇到“无法为参数设置值”的报错,就是 TypeHandler 不匹配。
参数绑定最常见的坑是 jdbcType 缺失。当参数为null且是#{}绑定时,如果没指定jdbcType,MyBatis 默认会使用JdbcType.OTHER,某些数据库驱动(比如 Oracle)对OTHER不支持,直接报错“无效的列类型”。解决办法是用#{param,jdbcType=NULL}显式指定,或者在配置里设置jdbcTypeForNull=JdbcType.NULL。虽然这是一个老生常谈的坑,但在 MyBatis 从 3.2 升级到 3.5 的过程中,默认行为有调整,依然容易复发。
3.4 创建 PreparedStatement 的细节
PreparedStatementHandler.instantiateStatement的核心一行是:
PreparedStatement ps = connection.prepareStatement(sql);但在这之前,statementLog会记录下这条 SQL,Connection是从Transaction对象里拿到的。MyBatis 的事务管理和Connection获取方式,决定了你会拿到什么样的 JDBC 连接。
如果使用 Spring 整合,SpringManagedTransaction会把Connection从DataSourceUtils里获取,并参与 Spring 的事务同步。如果使用 MyBatis 的JdbcTransaction,它会直接从JdbcDataSource的getConnection获取连接,并自己管理提交、回滚和关闭。
一个细节:prepareStatement会设置statement.setFetchSize(fetchSize)、statement.setTimeout(queryTimeout)。fetchSize默认是 Jdbc 驱动默认值,MyBatis 配置里的defaultFetchSize会覆盖这里的默认。对 MySQL 的ResultSet流式读取有要求时,必须设置fetchSize=Integer.MIN_VALUE,否则驱动会一次性加载全部结果到应用内存。这是典型的 MyBatis “流式查询”配置点。
在PreparedStatement创建完成后,下一步就是parameterHandler.setParameters(ps),把参数填进去,然后ps.execute()。到这一步,真正的 JDBCStatement诞生了,SQL 发出了。
4. ResultSetHandler:结果集映射与游标遍历
4.1 ResultSet 映射的三种形态:自动映射、注解映射与 XML 映射
StatementHandler.query返回结果之前,会调用ResultSetHandler.handleResultSets(Statement stmt)。它拿到 JDBC 的ResultSet,再根据MappedStatement中的resultMap或自动映射规则,把结果集转换成 Java 对象。
ResultSetHandler的默认实现是DefaultResultSetHandler,整个 MyBatis 映射体系里最复杂的类之一。它负责处理普通映射、嵌套结果映射、嵌套查询、懒加载、游标查询、多结果集等。
自动映射的规则在Configuration里配置:autoMappingBehavior有NONE、PARTIAL(默认)、FULL三个级别。PARTIAL表示没有嵌套结果时自动映射,FULL允许嵌套结果也自动映射。很多人分不清resultType和resultMap的边界:resultType指定了返回对象的 Class,MyBatis 会尝试将列名自动映射到属性名(下划线转驼峰由mapUnderscoreToCamelCase控制);resultMap是更精细的映射规则,可以处理列名不对应、嵌套对象、联合查询等。
一个常见的映射问题是表字段user_name对应 Java 属性的userName。如果没有开启mapUnderscoreToCamelCase,自动映射会失败,返回的对象里 userName 就是 null。这个配置在 Spring Boot 的application.yml里是:
mybatis: configuration: map-underscore-to-camel-case: true但如果你自己写 XML 的resultMap,每个<result column="user_name" property="userName"/>都要手写。
4.2 嵌套查询与懒加载
MyBatis 的一对多、多对一映射有三种实现路径:association和collection既可以使用嵌套结果(一条 SQL JOIN 查出来),也可以使用嵌套查询(分开多条 SQL 查询)。
嵌套查询就是在映射配置里写select="com.example.UserMapper.findById"之类的属性。DefaultResultSetHandler每处理一条主记录,发现需要某个关联属性时,会再次调用 Executor 去查那条子查询,并把子查询结果映射到当前对象的属性上。如果配置了懒加载,且开启aggressiveLazyLoading=false或lazyLoadingEnabled=true,那么子查询会延迟到第一次访问该属性时才触发。
嵌套查询是 N+1 问题的温床。主表查出 100 条用户,每条又触发一次查询用户地址,总共 101 条 SQL。很多人把锅甩给 MyBatis,其实是配置方式的问题:关联查询用join加resultMap嵌套结果,就能避免 N+1。
懒加载的底层实现是给目标对象创建了代理对象(通常是 Javassist 或 CGLIB 代理),在访问懒加载属性时,代理对象内会触发一次Executor.query。这个代理对象在SqlSession关闭后,如果懒加载尚未触发,再去访问属性会报LazyInitializationException,这是 MyBatis 最常见的坑之一。解决方案包括:
- 在 Spring 事务内完成所有属性访问;
- 配置
aggressiveLazyLoading=true使查询立即加载所有懒加载属性; - 使用
OpenSessionInView模式或关闭懒加载。
4.3 从 ResultSet 到对象图的完整装配
DefaultResultSetHandler处理单个行的方式是:遍历ResultSet,用ResultSetWrapper封装元数据,然后根据resultMap的映射关系,调用TypeHandler.getResult(rs, columnName)取出列值,再通过反射或MetaObject将值写入对象属性。
这里有一个性能关键点:ResultSetHandler每行数据都要创建对象,如果结果集有 10 万行,就创建 10 万个目标对象实例,不可避免有反射开销。MyBatis 在 3.5 版本之后对反射做了大量优化,包括Reflector缓存(每个类的属性元数据只解析一次),以及在自动映射时使用ObjectFactory实例化对象。但依然比直接手写 JDBC 的rs.next()加 getter 赋值要慢。对极致性能敏感的场景,可以考虑使用 MyBatis 的游标查询加流式处理,减少对象驻留内存,但性能基准还是不如原生 JDBC。
ResultSet遍历过程中,还有一处值得注意:列的序号和名字。TypeHandler.getResult优先通过列名取索引,如果列名重复(比如 JOIN 两个表都有id列),映射就会错乱。这也是为什么连接查询最好给查询列起别名:u.id AS user_id, a.id AS address_id,否则自动映射会把同名列的值赋给第一个属性。
handleRowValuesForNestedResultMap处理嵌套结果时还会维护一个“当前已处理行”的 Map,避免同一父对象被重复创建。这个逻辑在跨越多行结果拼接一对多集合时非常关键,比如一个用户带三个订单的三行结果,最终只产生一个用户对象,orders集合装三个订单。
5. 全链路时序与性能排查实战
5.1 一次 selectOne 的完整调用堆栈
用一个非常常见的场景:Spring 容器中,Service 调用UserMapper.findById(1L)。完整展开的执行链路如下:
- Spring 注入
UserMapper代理,实为MapperProxy动态代理对象。 MapperProxy.invoke解析方法,构建MapperMethod。MapperMethod.execute根据 SQL 类型调用SqlSessionTemplate.selectOne或SqlSession.selectOne。SqlSessionTemplate通过SqlSessionInterceptor调用DefaultSqlSession.selectOne。DefaultSqlSession.selectOne调selectList。DefaultSqlSession.selectList从Configuration获取MappedStatement,然后executor.query(ms, param, rowBounds, resultHandler, boundSql)。- 这里的
executor通常是SqlSessionInterceptor或 MyBatis Spring 集成中通过SqlSessionUtils获取的SqlSession持有的CachingExecutor。 CachingExecutor.query计算CacheKey,查二级缓存,未命中,调delegate.query(...)。delegate是SimpleExecutor(或ReuseExecutor),进入BaseExecutor.query。BaseExecutor.query查一级缓存,未命中,调doQuery。SimpleExecutor.doQuery创建PreparedStatementHandler,并调用prepareStatement。prepareStatement通过Transaction.getConnection()拿到 JDBCConnection,调用connection.prepareStatement(sql),并设置 fetchSize、timeout。PreparedStatementHandler.parameterize调DefaultParameterHandler.setParameters,通过TypeHandler.setParameter给PreparedStatement逐个绑定?参数。PreparedStatementHandler.query调PreparedStatement.execute(),得到ResultSet。PreparedStatementHandler.query调DefaultResultSetHandler.handleResultSets,从ResultSet遍历行,映射成User对象,返回List<User>。BaseExecutor.query把结果存入一级缓存,返回。CachingExecutor.query把结果存入二级缓存,返回。- 最终
UserMapper.findById返回单个User对象。
这个堆栈如果你用 debug 模式打断点,会走得非常累,但正是这条链路决定了 MyBatis 的所有行为特征:缓存、参数、事务、懒加载、映射。排查问题时,你会不由自主地在不同断点观察:
- 在
MapperMethod.execute看参数封装是否正确; - 在
BoundSql.getSql()看最终 SQL 是否拼对; - 在
DefaultParameterHandler.setParameters看TypeHandler是否有异常; - 在
DefaultResultSetHandler看映射结果为什么 null。
5.2 常见问题与排查技巧实录
把这么多年在项目里踩过和帮别人排查过的高频问题整理成一个速查表,方便以后直接参照:
| 现象 | 典型原因 | 排查切入点 |
|---|---|---|
| 同一条 SQL 查询返回旧数据 | 一级缓存未失效,跨 SqlSession 数据更新未同步 | 检查localCacheScope,必要时改为STATEMENT;在更新后手动clearCache() |
| 查询非常慢,第二次就快 | 二级缓存未生效或未命中 | 检查cacheEnabled,<cache>是否配置,实体是否实现Serializable |
报错TooManyResultsException | selectOne返回多条 | 查数据库实际数量,确认 LIMIT 1 或唯一索引 |
报错SQLSyntaxErrorException,SQL 里有奇怪的? | #{}被正确替换为?,但?不能被某些数据库语法接受(如 DDL 里),改用${}要谨慎 | 打印BoundSql.getSql()看真实 SQL |
| 参数为 null 报“无效的列类型” | 未指定jdbcType,默认JdbcType.OTHER不被驱动支持 | 给#{}加jdbcType=NULL,或全局配置jdbcTypeForNull |
| 对象属性为 null 但 DB 有值 | 自动映射失败,列名与属性名不对应,mapUnderscoreToCamelCase未开 | 开启驼峰映射或写resultMap |
访问关联对象报LazyInitializationException | SqlSession 已关闭,懒加载代理无法触发查询 | 在事务内访问,关闭懒加载,或配置 OpenSessionInView |
用JOIN查询返回的orders集合不完整 | 嵌套结果映射配置有误,或列名冲突 | 给列起别名,并检查collection的column和ofType是否匹配 |
BatchExecutor批量插入不生效 | 没有调用flushStatements()或没有 commit | 检查是否触发executeBatch,确认ExecutorType.BATCH已配置 |
排查技巧上,我个人的习惯是先打开 SQL 日志,确认数据库收到的 SQL 和参数值是否正确,再做断点定位。MyBatis 的日志实现里,PreparedStatementLogger会打印 SQL 和参数值,如果日志显示 SQL 正常但结果不符合预期,基本就把问题锁定在ResultSetHandler或缓存层;如果日志里 SQL 本身就缺少参数或拼错,就要去查BoundSql和ParameterHandler环节。
另外一个很实用的排查思路是“关闭二级缓存做对照实验”。二级缓存经常会掩盖真实查询问题的表象。如果你怀疑查询结果被缓存干扰,直接在配置里临时把<setting name="cacheEnabled" value="false"/>加上,重启应用,若问题消失,就是缓存层的问题,再进行细分。
6. 关于源码阅读与日常调试的一点经验
读 MyBatis 源码,比读 Spring 源码轻松很多。它的核心包结构非常清晰:session包是门面,executor包是执行器,mapping包是 SQL 映射模型,type包是类型处理器,scripting包是动态 SQL,plugin包是拦截器。想弄懂某个环节,直接按包定位。
我自己调试 MyBatis 时,一定会用“白板堆栈法”:把一次查询的调用链画出来,在每个组件旁边标注它负责的事情。遇到问题时看是哪个组件的职责范围,再去那个组件的源码里断点,效率比逐层 F7 高得多。比如,某次 SQL 执行很慢,我先确认不是数据库本身性能问题,然后把断点打在StatementHandler.query和ResultSetHandler.handleResultSets之间,看时间消耗主要卡在execute()还是映射处理阶段,就能判断是驱动网络问题还是映射性能问题。
还有一个小技巧:利用 MyBatis 的拦截器自定义日志,把BoundSql.getSql()和参数列表时间点打印出来,精确到毫秒。很多人不知道ParameterHandler也可以被拦截,在setParameters前后打印参数,能看出参数类型转换时产生的卡顿。这个对定位TypeHandler阻塞问题特别有效。
如果你正打算深入研究 MyBatis,我不建议直接啃源码 PDF,而是从一个小问题出发,比如“为什么selectOne超过一条记录会抛异常”这样的小问题,顺着调用链去源码里找答案。当你完整走通几条这样的调用链后,整个执行框架自然就在脑子里建立起来了。这比我当初读三遍源码再画图要高效得多。