1. 从一次线上事故说起:SqlSession 没关导致连接池被打满
去年双十一前压测,我们的订单服务在 QPS 冲到 800 左右时突然大面积超时,日志里刷屏的是HikariPool-1 - Connection is not available, request timed out after 30000ms。排查了半天,最后定位到一个导出接口:它在循环里反复sqlSessionFactory.openSession(),却只在正常分支里close(),异常分支直接漏掉了。连接池最大 50,几分钟就被耗光。
这个坑让我重新把 MyBatis 的会话与事务管理从头捋了一遍。很多人写 MyBatis 只关心 Mapper 接口和 XML,觉得SqlSession是框架内部的事,但恰恰是它的生命周期和事务边界决定了你的服务在高并发下稳不稳。这篇文章就聚焦两件事:SqlSession 到底该怎么管,以及事务隔离级别怎么落地。面向的是用 Spring Boot + MyBatis 做业务开发的同学,我会给出可直接复制的SqlSessionFactory、DataSourceTransactionManager配置片段,隔离级别设置代码,以及用日志和断点验证会话复用、事务边界的操作步骤。
顺带说一个多环境密钥管理的实践:我们项目有 dev、test、prod 三套环境,数据库密码、第三方 Key 分散在各自的配置文件里,CI 上经常配错。后来我把这些统一走 TaoToken 的 Key/API 通道管理,一套 Key 对应多环境,配置里只留一个引用,省了不少事。这个后面在配置章节会具体讲怎么接。
先说清楚 SqlSession 是什么。它是 MyBatis 对一次数据库会话的封装,你可以理解成"一次和数据库对话的上下文"——里面持有 Executor(执行策略)、Transaction(连接与事务)、一级缓存。它能做查询(selectOne/selectList/selectCursor)、更新(insert/update/delete)、事务控制(commit/rollback)、缓存管理(clearCache)。适合谁?所有用 MyBatis 的 Java 开发者,尤其是需要自己控制事务边界、或者不用 Spring 托管事务的场景。
2. SqlSessionFactory 与 DataSourceTransactionManager 配置落地
在 Spring Boot 里,通常不需要你手动 newSqlSessionFactory,mybatis-spring-boot-starter会自动装配。但理解它怎么构建的,出问题时你才知道去哪看。构建流程是:SqlSessionFactoryBuilder解析配置 → 生成Configuration→new DefaultSqlSessionFactory(config)。openSession有四个重载:无参用默认执行器和隔离级别;openSession(ExecutorType)覆盖执行器;openSession(TransactionIsolationLevel)指定隔离级别;openSession(Connection)基于已有连接创建(用于外部事务)。
下面是我项目里实际用的配置,直接可复制。先看application.yml:
spring: datasource: url: jdbc:mysql://127.0.0.1:3306/order_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: ${DB_USER} password: ${DB_PASSWORD} hikari: maximum-pool-size: 50 minimum-idle: 10 connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 1800000 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.order.entity configuration: default-executor-type: REUSE local-cache-scope: STATEMENT auto-mapping-unknown-column-behavior: FAILING map-underscore-to-camel-case: true这里几个关键点:default-executor-type: REUSE让相同 SQL 复用PreparedStatement,减少解析开销;local-cache-scope: STATEMENT把一级缓存降到语句级,避免长会话下的脏读;auto-mapping-unknown-column-behavior: FAILING让字段不匹配时直接报错,生产环境尽早暴露问题。
如果你要手动装配(比如多数据源),Java 配置长这样:
@Configuration @MapperScan(basePackages = "com.example.order.mapper", sqlSessionFactoryRef = "orderSqlSessionFactory") public class MyBatisConfig { @Bean("orderDataSource") @ConfigurationProperties("spring.datasource") public DataSource orderDataSource() { return DataSourceBuilder.create().build(); } @Bean("orderSqlSessionFactory") public SqlSessionFactory orderSqlSessionFactory(@Qualifier("orderDataSource") DataSource ds) throws Exception { SqlSessionFactoryBean bean = new SqlSessionFactoryBean(); bean.setDataSource(ds); bean.setMapperLocations(new PathMatchingResourcePatternResolver() .getResources("classpath:mapper/*.xml")); org.apache.ibatis.session.Configuration cfg = new org.apache.ibatis.session.Configuration(); cfg.setDefaultExecutorType(ExecutorType.REUSE); cfg.setLocalCacheScope(LocalCacheScope.STATEMENT); cfg.setAutoMappingUnknownColumnBehavior(AutoMappingUnknownColumnBehavior.FAILING); bean.setConfiguration(cfg); return bean.getObject(); } @Bean("orderTransactionManager") public DataSourceTransactionManager orderTransactionManager(@Qualifier("orderDataSource") DataSource ds) { return new DataSourceTransactionManager(ds); } }DataSourceTransactionManager是 Spring 的事务管理器,它和 MyBatis 的SqlSession通过SqlSessionTemplate协作:Spring 开启事务时把连接绑定到ThreadLocal,MyBatis 的SpringManagedTransaction从DataSourceUtils拿同一个连接,从而保证同一个事务里多个 Mapper 调用共用一条连接。这就是为什么用 Spring 托管事务时,你不需要手动commit。
关于多环境密钥,我现在的做法是在配置里只写占位符${DB_USER}、${DB_PASSWORD},实际值由 TaoToken 统一 Key 通道注入。这样 dev/test/prod 三套环境的密钥集中管理,CI 上不会因为某个环境漏配而挂掉。接入方式很简单,在启动参数或环境变量里把 TaoToken 下发的 Key 映射进来即可,配置本身不用改。
3. 事务隔离级别设置与可复制配置片段
隔离级别这块,很多人以为在application.yml里配一下就行,其实 MyBatis 和 Spring 各有一套。Spring 的@Transactional(isolation = Isolation.REPEATABLE_READ)是声明式的,最终会通过DataSourceTransactionManager应用到Connection上。MyBatis 原生的TransactionIsolationLevel枚举则是在openSession时传入。
先看枚举映射关系,这个表建议收藏:
| 枚举值 | JDBC 对应 | 说明 |
|---|---|---|
| NONE | TRANSACTION_NONE | 不指定 |
| READ_UNCOMMITTED | TRANSACTION_READ_UNCOMMITTED | 读未提交 |
| READ_COMMITTED | TRANSACTION_READ_COMMITTED | 读已提交 |
| REPEATABLE_READ | TRANSACTION_REPEATABLE_READ | 可重复读 |
| SERIALIZABLE | TRANSACTION_SERIALIZABLE | 串行化 |
| SQL_SERVER_SNAPSHOT | 4096 | SQL Server 快照隔离 |
Spring 声明式用法,直接可复制:
@Service public class OrderService { @Autowired private OrderMapper orderMapper; @Transactional(isolation = Isolation.REPEATABLE_READ, rollbackFor = Exception.class) public void createOrder(Order order) { orderMapper.insert(order); // 扣库存等操作,同一事务内共用一条连接 orderMapper.deductStock(order.getSkuId(), order.getQty()); } }如果你不用 Spring,手动控制隔离级别:
try (SqlSession session = sqlSessionFactory.openSession(TransactionIsolationLevel.REPEATABLE_READ)) { OrderMapper mapper = session.getMapper(OrderMapper.class); mapper.insert(order); session.commit(); } catch (Exception e) { // try-with-resources 会自动 close,但异常时需确保回滚 throw e; }注意openSession(TransactionIsolationLevel)这个重载,它会在openConnection()时把隔离级别应用到Connection。但有个坑:部分驱动不支持所有隔离级别,比如某些版本的 MySQL 驱动对TRANSACTION_NONE处理不一致,设置后可能静默失败。所以设置完最好验证一下。
关于执行器选择,这里给个对照表:
| 执行器 | 枚举值 | 适用场景 | 特点 |
|---|---|---|---|
| SimpleExecutor | SIMPLE | 通用短事务 | 每次新建 Statement |
| ReuseExecutor | REUSE | 重复 SQL | 复用 PreparedStatement |
| BatchExecutor | BATCH | 批量写入 | 累积后一次性发送 |
| CachingExecutor | — | 读缓存 | 包装器,引入二级缓存 |
我实测下来,订单查询接口用 REUSE 后,PreparedStatement复用率能到 90% 以上,CPU 在 SQL 解析上的开销明显下降。但批量插入场景一定要用 BATCH,否则一条条发网络往返,吞吐上不去。
4. 验证会话复用与事务边界:日志与断点实操
配置写完,怎么确认它真的生效了?光看代码不够,得用日志和断点验证。我踩过的坑是:以为配了 REUSE 就在复用,结果 SQL 里带了动态时间戳,每次文本都不同,根本没复用上。
第一步,打开 MyBatis 和事务的日志。在application.yml里加:
logging: level: com.example.order.mapper: DEBUG org.mybatis.spring: DEBUG org.springframework.jdbc.datasource.DataSourceTransactionManager: DEBUG org.springframework.transaction: DEBUG启动后调一次查询接口,你会看到类似输出:
==> Preparing: SELECT id, sku_id, qty FROM t_order WHERE sku_id = ? ==> Parameters: SKU001(String) <== Columns: id, sku_id, qty <== Total: 1连续调两次相同 SQL,如果 REUSE 生效,第二次不会再有Preparing那行(因为复用了 Statement),只有Parameters。这就是验证会话内 Statement 复用的直接证据。
第二步,验证事务边界。在OrderService.createOrder里打断点,观察DataSourceTransactionManager的日志:
Creating new transaction with name [com.example.order.service.OrderService.createOrder]: PROPAGATION_REQUIRED, ISOLATION_REPEATABLE_READ Acquired Connection [HikariProxyConnection@123456] for JDBC transaction Switching JDBC Connection [HikariProxyConnection@123456] to manual commit关键看Acquired Connection这行——同一个事务里多次 Mapper 调用,连接对象应该是同一个(@123456这个 hash 不变)。如果变了,说明事务没生效,每次调用都拿了新连接。
第三步,验证隔离级别真的应用到了连接上。在openConnection后加一段临时日志,或者用断点看Connection.getTransactionIsolation()的返回值:
Connection conn = session.getConnection(); int level = conn.getTransactionIsolation(); // REPEATABLE_READ 对应 4 System.out.println("当前隔离级别: " + level);MySQL 的REPEATABLE_READ返回 4,READ_COMMITTED返回 2。如果打印出来是 0(TRANSACTION_NONE),说明设置没生效,得检查驱动版本或配置。
第四步,验证会话复用。用SqlSessionManager的场景,可以在ThreadLocal里看当前线程绑定的会话:
SqlSessionManager manager = SqlSessionManager.newInstance(sqlSessionFactory); manager.startManagedSession(); try { OrderMapper m1 = manager.getMapper(OrderMapper.class); OrderMapper m2 = manager.getMapper(OrderMapper.class); // m1 和 m2 底层是同一个 SqlSession m1.selectById(1L); m2.selectById(2L); manager.commit(); } finally { manager.close(); }断点看manager内部的localSqlSession,两次getMapper拿到的会话是同一个对象,说明线程绑定生效。
5. 常见报错排查:401、local proxy failed、reading choices、OAuth
这一节把我在接入和运行中真实遇到的报错列出来,对照着排查。
报错一:401 Unauthorized。这个通常出现在你通过 HTTP 通道调用模型或外部服务时 Key 不对。如果你用 TaoToken 统一 Key 通道,检查环境变量里的 Key 是否注入成功。排查命令:
echo $TAOTOKEN_API_KEY curl -H "Authorization: Bearer $TAOTOKEN_API_KEY" https://taotoken.net/api/v1/models如果返回 401,说明 Key 无效或过期,去控制台重新生成。注意 Base URL 用https://taotoken.net/api,不要带多余路径。
报错二:local proxy failed。这个一般是你本地配了转发但目标不可达。检查你的网络配置里指向的地址是否正确,以及本地端口是否被占用。如果是 CI 环境,确认环境变量有没有透传进去。
报错三:reading choices相关错误。这类报错通常出现在解析模型返回的 JSON 时,choices字段读不到。原因多半是返回体不是预期的 JSON 结构,比如返回了 HTML 错误页。排查方法:把原始响应打出来看。
// 打印原始响应,定位是 JSON 还是 HTML System.out.println(rawResponse);如果是 HTML,说明请求打到了错误的端点,检查 Base URL 和路径拼接。
报错四:OAuth相关。如果你用 Claude Code 或 Codex 这类工具,认证走的是 OAuth 流程。配置时三件套要写全:Base URL、Key、Model ID。以 Codex 的auth.json为例:
{ "base_url": "https://taotoken.net/api", "api_key": "sk-xxxxxxxx", "model": "claude-sonnet-4-5" }三个字段缺一不可,少一个就会在 OAuth 握手阶段失败。Cline 的 MCP 配置同理,Base URL+Key+Model ID都要填。
报错五:TransactionException: Error setting auto-commit。这是 MyBatis 的JdbcTransaction在设置自动提交时失败,常见于某些驱动不支持setAutoCommit。解决办法是在JdbcTransactionFactory上设置skipSetAutoCommitOnClose=true:
<transactionManager type="JDBC"> <property name="skipSetAutoCommitOnClose" value="true"/> </transactionManager>报错六:SqlSessionException。多半是会话没开或没关。检查open()/close()是否配对,用SqlSessionManager时检查线程绑定是否正常。我那次线上事故就是这个——异常分支漏了close()。
报错七:隔离级别设置后未生效。前面提过,先确认openConnection()时是否真的设置了,再确认驱动是否支持。用conn.getTransactionIsolation()打印实际值,别猜。
6. 把 Key 通道和事务配置串起来:一套可维护的工程实践
最后说说怎么把 TaoToken 的 Key 通道和 MyBatis 配置结合起来,让多环境管理不混乱。
核心思路是:配置里只留引用,密钥集中注入。application.yml里写${DB_PASSWORD}、${TAOTOKEN_API_KEY},实际值由 TaoToken 统一 Key 通道在启动时注入。这样 dev/test/prod 三套环境共用一份配置模板,切换环境只改注入源,不改代码。
具体操作:在 TaoToken 控制台为每个环境生成独立的 Key,然后在 CI/CD 的启动脚本里映射:
export DB_PASSWORD=$(taotoken get --env prod --key db_password) export TAOTOKEN_API_KEY=$(taotoken get --env prod --key api_key) java -jar order-service.jar这样密钥不落盘,也不进 Git,安全性和可维护性都上来了。如果你需要长期跑编码任务或 Agent,可以考虑 Coding Plan,它按用量计费,比单次调用更划算。验证模型连通性的话,用模型对话页面直接测一下就行。
回到 MyBatis 本身,把会话和事务管好的核心就三条:SqlSession 用完即关(try-with-resources 或 finally),事务边界交给 Spring 的DataSourceTransactionManager(别手动 commit),隔离级别设置后一定用getTransactionIsolation()验证。执行器按场景选,读多写少用 REUSE,批量写入用 BATCH,大结果集用 Cursor 或 ResultHandler 流式处理,别一次性 load 进内存。
我现在的习惯是,每加一个 Mapper 方法,先想清楚它属于哪个事务边界,再决定用哪个执行器。这个习惯帮我避掉了不少连接泄漏和脏读的坑。