ARTICLE DETAIL

资讯详情

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

分布式事务实战:从2PC到Saga再到Seata,订单库存一致性方案详解

分布式事务实战:从2PC到Saga再到Seata,订单库存一致性方案详解 说实话搞了这么多年微服务真正让团队熬夜加班的往往不是接口性能不是并发调优而是分布式事务。尤其是订单和库存这种跨服务、跨库的核心链路一旦出现数据不一致第二天对账时发现订单显示已支付但库存没扣或者库存扣了订单却取消了那种排查过程真的能让人崩溃三天。这篇文章我把自己踩坑踩出来的实战经验整理一下从理论到落地讲清楚2PC为什么不够用、Saga模式怎么落地、Seata在整个方案里的定位和工作原理顺便把金融级场景里那些“教科书上不会写”的细节也一并说了。如果你正在做微服务拆分或者你们的系统里已经出现“跨库一致性”问题又或者你只是想把分布式事务这件事彻底搞明白这篇文章应该能帮上忙。我会尽量用聊天的口吻讲干货涉及到原理的地方给类比涉及到代码的地方给可直接抄的配置。1. 为什么分布式事务在金融场景里是个绕不开的坎1.1 单体时代没那么复杂在单体应用时代一个订单从创建到扣库存、扣余额、生成流水全部发生在同一个数据库事务里。BEGIN TRANSACTION到COMMIT之间所有操作要么全部成功要么全部回滚ACID特性由数据库本身保证。你不需要关心什么一致性协议也不需要设计补偿逻辑出了问题直接ROLLBACK数据天然是一致的。但微服务架构把这一切拆碎了。订单服务管订单库库存服务管库存库账户服务管资金库。原来的“一个本地事务”变成了“跨服务、跨数据库的多个本地事务”。每个服务各自提交自己的事务但在全局视角下这些事务加在一起是否具备原子性和一致性就变成了一个全新的工程问题。1.2 一个典型的订单与库存场景我举一个最常见的例子用户在电商平台下单前端调用订单服务创建订单订单服务内部要完成两步操作一是写入订单表状态为待支付二是调用库存服务扣减库存。在金融或电商场景里扣库存这件事往往还会关联到锁定库存、预占库存、释放库存等状态流转。问题在于订单服务写入订单成功但调用库存服务时网络超时了这时候订单已经落库库存却没有扣减。如果你重试可能会出现订单重复、库存超扣的风险如果你不重试订单和库存的数据就永远对不上。等到后续流程依赖库存数据做决策时整套系统的可信度就崩了。1.3 为什么金融级场景更敏感金融级场景和普通互联网业务最大的区别在于对数据一致性的要求是“零容忍”的。普通电商场景偶尔一个订单超卖运营做个补偿可能就过去了。但资金账户、交易流水、资产持仓这一类数据任何一条不一致的记录都会引发严重的对账问题甚至触碰合规红线。所以金融级场景里分布式事务方案不仅要解决“最终一致”还得考虑“实时一致窗口”多长、失败后的补偿是否可靠、幂等机制是否完善、会不会出现资金悬空等细节。这也是为什么从2PC到Saga再到Seata这类中间件始终没有一个“银弹”每种方案都在做取舍你需要找到最适合自己业务形态的那一种。2. 从2PC说起经典方案的优势与硬伤2.1 2PC的核心流程2PC两阶段提交协议是最经典的分布式事务方案之一它的核心思想是引入一个协调者Coordinator由协调者统一驱动所有参与者Participant完成事务提交。第一阶段是准备阶段Prepare。协调者向所有参与者发送准备请求每个参与者执行本地事务操作但先不提交把事务状态锁定在“可提交”状态然后向协调者返回“准备完成”或“准备失败”的响应。第二阶段是提交阶段Commit/Abort。协调者根据所有参与者的响应做决策如果全部参与者都返回准备完成协调者广播提交请求各参与者正式提交事务如果任何一个参与者返回准备失败协调者广播回滚请求各参与者回滚本地事务。2.2 2PC在真实场景里会遇到什么问题2PC理论上是完备的但它实际落地时存在几个硬伤我一个个说。第一同步阻塞问题。2PC的整个过程中所有参与者的事务资源比如数据库行锁、连接池连接都是保持持有的。参与者数量越多锁定的资源越多事务的整体耗时越长系统的并发吞吐能力就会被严重拖累。在高并发订单场景里这种阻塞几乎是不可忍受的。第二协调者单点问题。协调者本身也是系统的一部分如果协调者在第二阶段崩溃了所有参与者都处于“事务状态不确定”的情况——它们不知道到底是该提交还是该回滚。这个状态只能依赖协调者恢复后重新决策但在恢复之前相关资源一直被锁着。这个窗口期越长对业务的影响越大。第三数据可见性问题。在2PC的第一阶段事务操作的结果是处于中间状态的其他读操作可能会读到“未提交的修改”。如果业务场景要求强一致读2PC的中间状态会带来额外的复杂度。2.3 2PC适合用在什么场景说了这么多问题并不代表2PC一无是处。对于那些参与者数量少、事务执行时间短、并发压力不大的场景2PC依然是一个合理的选择。比如企业内部的管理系统、低频的管理操作2PC的简单直接反而是最大的优点。但是在电商大促、金融高频交易这类场景里2PC的同步阻塞和协调者单点问题会被放大成系统瓶颈。这也是为什么业界逐渐从2PC转向柔性事务方案Saga模式就是其中最具代表性的一种。3. Saga模式用补偿思维解决一致性3.1 Saga的核心思想Saga模式的核心思想是将一个长事务拆分成一系列有序的本地事务每个本地事务都有对应的补偿事务Compensation Transaction。正常流程走正向操作如果某个环节失败就反向执行已完成的环节对应的补偿操作实现数据回滚。举个例子订单流程拆成三个子事务创建订单 → 扣减库存 → 扣减余额。如果扣减余额发现余额不足就需要反向执行“补回库存”和“取消订单”两个补偿操作。注意Saga的补偿操作是业务层面的反操作不是数据库层面的回滚。扣库存对应的补偿是加库存创建订单对应的补偿是更新订单状态为已取消。3.2 编排式Saga与协同式SagaSaga有两种落地形态一种叫编排式Orchestration一种叫协同式Choreography。这两种我都实际用过各有利弊。编排式Saga引入一个中心化的协调器Orchestrator来管理整个事务流程。协调器负责按顺序调用各个参与服务监听每个服务的执行结果在出现失败时触发补偿操作。它的优点是流程清晰事务状态集中管理排查问题比较直观。缺点是协调器本身可能成为性能瓶颈也增加了额外的开发和维护成本。协同式Saga不引入中心协调器每个服务在执行完本地事务后通过事件总线比如Kafka发布事件由下一个服务监听事件并继续执行后续操作。它的优点是去中心化服务之间耦合度更低。缺点也很明显整个事务的流转逻辑分散在各服务的事件处理器里出了问题很难追踪全链路状态。从我的实践经验来看金融级场景我更推荐编排式Saga。因为金融场景对事务的可观测性和审计要求很高中心化的协调器可以记录每一步的执行状态和补偿状态对排查问题、做对账都更友好。3.3 Saga的保证范围与边界需要注意的是Saga模式提供的是最终一致性而不是实时一致性。在事务执行的窗口期不同服务的数据是处于“暂时不一致”的状态的。比如订单已经创建了但库存还没扣减成功这时候你去查库存看到的数据就是“还没扣减”的旧数据。这在很多业务场景里是没问题的因为读请求可以通过一些手段规避不一致的中间状态比如读请求统一走“只读已提交状态的服务”。但如果你有“必须实时强一致”的业务约束Saga模式就无法满足必须回到2PC或者改用其他方案。4. Seata架构解析TC、TM、RM到底各司其职4.1 Seata是什么Seata是阿里巴巴开源的一套分布式事务解决方案目前是Apache顶级项目。它把分布式事务的三方角色做了清晰划分——TC、TM、RM并把AT、TCC、Saga、XA等多种事务模式统一在一个框架里。对业务方来说用注解就能接入侵入性比自行实现要低得多。4.2 三个核心角色TCTransaction Coordinator事务协调器是独立部署的服务负责全局事务的注册、分支事务的状态记录、全局提交或回滚的决策。可以把它理解成Saga编排模式里的那个中心协调者但在Seata里TC是独立中间件不跟业务代码耦合。TMTransaction Manager事务管理器是嵌入在业务应用里的负责开启全局事务、提交或回滚全局事务。通常我们在业务代码里加GlobalTransactional注解实际上就是让TM工作——它向TC发起全局事务的开启和提交/回滚请求。RMResource Manager资源管理器同样嵌入在业务应用里负责管理分支事务的资源比如处理本地事务的提交、回滚、以及向TC注册分支事务和上报执行状态。在AT模式下RM还负责生成undo_log回滚日志。三者的协作流程是TM向TC申请开启全局事务得到全局事务IDXIDXID通过调用链传递到各个微服务每个微服务中的RM执行本地事务并向TC注册分支事务最终TM根据所有分支事务的结果向TC发起全局提交或回滚请求TC再统一协调所有RM完成提交或回滚。4.3 AT模式无侵入的自动补偿Seata最受欢迎的是AT模式Auto Transaction。AT模式利用了数据库本身的本地事务在业务操作之外通过拦截SQL解析来生成回滚日志。具体来说在执行业务SQL之前RM会先查询出要修改的数据的原始镜像执行完SQL后再查询出修改后的新镜像这两份镜像都保存到undo_log表中。当全局事务需要回滚时RM根据undo_log里的原始镜像生成反向SQL把数据恢复到修改之前的状态。AT模式对业务方的侵入性非常低基本只需要加GlobalTransactional注解不需要显式写补偿逻辑。但它的前提是数据库必须支持本地事务且所有参与事务的服务必须使用同一类支持事务的关系型数据库。另外AT模式的回滚依赖undo_log表如果数据在事务执行期间被其他本地事务修改了回滚时可能产生冲突。4.4 TCC模式业务补偿的精细化控制TCC模式Try-Confirm-Cancel是另一种在Seata中广泛使用的模式它要求业务方自己实现三个方法Try阶段完成资源预检和预留Confirm阶段完成业务确认提交Cancel阶段完成业务回滚释放资源。TCC的优点是比较灵活可以精确控制资源的预留和释放适合库存、资金这类需要预占/释放的场景。缺点也很明显业务侵入性很高需要为每个需要分布式事务的操作编写三套逻辑开发和维护成本都不低。我在实际项目里通常这么选如果业务比较标准、数据库统一优先用AT模式因为成本最低如果业务逻辑复杂、涉及资金账户的复杂状态流转或者对性能有极高要求那就用TCC自己控制资源锁定粒度减少行锁时间。5. 订单与库存分布式事务Seata在金融级场景的落地实践5.1 场景设定与技术选型我们用一个最常见的业务来演示用户在平台下单购买商品。整个流程涉及订单服务Order Service和库存服务Inventory Service两者各自使用独立的数据库。技术栈假定为Spring Boot MyBatis MySQLSeata版本为1.5.x以上。在这个场景里我要保证的核心约束是订单创建成功库存必须扣减成功如果库存扣减失败订单必须被标记为无效不能出现”订单有效但库存不足“的情况。5.2 部署Seata ServerTCSeata Server是独立部署的。下载Seata服务端包后需要修改两个配置文件持久化方式建议用数据库存储事务状态这样Seata Server重启后不会丢失事务状态信息。在application.yml里把存储模式改成db并配置对应的数据库连接。接着需要初始化Seata Server自己的库表包括global_table、branch_table、lock_table三张表。服务端配置的核心部分如下server: port: 7091 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/seata_server?useUnicodetruecharacterEncodingutf8 username: root password: root123 seata: store: mode: db db: datasource: druid db-type: mysql driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/seata_server?useUnicodetruecharacterEncodingutf8 username: root password: root123配置完成后启动Seata Server默认监听端口是8091服务端口是7091用于控制台。这一步没什么花哨的但要注意一个坑Seata Server自身的数据库和业务数据库千万不能混用否则后续排查问题会非常痛苦。5.3 业务服务集成Seata客户端订单服务和库存服务都需要引入Seata客户端依赖并配置接入参数。以订单服务为例pom.xml里需要加入dependency groupIdcom.alibaba.cloud/groupId artifactIdspring-cloud-starter-alibaba-seata/artifactId version2.2.2.RELEASE/version /dependency在application.yml里配置事务组和TC地址spring: application: name: order-service cloud: alibaba: seata: tx-service-group: my_test_tx_group seata: registry: type: file config: type: file service: vgroup-mapping: my_test_tx_group: default grouplist: default: 127.0.0.1:8091这里的tx-service-group建议起一个有意义的名字后续监控和排查时你能快速区分不同业务链路的事务组。数据库里还需要创建undo_log表这是AT模式回滚日志的存储表CREATE TABLE IF NOT EXISTS undo_log ( id BIGINT NOT NULL AUTO_INCREMENT, branch_id BIGINT NOT NULL, xid VARCHAR(100) NOT NULL, context VARCHAR(128) NOT NULL, rollback_info LONGBLOB NOT NULL, log_status INT NOT NULL, log_created DATETIME NOT NULL, log_modified DATETIME NOT NULL, PRIMARY KEY (id), UNIQUE KEY ux_undo_log (xid, branch_id) ) ENGINEInnoDB AUTO_INCREMENT1 DEFAULT CHARSETutf8;5.4 订单服务核心代码与事务链路在订单服务中创建订单的接口是分布式事务的发起方也就是TM所在的位置。我在这边的代码是这么写的Service public class OrderService { Autowired private OrderMapper orderMapper; Autowired private InventoryFeignClient inventoryFeignClient; GlobalTransactional(name create-order-tx, rollbackFor Exception.class) public void createOrder(OrderDTO orderDTO) { // 本地事务插入订单记录状态为待扣库存 Order order new Order(); order.setOrderNo(generateOrderNo()); order.setProductId(orderDTO.getProductId()); order.setStatus(CREATED); orderMapper.insert(order); // 远程调用扣减库存 InventoryReduceRequest request new InventoryReduceRequest(); request.setProductId(orderDTO.getProductId()); request.setQuantity(orderDTO.getQuantity()); inventoryFeignClient.reduceStock(request); } }在库存服务中扣减库存的方法加上Transactional即可Seata的RM会自动接管它的分支事务注册和回滚逻辑Service public class InventoryService { Autowired private InventoryMapper inventoryMapper; Transactional public void reduceStock(InventoryReduceRequest request) { int rows inventoryMapper.deductStock(request.getProductId(), request.getQuantity()); if (rows 0) { throw new BusinessException(库存不足); } } }整个执行链路是这样的当createOrder方法被调用时TM向TC注册全局事务并生成XID。随后本地插入订单的操作由RM执行并向TC注册分支事务。接着Feign调用库存服务时XID会通过调用链传递过去库存服务里的RM也向TC注册分支事务。如果库存扣减抛异常TM会捕获到异常并向TC发起全局回滚请求TC下发回滚指令两个服务里的RM根据undo_log执行回滚。5.5 金融级场景的关键细节调整上面这个demo能跑通但真要放到金融级场景还得额外处理几个问题。一是超时时间的设置。金融场景里远程调用超时不能设得太短否则会因为网络抖动导致事务误判。但也不能太长否则全局事务长时间挂起资源被锁住。我一般建议初始值设在3秒左右然后根据监控数据逐步调整。二是重试机制。Feign调用库存服务失败时不能直接连同订单库一起回滚就完事得设计合理重试。如果重试几次仍然失败才触发全局回滚。这样能显著降低偶发网络问题导致的无效回滚提升整个链路的事务成功率。三是幂等机制。因为分布式环境下重试、网络超时等都很常见下游服务必须对相同请求做幂等处理。比如库存扣减接口每次请求都带一个唯一业务主键可以用订单号商品号操作类型拼接数据库对该主键做唯一约束重复请求直接返回成功。6. 常见问题与排查技巧实录6.1 全局事务不回滚了怎么办这是用Seata最常遇到的问题代码里加了GlobalTransactional但服务B抛异常后服务A的本地事务竟然没有回滚。碰到这个情况先别急着怀疑框架有问题按照下面几步排查。第一步确认XID是否传递到服务B。在服务B的接口实现里加上日志打印RootContext.getXID()。如果XID是空的说明Seata的XID传递机制没生效。常见原因是Feign拦截器没配置或者使用了异步线程导致XID丢失。Feign的场景下需要确保SeataFeignClientAutoConfiguration被正常加载同时检查Hystrix或Sentinel之类的熔断组件是否把线程上下文切换了。第二步确认TC是否收到了分支事务注册。去Seata Server的日志里搜服务A和服务B对应的分支信息。如果服务B没有注册可能是RM没被正确初始化检查Seata客户端的配置和undo_log表是否创建成功。第三步确认异常是否被正常抛出。如果服务B的reduceStock方法内部catch住了异常没有继续往上抛TM就感知不到失败自然不会触发回滚。这一点经常被忽略代码里吞掉异常的行为在分布式事务场景里是致命伤。6.2 回滚冲突undo_log数据被覆盖AT模式的一个风险点在于如果全局事务执行期间同一行数据被其他本地事务修改了回滚时就会产生冲突。Seata的默认行为是尝试重试但如果重试后仍然失败就需要人工介入处理。我从实践中得到的经验是在设计表结构时尽量让分布式事务保护的数据行在事务执行期间不与其他本地事务产生交叉。比如库存表经常会进行并发扣减更新这种热点数据用AT模式要特别小心。实际的规避手段有两个一是用乐观锁在回滚时校验版本号二是把热点行拆分分散并发压力。6.3 事务隔离级别与脏读Seata AT模式默认的隔离级别是读未提交Read Uncommitted这意味着在全局事务执行过程中其他事务可能读到事务中尚未提交的数据变更。这在某些金融场景下是不能接受的。解决方案有两个。一是使用Seata提供的SELECT ... FOR UPDATE语句在读取时加锁保证读到的是已经提交的数据。二是在业务设计层面规避比如把查询口统一设计成“只读已确认数据”把未完成事务产生的中间状态数据标记为不可见。6.4 监控与告警金融级场景里没有监控的分布式事务方案等于裸奔。我在项目里会做三件事。第一Seata Server自身监控。开启Prometheus指标暴露监控TC所在的机器CPU、内存、堆积事务数量。全局事务堆积量是一个核心指标如果不断增长说明有事务卡住没有终态。第二业务侧监控。在GlobalTransactional方法入口和出口埋点记录每个全局事务的耗时和结果。同时在Feign调用和本地事务的关键节点打日志方便追踪全链路。第三告警规则。全局事务失败率超过阈值、分支事务注册数量异常、undo_log回滚失败等都应该触发告警。这样即使出了问题你至少能在用户发现之前感知到。7. 我对分布式事务方案选型的一些个人看法7.1 没有银弹按场景选型聊了这么多我最想传达的一个观点是分布式事务没有银弹没有哪种方案是“终极方案”。2PC、Saga、AT、TCC各有适用边界选型的核心依据是业务对一致性、可用性、性能三者之间的权衡。如果你的业务要求强一致、参与方少、并发量不高2PC或者Seata的XA模式是合适的它能给你最强的数据一致性保证。如果你的业务链条较长、参与方多、并发量大Saga模式是更好的选择通过补偿机制换来更高的可用性和性能。如果你的公司内部数据库统一、业务操作标准Seata AT模式是最省力的因为它对代码侵入最小。如果你的业务操作复杂需要精细控制资源预占和释放TCC模式值得投入研发资源。7.2 分布式事务方案只是工具核心还是业务设计我看到过很多团队上来就引入Seata指望靠一个框架解决所有数据一致性问题。但实际上很多场景根本不需要分布式事务。比如订单创建和库存扣减你可以通过库存预占接口先行锁定库存最后再通过消息队列异步确认扣减。订单创建成功后发消息库存服务消费消息执行扣减这个方案本质上用消息队列的可靠投递消费幂等解决了最终一致性完全不需要引入分布式事务中间件。所以我的建议是设计阶段先问自己这个操作的实时一致性是必须的吗有没有可能通过异步解耦来规避如果确实需要分布式事务再评估用Seata的哪种模式。中间件只是最后的兜底手段而不是第一选择。7.3 落地之后还要做的事Seata落地之后不是结束而是一个新的开始。你需要建立一套完整的演练和应急机制。我自己维护的项目里会定期做故障演练比如主动kill掉某个参与服务、让TC暂时不可用、模拟数据库网络分区然后观察全局事务的行为是否符合预期。这些演练能提前暴露很多配置问题比如超时时间设置不合理、回滚逻辑缺失、幂等保护不到位等。最后再分享一个小技巧上线初期别急着把所有跨服务操作全部接入Seata先挑一条低频、核心、容易出问题的链路做试点。等这条链路稳定运行一两个星期监控指标都正常了再逐步扩大覆盖范围。分布式事务这种机制本身就增加了系统的复杂度如果一开始就把所有业务都裹进来出了问题你连排查方向都找不到。老老实实一步一步来反而比追求一步到位更快。
返回列表