1. 从一次 selectOne 说起:SqlSession 到底做了什么
你写下一行sqlSession.selectOne("com.demo.UserMapper.selectById", 1L),方法返回一个 User 对象。看起来只是"调了个方法拿数据",但 MyBatis 在背后至少经过了六层协作:SqlSession 接口 → DefaultSqlSession 实现 → Executor 执行器 → StatementHandler 语句处理器 → ParameterHandler 参数处理器 → ResultSetHandler 结果集处理器。这条链路就是 MyBatis 执行一次查询的完整生命周期,也是源码阅读最值得先啃下来的一段。
SqlSession 是 MyBatis 暴露给应用层的核心接口,所有增删改查、事务提交回滚、Mapper 接口获取都从这里出发。它适合谁?适合已经会用 MyBatis 做 CRUD、但想搞清楚"为什么 Mapper 接口不用写实现类就能跑""为什么 selectOne 查到多条会抛 TooManyResultsException""为什么不开自动提交时查询也会占着连接"这类问题的开发者。搞懂 SqlSession 从接口到实现的拆解,后面看 Executor 的一级缓存、StatementHandler 的 SQL 预编译、ResultSetHandler 的自动映射,都会顺很多。
这篇不空谈结构,而是以一次 selectOne 调用为线索,给出可复制的断点调试配置和最小复现 Demo,把 DefaultSqlSession 里每个方法最终落到哪个组件讲清楚。你可以边读边在 IDE 里打断点,跟着调用栈走一遍,比只看类图印象深得多。阅读路径建议:先跑通 Demo,再按 selectOne → selectList → executor.query 的顺序单步,最后回头看 getMapper 的动态代理入口,整条链路就闭环了。
2. 前置准备:最小复现 Demo 与断点调试配置
在追源码之前,先把环境搭到"能一键复现"的程度。我用的组合是 JDK 8 + Maven + MyBatis 3.5.x + H2 内存库,好处是不用装 MySQL,跑完即销毁,断点随便打。下面这份 pom 依赖直接复制即可。
<dependencies> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>3.5.13</version> </dependency> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <version>2.2.224</version> </dependency> </dependencies>接着是 mybatis-config.xml,注意 environment 里的事务管理器用 JDBC,数据源用 POOLED,这样 openSessionFromDataSource 会走完整的 TransactionFactory 创建流程,方便观察。
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE configuration PUBLIC "-//mybatis.org//DTD Config 3.0//EN" "http://mybatis.org/dtd/mybatis-3-config.dtd"> <configuration> <environments default="dev"> <environment id="dev"> <transactionManager type="JDBC"/> <dataSource type="POOLED"> <property name="driver" value="org.h2.Driver"/> <property name="url" value="jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1"/> <property name="username" value="sa"/> <property name="password" value=""/> </dataSource> </environment> </environments> <mappers> <mapper resource="mapper/UserMapper.xml"/> </mappers> </configuration>UserMapper.xml 里放一条最简单的查询,statement id 就是后面 selectOne 要传的字符串。
<mapper namespace="com.demo.UserMapper"> <select id="selectById" resultType="com.demo.User"> SELECT id, name FROM t_user WHERE id = #{id} </select> </mapper>主类里手动建表、插数据,然后调用 selectOne。这里刻意用字符串 statement 而不是 Mapper 接口,是为了让调用栈更干净,先看清 DefaultSqlSession 本身。
public class Demo { public static void main(String[] args) throws Exception { InputStream in = Resources.getResourceAsStream("mybatis-config.xml"); SqlSessionFactory factory = new SqlSessionFactoryBuilder().build(in); try (SqlSession session = factory.openSession()) { session.getConnection().createStatement() .execute("CREATE TABLE t_user(id BIGINT, name VARCHAR(32))"); session.getConnection().createStatement() .execute("INSERT INTO t_user VALUES(1,'tom')"); session.commit(); User user = session.selectOne("com.demo.UserMapper.selectById", 1L); System.out.println(user); } } }断点怎么打?在 IDEA 里对DefaultSqlSession.selectOne、selectList、executor.query三处下断点,Debug 模式启动。第一次停在 selectOne 时按 F7 步入,你会看到它立刻转调 selectList;再步入 selectList,断点会落到executor.query(ms, wrapCollection(parameter), rowBounds, handler)这一行。此时在 Variables 面板展开 executor,能看到它是 CachingExecutor,内部还包着 SimpleExecutor——这就是 Executor 装饰链的入口。把这一步走通,后面 StatementHandler 的创建时机就一目了然了。
3. 可复制配置:SqlSessionFactory 到 DefaultSqlSession 的构建链路
要理解 selectOne 为什么能落到 executor,得先知道 SqlSession 是怎么被造出来的。SqlSessionFactoryBuilder 负责解析配置,DefaultSqlSessionFactory 负责生产 SqlSession,DefaultSqlSession 才是真正干活的实现类。这条构建链路每一步都有可观察的配置点。
先看 SqlSessionFactoryBuilder。它有三组 build 重载:Reader、InputStream、Configuration。前两种最终都会 new 一个 XMLConfigBuilder,调用 parse() 把 XML 解析成 Configuration,再交给build(Configuration);第三种直接new DefaultSqlSessionFactory(config)。也就是说,无论你从哪种入口进来,最后都收敛到同一个工厂实现。
public SqlSessionFactory build(Configuration config) { return new DefaultSqlSessionFactory(config); }DefaultSqlSessionFactory 只有一个 final 的 configuration 字段,构造时必须传入。它的 openSession 系列方法分两条路:带 Connection 参数的走 openSessionFromConnection,其余走 openSessionFromDataSource。两条路的核心动作一致——拿 TransactionFactory 造 Transaction,再用configuration.newExecutor(tx, execType)造 Executor,最后createSqlSession返回 DefaultSqlSession。
private SqlSession openSessionFromDataSource(ExecutorType execType, TransactionIsolationLevel level, boolean autoCommit) { Transaction tx = null; try { final Environment environment = configuration.getEnvironment(); final TransactionFactory transactionFactory = getTransactionFactoryFromEnvironment(environment); tx = transactionFactory.newTransaction( environment.getDataSource(), level, autoCommit); final Executor executor = configuration.newExecutor(tx, execType); return createSqlSession(configuration, executor, autoCommit); } catch (Exception e) { closeTransaction(tx); throw ExceptionFactory.wrapException("Error opening session. Cause: " + e, e); } finally { ErrorContext.instance().reset(); } }这里有个容易忽略的点:getTransactionFactoryFromEnvironment在 environment 为空或未配置事务工厂时,会兜底返回new ManagedTransactionFactory()。Managed 意味着事务交给容器管,自己 commit 不生效——如果你发现手动 commit 没反应,先回来看看这里是不是走了兜底分支。
再看configuration.newExecutor,它按 ExecutorType 决定基础执行器:SIMPLE 造 SimpleExecutor,REUSE 造 ReuseExecutor,BATCH 造 BatchExecutor;如果开了二级缓存,还会用 CachingExecutor 包一层。这就是为什么断点里看到的 executor 是 CachingExecutor 而不是 SimpleExecutor。
public Executor newExecutor(Transaction transaction, ExecutorType executorType) { executorType = executorType == null ? defaultExecutorType : executorType; executorType = executorType == null ? ExecutorType.SIMPLE : executorType; Executor executor; if (ExecutorType.BATCH == executorType) { executor = new BatchExecutor(this, transaction); } else if (ExecutorType.REUSE == executorType) { executor = new ReuseExecutor(this, transaction); } else { executor = new SimpleExecutor(this, transaction); } if (cacheEnabled) { executor = new CachingExecutor(executor); } executor = (Executor) interceptorChain.pluginAll(executor); return executor; }注意最后一行pluginAll,这是插件链的挂载点。你写的分页插件、SQL 打印插件就是在这里被织入 Executor 的。所以断点里 executor 的类型可能既不是 CachingExecutor 也不是 SimpleExecutor,而是插件生成的代理类——看到陌生类名别慌,展开它的 target 字段就能找到真身。
DefaultSqlSession 的字段也值得记一下:configuration、executor、autoCommit 三个 final,dirty 和 cursorList 两个可变。dirty 是事务是否需要提交/回滚的标记,cursorList 管理所有打开的 Cursor,close 时会统一关闭。理解这几个字段,后面看 commit/rollback/close 的isCommitOrRollbackRequired判断就不会绕。
4. 验证请求:selectOne 到 ResultSetHandler 的完整调用栈
环境搭好后,跑一次 selectOne,把调用栈打出来验证。在DefaultSqlSession.selectList的executor.query那行下断点,Debug 启动,控制台会停在断点处。此时按 Alt+F8 打开 Evaluate,输入this.executor.getClass().getName(),能看到类似org.apache.ibatis.executor.CachingExecutor的结果。
继续 F7 步入 executor.query,会进入 CachingExecutor.query。它先查二级缓存,未命中则委托给 delegate(即 SimpleExecutor)的 query。再步入,进入 BaseExecutor.query,这里会先查一级缓存 localCache,未命中才调queryFromDatabase。queryFromDatabase 里会list = doQuery(ms, parameter, rowBounds, resultHandler, boundSql),doQuery 是抽象方法,由 SimpleExecutor 实现。
SimpleExecutor.doQuery 是整条链路的关键节点,它做了四件事:拿 Configuration 造 StatementHandler、用 StatementHandler 造 Statement、用 ParameterHandler 设参、用 ResultSetHandler 处理结果。
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); } }configuration.newStatementHandler会根据 statementType 造 RoutingStatementHandler,默认内部包着 PreparedStatementHandler。PreparedStatementHandler 的构造函数里会new ParameterHandler和new ResultSetHandler,所以这两个处理器是在造 StatementHandler 时一并创建的。
prepareStatement里调用handler.parameterize(stmt),这一步会触发 ParameterHandler.setParameters,把#{id}替换成?并绑定实参。你可以在这里下断点,观察parameterObject的值和boundSql.getParameterMappings()的映射关系。
最后handler.query(stmt, resultHandler)进入 PreparedStatementHandler.query,它调ps.execute()拿到 ResultSet,再交给resultSetHandler.handleResultSets(ps)。DefaultResultSetHandler 会遍历结果集,按 resultType 反射创建对象、逐列赋值,最终返回 List。回到 DefaultSqlSession.selectOne,判断 list.size()==1 就返回第一个元素。
把这条栈完整走一遍,你会得到类似这样的调用链:
DefaultSqlSession.selectOne -> DefaultSqlSession.selectList -> CachingExecutor.query -> BaseExecutor.query -> SimpleExecutor.doQuery -> Configuration.newStatementHandler -> PreparedStatementHandler.parameterize -> DefaultParameterHandler.setParameters -> PreparedStatementHandler.query -> DefaultResultSetHandler.handleResultSets验证成功的标志:控制台打印出User{id=1, name='tom'},且断点依次命中上述方法。如果中途某一步没进去,多半是缓存命中了——把localCacheScope设为 STATEMENT 或换一个不同的参数再试。
5. 常见报错排查:从 BindingException 到 TooManyResultsException
追源码时最容易卡在几个典型报错上,这里按真实异常信息对照排查。
第一个是org.apache.ibatis.binding.BindingException: Type com.demo.UserMapper is not known to the MapperRegistry.。这个错出在MapperRegistry.getMapper,原因是 knownMappers 里没有这个接口。knownMappers 是在解析 mapper 文件或configuration.addMapper时填充的。排查顺序:确认 mybatis-config.xml 的<mappers>里配了对应文件;确认 namespace 和接口全限定名完全一致;如果用注解方式,确认接口上有@Mapper或已被扫描。三者缺一,knownMappers 就是空的。
第二个是TooManyResultsException: Expected one result (or null) to be returned by selectOne(), but found: N。这是 selectOne 的语义决定的——它内部调 selectList,size>1 就抛。别急着改 SQL,先想清楚业务上是不是真的只该有一条。如果确实可能多条而你只要第一条,用 selectList 取 get(0),或者加 limit。这个异常本身是保护机制,不是 bug。
第三个是ExecutorException: Error preparing statement. Cause: java.sql.SQLException。这类错通常出在prepareStatement阶段,根因在 SQL 或参数映射。检查#{}里的属性名和 parameterType 是否对得上,检查 resultType 的类是否有无参构造。如果用了$ {}拼接,还要注意注入风险。
第四个是PersistenceException: Error querying database. Cause: ...,外层被 ExceptionFactory.wrapException 包了一层。真正的 Cause 在冒号后面,别只看外层。常见内因是连接池耗尽或事务未提交导致锁等待。回到 openSessionFromDataSource,确认 autoCommit 的取值符合预期。
第五个是Error committing transaction. Cause: ...。commit 时抛错,先看isCommitOrRollbackRequired(force)的返回值。如果 autoCommit 为 true,!autoCommit && dirty恒为 false,只有 force 为 true 才会真正提交。很多人手动 openSession(true) 后又调 commit,结果什么都没发生,就是这里的原因。
排查通用套路:先在ExceptionFactory.wrapException下断点,看原始异常;再顺着调用栈往上找第一个业务相关的方法;最后对照上面几个高频点定位。别一上来就搜异常字符串,先看 Cause。
6. 继续深入:把 SqlSession 链路接到真实工程
源码读到这一步,你已经能把一次 selectOne 的完整生命周期讲清楚了。但真实工程里还有几块值得接着挖:Mapper 接口的动态代理入口(MapperProxy 如何把接口方法转成 MappedStatement id)、插件链如何织入 Executor 和 StatementHandler、二级缓存的 TransactionalCache 如何跨 session 生效。这些都可以在现有 Demo 上继续打断点验证。
如果你想把这条链路用到实际项目里,建议先在一个独立模块里跑通最小 Demo,再逐步替换成项目里的数据源和 Mapper。调试时把日志级别调到 DEBUG,MyBatis 会打印 SQL、参数和结果行数,配合断点能省很多时间。需要对照接口签名和配置项时,可以查官方文档;想快速验证某条 SQL 在 MyBatis 下的参数绑定行为,用模型对话跑一遍比翻源码快。
- 接入文档与 API Key 管理:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite
- 接入文档:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite
- 模型对话验证 SQL 与参数:https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite
- 长期编码与 Agent 场景:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite
最后留一个实操建议:把DefaultSqlSession的每个 public 方法都打断点跑一遍,尤其是 selectMap 和 selectCursor,它们和 selectList 的差异点正是理解 MyBatis 结果集处理多样性的钥匙。跑完这一轮,再回头看 Executor 的缓存和事务,就不会觉得突兀了。