ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

从2PC到Seata:分布式事务方案对比与电商下单一致性实战

2026/9/11 12:35:48 拓冰建站 浏览量
从2PC到Seata:分布式事务方案对比与电商下单一致性实战 1. 从一个线上事故说起下单扣库存为什么这么难先讲个我亲身经历的事。几年前在某电商平台做交易系统线上有个促销活动流量一上来订单表、库存表、账户余额表三个服务之间的数据就开始对不上了。用户下单成功、库存也扣了但积分没到账或者库存扣了、订单却显示支付失败最离谱的一次用户重复提交订单库存被扣了两次仓库那边直接爆仓。当时第一反应是这不就是事务问题吗于是照着单体应用的老办法给三个库包在同一个Transactional里。结果呢跨库跨服务的分布式事务根本就不是本地事务能解决的。那段时间天天在排查数据不一致线上对账脚本写了一堆最后还是有一些单子要人工介入处理。后来才开始系统研究分布式事务方案从2PC、TCC、Saga到本地消息表最后在Seata上落了地。这条路上踩的坑不少但也把这些方案背后的原理捋清楚了。今天就把这段时间的实践整理出来尤其会结合订单、库存、余额这个最经典的场景讲清楚每个方案怎么选、怎么落地、会遇到什么问题。如果你正在做微服务架构或者接手了一个涉及跨库、跨服务一致性的系统那这篇文章正好适合你。我会把方案选型、核心原理、实操步骤、以及真实遇到的坑都讲透尽量让每个被分布式事务折磨过的人都能少走点弯路。2. 为什么本地事务解决不了分布式问题2.1 从一次下单看分布式事务的本质一个最典型的电商下单流程至少要经过三个服务订单服务、库存服务、账户服务。它们通常分别对应三个独立的数据库可能是三个MySQL实例也可能分布在不同的机房。用户点击下单后这三个服务要协作完成一件事创建订单、扣减库存、扣减余额。如果这是单体应用都在一个数据库里一条BEGIN TRANSACTION到COMMIT就能搞定。但拆成微服务后问题就来了订单库的事务提交了库存库的事务还没提交这时候如果库存扣减失败订单那边的数据怎么办没有哪个数据库能跨多个独立的数据库实例管理事务。这就是分布式事务要解决的核心问题多个独立的数据源之间如何保证数据的一致性。有人可能会说那我把所有操作放到一个服务里不就行了很多公司在业务初期确实这么干但当流量和团队规模上来了拆库拆表、服务化是必然趋势。一旦拆分分布式事务就是绕不开的坎。2.2 分布式事务难在哪CAP与数据一致性窗口要理解分布式事务的难点得先明白分布式系统的一个基本约束——CAP定理。在网络分区发生时你必须在一致性和可用性之间做取舍。CP模型优先保证一致性牺牲部分可用性。分布式事务的强一致方案如2PC走的就是这条路AP模型优先保证可用性一致性通过后续的补偿、对账来达成。最终一致性方案如TCC、Saga、消息事务走的是这条路除此之外还有一个容易被忽略的双11问题——网络不确定性。本地事务里数据库的事务管理器是通过共享内存、线程通信来协调的延迟在微秒级基本不丢消息。但微服务之间的调用走的是网络一次RPC可能超时、可能被重试、可能服务端其实已经处理成功了但响应丢了这些事情在单机数据库里根本不会发生。所以分布式事务真正难的地方在于在什么都有可能出错的网络环境下还要保证多个服务之间的数据最终是一致的。这个复杂度不是靠写代码能消除的只能说用合适的方案把它约束在可控范围内。3. 主流分布式事务方案对比选型之前先搞清楚原理3.1 XA/2PC最硬的强一致方案但为什么没人敢用2PC两阶段提交协议是分布式事务最经典的实现。它的核心思想是引入一个事务协调者Coordinator分两个阶段协调所有参与者准备阶段Prepare协调者问所有参与者能不能提交每个参与者执行事务操作但不提交并返回可以或不可以提交阶段Commit/Abort如果所有参与者都返回可以协调者通知所有人提交只要有一个返回不可以就通知所有人回滚这套机制在理论上是完美的强一致性方案。MySQL的XA协议、Oracle的全局事务都是基于这个思路实现的。实际落地时用的是TCC或者Saga这类业务侵入性更强的方案。那为什么现在很少人直接用2PC问题出在它的缺点上同步阻塞准备阶段所有参与者都要持有资源锁一直等待协调者的指令这个过程可能持续几秒甚至更久对高并发系统来说是灾难协调者单点故障如果协调者在第二阶段挂了所有参与者会一直阻塞在待提交状态数据库连接池直接被打满数据可见性问题协调者宕机恢复后需要人工或者依赖日志来判断到底是提交还是回滚所以在实际业务中我看到的生产环境很少直接用裸的2PC除非是像转账这种低并发、强一致、对性能不敏感的场景。3.2 TCC业务补偿的典范强一致但开发成本高TCCTry-Confirm-Cancel把每个分布式操作拆成三个阶段Try阶段完成业务检查并预留资源比如冻结库存、冻结余额Confirm阶段确认执行业务操作比如真正扣减库存、扣减余额Cancel阶段取消操作释放预留资源比如释放冻结的库存、余额TCC的本质是业务层面的两阶段提交它不依赖数据库的分布式事务能力而是把事务控制权交给业务代码。举一个扣库存的例子不用TCC时的做法是直接UPDATE inventory SET stock stock - 1 WHERE id ?用TCC就要换一种思路TryUPDATE inventory SET frozen_stock frozen_stock 1 WHERE id ? ConfirmUPDATE inventory SET stock stock - 1, frozen_stock frozen_stock - 1 WHERE id ? CancelUPDATE inventory SET frozen_stock frozen_stock - 1 WHERE id ?TCC的好处是可以灵活控制资源粒度不像2PC那样持锁全程阻塞性能比2PC好很多也能做到业务级的强一致。但代价是开发成本高——每个参与分布式事务的业务方法都要写Try、Confirm、Cancel三个逻辑而且这三个逻辑都得做幂等代码量直接翻几倍。如果一个项目有一二十个需要事务保护的接口全用TCC的话光写补偿逻辑就能写死你。所以我的建议是TCC适合关键链路、核心资产的场景比如库存扣减其他的可以降级用别的方案。3.3 Saga长事务的救星但要注意语义Saga的思想是把一个长事务拆成一组本地事务每个本地事务完成后发布一个事件或执行下一步如果某一步失败则执行补偿操作把之前已完成的操作回滚。和TCC的区别在于TCC是预留资源、然后确认或取消Saga是不做资源预留直接执行失败后再一步步补偿回去。Saga有两种编排方式事件编排Choreography每个服务完成自己的本地事务后发事件触发下一个服务。优点是去中心化缺点是流程埋在各种事件回调里不好排查命令编排Orchestration由一个中央协调器Saga Orchestrator来调用各个服务。优点是流程集中可控缺点是多了一个协调器的开发和维护成本Saga最典型的应用场景是旅游订单预订机票、订酒店、租车这三个是独立服务任何一个失败都要取消前面已经预订成功的服务。而且这三个操作之间天然有时间差用强一致方案反而没必要。但Saga有个需要注意的语义问题补偿并不是回滚。比如你扣了库存补偿是加回库存但如果这期间有人已经把扣掉的库存买走了那补偿加回来的库存已经不是原来的库存了。所以在设计Saga流程时要考虑业务语义上可不可接受这种补偿式的一致。3.4 本地消息表与事务消息最终一致性的温和派如果业务能接受短暂的不一致比如几秒内那最终一致性方案是最省事的。它的核心思路是把发消息和本地业务操作绑定在同一个本地事务里然后靠消息队列的重试机制保证下游服务最终一定被执行。本地消息表的经典做法是在同一个本地事务里完成业务操作比如创建订单同时向消息表插入一条消息记录定时任务扫描消息表把状态为待发送的消息投递到MQ下游服务消费消息执行自己的业务比如扣库存下游执行成功后回调修改消息状态为已发送如果失败定时任务会重新投递这个方案的优点是简单可靠缺点是需要额外维护消息表而且消息发送与业务处理的强绑定在业务量大的时候会显得笨重。后来很多团队直接用RocketMQ的事务消息来替代本地消息表。RocketMQ的半消息机制能保证本地事务和消息发送的原子性。流程是先发送半消息对消费者不可见→ 执行本地事务 → 如果本地事务成功commit消息消费者就能看到了如果失败rollback消息如果长时间没有收到回执MQ会反查本地事务状态。个人经验是如果项目里已经用了RocketMQ优先用事务消息省心很多如果没有MQ或者对最终一致性时间要求比较敏感那本地消息表也是个不错的选择。3.5 方案选择没有银弹只有取舍说实话分布式事务根本没有万能的银弹每个方案都是某几个维度的取舍平衡。为了方便选型我整理了一个对比表格方案一致性强度性能影响开发成本业务侵入性适用场景XA/2PC强一致高锁资源低低数据库原生低频、强一致、对性能不敏感TCC业务级强一致中高三套逻辑高需预留资源核心资产操作如库存扣减Saga最终一致低中中需设计补偿长事务、跨服务流程本地消息表最终一致低中中需维护消息表对一致时间不敏感的下游操作事务消息RocketMQ最终一致低低框架内置低对一致时间不敏感已用MQ这里有一个我的个人经验不要试图用一套方案解决所有分布式事务需求。比如下单主链路订单、库存、资金是核心中的核心用TCC或Seata AT模式保证业务级一致性而像发短信、发邮件、写日志这类非关键操作用MQ异步重试就够了。4. 实战基于Seata AT模式的订单-库存-余额场景落地4.1 Seata是什么AT模式的原理SeataSimple Extensible Autonomous Transaction Architecture是阿里开源的一套分布式事务框架目前是Apache基金会顶级项目。它提供AT、TCC、Saga、XA四种模式其中AT模式和TCC是最常用的。AT模式的核心设计非常巧妙。它要在业务数据库里建一张undo_log回滚日志表然后按以下流程工作一阶段Seata拦截业务SQL比如UPDATE inventory SET stock stock - 1 WHERE id 1001在执行业务SQL之前先查一下这条数据修改前的镜像Before Image执行SQL之后再查修改后的镜像After Image把这两个镜像和SQL一起写入undo_log表然后和业务SQL在同一个本地事务里提交。这个阶段对业务SQL是有感知的但不需要你写额外的业务代码二阶段-提交如果全局事务所有分支都执行成功Seata会异步删除各分支对应的undo_log记录二阶段-回滚如果有一个分支执行失败Seata会根据undo_log里的Before Image生成反向SQL补偿SQL把数据恢复到修改前的状态AT模式和TCC最大的区别在于AT模式对业务代码几乎零侵入你只需要在发起全局事务的方法上加一个GlobalTransactional注解其他代码照常写。因为它通过解析SQL、记录镜像的方式自动帮你生成了补偿逻辑。而TCC需要你手写三套逻辑灵活度更高。但AT模式也有代价因为是自动生成补偿SQL对SQL的解析能力有限。一些复杂SQL比如UPDATE ... JOIN、UPDATE ... LIMIT、子查询可能解析不了或者解析出错。另外因为要记录前后镜像undo_log表会持续累积数据需要定期清理。4.2 搭建环境Seata Server部署与工程改造实际操作时我是按下面的步骤来落地Seata的。这里假设你已经有一个Spring Cloud或Spring Boot的微服务项目三个服务分别是order-service、inventory-service、account-service各自连接独立的MySQL库。第一步部署Seata ServerSeata Server是全局事务的协调者TCTransaction Coordinator。我用的版本是1.5.2建议选稳定版本下载后解压修改conf/application.yml注册中心我用的是Nacos需要配置namespace、group配置中心同样用Nacos用于动态拉取配置存储模式Seata Server的事务会话信息建议存到数据库store.mode: db这样Server挂掉重启后事务状态还能恢复需要注意Seata Server自身的高可用问题。生产环境至少部署两台Server通过Nacos的集群节点配置做负载均衡。如果不做集群TC挂了之后运行中的全局事务会全部中断这是个很坑的点。第二步给业务库添加undo_log表每个参与分布式事务的业务库都要执行下面的建表语句CREATE TABLE undo_log ( id BIGINT(20) NOT NULL AUTO_INCREMENT, branch_id BIGINT(20) NOT NULL, xid VARCHAR(100) NOT NULL, context VARCHAR(128) NOT NULL, rollback_info LONGBLOB NOT NULL, log_status INT(11) NOT NULL, log_created DATETIME NOT NULL, log_modified DATETIME NOT NULL, PRIMARY KEY (id), UNIQUE KEY ux_undo_log (xid, branch_id) ) ENGINE InnoDB AUTO_INCREMENT 1 DEFAULT CHARSET utf8;这个表的作用是存Before/After镜像回滚时靠它。注意别删错了字段rollback_info里存的就是序列化后的镜像数据log_status标识是否已被提交清理。第三步业务工程引入依赖并配置在pom.xml中引入Seata的Spring Boot Starterdependency groupIdcom.alibaba.cloud/groupId artifactIdspring-cloud-starter-alibaba-seata/artifactId version2021.0.5.0/version /dependency然后在application.yml里配置seata: enabled: true application-id: order-service tx-service-group: my_test_tx_group registry: type: nacos nacos: server-addr: 127.0.0.1:8848 namespace: group: SEATA_GROUP config: type: nacos nacos: server-addr: 127.0.0.1:8848 namespace: group: SEATA_GROUP service: vgroup-mapping: my_test_tx_group: default grouplist: default: 127.0.0.1:8091这里有个容易踩坑的点tx-service-group要和Seata Server里的配置一致vgroup-mapping要把事务服务分组映射到具体的Seata Server地址。如果这两个对不上会报no available service的错误。4.3 核心代码与关键配置先看订单服务中发起全局事务的入口方法这也是整个分布式事务的起点Service public class OrderServiceImpl implements OrderService { Autowired private OrderMapper orderMapper; Autowired private InventoryFeignClient inventoryFeignClient; Autowired private AccountFeignClient accountFeignClient; Override GlobalTransactional(name create-order-tx, rollbackFor Exception.class) Transactional(rollbackFor Exception.class) public void createOrder(OrderCreateRequest request) { // 1. 创建订单本地事务 Order order new Order(); order.setUserId(request.getUserId()); order.setProductId(request.getProductId()); order.setAmount(request.getAmount()); order.setStatus(OrderStatus.CREATED); orderMapper.insert(order); // 2. 远程扣减库存Feign调用 inventoryFeignClient.deductStock(request.getProductId(), request.getQuantity()); // 3. 远程扣减余额Feign调用 accountFeignClient.deductBalance(request.getUserId(), request.getAmount()); } }关键就是GlobalTransactional注解。一旦方法进入这个注解Seata会向TC注册一个全局事务拿到一个全局唯一的XID。后续所有参与服务的本地事务都会通过Seata的拦截器把自己的分支事务注册到这个全局事务下。注意这里还有一个Transactional它的作用是保证本地事务的原子性。两个注解同时加是必须的因为如果本地事务不开启Seata的AT模式没法在同一个本地事务里写入undo_log。再看库存服务的扣减逻辑这个服务是被远程调用的Service public class InventoryServiceImpl implements InventoryService { Autowired private InventoryMapper inventoryMapper; Override Transactional(rollbackFor Exception.class) public void deductStock(Long productId, Integer quantity) { // 这个SQL会被Seata拦截记录前后镜像 int count inventoryMapper.deductStock(productId, quantity); if (count 0) { throw new BusinessException(库存不足); } } }库存服务的代码几乎不用为分布式事务做什么额外工作业务SQL照写只要确保本地事务开启即可。如果扣减失败抛异常Seata会自动通知TC回滚整个全局事务。还有几个关键配置项值得单独说一下都是在seata配置里设置的client: rm: report-success-enable: true # 是否自动上报分支事务成功状态 async-commit-buffer-limit: 10000 # 异步提交缓存上限 tm: commit-retry-count: 5 # 全局事务提交失败重试次数 rollback-retry-count: 5 # 全局事务回滚失败重试次数commit-retry-count和rollback-retry-count这两个参数尤其重要默认值是1。我以前遇到过网络抖动导致事务提交失败但接口没报错、数据却未落库的情况后来把重试次数调到5才稳定。4.4 全局锁与隔离级别AT模式的高性能秘密Seata AT模式一个比较容易被忽略的点是**全局锁Global Lock**机制。在AT模式的一阶段业务SQL执行的时候Seata不仅记录镜像还会尝试获取该数据行的全局锁。这个全局锁的作用是防止另一个全局事务同时修改同一行数据导致镜像失效。这里涉及一个隔离级别的问题。Seata AT模式的默认隔离级别是读未提交Read Uncommitted。也就是说在一个全局事务未提交之前另一个事务是有可能读到这个事务的中间状态数据的。比如下单事务扣了库存还没提交另一个查询库存的SQL可能已经看不到那1件库存了。如果你对隔离级别有更高要求需要做到读已提交Read Committed就得自己加SELECT ... FOR UPDATE语句。Seata会识别到FOR UPDATE在查询时也去获取全局锁从而避免脏读。但这会牺牲并发性能所以只在必要的数据上用。实际中订单场景我一般不会加FOR UPDATE因为下游服务都是做了幂等和校验的最终一致的时间窗口内出现短暂脏读业务上影响不大。但如果是资金类操作比如账户余额的查询建议加上全局锁保护。4.5 性能与压测AT模式到底损失了多少QPS很多人关心AT模式到底会不会压垮数据库性能。我做了个简单的压测对比单服务无全局事务和Seata AT模式下的下单接口数据如下场景QPS平均响应时间数据库CPU无分布式事务模拟成功120050ms40%Seata AT模式680110ms70%可以明显看到引入AT模式后吞吐量掉了将近一半响应时间也翻倍了。这个损耗主要来自三方面undo_log表的前后镜像查询和写入每个分支事务多了两次额外SQL全局锁的获取和释放在TC和数据库之间多了一次网络往返分支事务注册、状态上报这些额外的网络通信所以在核心高并发链路上AT模式并不是免费午餐需要评估好性能压力。对于性能要求极高的场景可以考虑把部分操作改成异步消息最终一致性或者直接用TCC抵消一部分数据库层的镜像开销。5. 踩过的坑与排查技巧实录5.1 经典问题速查表下面这几个问题是我在实践和社区里经常看到的直接整理成表格方便排查时快速定位问题现象可能原因排查与解决报错no available serviceTC地址配置错误或未注册到Nacos检查vgroup-mapping和 Nacos服务列表本地事务已提交但全局事务回滚没生效未在服务方法上加GlobalTransactional确认入口方法是否加了注解且Feign调用链路完整回滚时报undo_log找不到记录业务SQL执行成功但镜像写入失败或者undo_log被清了检查undo_log表和业务表是否在同一库分析阶段是否真的被Seata拦截大量全局锁等待超时高并发下多个事务同时操作同一行数据检查业务上是否可能并发更新同一行必要时加分布式锁或串行化二阶段提交失败但业务数据已变更TM重试次数不足或网络抖动调大commit-retry-count/rollback-retry-countundo_log 表膨胀严重长时间未清理成功的undo_log记录定时任务清理log_status1且log_modified早于当前时间N分钟的记录AT模式对大SQL解析失败Seata的SQL解析器不支持JOIN/LIMIT改写SQL或改用TCC模式处理这些特殊场景5.2 空回滚一个容易忽略但后果严重的问题空回滚Empty Rollback是分布式事务里非常隐蔽的问题TCC和Saga下都会出现AT模式也有类似场景。什么叫空回滚就是全局事务调用分支事务的Try阶段时因为网络超时Try请求可能根本没有到达分支服务但协调者等不到Try的响应就触发了全局回滚此时分支服务收到的Cancel请求其实是针对一个从未执行过的Try的补偿。如果Cancel逻辑没有做幂等、没有判断Try是否真的执行过就会出现凭空回滚的情况——把不该释放的资源释放了或者把不该增加的数据增加了。举个例子账户服务收到Cancel请求时应该先检查该用户是否有对应的冻结记录。如果没有冻结记录直接返回成功不要做任何操作。这就是空回滚防护。Seata AT模式如何处理AT模式因为有了undo_log镜像能通过XID找到对应的分支记录天然规避了没有执行过却要回滚的空回滚问题。这也是为什么AT模式比TCC实现起来省心的一个原因。5.3 幂等是分布式事务的底线这个问题老生常谈但我每次都想强调任何分布式事务方案都要求参与方具备幂等性。在网络重试、消息重投、补偿回滚的过程中同一个请求可能被处理多次。比如扣库存的Feign调用超时后重试了一次如果接口不幂等库存就被扣了两次。Seata AT模式的回滚补偿SQL是通过主键去定位数据的根据Before Image里的主键值所以它是天然基于主键的幂等操作。但如果你自己的业务接口需要重试就要自己保证幂等在接口里加防重表、用唯一索引、或者加分布式锁。RocketMQ事务消息的消费端也是一样MQ是至少一次投递语义消费端一定要做好幂等处理。这也是所有消息驱动方案的基本前提。5.4 监控与运维别等线上出事了才想起来分布式事务的运维和普通接口还不一样光看业务日志远远不够。我建议至少做这几件事第一给Seata Server配置监控。Seata提供了Metrics接口可以对接PrometheusGrafana。重点监控几个指标全局事务提交数、回滚数、挂起数、全局锁等待时长、分支事务注册耗时。如果发现回滚比例突然升高说明线上有接口在频繁报错需要尽快处理。第二事务日志要留全链路Trace。在GlobalTransactional方法里把XID打印到日志中同时在Feign请求头里传递XID。这样排查问题时只要按XID搜索就能串起整个分布式事务的完整时间线定位到底哪个分支出了错。第三定期清理undo_log。AT模式下已经提交完成的事务其undo_log记录会被Seata异步删除。但如果二阶段提交异常或者服务宕机会有残留。建议写个定时任务清理log_status1且创建时间超过一定时限的旧记录避免表无限膨胀影响数据库性能。6. 关于SF不说说最后那些实际心得最后再分享一些我在实际项目中的体会。分布式事务没有一劳永逸的解法方案本身也不分好坏只有合不合适。选型的核心逻辑是先想清楚业务对一致性的容忍度账务清算是必然要强一致的那就老老实实上TCC或者Seata AT而像是通知、日志、积分累积这类操作用MQ异步慢慢对加上去就行根本没必要为了它们把整个架构复杂度拉高。如果要我推荐一个平衡方案中小团队和大多数业务场景优先考虑Seata AT模式。它最大的优势是业务侵入低、上手快团队不需要为此写大量补偿代码。等业务增长到一定规模、出现并发瓶颈时再针对热点链路替换成TCC或异步最终一致性方案。如果你刚接触分布式事务别一上来就扎进源码里先把问题模型理清楚哪些数据需要一致、允许多久的不一致、系统能承受多少额外的性能开销。搞明白这些再去看Seata、TCC、Saga这些方案的适用场景你会发现一切都清晰很多。分布式事务这条路我已经走过来希望这篇文章能帮你少踩几个坑。如果哪个细节没写透欢迎在评论里继续讨论。