MYBATIS-SPRING · SQLSESSIONTEMPLATE · TRANSACTIONSYNCHRONIZATIONMANAGER

Spring 开的事务,MyBatis 的 SQL 怎么进去的

@Transactional 归 Spring 管,SQL 却是 MyBatis 发的。两边能对上,靠的是同一个线程上的一个“储物柜”:Spring 把数据库连接放进去,MyBatis 从里面拿。

Spring 这边

@TransactionalAOP 代理在方法前后插手
DataSourceTransactionManager事务经理

开事务:拿一个连接,关掉自动提交,放进柜子。结束时由它提交或回滚。

当前线程的储物柜
连接
#A · 手动提交
MyBatis 会话
S1
收尾清单
事务结束时通知 S1

MyBatis 这边

orderMapper.insert(o)
SqlSessionTemplate柜子里有会话就复用
SpringManagedTransaction要连接时去柜子里拿 #A
事务经理DataSourceTransactionManager

开事务时拿连接、放进柜子;真正的提交和回滚都是它做。

Spring
储物柜TransactionSynchronizationManager

几个 ThreadLocal。一个线程一个柜子,按 key 存东西。

Spring
会话管家SqlSessionTemplate

每次 Mapper 调用都经过它。柜子里有会话就复用,没有就新开并登记。

mybatis-spring
对接员SpringManagedTransaction

MyBatis 要连接,它去柜子里拿 Spring 的;MyBatis 要提交,它说交给 Spring。

mybatis-spring
配角 连接 #A 整个事务从头用到尾的那一个 收尾清单 SqlSessionSynchronization,提交前叫会话清缓存,结束前关掉它 DataSourceUtils 取连接的统一入口,JdbcTemplate 也走它

启动时:两边接到同一个 DataSource 上

柜子里的连接是按 DataSource 这个 key 存的。事务经理和 MyBatis 用的必须是同一个 DataSource 对象,才能存取到同一格。

事务经理DataSourceTransactionManagerBoot 自动配置好(也可能是它的子类 JdbcTransactionManager)
同一个 DataSource比如 HikariDataSource
SqlSessionFactorySqlSessionFactoryBean没指定 transactionFactory 时,默认用 SpringManagedTransactionFactory
不用 Boot 时自己写,就是这两个 Bean
@Bean
SqlSessionFactory sqlSessionFactory(DataSource ds) throws Exception {
    SqlSessionFactoryBean f = new SqlSessionFactoryBean();
    f.setDataSource(ds);                  // 和事务经理用同一个
    // 不设 transactionFactory,默认就是 SpringManagedTransactionFactory
    return f.getObject();
}

@Bean
PlatformTransactionManager txManager(DataSource ds) {
    return new DataSourceTransactionManager(ds);   // 同一个 DataSource
}

@Bean
SqlSessionTemplate sqlSessionTemplate(SqlSessionFactory f) {
    return new SqlSessionTemplate(f);         // 你的 Mapper 都通过它干活
}
  • 用 Spring Boot 时,这三个 Bean 都由自动配置给你造好(Spring 第 03 篇):MyBatis 的 starter 造前一个和最后一个,Spring 的 JDBC 自动配置造事务经理。
  • @MapperScan 登记的每个 Mapper,底下用的都是这个 SqlSessionTemplate。
  • JdbcTemplate 取连接也走 DataSourceUtils,所以和 MyBatis 混用时,同一个事务里拿到的是同一个连接。

柜子里放了什么

储物柜就是 TransactionSynchronizationManager 里的几个 ThreadLocal。事务里最要紧的是这三格。

连接

key
DataSource
放的是
ConnectionHolder,里面是连接 #A
谁放的
事务经理,开事务时
谁来拿
MyBatis、JdbcTemplate,都经过 DataSourceUtils
何时清走
事务提交或回滚之后

MyBatis 会话

key
SqlSessionFactory
放的是
SqlSessionHolder,里面是会话 S1
谁放的
SqlSessionTemplate,事务里第一次调 Mapper 时
谁来拿
同一事务里,后面每一次 Mapper 调用
何时清走
提交或回滚之前

收尾清单

形式
一个列表,不按 key 存
放的是
SqlSessionSynchronization
提交前
叫 S1 commit:清一级缓存、刷出攒着的批量语句
结束前
把 S1 从柜子里拿走并关掉
挂起时
把 S1 挪出柜子,恢复时再放回来
记住一个线程一个柜子。换了线程,柜子是空的,事务就跟不过去。

跟着一个下单方法走一遍

选一个剧情,一步一步看:代码走到哪一行,柜子里有什么,数据库里哪些改动已经生效,日志打出了什么。

