ARTICLE DETAIL

资讯详情

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

分布式事务选型指南:从CAP到2PC/TCC/消息表/SAGA

分布式事务选型指南:从CAP到2PC/TCC/消息表/SAGA 你手头是不是也有这么一摊子事儿订单服务扣了库存结果库存服务那边崩了钱付了但货没发或者促销活动把库存扣超了用户下单直接超卖。如果是单机数据库一条事务全搞定。但一旦拆成微服务、做成大数据集群跨库跨表的事务就像流水线上多个工人各拿各的料谁都不知道别人干到哪一步一不留神就出乱子。大数据分布式事务说白了就是解决“多个系统之间的数据一致性”问题。业界聊这个绕不开CAP定理但真正落地时你会发现CAP不是万能钥匙它只是规划约束怎么在约束下做出可用又一致的系统才是所有事务方案的差异点。这篇文章我会从CAP的视角出发把分布式事务的主流解决方案——从2PC、TCC、本地消息表、事务消息到SAGA——逐一拆开对比结合“订单扣库存”这个经典场景讲清楚每个方案的优劣势以及我在实际项目中踩过的坑。适合正在做微服务拆分、数据中台建设或者刚接手分布式系统、被一堆“最终一致性”概念绕晕的朋友。1. 项目整体设计与思路拆解1.1 CAP定理先放在前面别让它吓到你CAP定理说的是在一个分布式系统中一致性Consistency、可用性Availability、分区容错性Partition tolerance这三者最多只能同时满足两个。很多人一看到这个“三选二”就慌了觉得系统做不大其实这是误解。分布式系统只要跨网络网络故障就是必然的所以P分区容错性不是选项是前提。真正需要权衡的只有C和A要么保证强一致写入后所有节点立马看到同样的数据代价是部分节点故障时系统拒绝服务要么保证可用性节点有问题也尽量响应但可能出现数据滞后、读到旧值。实际业务中绝大多数系统都不是强一致需求。比如订单累计金额多算了几分钱用户可能没感觉但账户余额扣多了用户立刻投诉。所以设计事务方案的第一步不是选一个“最牛的中间件”而是先问自己这笔业务能容忍多久的不一致能容忍多大程度的不一致这个答案会直接决定方案的复杂度。1.2 分布式事务为什么难难在“多个数据库”之间单机数据库有ACID兜底事务要么全部成功、要么全部回滚靠锁和日志就能搞定。到了分布式环境下每个微服务各自持有独立的数据库没有共享的锁也没有共享的日志。A库提交了B库失败谁来通知A库回滚如果A库已经对外返回成功回滚就变成了“让事实倒退”业务上很难接受。所以分布式事务的核心思路分两条路一条是尽量把多个操作绑成一个整体用协调者去推进和回滚典型代表是2PC、3PC、TCC另一条是放弃强一致接受“最终一致”把事务拆成一系列本地操作用消息、重试、对账来保证最后数据收敛典型代表是本地消息表、事务消息、SAGA。这两种思路没有高下之分。强一致方案适合资金类、对账类业务但往往吞吐低、实现复杂最终一致方案适合大多数互联网业务响应用户快但需要额外的落盘、重试、补偿逻辑。接下来的对比我会始终沿着这两条主线展开。1.3 你的业务场景决定你需要的“一致性等级”在动手选型前我建议你先给业务做一个“一致性等级”评估。虽然这篇文章叫“解决方案对比”但真正有价值的不是那张方案对比表而是你判断自己该用哪一类方案的推理过程。强弱一致型账户扣款、转账、订单支付状态。这类业务如果出现不一致会直接造成资金损失或用户矛盾。需要2PC、TCC这类有“现场回滚”能力的方案。最终一致型订单创建后通知库存扣减、积分累积、搜索索引更新。这类业务允许短暂延迟只要最终状态对上即可。可以用消息表、事务消息、SAGA。准实时型一些大数据集群内的状态同步比如HDFS副本写入。这种是基础设施层的强一致通常由系统本身保证业务侧不用管。把业务分成这三个等级之后你会发现大部分场景根本不需要上TCC一个可靠消息重试就能解决。这不是偷懒这是架构常识。2. 核心细节解析与实操要点2.1 2PC一台“独裁协调者”的强一致努力2PC两阶段提交是最经典强一致方案思路非常直白引入一个协调者Coordinator所有参与者先执行事务、但不提交进入“待命”状态协调者收到所有人的“准备好了”之后再广播“提交”只要有一方说“不行”就广播“回滚”。流程就两步准备阶段和提交阶段。听上去很完美实际跑一遍就发现问题了。准备阶段要持有所有参与者的资源锁比如数据库的行锁、表锁。如果某个参与者执行特别慢其他参与者都得跟着等吞吐量直接降低。如果协调者在第二阶段宕机了所有参与者都处于“事务状态未知”的锁定状态既不能提交也不能回滚直到协调者恢复。这个窗口期非常难受。网络分区时参与者收到了提交指令但协调者失联无从确认状态。所以2PC在实际业务项目中其实用得不多尤其是在大数据集群这种高并发的环境里。它更多出现在单机数据库的XA协议中或者某些对强一致要求极高、并发量不大的内部系统里。如果你要在一个订单系统和库存系统之间做2PC光维护协调者就够你头疼的。2.2 3PC把“两阶段”拆成“三阶段”没那么简单3PC三阶段提交是对2PC的改良主要加了两个东西超时机制和准备提交阶段。它在2PC的基础上多了一步“CanCommit能否提交”的询问并在第二阶段增加了超时自动提交。理论上3PC能减少协调者单点故障时的僵持时间。但实战中3PC也很少单独出现。原因在于它虽然降低了阻塞风险但引入了新的不一致场景。比如参与者超时后自动提交了而其他参与者回滚了数据照样不一致。3PC更像一个理论模型给你的启发是——分布式事务方案必须考虑超时后的行为不能让参与者无限期等待。2.3 TCC业务级补偿把“回滚”写进业务代码TCC本质上还是2PC的变种但它把“事务”从数据库层面提升到了业务层面。每个参与者需要实现三个方法Try预留资源比如扣库存时先冻结一部分库存量而不是直接扣掉。Confirm确认执行把冻结的库存真正扣减。Cancel取消执行把冻结的库存释放。TCC最大的优点是把资源锁粒度控制在“预留”阶段减少了数据库锁的持有时间并发性能比2PC好很多。但它也是最“麻烦”的方案因为它要求业务系统为每个操作都实现Try/Confirm/Cancel三套逻辑代码量翻倍且要处理的边界情况非常多。举个我真实做过的例子订单系统调用库存系统扣减库存用TCC实现时Try阶段在库存表里新增一条“冻结流水”库存字段不变冻结字段1。Confirm阶段把冻结流水变成实际扣减库存字段-1冻结字段-1。Cancel阶段直接把冻结流水作废冻结字段-1。这个设计能避免两个问题一是Try成功但Confirm失败时冻结的库存不会丢二是Cancel时不会误扣真正的库存。听起来简单但真正的坑在防悬挂、防幂等、防重复提交上后面我会专门展开。2.4 本地消息表最朴素也最管用的最终一致方案本地消息表是很多老系统最常用的做法思路陌生但特别接地气。你在业务系统里建一张local_message表业务操作和消息写入放在同一个本地事务里提交后由后台任务把消息发给MQ或下游系统下游成功处理后回调确认本地消息表就删除这条记录。这个方案的巧妙之处在于它把“跨数据库的事务”转换成了“本地数据库事务异步消息”利用了数据库ACID来保证业务和消息的一致性。具体流程是这样订单服务在自己的库中开启事务插入订单数据同时插入一条状态为“待发送”的消息记录一起提交。后台定时任务扫描local_message表中“待发送”的消息调用库存服务提供的扣减接口。库存服务扣减成功后调用订单服务的消息确认接口把消息状态改为“已发送”。如果消息发送失败或确认超时定时任务会不断重试直到成功。本地消息表在工程实现上最容易理解而且不依赖任何MQ的高级特性。但它也有明显短板业务库和消息表耦合在一起对于高并发写入的表会产生额外IO压力且重试逻辑如果没做好可能造成接口幂等性问题导致库存重复扣减。还有一个很现实的问题如果业务系统用的是不同的数据库类型比如一个MySQL一个PostgreSQL你没法把它们拉进同一个本地事务里。2.5 事务消息RocketMQ那种“半消息”思路如果你嫌本地消息表侵入性强事务消息是不错的选择。RocketMQ的事务消息机制本质上是从“让业务库托底”改为“让MQ托底”。流程大致是生产者向MQ发送一条“半消息”half message此时消费者不可见。本地事务执行。本地事务成功则向MQ发送commit让消费者可见失败则发送rollbackMQ丢弃半消息。如果MQ迟迟没收到commit/rollback会往回查询生产者让生产者检查本地事务状态再决定提交还是回滚。这个“回查”机制是整个事务消息的灵魂。它保证了只要业务本地事务提交了消息就一定能发给消费者本地事务没提交消息就永远不可见。事务消息相比本地消息表的优势是不需要额外建表业务代码更干净事务跨度和消息生命周期都由MQ管理。但前提是你得把MQ换成RocketMQ或支持事务消息的中间件并接受“回查”机制带来的额外实现成本。另外RocketMQ的并发事务量很大时半消息表本身也会成为压力点这个在集群部署时要注意。2.6 SAGA长事务的“反向操作”大师SAGA是一种“无协调者”的最终一致方案核心思想是把一个长事务拆成一串本地事务每个本地事务都有对应的补偿事务。执行顺序是正向执行某一步失败了就反向执行之前所有步骤的补偿操作。SAGA特别适合那种步骤很多、跨系统很多、耗时很长的业务流程比如订单创建→支付→出库→配送。每一步都是独立的本地事务提交即可释放资源不需要长时间持有锁这在微服务环境下是很大的优势。但它也有一个让新手误会的地方SAGA补偿不一定能“恢复原样”。比如订单创建后发了优惠券反向补偿时只能“作废优惠券”而不是“把券变成未发放状态”再比如已经发了短信通知补偿没法撤回短信。所以SAGA适合处理那些“可逆痕迹”明确的业务不适合资金流水、账务调整这类必须精确回退的强一致场景。3. 实操过程与核心环节实现3.1 先搭一个真实的业务场景订单与库存分布式事务整个对比如果只停留在概念看完就忘。我拿一个最常见的场景来讲用户下单扣减库存。订单服务Order和库存服务Stock是独立部署的微服务各自用独立的MySQL数据库。同一个操作里既要插入订单表又要扣减库存表天然就是分布式事务。我把这个场景拆成几个实现步骤分别套用到不同方案里去你看完就知道每种方案的代码量、复杂度和适用边界。3.2 用TCC实现订单库存的“资源预留”如果你是第一次上TCC先别急着写Confirm和Cancel把字段设计好是关键。我给库存服务设计一张stock_frozen表专门记录冻结库存CREATE TABLE stock_frozen ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id VARCHAR(64) NOT NULL COMMENT 订单号, product_id VARCHAR(64) NOT NULL, frozen_qty INT NOT NULL COMMENT 冻结数量, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待确认 1已扣减 2已释放, create_time DATETIME NOT NULL );Try阶段的逻辑是在stock_frozen表插入一条状态为“待确认”的记录同时用UPDATE stock SET available_qty available_qty - #{qty} WHERE product_id #{productId} AND available_qty #{qty}来预留可用库存。这里一定要用带条件的UPDATE靠数据库行锁保证不超卖。Confirm阶段把stock_frozen里对应记录的状态改成“已扣减”同时执行UPDATE stock SET sold_qty sold_qty #{qty}把预扣变成实扣。这里不需要再改库存总量因为Try阶段已经减掉了。Cancel阶段把记录状态改成“已释放”并执行UPDATE stock SET available_qty available_qty #{qty}把预留的库存加回去。这段逻辑写起来不复杂但调试时你会发现真正的bug往往出在“重复调用”上。比如网络超时导致订单服务重试了Try那库存就被冻了两笔。所以TCC的每个方法都必须是幂等的要么靠唯一索引、要么靠状态判断。我在项目里是给stock_frozen表加了order_id唯一索引并在Try和Cancel方法里用INSERT ... ON DUPLICATE KEY UPDATE的方式防重。3.3 用本地消息表实现“最终一致”如果你不想写TCC的三套方法本地消息表能省不少心。我把订单服务里的表设计成这样CREATE TABLE local_message ( id BIGINT PRIMARY KEY AUTO_INCREMENT, msg_key VARCHAR(128) NOT NULL UNIQUE COMMENT 业务幂等键, msg_body TEXT NOT NULL COMMENT 待发送的消息体, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待发送 1已发送 2已消费, retry_count INT NOT NULL DEFAULT 0, next_retry_time DATETIME NOT NULL, create_time DATETIME NOT NULL );订单事务里同时插入订单记录和local_message记录在同一个数据库事务内提交。之后有个后台任务每隔几秒扫描状态为“待发送”的记录调用库存服务的扣减接口。调用成功返回后把消息状态置为“已发送”。为了防止某个消息一直重试失败我会加retry_count和next_retry_time超过重试次数就报警人工介入。这方案的优点是很直观缺点我刚才也提了它把“消息发送”这个异步行为绑定在了业务库上订单表一旦数据量大local_message表的扫描成本会拖累整体性能。所以我会建议给local_message表单独做历史归档或者定期清理状态为“已发送”的过期记录。3.4 用事务消息替换本地消息表的步骤如果项目已经把RocketMQ引入进来了换事务消息会舒服很多。核心代码逻辑如下TransactionMQProducer producer new TransactionMQProducer(order-producer); producer.setTransactionListener(new TransactionListener() { Override public LocalTransactionState executeLocalTransaction(Message msg, Object arg) { // 执行本地事务插入订单 调用库存或发扣减命令 boolean success orderService.createOrder((OrderReq) arg); return success ? LocalTransactionState.COMMIT_MESSAGE : LocalTransactionState.ROLLBACK_MESSAGE; } Override public LocalTransactionState checkLocalTransaction(MessageExt msg) { // MQ回查检查本地事务是否已提交 return orderService.isOrderCreated(msg.getKeys()) ? LocalTransactionState.COMMIT_MESSAGE : LocalTransactionState.ROLLBACK_MESSAGE; } });注意executeLocalTransaction里的本地事务和消息发送是“先发半消息、再执行本地事务”不是“先执行本地事务、再发消息”。顺序反了会导致本地事务成功了但半消息没发出去下游永远收不到扣减指令。这一点在代码里要严格保障。RocketMQ回查机制的实现其实还藏着一个细节回查不是即时发生的默认在消息到达“半消息状态”的一段时间后才触发。所以如果下游对订单创建的实时性要求特别高事务消息未必能满足。比如用户下单后希望库存页面立刻刷新几百毫秒的延迟可能都不可接受。3.5 在大数据集群里的特殊注意事项网约车大数据项目里经常需要做用户日志清洗、订单聚合分析这时候其实不太会遇到“订单与库存这种跨库强事务”更多是数据同步、批量归集、结果落库的一致性问题。比如用Spark清洗完一批网约车订单数据写入结果表时如果写入失败怎么保证不丢失、不重复这类大数据场景推荐的方案往往不是TCC而是离线批处理幂等写入。比如用Hive或Spark生成结果数据时给每条数据带上批次ID写入时用INSERT OVERWRITE或者“先删后插”的方式保证同一批次内幂等。这里没有任何分布式事务协调者靠的是大数据框架自身的容错和重跑机制。所以当你在“大数据分布式事务”这个话题下检索时要分清楚两个层面一个是微服务之间的在线事务一个是大数据集群里ETL批处理的一致性。后者一般用“幂等重跑分区覆盖”来解决问题不要硬套TCC否则你会被复杂的协调逻辑折腾疯。4. 常见问题与排查技巧实录4.1 流水线上的“重试地狱”接口幂等怎么做分布式事务里最容易翻车的不是技术选型而是幂等。不管用哪种方案消息可能重复投递、接口可能重复调用。如果扣减库存的接口不是幂等的同一张订单被消费两次库存就扣多了。我的经验是在进入业务逻辑之前先做一个“处理记录”表来防重。比如库存服务收到扣减请求时先查stock_operation表如果订单号已经存在直接返回成功不再执行扣减。CREATE TABLE stock_operation ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id VARCHAR(64) NOT NULL UNIQUE, status TINYINT NOT NULL, create_time DATETIME NOT NULL );插入时用INSERT ... ON DUPLICATE KEY UPDATE或者先查后插只有真正插入成功的那一次才继续执行扣减。这个表就是整个接口的“防重复门卫”。4.2 消息一直重试失败别急着堆机器状态为“待发送”的消息如果重试了几十次都失败很多人第一反应是调大重试间隔、加线程并行。但真正原因往往不是下游服务挂掉了而是消息体里的数据格式对不上。比如订单服务发过来的product_id为空、库存表里根本没有这个商品。重试多少次都没有意义只会把错误日志刷屏。正确的排查思路是先看消息体的业务字段再联系下游确认数据。我建议在重试超过5次后触发“死信机制”把消息转到单独的死信队列或者单独的表里隔一段时间人工review而不是无脑重试。无脑重试除了制造数据库压力还会掩盖真正的数据质量问题。4.3 本地消息表会不会和业务表数据量一起膨胀这是个很现实的问题。订单量大的时候local_message表的膨胀速度惊人如果只insert不清理一个月就能破千万行。扫描任务也会随着表变大越来越慢。我的做法是给local_message表加一个“业务日期”的归档策略。每天凌晨把状态为“已发送”且创建时间在3天前的记录搬到历史库运营报表需要的统计结果直接查历史库避免每天扫描大表。同时给next_retry_time建索引扫描时按next_retry_time NOW()筛不要把“已发送”的历史记录也捞出来。4.4 冻结库存好几天不释放怎么办TCC方案最常见的故障是Try阶段成功了冻结了库存但后续的Confirm或者Cancel一直没到或者协调者宕机导致没人推进。用户看着有库存但就是下单失败。这个问题单纯靠事务中间件解决不了需要业务侧做“超时巡检”。我设计了一个定时任务扫描stock_frozen里创建时间超过30分钟且状态仍为“待确认”的记录主动调用订单服务查询订单状态。如果订单已支付就补执行Confirm如果订单已取消或者不存在就执行Cancel。这个“巡检补偿”虽然代码写起来有点土却是我见过最稳妥的兜底方案。提示任何分布式事务方案都请设计一个“对账补偿”兜底。协调者、消息队列、网络都可能出错最终一定有一个后台任务来纠正那些“状态卡住”的历史数据。4.5 CAP视角下的真实取舍一致性还是可用性网约有段时间对订单状态做了调整支付环节偏重强一致查询和展示环节偏重最终一致。因为支付失败用户会立刻跳起来但订单列表晚几秒出现“已发货”状态用户基本感知不到。这个取舍在CAP视角下很清晰支付链路选择CP牺牲一部分可用性也要保证一致查询链路选择AP保证用户随时可查允许短暂不一致。不要把整个系统一刀切成CP或AP而是按接口粒度去切。有些团队为了“技术统一”硬把所有接口都设计成强一致最后系统直接被拖垮反过来全部做最终一致资金对账时又漏洞百出。这是我在多个项目里反复验证过的教训。5. 方案对比与选型建议5.1 一张表看懂六大方案的分水岭方案一致性类型协调者侵入性性能损耗适用场景2PC强一致有高资源锁高传统XA、金融转账3PC强一致有超时改进高中高理论居多少数对超时敏感的系统TCC强一致业务补偿实现有很高每操作三方法中订单扣库存、资金账户操作本地消息表最终一致无中额外建表/扫描低内部系统、跨库操作少事务消息最终一致无中依赖MQ低微服务异步解耦、高并发订单SAGA最终一致无中补偿动作低长流程、多步骤、跨系统大事务这张表我一直贴在工位上。选型时先看一致性和侵入性再看性能别把“性能”放第一位。因为性能问题通常可以通过扩容、异步化解决但方案侵入性一旦选错改造成本就非常高了。5.2 我的选型心法三步走第一步判断业务是否允许短暂的不一致。允许进最终一致分支不允许进强一致分支。第二步强一致分支里如果跨系统数量少于3个、并发不高优先TCC如果安全等级极高且团队驾驭能力一般谨慎用2PC。最终一致分支里如果系统已经成套使用RocketMQ优先事务消息如果不想引入新中间件本地消息表也够用。第三步无论选哪个方案都先想清楚补偿逻辑和幂等策略。没有补偿兜底的分布式事务就像一个没有安全绳的杂技演员平时看着灵活一出事就是大事。5.3 大数据场景的另类“事务”批次幂等如果你做的是大数据集群里的数据同步、离线计算、报表生成那上面这些方案经常显得“杀鸡用牛刀”。大数据场景下更常见的是“批处理失败后重跑”所以核心不是分布式事务而是保证同批次数据重复写入不影响结果。比如用Hive跑完一张聚合表写入目标分区时先删除该分区再写入或者用“覆写”语义用Spark实时写入Kafka后由Flink消费消费端做去重。这些手段都不是传统意义上的“事务”但配合重试、幂等最终一致性完全够用。很多网约车数据项目、电商数仓项目用的就是这个思路而不是去折腾TCC或2PC。6. 一点实操体会我个人在实际项目里最常用的方案其实是本地消息表和事务消息而非TCC。原因很实在真正需要强一致的业务一个项目里可能就两三个接口为了它们引入一套复杂的TCC框架团队每个新人都要花几周学习运维还要盯着协调者状态性价比太低了。反过来你把那两三个真正要强一致的口子用TCC单独处理其他所有接口都靠消息最终一致系统反而更稳。还有一个小技巧不管用哪种方案都要在链路里埋上唯一的业务流水号。订单号、支付流水号、库存冻结号这些号不仅是为了查询方便更是为了在排查分布式事务问题时能沿着一条完整链路串起所有服务的日志。我见过太多团队方案选得挺好结果排查问题时因为缺了流水号不得不几台机器翻日志手工拼接非常痛苦。最后再提醒一句别迷信单一方案。没有一个分布式事务框架是银弹真实系统往往是多个方案共存的核心资金链路用TCC订单创建用事务消息报表统计用幂等重跑各取所长。想清楚每个方案的边界才是CAP定理真正教会我的东西。
返回列表