1. 事务在 MyBatis 里到底是个什么东西
1.1 先解决一个关键追问:事务真的存在 MyBatis 里吗
很多刚接触 MyBatis 的人,会把“MyBatis 的事务”理解成一个由框架维护的小盒子:你打开开关,事务开始;你关上开关,事务结束。这个理解不算全错,但会让你在后面的问题排查里走很多弯路。
实际上,数据库事务是底层数据库连接的一个属性,MyBatis 并没有内置任何事务引擎。MyBatis 能做的全部事情,就是拿到一个 JDBC 的 Connection,把“开启事务”翻译成setAutoCommit(false),把“提交”翻译成Connection.commit(),把“回滚”翻译成Connection.rollback()。它自己不会去决定一条 SQL 到底算不算数,真正的裁判永远只有底层数据库。
你可以把 Connection 想象成一本草稿账,数据库是一个铁面会计。你在草稿账上写了很多笔,但只要没喊“入账”,这些记录在别人眼里都不存在;喊了“入账”,正式生效;喊了“作废”,草稿全撕掉。MyBatis 就是那个帮你拿笔、替你喊话的柜员。柜员再熟练,也不能替会计做决定。
1.2 三条事务路径,边界完全不同
第一个要认清的事实是:MyBatis 事务有不止一种玩法,选择不同,事务边界就差之千里。
第一种是原生SqlSessionFactory加SqlSession的手工控制,常见于不用任何容器框架、单独引入 MyBatis 的项目。代码大概是这个样子:
try (SqlSession session = sqlSessionFactory.openSession(false)) { UserMapper mapper = session.getMapper(UserMapper.class); mapper.insert(user); session.commit(); } catch (Exception e) { // 手动回滚 session.rollback(); throw e; }注意openSession()不传参时的默认行为是autoCommit=false。这个时候如果你insert完忘了commit(),连接一关,数据是不会落库的。这个行为和很多人最初用 JDBC 的印象不一样:JDBC 连接默认是自动提交,每条 SQL 独立成一个事务;MyBatis 为了让多条语句能聚合在同一个事务里,特意默认关掉了自动提交。
第二种是容器框架接管后使用SqlSessionTemplate的情况,这是目前绝大多数 Java 后端项目的常态。开发者平时根本接触不到 SqlSession,Mapper 也只是一个代理接口。这里的核心机制是SqlSessionTemplate会以线程为单位复用同一个 SqlSession,而这个 SqlSession 内部持有的数据库连接,来自容器当前事务管理器绑定的那个连接。这个逻辑我下面会单独拆开讲,它是理解“为什么一个注解能管住 MyBatis”的关键。
第三种是独立环境里配置transactionManager选择 JDBC 还是 MANAGED。选择 JDBC,事务管理器就是直接调用 Connection 自带的commit/rollback;选择 MANAGED,提交和回滚交给外部容器来管,MyBatis 自己不主动提交。MANAGED 模式如果外部容器没接好,就容易出现“数据迟迟不落库”的冷门坑,独立部署时尤其要注意。
1.3 autocommit 是最容易忽略的第一现场
有一个非常简单却经常被忽略的实验,我建议每个想搞懂 MyBatis 事务的人都做一次:分别用openSession(true)和openSession(false)各执行一次insert,并且都不调用commit()。前者执行完数据已经存在,后者执行完数据不存在。
try (SqlSession session = sqlSessionFactory.openSession(true)) { UserMapper mapper = session.getMapper(UserMapper.class); mapper.insert(user); // 没有 commit,但这条数据已经落库 }传入true时,MyBatis 拿到连接后不会关闭 JDBC 的自动提交,每条 SQL 执行完,JDBC 驱动自己就提交了。传入false时,所有 SQL 都攒在同一个尚未提交的事务里,只有commit()才会让它们一起生效。
排查事务不生效时,第一件事就应该确认连接身上的autocommit到底是谁改的。容器框架接管业务方法时,会把它改成false;如果连接根本没有被任何事务管理器接管,它很可能还是true。这个小事往往决定了你后续排查的大方向。
提示:当有人在群里问“MyBatis 事务没生效”,我一般先让他打印连接日志,确认连接上有没有出现
Setting autocommit to false。这一行日志看起来不起眼,但它直接说明事务有没有真正进入接管状态。
2. 为什么 @Transactional 管得住 MyBatis 却管不住多数据源
2.1 先搞懂 SqlSession 的线程绑定逻辑
很多文章一上来就讲代理、讲 AOP,但真正难住大家的是另一个问题:为什么同一个线程里的多个 Mapper 调用会被自动归到同一个事务?
道理其实不复杂。容器框架接管后,Mapper 实际注入的是 MapperProxy,它在执行时调用SqlSessionTemplate的 execute 方法。SqlSessionTemplate不会直接new一个 SqlSession,而是先看当前线程有没有事务同步资源。如果@Transactional生效,事务管理器在进入方法时已经做了一件事:从数据源拿一个连接,把它的自动提交关掉,然后把这个连接放进线程本地存储。
之后,整个线程内的所有 Mapper 操作,都从线程本地存储里取连接。也就是说,第一个 Mapper 用这个连接,第二个 Mapper 还是这个连接,无论你在这个事务方法里调多少个 Mapper,它们天然都在同一个数据库事务上。这就是“一个注解管住所有 MyBatis 操作”的真相。
这里有一个特别重要的推论:如果某个操作跑在别的线程里,它拿不到当前线程绑定的连接,也就不会加入当前事务。多线程环境下,@Transactional并不会让子线程自动继承事务。常见踩坑场景是线程池里异步写数据,外层方法虽然标着注解,但子线程用的连接完全独立,它们各自有自己的事务边界,甚至可能是自动提交。
2.2 传播行为:每个传播级别都对应一种连接命运
容器框架里的事务传播行为,本质是描述“当前线程已经有事务连接时,新调用到底怎么处理”。理解了这一点,MyBatis 在什么情况下会开新连接、什么情况下共用连接,就会很清楚。
| 传播行为 | 连接层面发生什么 | 典型场景 |
|---|---|---|
| REQUIRED | 当前没有事务就新拿连接并开启;已经有事务就直接使用现有连接 | 默认的传播行为,绝大多数 Service 方法用它 |
| REQUIRES_NEW | 挂起当前事务,重新拿一个连接开启新事务,方法结束后恢复外层事务 | 审计日志、埋点记录,需要独立提交的场景 |
| SUPPORTS | 有事务就跟着事务走,没有事务就以非事务方式执行 | 只读查询、非关键写操作 |
| NOT_SUPPORTED | 挂起当前事务,以非事务方式执行 | 特殊清理任务 |
| MANDATORY | 当前没有事务直接抛异常 | 严格约定必须在事务内执行的方法 |
| NEVER | 当前存在事务直接抛异常 | 禁止在事务里执行的接口 |
| NESTED | 复用外层连接,但会设置保存点 | 需要内层独立回滚但不想开新连接 |
很多人容易混淆 REQUIRES_NEW 和 NESTED,区别特别实际:REQUIRES_NEW 是暂停外层事务,开一个新的独立连接,内层回滚不影响外层;NESTED 是沿用外层同一个连接,只是在上打个标记,内层回滚时退回到标记点,外层还能继续。
对连接池来说,REQUIRES_NEW 会额外占用一个连接,NESTED 不会增加连接数。如果一个接口里嵌套了三层 REQUIRES_NEW,那一次请求就可能同时占用多个连接,并发一高,连接池很容易被打满。所以看到“事务方法套事务方法导致连接不够用”时,先检查是不是用了太多 REQUIRES_NEW。
2.3 多数据源:不是注解不灵,是边界划错了
再来看一个常见场景。项目里有两个数据源,A 库负责订单,B 库负责流水。Service 方法上加一个@Transactional,先向 A 库写订单,再向 B 库写流水。你期望两条写操作要么都成功,要么都失败。
但实际上,这个注解通常只绑定一个事务管理器,也就是默认的主数据源。A 库的写入会被接进当前线程的事务连接,B 库的写入如果走的是第二个数据源,而它的连接并没有被放进当前线程的事务同步资源里,那 B 库就是一条独立连接,A 库回滚时,B 库可能已经悄悄提交了。
所以“@Transactional 管不住多数据源”并不是注解失效,而是你把两个数据源两个连接,硬塞给了一个只认识其中一个连接的事务管理器。它根本不知道第二个连接的存在,自然无法替它做决定。
多 SqlSessionFactory 会让这个问题更隐蔽。MyBatis 多数据源配置时,不同 Mapper 会被扫描到不同的 SqlSessionFactory 下。如果某个 Mapper 绑定错了 factory,它执行时拿到的连接,就不是当前事务持有的那个连接。表现就是你明明加了注解,这个 Mapper 的写入竟然不回滚。
遇到这种情况,别先去翻事务注解,先画一张图:当前请求会经过哪些数据源、哪些连接、哪些 Mapper,把连接的归属从头理一遍。跨数据源的强一致,优先考虑拆成单数据源事务加对账、补偿;真要坚持强一致,需要引入分布式事务方案或事务中间件,但要接受它带来的性能和运维成本。指望一个注解同时指挥两个数据库,这条路从一开始就走不通。
注意:多数据源事务问题的第一件事不是找“通用注解”,而是重画事务边界。边界画错了,任何框架都救不了。
3. 事务不生效的排查链路:从一次诡异回滚失败说起
3.1 当时看到的现象
某天凌晨,对账跑批系统报出了一条差异:订单表里多了一笔扣款记录,流水表里却没有对应的流水。两段写操作明明在同一个方法里,方法上也标着@Transactional。按道理应该是要么两条都有,要么两条都没有,结果出现了一半,非常诡异。
当时的方法大概长这样:
@Transactional public void createOrder(OrderDTO dto) { try { orderMapper.insert(dto); logMapper.insert(dto.getLog()); } catch (Exception e) { // 这里只是打日志,没有往外抛 log.error("create order failed", e); } }第一眼看上去,代码写得挺规范,该加的注解加了,该记的日志记了。但问题恰恰出在这个 catch 上:logMapper.insert如果抛了异常,异常被 catch 吃掉,容器框架的代理在方法边界上根本感知不到任何失败信号,它以为方法正常返回了,于是把前面的扣款记录一并提交。数据库里的数据就是这样的“半截事务”。
3.2 完整链路排查的五步定位
这个案例如果重新走一遍排查链路,步骤是非常清晰的。我把完整的排查过程拆成五步,每一步都有对应的验证手段。
第一步,查异常是否被吞。检查方法里的 try/catch,看 catch 之后有没有重新抛出异常。事务代理是在方法边界观察异常的,你把异常抓住了,它就只能看到成功。这是声明式事务不生效里最常见的原因,没有之一。
第二步,查是不是自调用。在一个类的内部,用this.innerMethod()去调用另一个标了@Transactional的方法,代理不会参与包装。容器框架的代理只有在外部调用进入 Bean 时才会触发,自己调自己等于绕过代理。
第三步,查方法可见性和调用入口。私有方法、非 Bean 类里的方法,默认拿不到事务代理。所以@Transactional只能标注在由容器管理的 Bean 的 public 方法上,并且要从外部调用进入。
第四步,查异常类型和 rollbackFor 配置。声明式事务默认只对 RuntimeException 和 Error 回滚,检查异常默认不会触发回滚。很多团队为了避免这种心智负担,会统一配置rollbackFor = Exception.class。
第五步,查连接是否真的被事务管理器接管。这一步最容易被忽略。你需要确认当前 Mapper 使用的连接上,出现了autocommit=false,而且这个连接就是事务管理器持有的同一个连接。如果 Mapper 绑定错了数据源,事务管理器管的是 A 连接,Mapper 用的却是 B 连接,那么注解做得再对也没用。
| 嫌疑点 | 怎么识别 | 怎么修 |
|---|---|---|
| 异常被 catch 吞掉 | 日志有错误,但没有事务回滚日志 | catch 后重新抛出 RuntimeException,或改用编程式事务 |
| 自调用 | 方法内 this 调用另一个事务方法 | 拆到另一个被代理的 Bean,或注入自身代理 |
| private / 非 Bean 方法 | 方法没有经过代理调用 | 改为 public 且从外部调用 |
| rollbackFor 没配 | 抛了检查异常,事务没回滚 | 显式配置 rollbackFor = Exception.class |
| 连接没被接管 | 日志无 autocommit=false,或 Mapper 连接和事务连接不一致 | 核对数据源、事务管理器、SqlSessionFactory 绑定 |
3.3 修复与验证
这个案例的修复本身不复杂:把 catch 里的异常重新抛出,或者把整个逻辑改成编程式事务,让异常能完整传到代理边界。但真正的收获在于验证方式。
我自己的习惯是:在事务方法里故意抛一个RuntimeException,然后查数据库,看数据到底还在不在。如果还在,那么问题一定出在上面五步的某一步。如果不在,说明事务机制本身是通的,问题出在业务代码的异常路径上。
日志验证也很关键。打开容器框架和 MyBatis 的事务日志后,正常流程应该能看到类似这样的输出:
Setting autocommit to false on JDBC Connection ... Completing transaction for [xxxService.createOrder] after transaction synchronization如果最终事务被回滚,还会出现:
Rolling back transaction for [xxxService.createOrder]读日志时注意一个细节:出现autocommit=false只代表事务开始,并不代表事务最终一定回滚。如果方法没有向外抛异常,后面的日志大概率是提交而不是回滚。所以“看到 false 就以为事务生效”是个常见误判,你必须看到回滚日志,才说明异常路径真的被代理感知到了。
3.4 为什么这个坑会反复踩
声明式事务最大的优点是省心,最大的缺点是它把事务边界从业务代码里抽走了。当你看着自己写的方法正常 return 时,总以为方法结束等于事务结束;但真正决定事务走向的,是代理在方法边界周围设置的“哨兵”。只有异常从业务方法一路冒出边界,哨兵才会下令回滚。
任何吞异常、绕代理、跨线程、错连接的行为,都会让哨兵觉得一切正常。所以排查 MyBatis 事务不生效,建议每次都按上面五步走一遍。看上去耗时,其实是所有路径里最快的。
注意:注解不是写给业务代码看的,是写给代理看的。代理看不到异常,它就只能提交。
4. 绕不开的性能与锁:事务隔离级别与长事务怎么权衡
4.1 一级缓存、二级缓存和事务的纠缠
很多人以为 MyBatis 的缓存只是查询性能优化,跟事务没多大关系。实际上,MyBatis 的缓存生命周期和事务边界是纠缠在一起的。
一级缓存是 SqlSession 级别的缓存。在容器框架里,一个事务对应一个 SqlSession,所以一级缓存的生命周期其实就是事务的生命周期。同一个事务里,用同一个 SqlSession 执行同样的 SQL 两次,第二次很可能直接命中缓存,不会真的去查数据库。事务提交后,SqlSession 关闭或清理,一级缓存也被清掉。这也意味着,你在一个长事务里先查一遍数据,然后别的线程更新了同一行,你在这个事务里再次查询,看到的可能还是第一次查出来的旧值。
二级缓存则是跨 SqlSession 的,作用范围是 Mapper 的 namespace。它和事务的关系更微妙:只有事务提交成功后,更新过的数据才会被同步到二级缓存;事务回滚的话,不会刷新缓存。这个机制保证了其他会话不会读到“半截提交”的数据。
所以我的建议是:事务方法里不要放大查询,尤其是那种全表扫描或者多表关联的大查询。一个长事务把大量数据塞进一级缓存,内存压力会变大,对应的数据库事务又占着连接,属于典型的不划算。把只读查询挪到事务外面去,事务里只保留必须原子化的写操作,性能会有明显改善。
4.2 隔离级别不是越高越好
隔离级别说到底是数据库连接上的属性,MyBatis 只是负责在拿连接时去设置它。Spring 注解里可以这样配:
@Transactional(isolation = Isolation.READ_COMMITTED) public void doSomething() { // ... }四种隔离级别对应的常见问题,可以用一张表说清楚:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 说明 |
|---|---|---|---|---|
| READ_UNCOMMITTED | 可能 | 可能 | 可能 | 几乎不用,除非你完全不在乎一致性 |
| READ_COMMITTED | 不会 | 可能 | 可能 | 多数业务系统的默认推荐 |
| REPEATABLE_READ | 不会 | 不会 | 可能 | 很多数据库的默认级别 |
| SERIALIZABLE | 不会 | 不会 | 不会 | 并发能力下降最严重,慎用 |
加隔离级别的时候,我见过很多团队直接往 REPEATABLE_READ 甚至 SERIALIZABLE 上配,觉得“高一点更安全”。其实隔离级别越高,锁竞争越严重。默认推荐 READ_COMMITTED,只有业务确实需要“一个事务内两次读结果完全一致”时,才考虑更高级别。测试隔离级别时,要开两个连接人工模拟并发,别只在一个连接里看行为。
4.3 长事务和连接池的真实账本
一次长事务,等于一个连接被长时间占用,连接上的锁也被长时间持有。假设连接池最大 20 个连接,有 18 个被长事务占着,第 19 个请求就只能等待。等到连接获取超时,接口就报错。这就是线上最常见的“某个接口偶尔超时、并发上来连接池就满”的根源。
循环 insert 的场景尤其明显。有人会把一万条数据放在一个事务里循环插入,意图是“要么全部成功,要么全部回滚”。这个想法没错,但代价是这一万条数据涉及的行锁,从第一条开始到第最后一万条结束,全程不释放。中间任何一条冲突,其他请求都得排队。
我后来对这类批量任务定的规则是:除非有强一致性要求,否则每批 500 到 1000 条提交一次,把锁持有时间缩短到秒级。如果必须在一个事务里保证全部成功,那就要尽量压缩每批 SQL 的执行时间,而不是简单地把事务范围越拉越大。
关于超时,还有一对概念很容易混淆:事务执行超时和锁等待超时。事务超时是容器框架层面的,超过时间会把事务标记为回滚;锁等待超时是数据库层面的,控制一条 SQL 等锁最多等多久。两者不是一个东西,配置时要分开看。
| 类型 | 由谁控制 | 超时后发生什么 |
|---|---|---|
| 事务执行超时 | 事务管理器 | 标记回滚,业务方法抛出异常 |
| 锁等待超时 | 数据库、连接池 | 当前 SQL 报锁等待超时错误,事务是否回滚取决于代码 |
4.4 一个典型的导出场景改造
我处理过一个类似月初账单导出的项目。一开始导出整个流程套了一个大事务,每天凌晨定时任务跑,和用户的在线查询挤在一个连接池里,一到月底就出现接口偶发超时,连接池监控里看到的全是“Active 连接数打满”。
改造思路很简单:查询阶段不加事务,让连接只做只读操作;标记和落库阶段用短事务;每批 500 条提交一次。改完之后,锁竞争和连接占用都降下来了,跑批时间反而缩短了不少。这就是长事务的真实成本:它丢失的不仅仅是回滚能力,还有并发能力。
5. 编程式事务与声明式事务的取舍
5.1 声明式很方便,但代价是黑盒
@Transactional声明式事务的好处很明显:一行注解,统一管理,代码干净。但它也有代价,事务边界不直观,异常路径容易被误解,自调用失效、异常被吞这些坑,本质上都是“黑盒”带来的。
普通单库、Service 层方法、异常路径已经处理清楚的情况下,我默认用声明式事务。但有几类场景,我会主动改写成编程式事务:
- 批处理任务需要分批提交,比如每处理 1000 条提交一次;
- 事务过程中需要调外部接口,而你又不想让外部调用占住事务连接;
- 需要在 catch 里决定“这个异常到底要不要回滚”;
- 一个方法里需要多段事务,前一段提交、后一段独立。
编程式事务最常用的写法是TransactionTemplate。它比手工拿 SqlSession 更安全,因为连接获取和释放都交给模板管理:
@Autowired private TransactionTemplate transactionTemplate; public boolean settle(List<Order> orders) { return transactionTemplate.execute(status -> { try { for (Order order : orders) { orderMapper.updateStatus(order); recordMapper.insert(buildRecord(order)); } return true; } catch (Exception e) { status.setRollbackOnly(); throw e; } }); }在 execute 回调里抛出异常,事务会自动标记回滚。如果你想在 catch 里做点额外处理,再决定是否回滚,可以用setRollbackOnly()手动标记。这种方式比声明式事务多了几行代码,但每一步都是可见的,对复杂流程的调试更友好。
5.2 嵌套事务:savepoint 的真相
NESTED 传播行为经常被误解为“独立事务”,其实它的底层是 JDBC 的保存点。也就是说,它没有新开连接,而是在外层同一个连接上打一个标记,内层事务回滚时,回到这个标记点。
REQUIRES_NEW 和 NESTED 的区别,用一张表来说:
| 对比项 | REQUIRES_NEW | NESTED |
|---|---|---|
| 连接 | 新拿一个独立连接 | 复用外层连接 |
| 内层提交 | 独立提交,不受外层影响 | 依赖外层最终提交 |
| 内层回滚 | 只回滚内层自己的数据 | 回滚到保存点,外层可以继续 |
| 外层回滚 | 不会带内层回滚 | 会连带内层一起回滚 |
| 连接池占用 | 增加连接占用 | 不增加连接占用 |
NESTED 在批量导入子项时的语义很实用:外层主单先插入,内层子项逐个处理,某个子项出错时只想回退这一项,而不是把整个主单一起回滚。但要注意,如果底层数据库驱动不支持保存点,NESTED 可能退化成普通事务行为,甚至报错。别把 NESTED 当成分布式事务或者“多数据源部分成功”的终极方案。
5.3 多数据源和一致性方案的工程取舍
再回到多数据源。如果两个库必须强一致,我的建议排序是:先问能不能拆成单库事务加异步补偿,再考虑本地消息表,最终才考虑分布式事务方案。
本地消息表是工程上比较稳的方案,思路不复杂:
- 业务表和消息表在同一个本地事务里写入;
- 一个后台任务扫描消息表,把消息发出去或交给下游处理;
- 下游处理成功后回调,更新消息状态。
这样主库的操作和消息记录是原子性的,跨库的一致性问题变成了“消息最终被消费”的问题。相比直接上分布式事务,它更容易理解和排查。做故障演练时,把下游服务停掉、重启服务,确认消息不丢,整个链路才算稳。
5.4 几条我自己验证过的经验
我做过的项目里,因为事务出过不少事故,也整理出几条固定经验。
第一,加事务注解前先写失败路径。想想异常会从哪里抛、会被谁吞、代理能不能看见。如果一条异常路径都说不清楚,这个事务大概率是假的。
第二,看日志别只看业务日志。把事务日志、连接日志一起打开,确认autocommit=false、同一个连接、最终有没有 rollback,这三件事都成立,才算事务真正生效。
第三,事务里别做远程调用。一次外部调用几百毫秒,长事务就被拖长,连接和锁都跟着受累。非要调用,就把远程调用挪到事务提交后再触发,比如用事务同步事件或消息队列。
第四,不要追求“所有方法都可回滚”,要追求“每个事务方法都尽量短”。事务代码越短,锁占用时间越短,系统整体越稳。
第五,多数据源问题不要试图靠一个注解、一个框架、一个技巧去硬解。先重画事务边界,再决定是用本地消息表、对账补偿,还是引入分布式事务方案。MyBatis 的事务能力边界,从来都只是“一个连接”这么宽。想清楚这一点,后面所有坑就都有迹可循了。