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()不传参时的默认行为是autoCommitfalse。这个时候如果你insert完忘了commit()连接一关数据是不会落库的。这个行为和很多人最初用 JDBC 的印象不一样JDBC 连接默认是自动提交每条 SQL 独立成一个事务MyBatis 为了让多条语句能聚合在同一个事务里特意默认关掉了自动提交。第二种是容器框架接管后使用SqlSessionTemplate的情况这是目前绝大多数 Java 后端项目的常态。开发者平时根本接触不到 SqlSessionMapper 也只是一个代理接口。这里的核心机制是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 使用的连接上出现了autocommitfalse而且这个连接就是事务管理器持有的同一个连接。如果 Mapper 绑定错了数据源事务管理器管的是 A 连接Mapper 用的却是 B 连接那么注解做得再对也没用。嫌疑点怎么识别怎么修异常被 catch 吞掉日志有错误但没有事务回滚日志catch 后重新抛出 RuntimeException或改用编程式事务自调用方法内 this 调用另一个事务方法拆到另一个被代理的 Bean或注入自身代理private / 非 Bean 方法方法没有经过代理调用改为 public 且从外部调用rollbackFor 没配抛了检查异常事务没回滚显式配置 rollbackFor Exception.class连接没被接管日志无 autocommitfalse或 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]读日志时注意一个细节出现autocommitfalse只代表事务开始并不代表事务最终一定回滚。如果方法没有向外抛异常后面的日志大概率是提交而不是回滚。所以“看到 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(ListOrder 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_NEWNESTED连接新拿一个独立连接复用外层连接内层提交独立提交不受外层影响依赖外层最终提交内层回滚只回滚内层自己的数据回滚到保存点外层可以继续外层回滚不会带内层回滚会连带内层一起回滚连接池占用增加连接占用不增加连接占用NESTED 在批量导入子项时的语义很实用外层主单先插入内层子项逐个处理某个子项出错时只想回退这一项而不是把整个主单一起回滚。但要注意如果底层数据库驱动不支持保存点NESTED 可能退化成普通事务行为甚至报错。别把 NESTED 当成分布式事务或者“多数据源部分成功”的终极方案。5.3 多数据源和一致性方案的工程取舍再回到多数据源。如果两个库必须强一致我的建议排序是先问能不能拆成单库事务加异步补偿再考虑本地消息表最终才考虑分布式事务方案。本地消息表是工程上比较稳的方案思路不复杂业务表和消息表在同一个本地事务里写入一个后台任务扫描消息表把消息发出去或交给下游处理下游处理成功后回调更新消息状态。这样主库的操作和消息记录是原子性的跨库的一致性问题变成了“消息最终被消费”的问题。相比直接上分布式事务它更容易理解和排查。做故障演练时把下游服务停掉、重启服务确认消息不丢整个链路才算稳。5.4 几条我自己验证过的经验我做过的项目里因为事务出过不少事故也整理出几条固定经验。第一加事务注解前先写失败路径。想想异常会从哪里抛、会被谁吞、代理能不能看见。如果一条异常路径都说不清楚这个事务大概率是假的。第二看日志别只看业务日志。把事务日志、连接日志一起打开确认autocommitfalse、同一个连接、最终有没有 rollback这三件事都成立才算事务真正生效。第三事务里别做远程调用。一次外部调用几百毫秒长事务就被拖长连接和锁都跟着受累。非要调用就把远程调用挪到事务提交后再触发比如用事务同步事件或消息队列。第四不要追求“所有方法都可回滚”要追求“每个事务方法都尽量短”。事务代码越短锁占用时间越短系统整体越稳。第五多数据源问题不要试图靠一个注解、一个框架、一个技巧去硬解。先重画事务边界再决定是用本地消息表、对账补偿还是引入分布式事务方案。MyBatis 的事务能力边界从来都只是“一个连接”这么宽。想清楚这一点后面所有坑就都有迹可循了。