代码

    DEBUG 日志(连接写成 #A,会话写成 S1)黄色是这一步新打的

    储物柜

    数据库里的改动

    1/9
    doBegin

    三段关键源码

    都是简化版,去掉了日志和边角判断,留下决定行为的那几行。

    ① SqlSessionTemplate每次 Mapper 调用都会走进这个拦截器
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        SqlSession session = getSqlSession(factory, executorType, translator);  // 拿会话,见 ②
        try {
            Object result = method.invoke(session, args);                       // 真正执行 SQL
            if (!isSqlSessionTransactional(session, factory)) {
                session.commit(true);                                          // 不在事务里:自己提交
            }
            return result;
        } catch (Throwable t) {
            throw translate(t);   // MyBatis 异常 → Spring 的 DataAccessException
        } finally {
            closeSqlSession(session, factory);   // 在事务里:只是还回柜子;不在:真的关掉
        }
    }
    ② SqlSessionUtils.getSqlSession先翻柜子,没有再新开
    SqlSessionHolder holder = (SqlSessionHolder) TransactionSynchronizationManager.getResource(factory);
    if (holder != null && holder.isSynchronizedWithTransaction()) {
        return holder.getSqlSession();                       // 柜子里有:复用
    }
    SqlSession session = factory.openSession(executorType);   // 没有:新开一个
    if (TransactionSynchronizationManager.isSynchronizationActive()) {   // 正在 Spring 事务里
        holder = new SqlSessionHolder(session, executorType, translator);
        TransactionSynchronizationManager.bindResource(factory, holder);              // 放进柜子
        TransactionSynchronizationManager.registerSynchronization(
                new SqlSessionSynchronization(holder, factory));                           // 登记收尾
    }
    return session;
    ③ SpringManagedTransactionMyBatis 眼里的“事务”,其实处处让着 Spring
    public Connection getConnection() throws SQLException {
        if (connection == null) {
            connection = DataSourceUtils.getConnection(dataSource);    // 柜子里有 Spring 的连接,就拿它
            autoCommit = connection.getAutoCommit();
            isConnectionTransactional = DataSourceUtils.isConnectionTransactional(connection, dataSource);
        }
        return connection;
    }
    
    public void commit() throws SQLException {
        if (connection != null && !isConnectionTransactional && !autoCommit) {
            connection.commit();     // 只有连接不归 Spring 管时,才自己提交
        }
    }
    和 JdbcTemplate 混用

    它也走 DataSourceUtils,拿到的是同一个 #A,所以在同一个事务里。

    一级缓存在事务里才有用

    事务里会话复用,第二次查才能命中。MyBatis 第 01 篇的剧情演示过。

    异常会被翻译

    比如唯一键冲突变成 DuplicateKeyException。都是 RuntimeException,默认会触发回滚。

    看日志就知道有没有进事务

    配上 logging.level.org.mybatis.spring=debug 和 logging.level.org.springframework.jdbc=debug,每次 Mapper 调用都会说明自己的处境。

    在事务里

    Registering transaction synchronization for SqlSession [S1]
    JDBC Connection [#A] will be managed by Spring
    Releasing transactional SqlSession [S1]
    Fetched SqlSession [S1] from current transaction
    Transaction synchronization committing SqlSession [S1]

    不在事务里

    SqlSession [S1] was not registered for synchronization because synchronization is not active
    JDBC Connection [#A] will not be managed by Spring
    Closing non transactional SqlSession [S1]

    以为加了事务却看到右边这几行,说明这次调用根本没进事务。常见原因是自调用、方法不是 public、另开了线程,见下面的坑。

    JDK 8 和 JDK 17:机制一样,包名和版本不同

    柜子、会话复用、谁来提交,这一套在两个版本里完全相同。

    Spring 5 · Boot 2

    JDK 8+
    • mybatis-spring 2.x(starter 2.3.x)
    • 除了 Spring 自己的 @Transactional,也认 javax.transaction.Transactional
    • CGLIB 代理下,@Transactional 只对 public 方法生效

    Spring 6 · Boot 3

    JDK 17+
    • mybatis-spring 3.x(starter 3.0.x),要求 Spring 6
    • JTA 注解换成 jakarta.transaction.Transactional;还写 javax 版的,不报错,也不开事务
    • 6.0 起,CGLIB 代理下 protected 和包可见的方法上的 @Transactional 也生效
    没变的:同一个 DataSource 是纽带线程储物柜SqlSessionTemplate 复用会话Spring 提交连接,MyBatis 只清缓存

    常踩的坑

    先看日志里那几句,再对照柜子想:这次调用时,柜子里有没有东西。

    加了 @Transactional,出错了还是没回滚

    事务压根没开:同一个类里自己调自己、方法不是 public、异常被 catch 吞了、抛的是受检异常。原因都在 Spring 第 02 篇。

    线程池、@Async、parallelStream 里的 SQL 不归这个事务

    那个线程的柜子是空的,用的是另一个自动提交的连接。要么放回当前线程做,要么让它自己开事务,并接受两边不一致的可能。

    多数据源:一个库回滚了,另一个库没回滚

    事务经理按它自己那个 DataSource 存连接,另一个库的 Mapper 拿到的是别的连接。用 @Transactional("reportTxManager") 指定事务经理;真要跨库一起成败,得上分布式事务。

    Connection is not available, request timed out after 30000ms

    连接从开事务一直占到提交。事务里调远程接口、发消息、做大计算,连接池很快被占满。把慢操作挪到事务外面。

    BATCH 执行器下,insert 返回 -2147482646

    批量模式先攒着,到提交前(收尾清单通知会话时)才真正发出去,所以拿不到影响行数。要结果就在事务里手动调用 flushStatements()。

    Cannot change the ExecutorType when there is an existing transaction

    同一个事务里,先用普通的 SqlSessionTemplate,又用了 BATCH 的。柜子里那个会话类型已经定了,不能换。把批量操作放进单独的事务。

    同一个事务里,读不到别人刚提交的数据

    两个原因叠在一起:会话复用,一级缓存命中了;MySQL 默认的可重复读隔离级别,本来就看不到。

    升级 Boot 3 后,javax.transaction.Transactional 不生效

    Spring 6 只认 jakarta 版。换成 jakarta.transaction.Transactional,或者直接用 Spring 自己的 @Transactional。