ARTICLE DETAIL

资讯详情

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

分布式事务选型指南:强一致与最终一致方案对比与取舍

分布式事务选型指南:强一致与最终一致方案对比与取舍 前段时间有位做电商系统的朋友问我一个特别经典的问题商城下单库存扣减成功了但订单创建却失败了用户手里没有订单库存却少了这怎么解释我告诉他这就是典型的分布式事务一致性问题。放在单体应用里一个本地事务就能把订单、库存、账户打包搞定一旦拆成多个微服务、多个数据库原来那套事务保证直接失效数据一致性的账就得从架构层面重新算了。很多人一听到分布式事务就本能地想到 Seata、2PC、消息队列这些名词然后在网上翻了一堆文章看完还是不知道自己的订单系统该选哪个方案。这篇文章不打算再复述一遍理论而是从实际决策的角度把强一致和最终一致几个主流方案摆在一起对比说清楚它们各自解决什么问题、代价是什么、在你的项目里怎么选。如果你正在为订单、库存、账户这类跨服务数据一致性发愁这篇文章应该能帮你少走弯路。1. 从一次下单事故说起分布式事务到底在解决什么1.1 一个最典型的分布式事务场景拆解下单链路涉及哪些状态一致性先别急着聊方案把问题本身拆透。假设你有一个电商系统订单服务管订单数据库存服务管库存数据账户服务管余额扣减这三个服务各自拥有独立数据库。用户下单的完整动作如下订单服务创建一条待支付订单库存服务扣减对应商品库存账户服务扣减用户余额或生成待支付账单。这三个动作之间相互依赖且任何一步失败前面的成功操作都必须回滚否则就会出现有订单没库存扣了库存没订单扣了钱没订单这类脏数据。真正的难点在于这三个动作已经不是同一个数据库连接上的三条 SQL 了而是三个独立进程、三套数据库、三次网络调用。传统数据库提供的 ACID 事务要求所有操作都发生在同一个事务边界内比如我在 A 库里执行 INSERT在 B 库里执行 UPDATE本地事务根本管不住 B 库。一旦中途某个服务挂掉、网络超时或者数据冲突你无法像单体应用那样执行一个 ROLLBACK 就把所有痕迹清掉。这就引出了分布式事务的核心目标让多个独立资源上的操作在逻辑上保持原子性或者至少在最终状态上保持一致。这个保持一致有不同的强度直接决定你选哪套方案。1.2 为什么不能用扣库存失败就回滚订单这种粗暴方案初次接触这个问题的人最容易想到的方案是先创建订单再调库存服务扣库存如果扣库存接口返回失败就在订单服务里把刚才创建的订单删掉/标记为取消。这个方案听起来很合理但仔细一推敲就发现漏洞百出。比如扣库存接口实际已经扣成功了但返回响应时网络超时。订单服务收到的是调用异常于是执行本地回滚取消了订单。而库存服务那边扣减成功并没有接到任何补偿指令于是库存少了、订单不存在。这还不算完如果此时你用一个定时任务去扫描超时订单发现某个订单既未支付也未取消又去补发取消请求可能正好赶上用户重新提交了相同商品的下单请求两个操作互相覆盖数据彻底乱套。所以问题不只是异常时回滚还包括网络抖动造成的结果不确定、回滚操作本身可能失败、并发请求下补偿动作相互干扰。这些靠一个简单的 try-catch 是永远搞不定的必须有一套显式的事务协议来约定什么时候提交、什么时候回滚、回滚失败怎么办、接口重复到达怎么识别。1.3 一致性强弱的分类从强一致到最终一致的谱系分布式事务领域的方案五花八门但归纳起来就是沿着一条谱系从强一致走向最终一致。强一致端的代表是 2PC/XA 以及基于它做的 Seata AT 模式核心思路是让所有参与方在同一时刻对外呈现相同的数据状态最终一致端的代表是本地消息表、事务消息、SAGA核心思路是允许短暂的不一致但通过补偿和重试保证最终所有服务的数据收敛到一致状态。我不建议一上来就给自己扣必须强一致的帽子而是先接受一个事实分布式环境里不存在免费的一致性。你要求的实时性越高系统付出的代价就越大。下面给出一个大致分类表后面两章会逐个展开分析方案类型代表性技术一致性强度业务停顿/效果实现成本典型场景强一致XA/2PC同步强一致有全局锁吞吐受限高金融转账、短事务、跨库且并发低强一致(优化)Seata AT逻辑强一致(带补偿)全局锁冲突仍存在中高尖刺并发低的中小型微服务最终一致本地消息表异步最终一致无阻塞吞吐高中订单状态、积分、通知类最终一致事务消息异步最终一致无阻塞依赖MQ中核心订单库存异步化最终一致SAGA异步/同步补偿无锁流程长中高长流程、多服务编排下面逐个说透。2. 强一致阵营的底牌2PC/XA与Seata AT模式2.1 2PC/XA 的原理与真正的坑2PC 两阶段提交是分布式事务的老祖宗前面提到的 XA 就是它在数据库层面的实现标准。整个过程由一个协调者通常叫事务管理器和多个参与者每个数据库组成。第一阶段叫准备阶段协调者问所有参与者你们能不能提交参与者各自执行事务内操作、写入 undo/redo 日志但暂不提交然后返回准备好了。第二阶段叫提交阶段协调者收到所有参与者的准备好了之后广播提交参与者才真正提交。只要有一个参与者说不行协调者就广播回滚。听着挺严谨但这套方案落到生产环境问题一个比一个致命。第一个是阻塞问题准备阶段所有参与者都要持有数据库行锁直到第二阶段提交或回滚才能释放。如果某个参与者迟迟不响应其他参与者的锁就一直挂着数据库连接池被耗尽后台任务全部卡死。第二个是协调者单点问题协调者宕机后所有参与者都停在已准备状态没人能通知它们提交还是回滚事务只能僵在那里需要人工介入。第三个是网络分区问题准备阶段大家都通过了但最终提交阶段网络把协调者和某个参与者隔开了导致部分提交成功、部分没有提交所谓强一致直接破功。这些坑在单机数据库环境里不那么明显一旦跨机房、跨地域网络延迟和故障概率成倍放大2PC/XA 的可用性就很脆弱了。我把话放这儿如果你要跨机房部署最好直接放弃 XA除非你的业务能接受数据库长时间锁等待和随时可能人工捞数据。2.2 Seata AT模式对2PC的改良与自动补偿Seata 的 AT 模式本质上是对传统 2PC 的一次工程化改良理解它之前先理解为什么不用 XAXA 要求参与者数据库真的去 prepare 一份事务锁时间太长而且对数据库的事务隔离级别有严格要求。AT 模式换了个思路不要求数据库提前 prepare而是利用数据源代理在业务 SQL 执行前后记录数据快照事务提交前通过全局锁来防止其他事务修改同一行数据提交后如果发现需要回滚就根据快照自动生成反向 SQL 把数据还原。具体落地时你需要在业务数据库里建一张 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), KEY ux_branch_id (branch_id), KEY ux_xid (xid) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;业务代码看起来和本地事务几乎没有区别核心逻辑里加上 GlobalTransactional 注解Seata 客户端会拦截方法开启一个全局事务所有参与的分支事务都自动纳入管理。这相比 XA 的最大好处是参与者不需要提前锁资源而是通过记录前后镜像做到按需回滚数据库层面的锁等待时间大大缩短。但 AT 模式并非银弹。它引入了全局锁的概念在全局事务提交前Seata TC 会锁住涉及的数据行其他本地事务如果也想修改同一行会被阻塞或等待。如果系统并发很高热点商品频繁被下单全局锁冲突会非常明显延迟飙升。另外 undo_log 表会持续积累历史回滚记录需要定期清理回滚事件本身也要做监控否则 undo_log 膨胀会影响数据库性能。我的建议是AT 模式适合并发量要求不极端的内部系统、后台运营系统比如给商家后台做库存同步、给客服做退款审批这类系统一天几万笔事务用 AT 模式很舒服。如果是面向用户的高并发交易链路想清楚自己是否能接受全局锁带来的排队问题。2.3 强一致方案的适用范围与看起来可靠的陷阱说句容易得罪人的话大部分业务根本就不需要强一致只是被数据一致性这四个字吓住了才拼命往强一致方案上靠。强一致方案的可靠性是有条件的条件不满足时它比异步补偿方案更容易翻车。强一致的适用条件大概是并发量可控、事务链路短只涉及两三个服务、数据敏感度极高且必须实时一致、团队有能力维护协调者和数据库锁监控。比如银行核心转账A 账户扣钱和 B 账户加钱必须同步成功或同步失败这个用 TCC 或 XA 都有道理。但你做一个电商订单用户下单后看到订单已提交和库存实际扣减之间有 100 毫秒的延迟完全不影响体验就没必要付出全局锁的代价。还有一个常见的误解是AT 模式有自动回滚所以很安全。实际上自动回滚只保证已记录的前后镜像能还原如果镜像记录本身因为数据库问题没有写入或者业务代码里混了不支持的类型回滚会出现异常。而且全局事务过程中如果忘记设置超时时间异常情况下分支事务可能长期挂起把所有连接占满。强一致方案不是不用想一致性了而是你要想的事情更多了。3. 最终一致阵营的主流套路消息事务、本地消息表和SAGA3.1 本地消息表与定时任务最朴素但最稳的最终一致方案如果你不想依赖 Seata 这类协调者又不具备引入复杂业务消息队列的条件本地消息表几乎是最稳妥的选择可能没有之一。它的核心思想是把发送消息和业务操作放在同一个本地事务里然后再由异步任务把消息可靠地发给下游服务。具体流程先拆成五步在订单服务开启本地事务写订单表同时写一张消息表消息状态为待发送。本地事务提交后立即由后台线程或定时任务扫描消息表把未发送消息投递到下游服务接口Mq 或 HTTP。下游服务收到消息后执行库存扣减执行成功调用确认接口或发送 ack订单服务把消息状态更新为已发送。如果投递或执行失败消息状态还是待发送定时任务继续重试直到成功。为防一些下游永远无法处理的消息积压可以设置最大重试次数超过后进入死信状态由告警人工处理。消息表不能随便设计它至少要包含CREATE TABLE t_message_record ( id bigint(20) NOT NULL AUTO_INCREMENT, biz_type varchar(32) NOT NULL COMMENT 业务类型比如ORDER_CREATED, biz_id varchar(64) NOT NULL COMMENT 业务唯一ID比如订单号, content text NOT NULL COMMENT 投递内容JSON, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 0待发送 1已发送 2死信, retry_count int(11) NOT NULL DEFAULT 0, next_retry_time datetime NOT NULL, create_time datetime NOT NULL, update_time datetime NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_biz_type_biz_id (biz_type,biz_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里唯一键 uk_biz_type_biz_id 非常重要它可以防止同一笔业务被重复插入消息记录保证消息只会产生一条。下游投递时要配合消费幂等靠业务唯一键去重比如库存扣减表对 order_no 建唯一索引。这个方案看下来可能很多人觉得太土了没有技术含量。但实际上它很皮实不依赖额外中间件的高可靠特性数据库本身就是消息的持久化存储只要数据库不丢消息就不会丢。当然它的缺点是业务表与消息表耦合在一起对数据库有一点额外压力并且定时任务有秒级延迟不是实时性要求高的场景。3.2 RocketMQ事务消息半消息与回查机制如果说本地消息表是把消息存在自己库里那么 RocketMQ 事务消息就是把消息的可靠性存放托管到 MQ同时通过半消息 回查机制解决本地事务和消息发送的原子性问题。事务消息的执行流程与本地消息表神似生产者先发送一条半消息half message到 BrokerBroker 能收到但对消费端不可见随后生产者执行本地事务根据本地事务执行结果向 Broker 发送 commit 或 rollback只有 commit 之后这条消息才真正对消费者可见。如果生产者迟迟没有上报结果Broker 会主动调用生产者的回查接口去确认本地事务到底成没成再决定将半消息放行或删除。这段逻辑落到代码里大致是Transactional public void createOrderAndSendMessage(OrderDTO orderDTO) { // 1. 本地事务创建订单 orderMapper.insert(orderDTO); // 2. 发送事务消息 TransactionSendResult result rocketMQTemplate.sendMessageInTransaction( order-topic, MessageBuilder.withPayload(orderDTO).build(), orderDTO.getOrderNo() ); } // 事务消息监听器执行本地事务 Override public LocalTransactionState executeLocalTransaction(Message msg, Object arg) { try { // 这里实际上已经在上面的Transactional里执行业务了 return LocalTransactionState.COMMIT_MESSAGE; } catch (Exception e) { return LocalTransactionState.ROLLBACK_MESSAGE; } } // 回查接口Broker没收到commit/rollback时调用 Override public LocalTransactionState checkLocalTransaction(MessageExt msg) { String orderNo msg.getUserProperty(orderNo); OrderDO order orderMapper.selectByOrderNo(orderNo); if (order ! null) { return LocalTransactionState.COMMIT_MESSAGE; } return LocalTransactionState.ROLLBACK_MESSAGE; }事务消息对比本地消息表省去你自己维护消息表和定时任务也让业务表更干净。但代价是你必须把 RocketMQ 自身的可用性当回事如果 Broker 集群挂了消息发不出去本地事务就无法正常结束。为了降低这个风险你在 RabbitMQ/Kafka 里可能找不到现成的事务消息机制Kafka 的 EOS 只能保证单分区有序无法直接用于跨服务的分布式事务。如果团队还没引入 RocketMQ为了做事务消息而专门上线一套新中间件这个成本要好好掂量。3.3 SAGA长流程与编排/协同模式SAGA 和前面几种方案最大的区别在于它不追求不留中间态而是把一个长事务拆成一组有序的本地短事务每个短事务都配一个反向补偿事务。执行过程像多级台阶成功就逐级往下走任何一步失败就沿着已走过的台阶反向回退。比如一个完整的订单履约链路创建订单 - 扣库存 - 冻结优惠券 - 调用支付。如果调用支付失败就依次执行解冻优惠券、加回库存、取消订单。每个步骤都是独立本地事务不需要全局锁长时间运行的流程也不会占用数据库资源。SAGA 有两种落地形态编排Orchestration和协同Choreography。编排模式里有一个专门的流程控制器它负责告诉每个服务现在执行哪一步上一步失败请补偿所有流程状态都集中在控制器里可控性高但控制器本身可能成为单点或复杂逻辑重灾区。协同模式则完全靠事件驱动每个服务完成自己的本地事务后往消息中间件发一个事件后续服务监听事件继续执行失败也通过事件触发前序服务的补偿方法。协同没有中心控制扩展性好但流程分布式地隐藏在事件链路里排查问题很费劲。下面给一个比较模板化的实现思路假设用一个流程编排器SagaTransaction public void executeOrderProcess(OrderContext ctx) { // 依次调用各参与者的正向服务 orderService.createOrder(ctx); // 失败则跳转到补偿流程 inventoryService.deduct(ctx); // 失败则调用 orderService.cancelOrder couponService.freeze(ctx); // 失败则调用 inventoryService.addBack paymentService.pay(ctx); // 失败则调用 couponService.unfreeze }落到生产环境SAGA 必须处理三个经典问题幂等、空补偿、悬挂。幂等要求每个服务对同一个流程重复调用结果一致空补偿是指补偿动作发生时正向事务压根就没执行成功你必须能识别这种情况悬挂是指正向请求和补偿请求到达顺序错乱导致服务端出现了不该补偿却补偿的尴尬状态。这三点都要通过状态机加唯一流程 ID 来控制。SAGA 适合的业务有旅行预订订酒店、订机票、租车、长周期订单履约这类天生可以分成多步、且不强求同步一致的长链路。3.4 最终一致方案的可靠性闭环幂等表、对账任务与状态机很多人实现最终一致方案时只关注消息能不能发出去结果忽略了更关键的一层消费端和补偿端怎么保证不重复、不遗漏、不搞错时序。最终一致不是一个异步调用就完事了它是一个闭环。首先是消费幂等。库存扣减、积分增加、账户变更这类操作必须允许同一笔消息被重复投递而不能重复生效。最简单的做法是在消费端状态表里对业务唯一号建唯一索引比如扣减库存表里对 order_no 建唯一键第一次插入成功重复插入直接 throws DuplicateKey业务理解为已处理即可。也可以用状态表单独记录消费过的消息 ID每次消费前先查状态表但唯一索引的方式更不容易受并发影响。其次是对账任务。消息表里一堆消息处于已发送状态不代表下游真的处理成功了因为 ack 可能丢失。所以必须设计一个对账定时任务每隔几分钟扫描订单库和库存库找出那些在订单侧显示成功、但在库存侧没有扣减记录的订单补偿库存操作。对账是真实生产里让一致性最终收敛的关键没有对账消息一旦丢失就是永久丢。最后是状态机。订单状态至少要有待支付、已支付、已取消、已完成、关闭每个状态间的流转必须由明确的动作触发。分布式环境下状态不是由一个事务改的而是多个服务异步改的所以需要用状态机约束非法跳转比如已取消订单不能再执行扣库存动作。状态机可以简单写成一个枚举加一个合法流转表但一定要在代码里硬性校验不能只靠约定。4. 真实决策复盘订单与库存这类场景我最后怎么选4.1 不同体量的三种选择思路很多技术纠结到底选哪个方案其实答案取决于你的系统处在什么阶段没有绝对的好只有适合不适合。我把团队常见的三种体量拿出来说。第一种还是单体应用刚刚拆了库或者拆了模块但业务量一天几千单。这时候引入 Seata 或者自己写的分布式事务协调器反而增加复杂度。对这类系统我倾向于用本地消息表 下单主流程同步降级的方式先把核心链路保住。你甚至可以把订单和库存放同一个物理库的不同 schema还是走本地事务等到真的必须拆服务了再迁移。第二种微服务改造完成订单、库存、账户分属三个团队单量几万到几十万一天。这个阶段事务消息是最顺手的方案配合消费幂等和对账任务能把一致性问题控制在可接受范围。如果你已经用了 RocketMQ直接用事务消息没必要再重复造一个消息表轮询。如果没用 RocketMQ用本地消息表 HTTP 回调也能做到同样效果成本更低。第三种流量很大、链路很长的电商平台或者明显需要强一致且低并发的特定业务域。这种情况下建议分域处理交易主链路用事务消息 对账支付/退款这类资金操作单独接 SAGA 或 TCC 保证资金安全。不要让全站统一到一种方案每个业务域根据数据敏感度差别对待才是大型系统的常态。4.2 选型清单与验证步骤我给自己做技术选型时不太看网上那些XX方案最牛的争论而是拿一张清单挨个过。你可以照抄一致性要求业务能否接受订单下了但库存几百毫秒后才看到少了能接受就选最终一致不能就直接淘汰消息类方案。链路长度涉及服务是否超过三个如果只有一个下游甚至没必要引入分布式事务框架本地事务加可靠消息就够了。并发与热点是否有高并发抢购、秒杀类热点扣减有热点响应优先考虑异步削峰和库存预占不要碰全局锁方案。中间件现状团队是否已运维 RocketMQ没有的话不建议只为事务消息强行引入本地消息表这样只依赖数据库的方案可能更划算。故障恢复能力假设消息队列故障 30 分钟业务是否可接受不可接受就得多设计降级策略而不是单纯依赖一个中间件保命。团队维护成本Seata 又装服务端又管 undo_log事务消息也要监控消息积压问问自己有没有人值班处理这些告警。按这张清单走一遍70% 的情况下答案已经出来了。如果还是拿不准我建议做一次小规模压测照着目标方案搭一套最小链路模拟下游服务 50% 超时看系统能否在 10 分钟内恢复一致。能恢复、不丢数据的方案才值得往生产推。4.3 我在下单-扣库存场景落地的具体配置与踩坑聊点实际的我在一个中等体量的电商系统里的选择是RocketMQ 事务消息 库存扣减幂等表 对账任务。下单接口伪代码如下订单服务创建订单状态为待支付。发送事务消息ORDER_CREATED到 RocketMQ消息内容包含订单号、商品 SKU、扣减数量、本次扣减唯一流水 ID。库存服务消费消息先检查 inventory_flow 表里是否存在相同的流水 ID存在直接返回不存在则扣减库存并写入流水记录。如果库存不足或商品已下架消费逻辑直接抛出异常并通知告警但消息不能简单 reconsume 无限重试而是经过几次重试后进入死信队列由对账任务处理。对账任务每 5 分钟扫描订单表最近 30 分钟创建且状态为待支付/已支付的订单与库存流水比对发现缺失的扣减记录则补扣或标记异常。这中间我踩过几个比较典型的坑。第一个是消费者重试导致库存重复扣减虽然我设计了幂等表但最初漏掉了扣减库存 SQL 和插入流水表 SQL 必须放在同一个本地事务里这一点导致偶尔出现库存扣了、流水没插进去重试又扣一次。后来把两个操作包进一个 Transactional 才解决。第二个是事务消息回查慢导致订单发送流程被拖住。RocketMQ 半消息发出后如果本地事务执行很快但 commit 回执因为 GC 或者网络抖动延迟了Broker 就会触发回查回查又要查数据库极端情况下多个线程同时回查同一订单号。我在回查接口里忘了加幂等控制导致数据库压力瞬时变大。后来在回查逻辑里做了缓存和唯一约束问题才缓解。第三个是对账任务的时间偏移问题。最开始对账任务只查创建时间在最近 5 分钟的订单结果因为应用服务器和数据库时钟存在两秒偏差部分订单被漏扫库存缺口没有及时补上。最终改为按照订单 ID 增量 状态筛选的方式去扫不依赖时间字符串稳定很多。4.4 如果必须强一致低并发场景下的Seata AT配置建议如果你评估下来确实需要强一致比如做退款审批、资金冻结并发又不高可以考虑 Seata AT 模式。我见过不少团队把 Seata 默认配置丢上去就跑后来出现一堆超时和锁等待问题所以给几个实操建议。Seata Server 端先定好存储模式生产环境不要用 file 模式做集群要使用 DB 模式全局事务记录会写到 seata 库。配置大致如下store.modedb store.db.datasourcedruid store.db.db-typemysql store.db.urljdbc:mysql://your-seata-db:3306/seata?useUnicodetruecharacterEncodingutf8 store.db.useryour_user store.db.passwordyour_password业务侧的几个参数更重要。客户端需要设置合理的全局事务超时时间短事务建议 30 秒以内如果超过 1 分钟还没结束基本是业务逻辑混入了远程慢调用不应该靠加超时解决而是把远程调用拆出去。数据库连接池大小也要放大一些因为 AT 模式在事务期间对一个连接占用时间变长连接池太小容易排队。另外 undo_log 表按天做分区或者定期清理不要让回滚日志只增不减。还有一个很多人踩过的点被 Seata AT 管理的数据库表必须有主键否则无法生成准确的回滚 SQL字段类型也尽量规整某些数据库函数比如 NOW() 可能导致前后镜像不一致。初始化阶段最好自己在测试环境做一轮故障演练手动 kill 掉一个分支事务进程观察全局事务是否能正确判超时回滚数据是否完整恢复。5. 终局分布式事务没有银弹只有取舍回到开头的那个问题扣了库存但订单创建失败这属于一致性被破坏后的典型表现。但我现在会告诉你比起追求一套完美的分布式事务方案更值得花时间的是把一致性需求是什么想清楚以及为最终一致方案配上幂等、重试、对账这套兜底体系。很多时候高可用和强一致无法兼得必须根据业务价值做取舍。我在实际项目里反复体会到一个原则让应该异步的流程异步化让必须同步的步骤尽量短。用事务消息解耦订单和库存用户看到的是快速下单成功后台通过消息和对账把库存数据收敛到一致比在链路上强加全局锁要优雅得多。如果你的业务必须强一致那就老老实实接受全局锁的约束并做好连接池、超时、undo_log 的运维如果你能接受秒级最终一致异步消息加对账是我更推荐的方向。最后再分享一个小技巧无论用哪种方案先把核心表的主键、业务唯一号、状态字段设计好分布式事务里 80% 的重复和乱序问题都能通过唯一约束和状态机拦截掉。模型扎实了方案选哪个都不会太难看。
返回列表