资讯动态

分布式事务实战:Saga+本地消息表如何解决跨服务数据一致性

发布时间:2026/10/23 3:23:59 来源:尧图企业网站定制
每次说到分布式事务后台总有人问我直接在代码里加一个数据库事务不就行了吗这种问题在没经历过分布式改造之前特别常见。我接手joyAI项目时就吃过这个亏把原本放在同一个单体应用里的一段业务逻辑拆到三个服务、两个数据库之后一切都从理所应当变成了暗雷密布。用户调用AI能力时任务状态、计费扣费、结果落库如果不同步成功就会出现钱扣了但任务没执行或者任务执行了却没扣到钱的情况。今天就把我在joyAI上解决分布式事务一致性的完整过程写出来包括为什么ACID在跨服务、跨数据库场景下帮不上忙以及最终怎么用Saga加本地消息表这套组合把系统救回来。1. 从joyAI项目场景说起为什么跨服务、跨数据库必须面对一致性1.1 joyAI项目的业务链路拆解joyAI是一个AI能力开放平台并不是什么复杂的大型系统但业务链路天然是分布式的。用户从前端提交一个任务比如把一段语音转成文字或者用一张图片生成一段描述这个请求进来之后并不是一个服务把所有事情干完而是经过API网关分流到任务服务任务服务负责创建任务记录、调度模型推理计费服务负责按调用次数或时长扣减用户的余额最后模型推理结果还要落到结果存储库里。整个过程涉及任务服务、计费服务、推理服务三个独立进程背后是任务库、用户账户库两个数据库如果再加上缓存和消息中间件一次用户请求要跨的边界就更多了。在拆分成微服务之前joyAI是一套单体应用所有表和业务逻辑都在同一个数据库里扣钱和更新任务状态只需要一个数据库事务要么一起成功要么一起回滚不会有任何中间状态暴露给用户。但问题也随之而来模型推理功能被单独拆出去之后用户余额和任务记录都放在自己的数据库里单体事务就没法用了。这时候我才真正理解分布式系统里最难的往往不是并发高而是事情做到一半某个服务挂了该怎么把已经做的事和没做的事对齐。1.2 本地事务与分布式事务的距离差本地事务之所以让人觉得可靠是因为它把所有操作收敛到一个数据库连接上。ACID这四件事——原子性、一致性、隔离性、持久性——由数据库引擎在本地完成开发者不需要关心事务日志怎么写、锁怎么加、隔离级别怎么选只要告诉数据库BEGIN和COMMIT就可以了。分布式事务完全不是这个逻辑。一次跨服务的业务操作等于要协调多个数据库连接、多个进程甚至多个物理资源。joyAI里的提交任务这个操作至少要同时改变任务库里的状态记录和账户库里的余额记录这两条记录分布在两个独立的数据库里任何一个数据库都无法感知另一个数据库是否提交成功。这就像两个人约好在两个房间分别写下同一个约定的后半句没人能保证两个人同时落笔、同时完成、同时擦掉只要一个人先写、另一个人后写外部看到的就是一个对不上的中间状态。这里有个很关键的点需要讲清楚跨服务、跨数据库场景下ACID不是被禁用了而是原本由数据库集中保证的约束被网络和进程边界打破了。原子性需要靠应用层来编排协调一致性需要靠业务规则来兜底隔离性几乎无法保证并发事务互相不干扰持久性虽然每个库各自有但跨库崩溃后就没人能做到要么都持久要么都不持久。这就是分布式事务一致性问题最核心的挑战来源。2. ACID在分布式环境下的三个失效点核心挑战的本质2.1 原子性无法跨越网络边界原子性的本质是要么全做要么全不做。在单库环境里数据库通过事务日志和回滚段保证这一点。但在分布式环境里一次业务操作的所有子步骤分布在多个网络上每个子步骤各自有独立事务。假设joyAI的一个任务流程包含创建任务记录、扣减用户余额、调用推理服务、存储结果。前两步用数据库事务各自提交了第三步才调用推理服务结果推理服务超时这时任务库里已经有一条待处理的任务记录账户库里也已经扣了钱后续所有步骤都没执行。从业务视角来看用户既没有获得结果也不能马上知道钱被扣了这个状态就是原子性被破坏的结果。更麻烦的是你无法用单个数据库的回滚命令把已经提交到两个库的数据一起撤销。要做到真正意义上的原子必须引入一个全局协调者让每个参与者都预留资源、确认准备完毕之后再统一提交或者反过来设计一套补偿机制把所有已经完成的操作反向执行一遍。分布式事务的方案绕不开这两个方向这也解释了为什么会有后面的2PC和Saga。2.2 隔离级别与锁的全局协调难题隔离性是ACID里最容易被忽略、但在分布式环境中最容易出事的特性。本地数据库里事务隔离级别决定了事务之间怎么防干扰比如READ COMMITTED只允许读已提交数据REPEATABLE READ会对读到的记录加锁防止其他事务修改。这些机制都建立在同一套数据库锁表的基础上。把同样的需求放到分布式环境里问题就来了用户的余额记录在账户库任务记录在任务库两个库各自的锁和隔离级别是完全独立的。服务A在账户库里扣款时锁住了余额记录服务B在任务库里更新状态它并不知道服务A的事务到底提交了没有。如果服务A扣款后进程崩溃服务B已经根据扣款成功的消息去调用了推理服务最后账户库回滚服务B却已经执行了不可逆的外呼动作系统就产生了脏数据。在joyAI的账单场景里我踩过一次并发扣费的坑同一个用户在短时间提交了多个任务两个并发请求同时在账户库执行余额扣减由于账户库的隔离级别用的是默认的READ COMMITTED两个事务都读到同一个余额值各自扣费后写入导致最终只扣了一份钱另一份被覆盖。这个问题在单体应用里可以通过数据库行锁轻松避免但在分布式事务里想用全局锁去协调多个库的并发操作代价和复杂度都不是普通团队能承受的。2.3 一致性模型从强到最终选择即妥协既然ACID在分布式环境里全面失效那是不是只能放弃一致性不是而是要把一致性这个目标的粒度拆分。分布式系统里存在一个基本事实跨网络的操作永远存在延迟和失败的可能你无法同时保证强一致性、高可用和低延迟。实际上在做架构决策时必须接受某种程度的妥协。joyAI最初就是死磕强一致性的典型。当时我们的设想是所有服务操作都通过某种分布式事务中间件来保证要么全部成功、要么全部失败。但设计到一半就发现模型推理这个环节根本没法参与类似2PC这种需要准备-提交的协议因为你没法让一个GPU推理进程本地锁定某个资源说我准备好了然后等全局协调者发来提交指令推理是长时间计算过程中根本没有办法对外暴露可回滚的中间状态。于是我们接受了最终一致性这个模型数据在某一刻可能处于中间状态但系统保证在没有任何失败注入的情况下经过一段时间内部修正后数据会达到一致状态。这个选择即妥协的过程是整个joyAI分布式事务改造的决策基石后面所有具体的方案选择本质上都是在围绕这个妥协做文章。3. 主流方案横向对比2PC、TCC、Saga、消息表谁适合joyAI3.1 四个方案的核心思想对比业界解决分布式事务的主流方案我已经全部梳理过一遍并且都写过一个小的验证Demo把它们的核心思想放在一张表里可以看得非常清楚方案核心思想适用场景主要缺点2PC两阶段提交全局协调者向所有参与者发Prepare全部成功后发Commit短事务、资源可锁定、参与节点少阻塞时间长协调者单点故障恢复复杂TCCTry/Confirm/Cancel每个业务操作拆成预留、确认、取消三个动作需要强资源预留、业务可裁剪的场景侵入性强每个接口都要实现三套逻辑Saga通过补偿事务来回滚已执行步骤长时间业务流、不可锁定资源隔离性弱缺少实时回滚需自行处理中间状态本地消息表业务操作和消息记录放在同一个本地事务异步投递数据最终一致、消息驱动场景业务和消息耦合消息表可能成为瓶颈这里我要特别说明一下表格里写的缺点是相对理想场景来说的没有绝对的好与坏。2PC适合强一致要求非常高的金融级短事务TCC适合库存扣减这类需要预先占坑的业务Saga适合跨多个长任务的服务编排本地消息表适合异步化程度高、接受最终一致性的互联网场景。joyAI的任务流从创建到推理完成往往要几十秒甚至几分钟这决定了它跟2PC这类强同步方案在基因上就不匹配。3.2 为什么我放弃了纯2PC2PC的字面意思是全局事务被拆成准备阶段和提交阶段所有参与者先准备好然后统一提交。看起来无懈可击但落地时有一堵墙翻不过去资源锁定时间。参与2PC的服务必须在Prepare阶段把所有相关资源锁住等待全局协调者确认所有参与者都准备好了再发Commit指令。一旦某个参与者长时间没有返回其他所有参与者的资源就全部被锁住整个系统会从等待最慢的一个节点慢慢演变成所有人都等不下去。joyAI的系统里有一个高延迟环节就是模型推理。推理服务本身是一个独立的计算集群每次推理调用可能需要几秒到几十秒它没有办法先锁定资源再等待提交指令因为GPU资源的使用和释放不在应用层简单的数据库事务控制范围内。我试过强行让推理服务参与2PC结果是把推理过程的超时重试搞得极其复杂得不偿失。另外2PC的协调者自身也是一个单点协调者崩溃后整个全局事务的状态恢复非常麻烦这对一个面向高并发的AI开放平台来说是不可接受的。3.3 最终选型Saga 本地消息表的混合路线在放弃2PC之后我认真比较了TCC和Saga。TCC要求每个业务操作都实现Try预留、Confirm提交、Cancel取消三套逻辑以充值扣费为例要做冻结余额和实际扣减两个状态代码复杂度比较高。boost项目的团队后来评估发现很多操作天然无法预留比如任务创建本身就是新增一条数据没有预留这个前置概念所以TCC的侵入性超出了我们愿意承受的界限。最后我们选型的组合是Saga加本地消息表各自承担不同的职责。Saga负责编排整个任务流的正向执行和异常补偿解决跨服务跨数据库的原子性问题本地消息表负责保障Saga执行过程中消息投递的可靠性解决异步通知会丢的问题。前者解决回滚后者解决传递两者配合起来才能覆盖完整的分布式事务场景。选这个组合还有一个原因是可以逐步演进先不引入任何外部组件用数据库表加定时任务就能跑起来后面流量大了再逐步替换成更成熟的消息中间件对上线节奏非常友好。4. joyAI核心代码落地状态机、补偿动作与消息表的实现4.1 Saga状态机的定义与状态流转Saga落地时最核心的不是怎么调接口而是怎么管理每一步的状态。我第一步就是为任务流设计一个显式的状态机。不要觉得状态机是多余的设计实际写分布式事务代码时没有状态机你没法回答任务执行到第三步失败了第三步对应的补偿动作是什么这个问题。joyAI的状态机定义如下TASK_STATES { INIT: 初始创建, TASK_CREATED: 任务已创建, BILLING_CHARGED: 已扣费, INFERENCE_RUNNING: 推理执行中, RESULT_SAVED: 结果已保存, COMPENSATE_BILLING: 补偿扣费, COMPENSATE_TASK: 补偿任务, SUCCEEDED: 成功, FAILED: 失败, }每个正向执行步骤都对应一个状态每个状态又对应着一个可能的补偿动作。比如从TASK_CREATED到BILLING_CHARGED之间如果计费服务调用失败状态就会迁移到COMPENSATE_TASK补偿动作是删除已创建的任务记录。这一步的逻辑很简单Saga编排器只是负责记录状态和按规则触发补偿并不关心每个服务内部的细节这样后续增加新服务时只需要扩展状态机和补偿映射表不需要改动整体框架。4.2 补偿动作的编写规范Saga模式最大的隐含成本是补偿逻辑必须单独写。很多人误以为补偿就是把接口参数换个方向重调一遍我在这上面交过学费。以缓存为例创建任务时往Redis里写了一条热点数据补偿时你不能简单调一个DELETE接口因为删除前要确认该数据是否已经被其他正常流程更新过否则会把别的请求刚写入的数据删掉导致更严重的脏数据问题。我最终总结了一份补偿动作的编写规范在joyAI项目里长期执行每个正向动作都必须配套一个补偿动作且补偿动作必须能被重复执行也就是补偿操作本身要幂等。补偿动作不再触发新的业务副作用比如只更新任务状态和退款不再调用外部的模型服务。补偿动作要记录执行时间、触发原因、操作人如果是人工介入等审计信息方便事后排查。正向操作完成后要标记已补偿标志位防止同一事务被多次补偿。比如joyAI里的扣费补偿是这样写的def compensate_billing(task_id, user_id, amount): # 补偿必须幂等先检查账户流水是否已经回滚 if billing_log_exists(task_id, actioncharge_refunded): return refund_balance(user_id, amount) record_billing_log(task_id, actioncharge_refunded, amountamount)代码里没有复杂的脑洞真正重要的是幂等检查和审计日志这两行。分布式环境里的补偿动作同样可能执行多次每多执行一次就要想清楚会不会多退款、多减库存。4.3 本地消息表的消息投递与重试机制本地消息表的核心思路是让业务操作和消息发送成为一个本地原子操作。以任务创建并通知推理服务为例如果直接先写业务表再发送MQ消息业务表提交成功、MQ发送失败时数据就断了。本地消息表方案会把写入消息记录和更新业务数据放在同一个数据库事务里提交后消息就一定会存在只是投递出去的动作由后台异步任务负责。我在joyAI里的具体实现分为三步第一步在同一个数据库事务里插入业务数据和一条消息记录消息状态为PENDING第二步后台定时任务扫描PENDING状态的消息逐条投递到消息中间件第三步消息消费成功后把本地消息记录状态改为DELIVERED。如果投递失败消息状态保持PENDING下一次扫描会重新投递直到超过最大重试次数后进入人工告警池。def create_task_and_publish(user_id, task_type): with db.transaction(): task_id insert_task_record(user_id, task_type) insert_message_record(msg_iduuid4(), task_idtask_id, statusPENDING) publish_message_with_retry(msg_id, task_id)插入任务记录和消息记录放在同一个事务里就意味着这两件事不会出现一个有、一个没有的情况。这里有个细节之前很多文章不会提本地消息表方案本质是把消息的可靠性责任从消息中间件转移到了业务数据库所以消息记录表的索引、字段设计要当成核心业务表来看待不能随便建个表就不管了否则定时任务一扫描慢SQL整个系统的吞吐量都会被拖垮。4.4 幂等性设计重复消息也要正确本地消息表把消息不会丢解决了但没解决消息会不会重复的问题。后台扫描任务投递消息时如果投递后进程在更新消息状态前崩溃重启后这条消息会被再次投递消费端就会接到两条一模一样的业务消息。要想让分布式事务真正一致消费端必须幂等。joyAI的消息消费流程里我把幂等设计成了一个统一的中间件层。每个消息都携带唯一的消息ID和事务ID消费时先往消费记录表里插入一条消息ID记录如果插入失败主键冲突直接返回成功表示这条消息已经处理过了。这样做的目的是把幂等校验放在业务逻辑之前业务代码本身不用关心是否会被重复调用。def consume_message(msg_id, task_id): # INSERT IGNORE 利用唯一键做幂等处理过则直接返回 insert_result insert_consume_log(msg_id) if not insert_result: return process_business_logic(task_id)这里还要提醒一下幂等设计不只针对消费端Saga里的正向服务接口也应该幂等。否则补偿与正向操作同时触发可能出现同一个性能测试用的任务被创建了两条记录结果对账时完全查不出问题但真实用户看到重复订单的情况。好消息是一旦把每个接口都设计成幂等的分布式事务很多看似诡异的问题都会自动消失。5. 实测中踩到的坑重复消费、超时补偿与死信堆积5.1 问题一补偿动作与正常流程竞争同一份数据第一次灰度上线时我们遇到了一个让人困惑的现象部分用户的任务明明最终是失败的但账户里的钱却被扣了两遍。排查日志发现Saga编排器在执行扣费补偿时之前定时扫描里已经投递出去的一个任务创建成功消息也恰好到达服务端正常流程把任务状态改成了执行中补偿流程又想把状态改回失败两个操作交叉覆盖最后业务数据变成了一种既失败又成功的矛盾状态。根因是补偿动作和正向动作共用了同一个服务接口没有隔离它们操作的数据段。解决办法是我把补偿接口和正向接口的状态字段分开管理补偿接口只允许修改状态机的补偿区域比如仅把状态从扣费成功改成取消扣费不允许去触碰任务创建推理执行这类业务区域。然后在Saga编排器里为每个事务增加一个版本号补偿触发时对比版本号只有版本号匹配时才允许执行避免新旧消息互相覆盖。5.2 问题二全局事务ID在跨服务调用中丢失分布式事务的排查链路有多难可能只有经历过的人才懂。某次压测时任务服务把任务状态更新为失败但计费服务里没有任何对应的退款流水两边日志都对不上。就这个问题开发人员查了一个下午最后发现是任务服务调用计费服务时只传了业务参数没有传递全局事务ID导致计费服务这边产生了两个孤立的事务记录一个属于正常流程一个属于补偿过程分析和清洗时根本没法把它们关联成同一条链路。从那以后我在joyAI里立了一条规矩所有服务间的RPC调用和MQ消息头部必须包含全局事务ID、源服务名、调用时间任何一个服务在写日志或操作数据库时都必须把全局事务ID落到业务表字段或日志上下文里。这个改动不复杂但排查效率提升了不止一个量级。现在只要发现数据异常直接按全局事务ID在日志平台里检索一次整个调用链路的每一步就全出来了。5.3 问题三本地消息表的分库困境joyAI初期流量小本地消息表跟着业务表放在同一个库里完全没问题但业务量上来之后任务库和账户库必须分开部署问题就来了本地消息表到底跟着哪个业务库走如果放在任务库里计费服务的消息就写不了如果单独建一个消息库写业务数据和写消息表又变成了跨库事务本地消息表方案的灵魂就丢了。我当时的处理方式是做了妥协把消息表拆分成了任务消息表和账户消息表每个表只负责自己所在库的事务。任务服务和计费服务在各自的数据库本地事务里写对应的消息后续通过一个统一的消息中间件把这两类消息汇聚起来交给下游消费。这种方案在设计上不算最优但在没有引入更强基础设施的前提下它保住了本地事务这个核心约束也避免了消息表成为跨库瓶颈。等到后面系统彻底切换到成熟的消息中间件后本地消息表会逐步淡出。5.4 排查链路完整复盘写到这里还是想完整复盘一次我们线上实际处理过的故障因为排查思路比最终结论更有参考价值。现象是很明确的用户反馈充值后立刻调用AI任务成功但当天账单里看到的余额和消费记录对不上明显少了一笔账单记录。我在看的时候第一步先不看代码而是直接查Saga状态机和消息表的落库状态。发现任务状态是SUCCEEDED但账户库里的账单流水表里没有对应扣费记录。第二步查消息表发现扣费成功的消息状态是DELIVERED但计费服务中有一串死信堆积原因是计费服务在处理该消息时抛了一个数据格式异常消息消费失败后不断重试达到上限后进了死信队列。第三步查异常发现异常是计费服务读取用户账户余额时缓存中没有该用户数据触发了空指针异常代码没有兜底逻辑就直接把消息处理流程中断了。最后我修复了三处缓存缺失时回源数据库读取消费消息捕获所有业务异常后进入一对一的补偿队列而不是直接死信对计费服务里所有涉及账户余额读取的代码统一封装了空值保护。这次排查给我最大的经验是分布式事务问题不能只看消息中间件的消费状态必须把状态机表、消息表、死信队列三者放到一起去对照任何一个环节脱节都要先从链路数据上找断点而不是靠看日志猜。6. 从一致性看更高维度什么才是分布式事务的正确目标6.1 强一致性 vs 业务对账以joyAI的账单场景为例很多人以为用了Saga和本地消息表之后分布式事务问题就彻底解决了这是一个很大的误解。这些方案能保证的是最终一致但最终一致不等于完全不会出错比如程序bug、人为误操作、第三方接口异常恢复逻辑不完善都会让数据永远停在不一致的状态。所以还需要一套兜底机制这就是业务对账。我在joyAI里跑了一个每天都执行的对账任务核心逻辑非常简单对比任务库里已成功任务的总数和账户库里扣费流水的总数发现不一致时生成异常清单。对账SQL类似这样SELECT t.task_id, t.status, b.billing_id, b.user_id FROM task_table t LEFT JOIN billing_table b ON t.task_id b.task_id WHERE t.status SUCCEEDED AND b.billing_id IS NULL;对账任务不追求实时每天凌晨跑一遍就行但它的价值在于兜底。分布式事务方案解决的是正常情况下的一致对账解决的是异常情况下的一致。也可以说对账是分布式事务的最后一道保险没有了它所有方案都是在赌系统永远不出错这显然是不现实的。6.2 一致性治理的长期手段从joyAI项目的数据一致性建设过程来看真正成熟的团队不会指望某一种事务模式解决一切而是会建立一套持续治理的体系。我目前保留的手段可以概括成三条第一条是可观测性所有服务调用必须带全局事务ID所有事务状态必须落库所有补偿动作必须留审计日志没有这些提出问题都无从谈起。第二条是自动报警消息表里滞留超过5分钟的PENDING消息、补偿动作执行超过3次的任务、死信队列里新增的消息全部接入告警系统宁可多报不可漏报。第三条是定期演练每个季度在模拟环境里人为制造一次计费服务宕机观察Saga编排器是否能在指定时间内完成补偿这个演练每次都能发现新问题比如某个补偿接口的响应超时阈值设置得不合理。如果让我给同行一个最实在的建议就是不要一上来就追求最强分布式事务方案而是先想清楚自己业务能接受的最终一致时间窗口是几秒、还是几分钟再决定选型。对joyAI这种AI开放平台来说用户更关心的是任务能不能被成功处理而不是账单扣费是不是在任务提交后的第100毫秒就一定要完成所以最终一致性完全够用。真正的成本不是方案本身而是为了让最终一致可靠你要搭建的那一整条状态管理、补偿、幂等、对账的链路。先把这套链路想清楚再动手写代码比任何中间件都重要。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