Mybatis源码学习(二)——SqlSession 从接口到实现:一次请求的完整生命周期拆解

发布时间:2026/10/18 23:49:14
Mybatis源码学习(二)——SqlSession 从接口到实现:一次请求的完整生命周期拆解
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 groupIdorg.mybatis/groupId artifactIdmybatis/artifactId version3.5.13/version /dependency dependency groupIdcom.h2database/groupId artifactIdh2/artifactId version2.2.224/version /dependency /dependencies接着是 mybatis-config.xml注意 environment 里的事务管理器用 JDBC数据源用 POOLED这样 openSessionFromDataSource 会走完整的 TransactionFactory 创建流程方便观察。?xml version1.0 encodingUTF-8? !DOCTYPE configuration PUBLIC -//mybatis.org//DTD Config 3.0//EN http://mybatis.org/dtd/mybatis-3-config.dtd configuration environments defaultdev environment iddev transactionManager typeJDBC/ dataSource typePOOLED property namedriver valueorg.h2.Driver/ property nameurl valuejdbc:h2:mem:testdb;DB_CLOSE_DELAY-1/ property nameusername valuesa/ property namepassword value/ /dataSource /environment /environments mappers mapper resourcemapper/UserMapper.xml/ /mappers /configurationUserMapper.xml 里放一条最简单的查询statement id 就是后面 selectOne 要传的字符串。mapper namespacecom.demo.UserMapper select idselectById resultTypecom.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 负责生产 SqlSessionDefaultSqlSession 才是真正干活的实现类。这条构建链路每一步都有可观察的配置点。先看 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 造 SimpleExecutorREUSE 造 ReuseExecutorBATCH 造 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 三个 finaldirty 和 cursorList 两个可变。dirty 是事务是否需要提交/回滚的标记cursorList 管理所有打开的 Cursorclose 时会统一关闭。理解这几个字段后面看 commit/rollback/close 的isCommitOrRollbackRequired判断就不会绕。4. 验证请求selectOne 到 ResultSetHandler 的完整调用栈环境搭好后跑一次 selectOne把调用栈打出来验证。在DefaultSqlSession.selectList的executor.query那行下断点Debug 启动控制台会停在断点处。此时按 AltF8 打开 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 ListE 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{id1, nametom}且断点依次命中上述方法。如果中途某一步没进去多半是缓存命中了——把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 的语义决定的——它内部调 selectListsize1 就抛。别急着改 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。调试时把日志级别调到 DEBUGMyBatis 会打印 SQL、参数和结果行数配合断点能省很多时间。需要对照接口签名和配置项时可以查官方文档想快速验证某条 SQL 在 MyBatis 下的参数绑定行为用模型对话跑一遍比翻源码快。接入文档与 API Key 管理https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite接入文档https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite模型对话验证 SQL 与参数https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite长期编码与 Agent 场景https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite最后留一个实操建议把DefaultSqlSession的每个 public 方法都打断点跑一遍尤其是 selectMap 和 selectCursor它们和 selectList 的差异点正是理解 MyBatis 结果集处理多样性的钥匙。跑完这一轮再回头看 Executor 的缓存和事务就不会觉得突兀了。