资讯动态

一边提交不等于一起办成:分布式事务的Outbox事件表、Saga补偿、状态机与最终一致性选型工程

发布时间:2026/10/24 2:12:35 来源:尧图企业网站定制
目录一、跨服务的一次业务动作到底承诺了什么一三句常见答案各自掩盖了什么二一致性的口径先说清楚「最终」到底有多久三真正要覆盖的能力项二、单库事务的答案为什么带不出去一单库事务承诺的四件事跨出去丢了几件二两阶段提交教科书答案的四笔代价1、协议本身2、四笔代价3、什么时候仍然该用它三范式转移把「锁出来的一致」换成「账算出来的一致」三、双写问题与Outbox先保证事件不丢一双写的三种排法三种都有失败窗口二Outbox事件表把「通知别人」变成同一笔本地事务里的一行数据1、表结构2、写入纪律3、事件语义三投递器把事件从表里搬到消息系统1、轮询2、CDC事务日志拖尾3、顺序与分区四至少一次的另一半消费端幂等五事务消息把事件表托管给消息中间件四、Saga把一个长事务拆成一串本地事务一结构每一步一个正向动作配一个补偿动作二两种组织方式编排与协同1、协同式2、编排式3、选择判据三补偿的设计难点1、补偿是业务动作不是回滚2、步骤排序三段式2.1 可补偿段2.2 枢轴2.3 可重试段3、不可补偿动作的处理四Saga没有隔离性两类异常与四种对策五、状态机让流程状态显式可查一状态字段与合法迁移表二超时与悬挂三TCC预留式方案与它的三件套1、三个阶段2、三件套空回滚、悬挂、幂等2.1 空回滚2.2 悬挂2.3 幂等3、TCC与Saga的分界六、按业务场景选型四个判据与一张决策表一四个判据二典型场景对号入座1、下单扣库存、锁优惠券、创建订单2、支付与转账3、一个方法里要改两个库4、通知类扇出三方案是组合不是单选七、对账与修复最终一致的最后一道防线一对账的口径、节奏与分层二修复动作的幂等与审计八、落地清单门禁规则与演进路线一可以写进评审模板的十四条门禁二三阶段演进路线结语参考资料干货分享感谢您的阅读单库时代的事务是一句承诺要么全做要么全不做。把订单、库存、账务拆进三个服务之后这句承诺就找不到落点了——每个服务只能保证自己那一半而业务要的是「一起办成」。于是出现了一批看起来都对的答案上一个分布式事务框架、用消息队列保证送达、大不了夜里跑对账。这些答案各自都成立但它们回答的其实是三个不同的问题混着用的结果是框架挡住了吞吐、消息丢在了双写的缝里、对账变成了每天早上的人工修数。这次我们想把它拆开来讲为什么两阶段提交的账在多数在线业务里算不过来Outbox 事件表如何把「本地改数据」和「通知别人」压进同一笔本地事务Saga 如何用一串本地事务加补偿动作替代一个全局事务代价是什么状态机为什么是这一切的地基而不是锦上添花以及最关键的——拿到一个具体业务场景时凭哪四个判据在事务消息、补偿、事件表与状态机之间做选择而不是凭最近读过哪篇文章。一、跨服务的一次业务动作到底承诺了什么一三句常见答案各自掩盖了什么评审会上讨论「下单要扣库存、锁优惠券、写订单怎么保证一致」时最常听到三句答案。三句都不算错但每一句都把一个关键前提藏了起来而藏起来的那部分恰恰是日后事故的来源。第一句是「上个分布式事务框架就行」。它掩盖的前提是框架提供的强一致模式本质上仍是两阶段提交的变体全局锁的持有窗口横跨所有参与方的网络往返。在参与方都是低延迟内网数据库、并发量可控的场景里这个窗口可以接受在参与方包含第三方接口、或者峰值每秒几千单的场景里这个窗口就是吞吐的上限和死锁的温床。框架解决的是「怎么写」没有解决「代价是否付得起」。第二句是「用消息队列保证」。它掩盖的缺口是消息系统只承诺「收到的消息不丢」不承诺「你想发的消息一定进得来」。业务库的提交和消息的发送是两个系统上的两次写入无论谁先谁后中间都有一个失败窗口——先提交再发消息进程在两步之间崩溃消息就永远丢了先发消息再提交事务回滚之后下游却已经收到了一个不该存在的事件。这个缺口有专门的名字叫双写问题它不是把消息系统配得更可靠就能消掉的因为它发生在消息系统的门外。第三句是「大不了对账兜底」。它掩盖的量级差异是对账作为最后一道防线和对账作为日常修复手段是两种完全不同的工程状态。前者处理的是每月个位数的意外后者说明主链路的一致性机制根本没建起来每天产生的差异会把对账从「核对」变成「代做」而人工修数本身又是新的差异来源。三句答案分别对应三种真实的机制——原子提交、可靠事件、事后核对——它们不是同一个问题的三个可互换解而是三个不同层次上的部件。这篇文章后面的全部内容可以概括为一句话先用口径定清楚业务要的一致到底是哪一种再决定这三个部件各放在哪一层。二一致性的口径先说清楚「最终」到底有多久「最终一致」这个词的麻烦在于它听起来像一个方案实际上只是一个承诺的弱化形式而弱化的幅度没有说。把它用成方案名词之前需要先回答三个口径问题。第一个口径是可见性窗口从第一个参与方提交到所有参与方达到目标状态中间这段时间叫不一致窗口。窗口期间系统对外呈现的是中间状态。「最终一致」的工程含义不是「总有一天会一致」而是「窗口有上界且上界写进了服务级别目标」。窗口一秒和窗口一小时是两个完全不同的方案等级前者用户无感后者需要产品层面配合比如订单页显示「处理中」。评审时问「窗口多大、谁在度量」比问「用了什么框架」有效得多。第二个口径是中间状态是否可见、可见给谁。转账场景里扣款已发生、入账未完成的中间状态绝不能让收款方看到「已到账」但可以让付款方看到「处理中」。库存场景里预占未确认的库存对下一个买家而言就是「已售出」。中间状态不是要消灭的脏东西而是要显式建模、显式命名、显式决定可见范围的一等公民——这正是后文状态机一章的出发点。第三个口径是失败之后收敛到哪边。一致性只承诺「两边最终一样」没承诺「一样成什么样」。库存扣了、优惠券锁失败是把库存补回去向后收敛业务动作整体取消还是重试优惠券直到成功向前收敛业务动作必须办成这不是技术选择是业务规则支付成功之后的发货流程通常只能向前收敛因为「把钱退了」的体验代价远高于「多试几次发货」而下单阶段的资源预占通常向后收敛因为用户还没付钱取消的代价最低。方案选型的一半分歧源于双方没有先对齐收敛方向。把三个口径钉死之后「强一致还是最终一致」就不再是站队问题强一致等于把可见性窗口压到零代价是所有参与方在窗口内互相等待。窗口需要为零的场景同一笔资金的借贷两条腿用原子提交窗口可以放宽的场景订单与积分用可靠事件加补偿。绝大多数跨服务业务属于后者这是本文把重心放在 Outbox 与 Saga 上的原因。三真正要覆盖的能力项把「分布式事务怎么做」当成一项可验证的工程能力要求的从来不是背出几个方案名词而是六项可以逐条验证的能力。第一项是边界判断能不能先问「这几张表真的必须分开吗」。相当一部分「分布式事务需求」是过度拆分制造出来的——两张强耦合的表拆进两个库再引入一套补偿机制把它们缝回来复杂度凭空翻倍。最便宜的分布式事务是不存在的分布式事务。第二项是失败窗口的枚举能力给定一个「先做A再做B」的序列能不能指出每个缝隙里进程崩溃、网络超时、消息重复各自造成什么后果。双写问题、悬挂、空回滚全部是缝隙分析的产物。第三项是幂等的落地能力知道「至少一次投递」意味着消费端必然收到重复并且能给出幂等键怎么选、去重表怎么建、结果怎么回放的具体设计而不是一句「消费端做幂等」。第四项是补偿的业务理解知道补偿不是数据库回滚而是一个新的业务动作——退款不是删除支付记录是多一条反向流水并且知道有些动作补不了已发出的短信、已出库的包裹补不了的动作必须排在序列的哪个位置。第五项是状态的显式化能力能把流程的每个中间态建成可查询、可超时、可对账的状态字段而不是散落在日志和消息里的隐式状态。第六项是兜底的分层意识知道重试、补偿、对账、人工介入是四层递进的防线每层拦截上一层漏下来的量并且能说出每层的预期拦截率——重试解决瞬时故障补偿解决业务失败对账解决机制自身的缺陷人工处理对账都分不清的残渣。后文按这六项能力的自然依赖顺序展开先算清原子提交的账第二章再解决事件不丢第三章、补偿有序第四章、状态可查第五章然后回答选型第六章与兜底第七章最后收敛成团队可执行的门禁第八章。二、单库事务的答案为什么带不出去一单库事务承诺的四件事跨出去丢了几件单库事务好用到让人忘了它在替我们做多少事。一条BEGIN ... COMMIT包住的语句序列同时拿到四件承诺原子性要么全做要么全不做、隔离性并发事务互相看不见对方的中间态、故障原子性崩溃恢复后自动回滚未提交的半截、以及一个常被忽略的——免费的失败通道任何一步失败只要抛出异常数据库替你把现场恢复干净代码里不需要写一行清理逻辑。把参与方拆到两个服务之后四件承诺的下落各不相同。原子性没有了两个库各自提交任何一边可以单独成功。隔离性没有了第一个服务提交的瞬间它的结果就对全世界可见哪怕整个业务动作还差一半。故障原子性退化成「各自恢复各自的」崩溃之后每个库都干净但业务层面留下一个「订单在、库存没扣」的半成品数据库层查不出任何异常。免费的失败通道彻底消失库存扣完之后优惠券锁失败把库存加回去的代码必须自己写而且这段代码本身还会失败。值得停下来体会的是最后一条。单库时代「失败处理」是数据库的事分布式时代「失败处理」变成业务代码的主体——后文的补偿、幂等、状态机、对账本质上全是在人工重建这条失败通道。理解了这一点就理解了为什么分布式事务的方案讨论总是围绕失败路径展开成功路径本来就没有难度。二两阶段提交教科书答案的四笔代价1、协议本身两阶段提交2PC是把原子性带出单库的标准协议XA 是它在数据库与中间件之间的接口规范。协议引入一个协调者第一阶段向所有参与方发「准备」请求参与方把事务做到只差提交的程度、持有全部锁并写下准备日志投出赞成或反对票第二阶段协调者根据投票结果统一广播提交或回滚。数学上它是干净的只要协调者的决定落了盘结局就唯一确定。2、四笔代价工程上它有四笔必须算清的账。第一笔是锁窗口横跨两个网络往返从参与方投出赞成票到收到最终指令行锁一直持有窗口长度由最慢的参与方和最差的网络决定。单库事务的锁窗口是微秒到毫秒级2PC 把它拉到几十毫秒起步——热点行上这等于把并发串行化吞吐的上限直接由锁窗口的倒数给出。第二笔是阻塞性参与方投出赞成票之后就进入「不能自作主张」的状态——它不能提交还没收到指令也不能回滚票已经投了只能抱着锁等。协调者如果在此刻崩溃所有参与方带锁悬置直到协调者恢复。这是协议层面的性质不是实现缺陷。第三笔是协调者成为新的单点与新的状态源协调者的决定日志成了整个系统里最关键的一份数据它自己的高可用、它与参与方日志的一致性都是额外的工程量。第四笔是参与方资格的门槛每个参与方都必须支持「准备好但不提交」的中间态并实现 XA 接口。第三方 HTTP 接口、Redis、多数消息系统、以及任何不受你控制的外部系统都做不了参与方——而现实业务链路里几乎总有它们。这一笔常常是一票否决不是不想用 2PC是链路上有成员根本进不了场。3、什么时候仍然该用它四笔账算下来2PC 的适用面收窄成一句话参与方全部是内网低延迟的事务型存储、并发不在热点上、且业务确实要求可见性窗口为零。典型如同一机房内跨两个分库的资金调拨、或者数据库与支持 XA 的消息中间件之间的组合。在这个窄场景里它是最省心的选择因为一致性由协议保证业务代码不用写补偿。出了这个场景就该换范式而不是换框架——把强一致框架从一个换成另一个四笔账一笔都不会少。三范式转移把「锁出来的一致」换成「账算出来的一致」2PC 的思路是空间换语义让所有参与方在同一时刻停住用锁把不一致的中间态挡在外面。替代范式的思路是时间换吞吐允许中间态存在并对外可见但保证系统始终朝目标状态收敛且收敛过程可观测、可干预。这个范式由四个部件咬合而成缺一个就会在某类故障下漏出永久不一致。第一个部件是可靠事件本地事务提交与「通知下游」这件事必须绑成原子的——这是 Outbox 事件表要解决的第三章展开。第二个部件是幂等消费可靠投递必然是至少一次下游必须把重复消息消化成一次效果否则「不丢」的代价就是「重复」。第三个部件是补偿向前收敛不了的时候要能向后收敛而补偿动作的排序与不可补偿动作的处理是 Saga 的核心第四章展开。第四个部件是状态机与对账中间态要显式可查收敛要有超时上界漏网的差异要有独立于主链路的第二双眼睛第五章与第七章展开。范式转移里最容易被低估的一点是心态的转移在原子提交范式里不一致是异常出现即事故在收敛范式里不一致窗口是设计的一部分事故的定义变成「窗口超了上界」或「收敛停了没人知道」。相应地监控的对象从「有没有报错」变成「事件表积压深度、最老未投递事件的年龄、补偿成功率、对账差异数」这些收敛过程的体征指标。很多团队方案抄得很像、运维出事故差的就是这一层。三、双写问题与Outbox先保证事件不丢一双写的三种排法三种都有失败窗口「改本地数据库同时发一条消息」是收敛范式的第一块砖也是最容易砌歪的一块。业务库和消息系统是两个独立的存储对它们的两次写入无论怎么排都存在一个进程崩溃或网络超时能钻进去的缝。排法一先提交事务再发消息。缝在两步之间提交成功、进程在发消息前崩溃或者发送超时后重试线程连同进程一起没了。结果是本地数据已变更、下游永远不知道——丢事件而且丢得无声无息只有对账能发现。排法二先发消息再提交事务。缝在消息已出、事务未提交后续任何原因导致回滚下游却已经收到了一个描述「从未发生之事」的事件。结果是幽灵事件比丢事件更糟——丢了还能补发幽灵事件驱动下游做了真实的动作发券、扣库存要靠反向补偿才能收回。排法三在事务内部发消息、之后提交。这是排法二的变体多数人写出它是无意的——消息发送语句夹在事务中间看起来「都在事务里」实际上消息系统根本不受数据库事务管辖发送在执行那一刻就生效了。它同时继承两种失败发送成功后事务回滚是幽灵事件发送本身超时还会把数据库事务拖长放大锁窗口。这一排法应当被静态检查直接禁止事务边界内不允许出现任何网络副作用。三种排法的共同病根是「数据变更」和「事件发出」写在了两个不同故障域的存储上而两个故障域之间没有原子性可用。解法不是调整顺序而是取消跨域——让「事件发出」这件事先变成同一个故障域内的一次写入。二Outbox事件表把「通知别人」变成同一笔本地事务里的一行数据1、表结构Outbox 模式本地消息表、事件表不同社区叫法不同机制相同的做法只有一句话在业务库里建一张事件表业务数据的变更和事件的插入放进同一个本地事务。事务的原子性是现成的——要么订单和「订单已创建」事件同时落库要么都不落双写的缝从根上消失。一张够用的事件表大致如下CREATE TABLE outbox_event ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 自增主键兼作同分区内的粗序, event_id CHAR(36) NOT NULL COMMENT 全局唯一事件号消费端幂等键, aggregate_type VARCHAR(64) NOT NULL COMMENT 聚合类型如 ORDER, aggregate_id VARCHAR(64) NOT NULL COMMENT 聚合标识如订单号兼作消息分区键, event_type VARCHAR(64) NOT NULL COMMENT 事件类型如 ORDER_CREATED, payload JSON NOT NULL COMMENT 事件体事实的完整快照, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待投递 1已投递 2投递失败待人工, retry_count INT NOT NULL DEFAULT 0, create_time DATETIME(3) NOT NULL, deliver_time DATETIME(3) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_event_id (event_id), KEY idx_status_id (status, id) ) COMMENT Outbox 事件表与业务变更同库同事务写入;几个字段的取舍值得说明。event_id是给消费端做幂等用的必须在生产侧生成并全程随消息传递不能用消息中间件自己的消息号——消息重发时中间件会换号幂等就破了。aggregate_id承担两个职责查询「这个订单发过哪些事件」以及作为消息的分区键保证同一聚合的事件有序。payload存事实的完整快照而不是「去查订单表」的引用原因是事件可能在业务数据已被后续变更覆盖之后才被消费事件必须自带当时的真相。2、写入纪律表建对只是一半写入侧有三条纪律。第一条事件的插入必须和业务变更在同一个事务方法里完成不允许「业务方法提交后再调一个记事件的方法」——那等于把双写问题换了个地方重演。第二条事件在事务里只做插入不做发送发送是投递器的事事务内出现网络调用就违反了上一节的禁令。第三条事件表和业务表必须同库跨库的事件表毫无意义——原子性只存在于单库事务之内这是整个模式的立足点也是分库分表时最容易被无意破坏的一点业务表分片之后事件表必须跟着同样的分片键走。3、事件语义事件体的设计决定下游能不能安全消费。原则是发事实不发指令「订单 1001 已创建金额 128 元」是事实谁消费、消费几次都不改变它的真值「给用户发一张券」是指令重复消费一次就多发一张券。事实型事件把「做什么」的决定权留给消费方消费方的幂等也更好做——同一个事实处理两遍只要判断「这个 event_id 我处理过」即可。此外事件要带上业务时间事实发生的时刻而非只有写入时间对账与排障时两者的差值就是链路延迟的直接证据。三投递器把事件从表里搬到消息系统事件安全落库之后需要一个独立组件把它搬到消息系统。搬运有两种实现路线工程性质差别很大。1、轮询轮询投递器是一个定时任务按status0且id升序捞一批事件逐条发给消息系统发送得到确认后把状态改为已投递。实现门槛低一个下午就能上线缺点有三延迟下界是轮询间隔轮询查询对数据库有持续压力索引设计好可以很小多实例部署时要处理「两个投递器捞到同一批事件」的问题——要么用分布式锁保证单飞要么干脆接受重复投递、把去重完全交给消费端幂等后者更简单也更符合「至少一次」的整体假设。状态更新和发送确认之间同样有缝发送成功、改状态前崩溃重启后这条事件会再发一次——又一个「必然重复」的来源再次指向消费端幂等是不可省略的。2、CDC事务日志拖尾第二条路线不查表直接订阅数据库的事务日志MySQL 的 binlog从日志流里过滤出事件表的插入记录转发给消息系统。捕获延迟是毫秒级对业务库几乎零压力事件表甚至可以不需要状态字段——位点消费到日志的哪个位置由 CDC 组件自己维护天然断点续传。代价是运维复杂度多了一个有状态的采集组件要管位点的持久化、要处理日志格式随数据库版本的变化、要在数据库主从切换时保证位点不错乱。成熟的开源组件如 Debezium 及其 Outbox 路由插件把这些做掉了大半但「多一个要值班的组件」这件事本身没有消失。选择判据可以压成一句事件量小、延迟容忍秒级用轮询简单可靠事件量大或延迟要求高用 CDC接受运维成本。两者可以共存演进——先轮询上线量起来之后切 CDC表结构不用动。3、顺序与分区同一个订单的「已创建」「已支付」「已发货」三个事件如果乱序抵达下游状态机会直接错乱。顺序保证要贯穿三段事件表内用自增主键给出同聚合内的先后投递时以aggregate_id作分区键保证同一聚合的事件进同一个分区消费时同一分区单线程处理。注意顺序承诺的范围刻意收窄到「同一聚合内」——跨聚合的全局有序既没有业务必要也会把并行度压回一。乱序仍可能出现比如投递重试跳过了一条所以下游状态机必须容忍乱序收到「已发货」时若还没见过「已支付」挂起等待或按版本号拒收这是第五章状态机要承接的职责。四至少一次的另一半消费端幂等Outbox 加投递器给出的承诺是「至少一次」事件不丢但会重。轮询器崩溃重发、消息系统内部重试、消费端处理完毕未及确认——重复的来源多到不值得逐一堵截正确的姿势是承认重复必然发生把「重复处理等于处理一次」做成消费端的硬性能力。标准做法是去重表消费端在自己的库里建一张已处理事件表主键就是event_id。处理逻辑严格三步走在同一个本地事务里——插入event_id唯一键冲突则说明处理过直接确认消息并返回、执行业务变更、提交。关键在「同一个本地事务」如果去重记录和业务变更分开提交「记了去重但业务没做」或「业务做了但去重没记」两种半截状态又会出现幂等就成了摆设。这个结构值得反复端详——它和生产侧的 Outbox 是完全对称的生产侧把「变更发出」绑进一个事务消费侧把「去重变更」绑进一个事务中间的消息系统只需要至少一次。两端各自守住自己的原子性全链路的恰好一次效果就拼出来了。有两类场景可以不建去重表。一类是业务动作天然幂等把状态置为某个值、按唯一键插入、以事件里的绝对值覆盖本地字段重复执行结果不变。另一类是能把幂等下沉给数据库约束比如「每单只发一张券」直接在券表上建(order_id)唯一键重复消费撞唯一键失败即等效于去重。反过来累加类动作余额加减、库存增减绝不可裸奔——重复一次就是资损必须走去重表或流水表加唯一键。五事务消息把事件表托管给消息中间件Outbox 的机制也可以由消息中间件原生提供典型是 RocketMQ 的事务消息。流程是四步生产者先发一条半消息半消息落在中间件里但对消费者不可见然后执行本地事务根据本地事务的结局向中间件提交或回滚半消息提交后消息才对消费者可见。第四步之外还有一条兜底路径——生产者迟迟不给结论比如提交本地事务后、确认半消息前崩溃了中间件会主动回查调用生产者提供的回查接口问「这条半消息对应的本地事务到底成没成」生产者按本地状态作答。回查接口是这个方案里最容易被轻视的部分。它必须能仅凭消息里携带的业务键查出本地事务的确定性结局——这意味着本地事务必须留下可查的痕迹一条订单记录、一条流水如果本地事务的效果是「不留痕迹的删除」或者查询条件传不过去回查就写不出来。实践中回查逻辑等价于一张微型事件表你还是得记「这件事我办没办成」只是记在业务表而不是专门的事件表里。两个方案怎么选逐项对照后差别其实很清晰。事务消息把投递器、状态管理、重试全部托管给中间件代码量少、延迟低但把技术栈锁死在特定中间件上且事件的存证随消息过期而消失——想查「三个月前这个订单发过什么事件」中间件帮不了你。本地事件表全部自持多写一张表、多运维一个投递器换来中间件无关Kafka、RabbitMQ、RocketMQ 都能接、事件天然留档可审计、以及事件表本身就是对账的数据源。选型判据落在两个问题上团队是否已经重度使用支持事务消息的中间件事件是否需要留档审计。金融属性的链路几乎总是选事件表因为审计要求摆在那里轻量的通知型链路用事务消息更省事。四、Saga把一个长事务拆成一串本地事务一结构每一步一个正向动作配一个补偿动作Outbox 解决的是「一步之后可靠地通知下一步」但一个完整业务动作往往是多步创建订单、锁优惠券、扣库存、请求支付。Saga 给出的结构是把全局事务拆成 n 个本地事务 T1…Tn 顺序执行为每个 Ti 预先定义补偿动作 Ci若 Tk 失败则按 C(k-1)…C1 的逆序执行补偿把已完成的步骤逐个业务性地撤销。每一步都是普通的本地事务锁窗口只覆盖自己那一步不存在全局锁——这正是它相对 2PC 的全部吞吐优势来源。这个结构最早由 Garcia-Molina 与 Salem 在 1987 年针对数据库长事务提出微服务时代被重新捡起来因为跨服务事务恰好就是「长事务」的现代形态。它的承诺比原子提交弱一档不保证「全做或全不做」在任意时刻成立只保证终局要么是全部完成、要么是全部补偿过程中的中间态对外可见。换句话说Saga 用第一章说的可见性窗口换来了每一步的本地化——理解这笔交换是用对它的前提。二两种组织方式编排与协同Saga 的步骤由谁串起来有两种答案工程性质差别比表面上大。1、协同式协同式choreography没有中枢订单服务发出「订单已创建」库存服务订阅它、扣完库存发出「库存已扣减」优惠券服务再订阅……每个服务只知道自己订阅什么、发布什么链路是事件级联出来的。优点是彻底解耦、没有中枢单点、加一个旁路消费者比如积分服务不用改任何现有代码。缺点在规模上暴露流程定义不存在于任何一处代码里想知道「下单到底会触发哪些动作」只能翻遍所有服务的订阅关系补偿同样靠反向事件级联一旦某个环节既要监听正向事件又要监听三种失败事件订阅图会指数式纠缠出问题时「流程卡在哪一步」没有一个地方可以直接回答。2、编排式编排式orchestration引入一个编排器它按流程定义依次向各参与方发出命令或调用收集每步结果决定下一步是前进还是转入补偿。流程集中在一处、状态集中在一处「这个订单的 Saga 走到哪了」一查便知补偿逻辑也集中在编排器里参与方只需要提供「正向接口补偿接口」两个哑端点。代价是编排器自身成为要高可用的有状态组件——它的每一次状态推进都必须先落库再发命令否则编排器崩溃后流程失忆本质上编排器内部也要有自己的 Outbox。另一个隐性风险是编排器容易膨胀成「上帝服务」把本该属于领域服务的业务规则吸进来需要纪律性地把它限制在「只管次序与状态不管业务判断」。3、选择判据判据可以压缩成步骤数与团队结构两条。三步以内、参与方稳定、没有复杂补偿的链路协同式的简洁是真优势四步以上、或有分支循环、或补偿关系复杂、或需要随时回答「卡在哪」的链路编排式几乎是必选。团队结构上参与方分属多个团队时编排式把流程主权交给一个明确的 Owner扯皮成本低得多。实践里常见的稳态是混合核心交易链用编排式旁路的通知、积分、报表用协同式挂在同一批事件上。三补偿的设计难点1、补偿是业务动作不是回滚数据库回滚是把物理页恢复原样仿佛什么都没发生过补偿做不到这一点也不应该尝试做到。支付的补偿不是删除支付记录而是新增一条退款流水库存扣减的补偿不是把行改回去而是一次带原因码的入库动作。差别有三层。第一层是审计金额相关的动作必须留痕「发生过又被撤销」和「从未发生」在账务上是两回事。第二层是级联正向动作可能已经触发了下游扣库存后触发了补货计算物理还原不会通知下游业务补偿会再发一个补偿事件让级联同步回退。第三层是并发正向和补偿之间可能夹了别人的操作——扣掉的 10 件库存中已有 3 件被别的订单占走「改回原值」会吞掉那 3 件而「加回 10 件」是安全的。所以补偿动作的通用形态是追加一条反向记录而不是修改原记录。2、步骤排序三段式补偿设计里最有杠杆的一步是在写任何代码之前把步骤重新排序。按「失败后怎么办」把每一步分进三类Saga 就自然分成三段。2.1 可补偿段失败了可以业务性撤销的步骤——锁券、预占库存、创建待支付订单。它们排在最前面这一段里任何一步失败把前面的步骤补偿掉即可用户看到的只是「下单失败」无资损无残留。2.2 枢轴整条 Saga 里那个「过了就回不了头」的决定性步骤典型是扣款成功。枢轴之前失败向后收敛全部补偿枢轴之后失败向前收敛重试到底。枢轴必须有且只有一个并且排在所有可补偿步骤之后——如果链路里出现两个「回不了头」的步骤中间夹着可失败的环节说明业务流程本身要改任何事务方案都救不了这种排序。2.3 可重试段枢轴之后的步骤必须设计成「必然最终成功」生成发货单、增加积分、发送通知。「必然成功」不是祈祷是设计约束——这些步骤不做业务校验校验全部前置到枢轴之前、天然幂等、失败只有技术性原因超时、宕机因此无限重试是安全的。一旦某个「枢轴后步骤」可能因业务原因失败比如发货时才校验地址合法性它就必须挪到枢轴前面去哪怕为此要引入预校验接口。这个三段排序值得当成评审模板拿到任何一条跨服务链路先问「枢轴是哪一步」「枢轴后有没有可能业务性失败的步骤」两个问题就能暴露大半设计缺陷。3、不可补偿动作的处理有些动作既不可补偿也不宜做枢轴发短信、调用只有单向接口的老系统、把包裹交给快递。处理原则是推迟与降级能推迟的排到可重试段的最末尾让它在全局结局已定之后才发生推不掉的业务要求早发短信就接受「补偿时再发一条冲正通知」的降级形态并把这一点写进产品预期。最忌讳的是把不可补偿动作夹在可补偿段中间然后在补偿逻辑里写一句「此步骤跳过」——那不是补偿是掩埋。四Saga没有隔离性两类异常与四种对策Saga 文献里最常被跳过、生产里最常付学费的一节是隔离性。每个 Ti 提交后立即可见别的事务可以在 Saga 完成之前读到、甚至改写它的中间结果两类异常随之而来。第一类是脏读驱动的连锁动作Saga A 给账户加了 100 元后续会补偿Saga B 在补偿发生前读到余额、判定「余额充足」放行了一笔消费——B 基于一个终将被撤销的中间态做了真实决定。第二类是覆盖更新Saga A 的补偿把字段改回旧值恰好覆盖了中间插进来的 Saga B 的正常更新B 的效果无声消失。对策有四种按代价从低到高。其一语义锁给处于 Saga 中间态的数据打上状态标记订单置 PENDING、预占库存单独放reserved字段其他事务读到标记后按业务规则处置——等待、拒绝或走特殊分支这其实就是把隔离性上移到业务语义层也是状态机与 Saga 的第一个交点。其二可交换更新把补偿写成增量加回 10 件而非赋值改回 100 件增量操作天然与并发更新可交换覆盖更新就不成立了。其三版本号或前置校验更新时带上读取时的版本版本变了就重读重算乐观锁思路的直接移植。其四重读校验关键步骤执行前重新读取依据数据确认决策前提仍然成立。工程上通常语义锁加可交换更新就覆盖了绝大多数场景——这也解释了为什么实践中的 Saga 几乎总是和状态机长在一起语义锁的那个「状态标记」就是状态机的状态字段。五、状态机让流程状态显式可查一状态字段与合法迁移表前几章反复把问题推给「状态」乱序事件要靠状态挡、语义锁要靠状态标、Saga 卡在哪要靠状态查。状态机不是一个可选的代码风格而是收敛范式的地基把流程的每个阶段建成显式的状态值把允许的状态变化建成一张迁移表任何变更必须是表里登记过的一次迁移。落到实现上是三条硬规则。第一条状态变更一律用条件更新UPDATE saga_order SET statusPAID WHERE id? AND statusPENDING_PAY影响行数为零说明当前状态不是预期前置态——要么是重复消息已经迁移过了幂等地忽略要么是乱序消息前置事件还没到挂起或拒收要么是真正的异常进入人工线。一条 SQL 同时完成了迁移合法性检查、幂等与并发防护这是状态机给出的最实惠的一件礼物。第二条迁移表代码化为显式的二维结构当前态 × 事件 → 目标态收到迁移表之外的组合直接告警而不是悄悄忽略——迁移表外的事件组合往往是上游缺陷的第一信号。第三条每次迁移追加一条迁移流水前态、后态、触发事件的 event_id、时间这张流水表既是排障的时间线也是第七章对账的原料。状态集的设计有一个常见误区把「失败」建成一个状态。失败要区分「迁移前失败」状态未动重试即可和「业务性终局失败」如 PAY_FAILED是一个合法终态把两者混成一个 FAILED重试逻辑和终局逻辑就搅在一起了。终态的判定标准是进入之后不再有任何出边人工冲正除外。二超时与悬挂状态机显式化之后第一个立刻能落地的收益是超时裁决。任何非终态都必须声明「最多停留多久、超时之后走哪条边」待支付超过三十分钟迁移到已取消并触发释放库存的补偿支付中超过五分钟触发主动查询向支付渠道查单按查询结果迁移而不是盲目重试或盲目取消。没有超时边的中间态就是潜在的永久卡单——收敛范式承诺「窗口有上界」上界就是靠这些超时边撑住的。超时和迟到的组合会制造悬挂支付中超时、系统判定取消并释放了库存两秒后支付成功的回调到了。防悬挂的通用做法是让超时裁决本身也是一次条件更新迁移从「支付中」迁到「超时关单」迟到的成功回调走条件更新时会发现前置态已经不是「支付中」于是拒绝迁移、转而触发自动退款的补偿流。核心原则是超时裁决和结果回调是对同一个状态的竞争写必须让它们串行化在同一张迁移表上分出先后而不是各自为政。三TCC预留式方案与它的三件套1、三个阶段TCCTry-Confirm-Cancel可以看作「把 2PC 的准备阶段从数据库锁上移到业务语义」的方案Try 阶段各参与方做业务校验并预留资源冻结金额、预占库存不真正生效全部 Try 成功后协调方逐个调 Confirm 把预留转正任何 Try 失败则逐个调 Cancel 释放预留。与 Saga 的关键差别在于中间态的暴露程度Saga 的每一步是完整生效的本地事务靠事后补偿收回TCC 的 Try 只做预留外界在 Confirm 之前看不到生效结果等于用「冻结」语义把隔离性买了回来。代价是侵入性每个参与方要为同一个业务动作实现三个接口冻结字段要侵入表结构改造量大约是 Saga 参与方的三倍。2、三件套空回滚、悬挂、幂等TCC 的三个接口都经由不可靠网络调用三类边界情形必须在参与方内部处理缺一个就是资损级缺陷。2.1 空回滚Try 请求没送达或还没执行协调方超时后已经广播了 Cancel——参与方会收到一个「没有 Try 与之对应」的 Cancel。参与方必须识别这种情况并把它当成功返回没预留过自然无可释放而不是报错报错会让协调方无限重试 Cancel流程卡死。识别手段是 Try 时登记一条参与记录Cancel 找不到记录即为空回滚。2.2 悬挂空回滚返回之后那个迟到的 Try 才姗姗抵达。如果照常执行预留的资源就永远没人来 Confirm 或 Cancel 了——这份冻结成为永久悬挂。防法与空回滚共用一张表Cancel 处理空回滚时插入一条「此事务已关闭」的记录迟到的 Try 先查这张表发现已关闭则拒绝执行。2.3 幂等Confirm 与 Cancel 都会被重试参与方必须保证重复调用效果不变。三件事共用同一张事务参与记录表全局事务号做唯一键记录当前阶段就能全部解决空回滚查它、防悬挂靠它、幂等判它。评审 TCC 实现时不用看别的先问这张表在哪。3、TCC与Saga的分界TCC 换回了隔离性付出了三倍接口与表结构侵入所以它的适用面窄而明确资金类的短流程——冻结语义天然属于账务领域账户本来就有冻结概念侵入成本已被业务吸收且流程短到三个接口的编排复杂度可控。库存这类「预占」语义同样现成的领域也适合。除此之外的长流程、跨团队流程、包含外部参与方的流程Saga 加状态机是默认答案外部参与方不可能为你实现三个接口。六、按业务场景选型四个判据与一张决策表一四个判据方案讲完回到开头那个真实处境拿到一个具体场景怎么选。四个判据按顺序问每一个都直接砍掉一些选项。判据一可见性窗口必须为零吗业务上是否存在「任何时刻两边不一致都不可接受」的硬约束。同一笔资金的借贷两条腿是订单与积分不是——绝大多数场景在这里就被分流去了最终一致阵营。回答「是」的场景再看参与方资格全是内网事务型存储用 2PC/XA 或强一致模式的框架否则要么重划服务边界把这几张表合回同一个库要么用 TCC 以冻结语义模拟。判据二失败之后向哪边收敛存在决定性步骤扣款、出票的流程要找出枢轴枢轴前可补偿、枢轴后必成功这直接给出 Saga 的步骤排序。全程无枢轴纯通知、纯同步类的链路不需要 SagaOutbox 加幂等消费就是完整答案。判据三中间态会被并发读写吗Saga 的中间态若会被其他流程读到并据此决策余额、库存这类共享资源必须加语义锁或改用 TCC 的冻结语义中间态只有流程自己关心的订单自身的状态推进普通状态机就够。判据四审计与留档的要求有多强要留档的选本地事件表而非事务消息要审计的补偿一律走反向流水而非改回原值两条在金融场景里都是硬约束。二典型场景对号入座1、下单扣库存、锁优惠券、创建订单窗口不必为零用户能接受下单后短暂的处理中枢轴是「提交支付」库存与券是会被并发争抢的共享资源。答案编排式 Saga枢轴前的库存与券用预占/冻结语义语义锁订单状态机承载全流程事件用 Outbox 出库。库存预占要带超时释放第五章的超时边否则弃单会永久占用库存。2、支付与转账同一账本内的借贷两腿窗口必须为零用单库事务或同库分片内的 2PC跨机构对接银行、第三方支付根本没有共同的事务边界可用行业的标准形态就是状态机加主动查询加对账发起方记「支付中」靠回调与定时查单推进状态日终以对方账单文件对账——这正是收敛范式在最严苛资金场景下的完整形态也从侧面说明该范式的可靠性上限足够高关键在四个部件是否齐全。3、一个方法里要改两个库先问判据零这两张表为什么在两个库如果是历史拆分失当合库是最优解——不存在的分布式事务才是最好的分布式事务。确实合不了的容量原因分库同实例内优先考虑跨库单事务的能力边界跨实例则退到本地事件表主库变更加事件投递器驱动从库变更从库幂等消费。4、通知类扇出订单完成后通知积分、推荐、报表、营销四个下游。无枢轴、无补偿下游失败不影响订单本身、各下游互不依赖。答案是最轻的组合Outbox 出一个事实型事件四个下游各自订阅、各自幂等、各自重试互相隔离。不需要 Saga——没有需要统一收敛的「全局结局」的地方就不要引入编排。三方案是组合不是单选把四个场景排在一起可以看出实际系统里这些模式几乎从不单独出现而是按固定的分工咬合成一套。分工是稳定的状态机是地基每个有中间态的业务对象都需要无论上面跑的是 Saga、TCC 还是纯事件流Outbox 是动脉所有「本地变更之后要让别人知道」的地方共用同一张事件表与同一个投递器包括 Saga 编排器自己的命令下发Saga 是骨架只出现在有枢轴、要补偿的多步流程上TCC 是特化件只出现在资金与预占语义现成的短流程上对账是免疫系统独立于以上全部专门抓机制自身的缺陷。评审一个方案时与其问「你用的是哪个模式」不如按这五个角色点名状态在哪、事件怎么出去、枢轴是哪步、隔离靠什么、对账对什么。五个都有着落用什么名词无关紧要。七、对账与修复最终一致的最后一道防线一对账的口径、节奏与分层前面所有机制都可能有缺陷投递器的边界 bug、补偿代码漏了一个分支、状态机少画一条超时边。对账的定位是用一条独立的数据通路验证主链路的结论——它必须不复用主链路的代码与事件流否则主链路的缺陷会同步复制到对账里两边一起错。有效的对账分三层拦截的差异类型不同。第一层是存量对定时比对两边的业务终态集合订单库里已支付的单 × 账务库里的收款流水全量或按时间窗增量跑抓「事件彻底丢失」这类主链路完全无感的缺陷。第二层是流水对比对事件表与消费端去重表「发出的每个 event_id 是否都被消费」抓投递与消费之间的缝。第三层是行为对核对状态机迁移流水的完整性——每个非终态的对象是否都在超时上界内发生了下一次迁移抓「卡住但没报错」的对象。三层的共同口径要求是定界对账必须锚定一个双方都不再变化的截止点账务日切、状态终态对还在变化的窗口内数据做比对只会产生大量假差异。节奏上第三层卡单扫描要分钟级常跑它实际上是收敛过程的心跳监控第二层小时级第一层日级。差异数要当成核心质量指标看板化——它的趋势比绝对值重要差异突增几乎总是新版本引入了机制缺陷。二修复动作的幂等与审计对出差异只是一半修复才是闭环。修复动作有三条纪律。第一条修复走正门补发的事件仍然写进事件表、状态修正仍然走迁移表登记的人工冲正边绝不允许直接 UPDATE 业务表——直接改数据会绕过所有幂等与流水机制制造出下一轮对账都解释不了的差异。第二条修复本身幂等同一笔差异被两个值班人各修一次效果必须等于修一次所以修复工具也要带自己的操作去重。第三条留审计谁、何时、依据哪个差异单、执行了什么修复全部落表月度回顾差异单的根因分布把「重复出现的人工修复」转化为机制修复的需求清单——人工修复量长期不降说明对账在替某个缺陷位的机制长期代班这是技术债最诚实的度量。八、落地清单门禁规则与演进路线一可以写进评审模板的十四条门禁把全文的判断收敛成可执行的规则分四组。事件组四条事务边界内禁止任何网络副作用消息发送、HTTP 调用业务变更与事件插入必须同库同事务事件体必须是自含事实带 event_id、业务时间、完整快照每个消费方必须能回答「幂等靠什么」——去重表、唯一键或天然幂等三选一并写进接口文档。补偿组四条多步流程必须指认唯一枢轴枢轴后步骤不得含业务校验补偿一律追加反向记录禁止改回原值不可补偿动作必须排在可重试段末尾或书面接受降级会被并发读写的中间态必须有语义锁或冻结语义。状态组三条状态变更一律条件更新影响行数为零必须显式分流处理每个非终态必须声明超时上界与超时边迁移表外的事件组合必须告警。兜底组三条事件表积压深度与最老未投递事件年龄必须有监控与告警阈值卡单扫描分钟级运行人工修复必须走冲正边并留审计。二三阶段演进路线存量系统不必一步到位按依赖顺序分三步走每一步都独立产生收益。第一阶段解决「不丢不重」给核心链路补上 Outbox 事件表与消费端去重表把事务内发消息的代码全部清理掉。这一步不改业务流程风险最低通常两周内可完成完成后双写类事故直接归零。第二阶段解决「可查可超时」把散在代码里的隐式状态收敛成显式状态机补齐条件更新、迁移流水与超时边。这一步的标志性收益是「卡单靠用户投诉发现」变成「卡单靠扫描发现」。第三阶段解决「可补偿可对账」为有枢轴的流程补上编排与补偿搭三层对账。到这一步一致性从「靠代码正确」升级为「靠机制收敛」——代码仍然会有缺陷但缺陷的影响被限制在一个可度量、可修复的窗口里。三个阶段的顺序不可颠倒没有可靠事件状态机收不到完整输入没有状态机补偿不知道该从哪个状态出发没有前两者对账对出的差异既多且无从修起。结语回到标题一边提交不等于一起办成。单库事务用锁和日志把「一起」做成了默认品分布式环境把这份默认品收走换给我们一组零件——事件表保证消息与变更同生共死投递与幂等把「至少一次」拼成「恰好一次的效果」Saga 用枢轴排序和逆序补偿给出失败的走法状态机让每个中间态可查、可超时、可裁决对账在所有机制之外再验一遍账。零件没有一个是新发明难的从来是两件事拿到场景先钉死口径——窗口多大、向哪收敛、中间态给谁看然后诚实地把失败路径写完——成功路径谁都会写分布式事务的全部工程含量都在失败路径里。把这两件事做成团队的评审习惯比记住任何一个方案名词都值钱。参考资料微服务模式Saga 分布式事务微服务模式事务性发件箱Transactional Outbox微服务模式事务日志拖尾Transaction Log Tailing微服务模式轮询发布器Polling Publisher微服务模式幂等消费者Idempotent ConsumerGarcia-Molina SalemSagas1987 年原始论文Pat HellandLife beyond Distributed TransactionsACM QueueDebezium 文档Outbox 事件路由器Debezium 博客用 Outbox 模式实现微服务间可靠数据交换Apache RocketMQ 文档事务消息Apache Seata 文档什么是 SeataApache Seata 文档TCC 模式Apache Seata 文档Saga 模式AWS 规范性指导Saga 编排模式Azure 架构中心Saga 设计模式Martin Fowler 站点两阶段提交Patterns of Distributed SystemsApache Kafka 文档消息投递语义MySQL 文档InnoDB 事务模型MySQL 文档二进制日志binlogX/Open 分布式事务处理XA 规范Google SRE Book管理关键状态——分布式共识

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

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

免费获取报价 →
↑