ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

事务回滚全解析:从undo log到Spring失效与分布式补偿

事务回滚全解析:从undo log到Spring失效与分布式补偿 谁还没在线上栽过跟头我刚工作那阵子接手过一个订单系统用户下单后一直提示“系统繁忙”。查了半天发现是订单表写进去了库存表扣减却因为一个字段超长报了错于是事务回滚了。订单没生成库存却少了不对事务回滚之后库存也应该恢复。可实际上偏偏库存就没恢复——后来才明白那个扣库存的操作根本不在同一个事务里或者压根就没开启事务。从那时候起我就对“事务的回滚性”这五个字有了切肤之痛。今天这篇就围绕事务回滚这个主题把原理、实现、失效场景和排查方法一次性讲清楚。适合正在写业务代码的开发者、刚接触数据库事务的初级工程师以及那些被“分布式事务”折磨过的架构师。搞懂回滚你才能明白“要么全成功、要么全失败”这句口号背后到底藏着多少门道。1. 回滚的本质——事务的“后悔药”机制1.1 为什么需要回滚先聊个朴素问题为什么程序需要回滚因为现实世界不允许“做一半”的操作存在。比如转账从A账户扣了1000元B账户没加上这钱就凭空消失了。比如下单订单创建了库存扣减了但支付失败了那用户手里就多了一张没付款的订单库存还白扣了。回滚就是给系统装上的“后悔药”一旦事务中任何一步失败就把已经做的修改全部撤销恢复到事务开始前的状态。这样业务数据永远是一致的、完整的不会出现“半成品”数据。从架构角度看回滚不是“可选优化”而是事务的四大特性——原子性Atomicity的直接体现。原子性要求“事务内的操作要么全部执行成功要么全部不执行”。这里说的“全部不执行”并不是物理上什么都没做而是逻辑上通过回滚把已做操作抵消掉。1.2 数据库底层怎么实现回滚很多人觉得回滚就是发一条ROLLBACK命令数据库就把数据改回去了。实际没这么简单InnoDB 实现回滚的核心机制是undo log回滚日志也叫撤销日志。简单说事务每修改一行数据InnoDB 就会生成一条 undo 记录里面存放的是“修改前的旧值”。如果事务需要回滚InnoDB 就根据 undo 记录把旧值写回去。但这里有个关键细节undo log 不是只存旧值那么简单。它按照事务内操作的顺序形成一个回滚链undo log 链表回滚时从最后一条 undo 记录开始逆序执行才能准确恢复数据。举个例子事务里先插入了记录 A又更新了记录 B再删除了记录 C。回滚时就必须反着来先恢复删除的 C再恢复更新前的 B最后删除插入的 A。为什么要逆序因为后一个操作可能依赖于前一个操作的结果。如果正序回滚前面记录被恢复成旧值后后面的回滚可能找不到对应数据或者把已经回滚的数据再改一遍数据就乱了。另外undo log 还承担着MVCC多版本并发控制的功能。读取数据时如果一个事务正在修改某条记录另一个事务读到的可能是旧版本——靠的就是 undo log 里的旧值。所以回滚不只是“为了失败准备的”它在日常并发读写中也默默起着作用。1.3 redo log 与 undo log 的分工聊回滚就绕不开 redo log。很多人搞混这两者的职责我这里用一个类比讲清楚redo log重做日志记录的是“修改后的新值”作用是掉了电、崩了机之后把已经提交但还没来得及写进磁盘的数据重新写一遍。它保证的是持久性Durability。undo log撤销日志记录的是“修改前的旧值”作用是事务失败时撤销修改。它保证的是原子性Atomicity。举个实际过程一条 UPDATE 语句执行时InnoDB 先读数据页到内存然后写 undo log 记录旧值再修改内存中的数据页然后写 redo log buffer。事务提交时redo log 会按规则刷盘。如果此时数据库崩溃重启后会根据 redo log 重放修改保证已提交事务数据不丢如果事务没有提交就崩溃redo log 里可能没有完整事务的提交记录此时就需要借助 undo log 做回滚把这些未提交的修改撤销。所以这俩是配合着工作的缺一不可。redo log 保证“做了的不白做”undo log 保证“没做完的不留残留”。2. 回滚的触发条件与失效场景2.1 哪些情况会触发回滚先明确一点回滚不是数据库自动对“任何错误”触发的它是有条件的。在数据库层面常见的自动回滚触发条件包括事务语句执行失败比如约束冲突主键重复、外键失败、字段超长、死锁被选为牺牲者等。客户端连接断开事务还没提交数据库会把这个事务标记为中止。显式执行ROLLBACK命令。数据库实例异常重启恢复过程中发现未提交事务。不过有一点要特别注意有些错误并不会自动回滚整个事务。比如插入了一条重复主键的数据如果把sql_mode配置成非严格模式可能只是报错但事务还是可以继续。更常见的是某些连接器比如 JDBC默认配置下SQL 语句执行失败后事务并不会自动回滚只标记当前语句失败。这就要靠应用层代码来决定是commit还是rollback。所以写代码时不能想当然地认为“数据库出错就会回滚一切”事务边界和回滚逻辑必须在应用层管好尤其是对事务的提交和回滚触发条件要有明确的代码路径。2.2 Spring 事务回滚机制与其“默认行为”Java 开发里最常用的就是 Spring 的声明式事务用Transactional注解。它的回滚规则和很多人理解的不太一样。Spring 的默认回滚策略是仅当抛出未检查异常RuntimeException 及其子类或 Error 时才回滚。如果是受检异常Checked Exception比如IOException、SQLException事务默认是提交而不是回滚。这个设计意图其实有历史原因Spring 遵循 EJB 时代的约定认为受检异常代表“业务上可恢复的错误”不应该直接回滚整个事务。但对绝大多数团队来说这个默认行为就是个坑。举个例子一个转账方法里转出成功后调用外部接口失败抛了BusinessException继承ExceptionSpring 默认不会回滚这账就平不了。解决办法很简单在注解里显式声明Transactional(rollbackFor Exception.class) public void transfer(String fromAccount, String toAccount, BigDecimal amount) throws Exception { // 扣减转出账户 // 增加转入账户 // 如果这里抛任何异常事务都会回滚 }这个rollbackFor就是我们常说的“回滚策略配置”。写代码时的个人习惯是所有 Transactional 方法都显式加上rollbackFor Exception.class不要依赖默认行为。别嫌麻烦这行代码能救你无数个通宵。2.3 事务不生效导致“没回滚”的经典场景排查看得最多的问题之一明明加了 Transactional异常也抛了数据还是写进去了。这通常不是回滚机制问题而是事务根本没开启。我统计了一下踩坑率最高的有这几个同类内部方法自调用。A 方法调用同类 B 方法B 上有 Transactional结果事务没生效。为什么Spring 事务是基于 AOP 代理实现的外部调用是走代理的内部this.xxx()调用走的是原始对象代理拦截不到事务自然不开启。Service public class OrderService { public void createOrder() { // 这里调用的是 this.createOrderWithTx() // 事务不生效因为没走代理 this.createOrderWithTx(); } Transactional(rollbackFor Exception.class) public void createOrderWithTx() { // 业务逻辑 } }解决办法把需要事务的方法拆分到另一个 Service 类里注入进来调用或者通过AopContext.currentProxy()获取代理对象再调用。方法不是 public 的。Spring 默认只对 public 方法进行事务代理管理private、protected 方法上的 Transactional 是无效的。异常被吞了。try-catch 捕获异常后没有重新抛出事务感知不到异常自然不回滚。有些老代码在 catch 里打印日志然后该方法返回正常最后事务提交数据就错了。数据库引擎不对。MySQL 下 MyISAM 引擎不支持事务即使代码全对也一样不会回滚。检查表的 ENGINE 是否为 InnoDB。多线程调用。子线程里抛出异常主线程事务无法感知子线程的操作也不在同一个事务里。Spring 事务默认绑定当前线程的数据库连接跨线程就断了。3. 分布式事务下的回滚难点与常见方案3.1 为什么单库事务到分布式就“不灵”了单体应用一个数据库一个事务回滚很自然。但一旦拆了微服务订单服务管订单库库存服务管库存库用户下单要同时写两个库这时候问题来了单数据库的事务边界无法跨库每个库只能保证自己的事务整体的一致性谁保证这就是分布式事务问题。分布式场景下的回滚官方术语叫分布式事务回滚更常见的是补偿Compensation。因为跨网络的调用没有全局锁没有统一的事务管理器一个服务已经提交的数据另一个服务失败了数据已经落库没法像单库那样靠 undo log 撤销了只能“反向操作”弥补回来。聊分布式事务方案之前必须先把一致性模型说清楚。单库事务追求的是 ACID到分布式环境传统 ACID 很难做到尤其是隔离性。于是大家引入 BASE 理论——基本可用Basically Available、软状态Soft State、最终一致性Eventually Consistent。BASE 的核心思想就是不强求实时一致允许系统存在中间状态但最终数据要收敛到一致。分布式事务回滚因此分成两派强一致派追求事务提交前就保证所有节点能成功不行就全部回滚对外表现像单库事务。最终一致派允许部分节点先提交通过异步补偿慢慢纠偏。没有绝对优劣看业务需求。金融支付可能更偏向强一致但代价是高延迟、低吞吐电商下单场景则往往用最终一致性方案换取更好的用户体验。3.2 常见回滚方案XA、TCC、SAGA、本地消息表下面整理主流的分布式事务方案每条我都结合回滚机制说明。XA 协议两阶段提交2PCXA 是数据库层面支持的标准协议有一个全局事务管理器TM下面管着多个资源管理器RM。流程分两步准备阶段PrepareTM 向所有 RM 发送 prepare 请求各 RM 执行事务但先不提交把资源锁住并写 undo/redo 日志。提交阶段Commit/Rollback所有 RM 都 prepare 成功TM 发 commit任何一个 prepare 失败TM 发 rollback所有 RM 回滚。它的回滚能力其实很强因为所有资源都还在本地事务的控制里可以真实回滚。但问题也明显准备阶段锁资源时间长高并发下性能差协调者单点故障如果准备成功后协调者挂了各 RM 会一直阻塞等待可用性受影响。TCCTry-Confirm-CancelTCC 是业务层面的两阶段把每个操作拆成三个方法Try资源检查和预留。比如扣库存时不下扣减只做“冻结库存”。Confirm确认执行。Try 全部成功后把冻结库存真正扣掉。Cancel取消执行。任何一个 Try 失败调用所有已成功 Try 的 Cancel把预留给释放。TCC 的回滚叫Cancel撤销它比 XA 灵活因为业务代码可以自定义补偿逻辑。比如下单业务Try 阶段创建订单状态为“待确认”冻结库存如果支付失败Cancel 阶段把订单状态改回“已取消”释放冻结库存。TCC 的优势是并发度比 XA 高因为锁的是预留资源而不是真实资源劣势是实现成本高每个操作都得写三段逻辑。SAGA 模式SAGA 是长事务场景下的方案核心思想是大事务拆成一堆小事务每个小事务都有对应的逆操作compensation。如果第 N 步失败就逐个逆序执行前 N-1 步的补偿操作。比如订单流程创建订单 → 扣库存 → 调用支付 → 发物流。如果支付失败就依次执行取消物流、退款、释放库存、关闭订单。SAGA 比 TCC 实现简单不需要预留资源更常配合消息队列异步执行。缺点是中间状态对外可见——用户可能短暂看到“订单已创建但支付失败”的状态不过最终会被补偿修正。这需要业务上能容忍最终一致性。本地消息表方案在不引入重量级中间件的情况下本地消息表是很多人起步的首选。核心思路是业务操作和消息写入放在同一个本地事务里然后通过消息队列异步通知其他服务。比如下单时订单服务和消息表在同一库里开启事务插入订单记录 插入一条“扣库存消息”事务一起提交。库存服务消费消息做扣减如果失败了就重试。如果库存确实无法扣减比如库存不足则调用订单服务的“关闭订单”接口做补偿回滚。这个方案回滚的准确性取决于补偿接口难点在于消息的幂等性和补偿链路的健壮性。3.3 一个订单库存场景的补偿设计示例用最常见的“订单与库存分布式事务”举例。假设两个服务订单服务订单库、库存服务库存库用户下单需要创建一个订单并扣减库存。如果用最终一致性的方案流程大致是订单服务开启本地事务创建一条状态为“待扣减库存”的订单同时往本地消息表写入一条“库存扣减消息”一起提交。一个异步任务扫描消息表把消息发给 MQ。库存服务消费消息执行库存扣减。扣减成功回调整订单服务把订单状态改成“已确认”。如果库存服务扣减失败比如库存不足它会向订单服务发起一个“库存扣减失败”的回执。订单服务收到失败回执后调用本地事务把订单状态改成“已取消”同时记录失败原因。这里面每一步失败都有两条兜底策略MQ 的消费重试机制保证消息最终被处理状态回查机制订单服务定期扫描超时未确认的订单保证就算回调消息丢了也能发现并重新触发补偿。这种方案的“回滚”不像单库那样一步到位而是靠一组异步消息和状态流转完成的。我在实际落地时最深的体会是必须保证每一步操作都幂等。比如补偿接口被重复调用不能让订单被取消两次、消息被消费两次导致库存重复扣减。幂等的做法很常规用一个“处理记录表”或者利用唯一键约束已经处理过的请求直接返回成功。4. 常见问题与排查技巧实录4.1 事务明明回滚了数据却还是不对这是我在社区被问过最多的问题之一。先说结论遇到这种场景不要一上来怀疑数据库回滚坏了先检查事务边界和数据源。具体排查路径记录一下你们可以直接对照用检查异常是否真的抛出到了代理层。如果方法内部自己 catch 了异常事务不会知道有错。在 catch 里加一行日志看看异常到底在哪层被消化了。检查事务是否真的开启。在 Spring 里可以把logging.level.org.springframework.jdbc.datasource.DataSourceTransactionManagerDEBUG打开观察日志里有没有Acquiring Connection ...、Beginning transaction ...、Initiating transaction rollback ...这类的输出。如果连Beginning transaction都没看到说明方法压根没走代理。检查事务管理器是否配到了正确的数据源。多数据源项目里经常配了 A 数据源的事务管理器却在操作 B 数据源那 B 的操作根本不在事务里回滚只对 A 生效。检查事务是否被提交了。如果代码路径上存在try { 业务; transactionTemplate.commit() } catch { ... }乱写的情况可能 catch 里没走 rollback异常吞掉后事务照样提交。给一个我常用的排查代码示例典型的多数据源隐患Bean public PlatformTransactionManager orderTransactionManager( Qualifier(orderDataSource) DataSource dataSource) { return new DataSourceTransactionManager(dataSource); } Bean public PlatformTransactionManager inventoryTransactionManager( Qualifier(inventoryDataSource) DataSource dataSource) { return new DataSourceTransactionManager(dataSource); }到了容器里如果有多个事务管理器Spring 的 Transactional 还要通过transactionManager参数指定用哪个否则可能选错Transactional(transactionManager orderTransactionManager, rollbackFor Exception.class) public void createOrderAndCallInventory() { // 订单库操作 // 跨服务调用库存 }4.2 线上案例Spring 事务没回滚的典型翻车现场说一个自己实际排查过的事情。业务背景是用户绑定手机号后发放优惠券代码大致长这样Transactional(rollbackFor Exception.class) public void bindPhone(String userId, String phone) { try { userDao.updatePhone(userId, phone); // 更新手机号 couponService.sendCoupon(userId); // 调远程服务发券 } catch (Exception e) { log.error(bind phone failed, e); } }问题现象用户手机号更新了但优惠券没发。由于Transactional(rollbackFor Exception.class)还在按理说内部有异常应该回滚手机号更新才对。结果线上手机号确实更新了。排查时发现couponService.sendCoupon()内部捕获了所有异常并且没有向上抛出。外层 catch 虽然捕获了但在 couponService 内部已经把异常吞掉了。更隐蔽的是userDao.updatePhone执行成功后catch 块里做了一些补偿操作——这些操作在同一个事务里于是手机号更新和补偿一起提交了。这类问题的修复建议凡是发起外部调用的服务方法契约里就要写明“调用失败必须抛出异常由上层决策是否回滚”。别在事务方法里 try-catch 所有异常然后假装无事发生。如果确实需要吞掉部分异常至少要用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()手动标记回滚。try { // 业务逻辑 } catch (BizException e) { log.warn(业务异常标记回滚, e); TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); throw e; }这是保底手段但实际中我更推荐谁调用谁负责处理事务方法内的异常一律抛出去。4.3 查看 MySQL 事务日志与回滚现场有时候数据库层面已经回滚了但想知道执行过程就得去看日志。网上经常搜“sqlserver 事务日志查看”“mysql 事务日志”这里把 SQL Server 和 MySQL 实战里的两类都说说。MySQL 方向MySQL 里没有类似“SELECT 查看某个事务的 undo 日志”的简单命令但有几个有用的手段-- 查看当前所有正在执行的事务 SELECT * FROM information_schema.INNODB_TRX; -- 查看当前有哪些锁等待和锁冲突 SELECT * FROM sys.innodb_lock_waits; -- 开启标准错误日志输出观察回滚记录 SET GLOBAL log_output TABLE; SET GLOBAL general_log ON; SELECT * FROM mysql.general_log ORDER BY event_time DESC LIMIT 50;INNODB_TRX是排第一优先要看的它能看到TRX_STATERUNNING / LOCK WAIT / ROLLING BACK / COMMITTED如果某个事务长时间处于ROLLING BACK状态说明大量 undo 需要清理性能可能受影响。SQL Server 方向如果要看事务日志的具体内容可以用系统函数SELECT [Current LSN], Operation, Context, Transaction ID, Description FROM fn_dblog(NULL, NULL) WHERE Transaction ID IS NOT NULL ORDER BY [Current LSN];重点看 Operation 为LOP_ABORT_XACT、LOP_BEGIN_XACT、LOP_COMMIT_XACT的记录配合操作事务 ID 就能还原整个事务的执行链路。排查回滚问题时常见操作是定位到某条记录看它的LOP_BEGIN_XACT和LOP_ABORT_XACT之间的操作序列。通用排查建议先看应用日志找到异常堆栈定位到哪一步开始失败。再看数据库事务日志确认这个事务有没有进入回滚态。最后通过 SQL 或工具反向验证数据实际状态不要只看日志结论。4.4 事务级别对回滚的影响排查回滚问题时很多人会忽略事务隔离级别的影响。隔离级别本身不决定回滚成功与否但决定了回滚发生时的可见性以及并发下是否更容易出现死锁回滚。以 MySQL InnoDB 为例四种隔离级别READ UNCOMMITTED、READ COMMITTED、REPEATABLE READ默认、SERIALIZABLE。隔离级别越高加锁范围越大事务之间互相阻塞越严重。比如SERIALIZABLE下两个事务同时修改同一行后到的事务就会进入锁等待超时后可能被选为死锁牺牲者触发回滚。如果你的系统频繁出现“莫名其妙事务回滚”检查一下是不是隔离级别设得太高导致锁竞争激烈。Spring 中设置隔离级别很简单Transactional(rollbackFor Exception.class, isolation Isolation.REPEATABLE_READ) public void doSomething() { // ... }我个人建议线上大多数业务用数据库默认级别就行除非有明确的一致性需求。别为了求稳直接把隔离级别拉到 SERIALIZABLE回滚率可能不降反升吞吐率先垮了。5. 回滚性设计中的几个“反常识”细节5.1 回滚成功不代表数据就是对的这是我最想强调的一个认知。很多人觉得回滚了就是数据恢复原状万事大吉。其实不然。回滚只是把数据库里的值还原到了事务开始前的状态但事务开始前数据可能是脏的、可能是过期的、可能已经被别的并发事务修改过。一个事务回滚后另一个事务可能已经基于“未提交的中间值”做了计算——虽然数据库通过锁和 MVCC 尽量避免这种情况但在某些低隔离级别下脏读、不可重复读就是这么产生的这时“回滚了”并不能挽回并发导致的问题。所以在设计事务时除了回滚机制还要关注隔离级别、锁的范围回滚不是银弹一致性是综合手段的结果。5.2 回滚比提交更耗时一个事务执行了 10 分钟修改了 100 万行数据然后失败了要回滚。你以为瞬间就回去了不是的。InnoDB 回滚是逐条读取 undo log 并反向执行的数据量越大回滚时间越长。这也是为什么有些系统偶尔出现“事务一直处于 ROLLING BACK 状态”的原因。因此实际工程里有一个优化原则一个事务里别做太多事情。把大事务拆成小事务每一步都能快速提交或快速回滚。这既减少锁持有时间也压缩回滚成本。5.3 回滚之后自增主键不会回退一个容易被忽略的细节InnoDB 的自增主键计数器在事务回滚后不会回退。比如一个事务里插入了 10 条记录事务回滚了但下一次插入的主键值不会从 10 之前继续而是继续往后排。绝大多数情况下这不是 bug但如果你有“ID 必须连续”的幻觉需求就会受到惊吓。一些导数据脚本里如果依赖主键连续做分页或者排序回滚后就会出现空洞逻辑就会出错。这个不能改只能接受。5.4 “补偿”和“回滚”不是一回事在分布式事务领域“回滚”和“补偿”经常被混着说但理解它们之间的区别很重要。数据库事务里的回滚是物理层面的撤销数据变回旧值依赖 undo log。分布式事务里的补偿是业务层面的逆向操作通过调用一个新的业务动作把之前的结果“抵消”掉。比如创建订单后取消订单扣减库存后加回库存。它不保证数据变回原样但保证业务结果等价。搞清楚这一点遇到“分布式事务失败要回滚”的需求时你就不会天真地去找一个“分布式 undo log”了而是老老实实把补偿动作设计完整。6. 实操总结回滚性设计的最佳实践清单工具和原理聊了很多最后整理一份可以直接照着做的实践清单都是踩过坑后留下的经验。设计阶段所有数据库事务方法显式指定rollbackFor Exception.class不依赖默认异常类型尤其是写公共封装类时。事务方法保持“入口”和“边界”一致一个事务内不要夹杂外部 RPC 调用。确需调用至少把外部调用挪到事务提交之后或者拆成事务外步骤用事务同步器监听提交后再执行。大事务拆小事务。单事务内操作行数建议控制在几千行以内尽量避免一次更新几十万行的场景。如果实在需要批量分批提交并把每批做成独立事务。每个分布式事务涉及的服务接口必须做幂等处理。补偿接口更要做幂等否则消息重试会产生二次扣减、二次取消。编码阶段不要在事务方法内 try-catch 吞掉异常。如果业务上确实要吞主动用TransactionAspectSupport标记回滚。跨服务调用的异常必须显式传播禁止在底层静默捕获后返回 null。多数据源事务记得指定transactionManager避免 AOP 路由到错误的事务管理器。自调用不走代理内部方法调用另一个事务方法时要么拆 Service要么通过AopContext.currentProxy()处理。排查阶段出现疑似“事务未回滚”问题先查应用日志有没有异常再查事务是否开启再查数据源是否一致最后看数据库事务日志状态。MySQL 用INFORMATION_SCHEMA.INNODB_TRX看事务状态SQL Server 用fn_dblog看事务操作序列。如果系统频繁出现死锁回滚降低锁粒度或隔离级别而不是只关注回滚代码逻辑。我个人在项目实施中最大的体会是回滚本身是个“兜底机制”不是主防御手段。把事务边界划清晰、把异常传播路径理干净、把补偿动作设计完整回滚永远只是最后一道防线而不是唯一的救命稻草。真正好的系统是要让回滚这件事尽量少发生甚至不发生——先从写好每一行事务代码开始。
返回列表