ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

为什么本地事务不够用?从分布式事务、Saga 到 Transactional Outbox

为什么本地事务不够用?从分布式事务、Saga 到 Transactional Outbox

前面我们学习 MySQL 时,已经知道事务可以保证一组数据库操作的原子性。

例如创建订单时,需要同时写入订单记录和扣减库存。如果两个操作都在同一个 MySQL 数据库中,可以使用本地事务:

STARTTRANSACTION;UPDATEinventorySETstock=stock-1WHEREproduct_id=1001ANDstock>0;-- 检查影响行数,确保库存扣减成功INSERTINTOorders(id,user_id,status)VALUES(10001,888,'CREATED');COMMIT;

如果库存扣减失败,就回滚整个事务。

这样可以避免出现订单创建成功、库存却没有扣减的情况。

但在微服务架构中,订单、库存、支付可能由不同服务管理,各自拥有独立数据库:

Order Service → order_db Inventory Service → inventory_db Payment Service → payment_db

现在用户提交订单,需要依次完成:

创建订单 | v 预留库存 | v 处理支付 | v 确认订单

如果订单创建成功,库存预留成功,但支付失败,该怎么办?

以前在同一个数据库中,我们可以使用ROLLBACK。

现在三个服务使用不同的数据库,本地事务已经无法覆盖整个业务流程。

这就是分布式事务要处理的核心问题。

分布式事务的难点不是让每个数据库都支持事务,而是多个独立系统之间怎样协调成功、失败与恢复。

一、本地事务为什么不能直接管理多个微服务?

先看一个简单场景。

Order Service 收到创建订单请求:

Order Service | | 1. INSERT orders v order_db | | 2. 调用库存服务 v Inventory Service | | 3. UPDATE inventory v inventory_db

Order Service 可以在自己的数据库中使用事务:

STARTTRANSACTION;INSERTINTOorders(id,status)VALUES(10001,'CREATED');COMMIT;

Inventory Service 也可以使用自己的事务:

STARTTRANSACTION;UPDATEinventorySETavailable_stock=available_stock-1,reserved_stock=reserved_stock+1WHEREproduct_id=1001ANDavailable_stock>0;COMMIT;

但这两个事务彼此独立。

假设发生下面的情况:

Order Service | v order_db COMMIT 成功 | v 调用 Inventory Service | X 网络超时

此时:

order_db: 订单 10001 已经存在

但:

inventory_db: 库存预留状态未知

Order Service 无法直接对inventory_db执行本地回滚。

即使它对自己的数据库执行一个新的ROLLBACK,也无法撤销早已提交成功的订单事务。

为什么不把一个事务一直保持到远程调用结束?

一种直观想法是:

Order Service: BEGIN | v 创建订单 | v 调用 Inventory Service | v 调用 Payment Service | v COMMIT

问题在于,MySQL 的一个普通本地事务只能管理其对应数据库事务范围内的操作。

它不会因为 Order Service 在事务中执行了一个 RPC,就自动把远端数据库事务纳入同一个原子提交过程。

而且,在本地事务中长时间等待远程 RPC,还可能导致数据库连接和锁被持续占用。

例如:

MySQL 事务已开始 | v 锁定订单相关记录 | v 等待支付服务响应 5 秒 | v 支付服务超时

在这五秒内,相关数据库资源可能一直被占用。

并发请求越多,锁等待和连接池压力就越明显。

因此,不能把多个本地事务简单串联起来,就认为它们自动组成了一个分布式事务。


二、分布式事务真正要保证什么?

在讨论具体方案之前,需要先区分两类一致性要求。

1. 所有操作必须作为一个整体提交

例如一次操作涉及两个支持协调事务的数据库:

Database A Database B

我们希望:

A 提交成功 B 提交成功

或者:

A 不提交 B 不提交

这类要求更接近跨资源原子提交。

可以考虑使用 2PC 等分布式提交协议,但必须承担协调与资源管理成本。

2. 允许短暂中间状态,最终恢复到合法业务状态

再看订单业务。

我们不一定要求:

创建订单 预留库存 处理支付

在同一个瞬间全部完成。

实际电商系统中,订单本来就可能有多个状态:

PENDING | v STOCK_RESERVED | v PAYMENT_PENDING | v PAID

如果支付失败,可以进入取消流程:

PAYMENT_FAILED | v 释放库存 | v CANCELLED

这里允许订单短暂处于处理中状态。

关键是最终不能出现无法解释的业务结果,例如订单显示取消,但库存预留始终没有释放;或者支付已经成功,却再次发起一笔相同扣款。

这种思路就是:

不强求整个业务流程像一个本地数据库事务那样瞬时提交,而是通过明确的业务状态和恢复机制,让系统最终达到允许的结果。

这也是理解 Saga 的基础。

三、2PC:能不能让两个数据库一起提交?

2PC(Two-Phase Commit,两阶段提交)是一种经典的分布式事务协调协议。

它引入事务协调者,让多个参与者按照两个阶段完成提交。

假设一个事务涉及数据库 A 和数据库 B。

第一阶段:Prepare

协调者要求所有参与者准备提交。

Coordinator / \ v v Database A Database B | | Prepare Prepare | | Ready Ready

在典型的数据库两阶段提交场景中,参与者需要让事务达到可以提交的准备状态,并保留后续完成提交所需的信息和资源。

但此时事务还没有被协调者最终决定为全部提交。

第二阶段:Commit 或 Rollback

如果所有参与者都准备成功:

Coordinator | +---- COMMIT → Database A | +---- COMMIT → Database B

如果某个参与者准备失败:

Coordinator | +---- ROLLBACK → Database A | +---- ROLLBACK → Database B

这样可以协调多个参与者作出一致的事务决定。

但 2PC 的问题也很明显。

1. 参与者可能长时间保留事务资源

假设 Database A 已经准备成功,却一直没有收到最终提交决定。

它可能需要持续保留相关事务状态和锁。

这会影响其他事务访问这些资源。

2. 协调者故障会影响事务完成

如果协调者在关键阶段发生故障,参与者可能处于已经准备成功、但尚未得知最终决定的状态。

这类未决事务需要通过恢复机制继续完成,不能简单地让某个参与者自行提交。

3. 并不是所有业务操作都能参与数据库式提交协议

例如:

MySQL 写入订单 调用第三方支付平台扣款 发送短信

MySQL 可以参与数据库事务,但第三方支付接口和短信发送通常不能直接加入同一个标准数据库两阶段提交事务。

所以 2PC 并不是微服务分布式事务的万能解法。

对于高并发、长业务流程和外部系统较多的场景,长期占用事务资源的协调方式往往不够理想。


四、TCC:把业务操作拆成预留、确认和取消

我们之前还学习过 TCC:

Try Confirm Cancel

与数据库层面的 2PC 不同,TCC 强调由业务接口明确实现三个阶段。

例如库存服务有 10 件商品。

用户准备购买其中 2 件。

Try:预留资源

available_stock = 10 reserved_stock = 0

执行预留:

available_stock = 8 reserved_stock = 2

Try 阶段不是直接完成整个业务,而是先确保所需资源可以被占用。

Confirm:确认资源使用

支付成功并满足其他条件后,确认预留库存。

reserved_stock = 2 | v 确认扣减 | v reserved_stock = 0

对应的库存已经成为实际消耗。

Cancel:取消预留

如果支付失败,就释放之前预留的资源:

available_stock = 8 reserved_stock = 2

取消之后:

available_stock = 10 reserved_stock = 0

从业务角度看:

Try | +---- Confirm | +---- Cancel

TCC 的优势是能够更细粒度地管理业务资源。

但代价是所有参与服务都需要实现相应的预留、确认和取消语义。

而且这些接口通常还必须满足幂等要求。

例如同一笔库存预留被调用两次Cancel,不能因此把库存释放两次。

TCC 还需要处理一些边界情况,例如取消请求先于对应 Try 请求到达,或者 Try 已超时但仍在执行。

因此,实现 TCC 不只是增加三个 API,而是要设计完整的预留记录与状态转换规则。


五、Saga:不用一个大事务,如何保证订单流程最终正确?

对于订单、库存、支付这样的业务,Saga 往往更容易理解。

Saga 的基本思想是:

把一个跨服务的长事务拆分成多个本地事务,每一步独立提交;如果后续失败,再通过业务补偿操作处理之前已经成功的步骤。

继续使用我们的电商场景。

整个业务可以拆成三个阶段。

T1:创建订单 T2:预留库存 T3:完成支付确认

其中每一步都由对应服务在自己的数据库中完成本地事务。

1. 正常执行流程

例如:

Order Service | | T1:创建订单 v Order DB | v Inventory Service | | T2:预留库存 v Inventory DB | v Payment Service | | T3:处理支付 v Payment DB / Payment Provider | v Order Service | v 订单进入 PAID

在这个过程中,订单可以经历:

PENDING | v STOCK_RESERVED | v PAYMENT_PENDING | v PAID

需要注意,支付成功到订单最终标记为PAID之间也可能存在消息延迟,因此状态机应允许出现短暂的待确认阶段。

2. 如果库存预留失败怎么办?

假设 T1 已经创建订单,但 T2 发现库存不足。

T1:创建订单成功 | v T2:预留库存失败

这时没有必要让订单永久停留在PENDING。

可以执行订单取消操作:

订单 PENDING | v 库存不足 | v 订单 CANCELLED

这里的取消是一个新的业务状态转换,而不是对已经提交的订单事务执行数据库ROLLBACK。

3. 如果库存成功,但支付失败怎么办?

假设:

T1:创建订单成功 T2:预留库存成功 T3:支付明确失败

这时需要:

支付失败 | v 释放之前预留的库存 | v 取消订单

例如:

Order:PENDING Inventory:RESERVED | v Payment:FAILED | v Inventory:RELEASED | v Order:CANCELLED

这就是 Saga 的补偿思路。

它不是把整个流程恢复成“什么都没有发生”,而是通过新的业务操作,让系统回到一个合法的最终状态。

4. 为什么补偿不能直接理解成回滚?

这是 Saga 最容易被误解的地方。

MySQL 本地事务中的回滚,可以撤销尚未提交的数据库修改。

但 Saga 中的前置步骤可能已经提交成功,甚至已经产生外部副作用。

例如:

用户支付成功 | v 银行或支付渠道已经扣款

如果后续业务失败,我们不能通过:

ROLLBACK;

让支付渠道中的扣款凭空消失。

可能需要发起新的退款操作。

支付成功 | v 后续业务失败 | v 发起退款 | v 退款完成

即使最终金额恢复,业务上也真实发生过一笔支付和一笔退款。

退款还可能需要一定时间才能完成。

所以:

补偿事务不是数据库意义上的精确逆操作,而是用于恢复合法业务状态的另一项业务操作。

对于短信发送、邮件通知等不可直接撤销的外部副作用,可能根本不存在完全对等的补偿操作。

这也是为什么 Saga 适合可以明确设计业务状态和补偿语义的流程,而不能无条件替代所有跨系统原子事务。


六、Saga 最难处理的不是明确失败,而是结果未知

这里需要联系我们之前学习 RPC 时讲过的一个结论:

Timeout 不等于操作失败。

假设 Order Service 请求 Payment Service:

Order Service | | Pay(order_id=10001) v Payment Service

支付服务调用外部支付渠道。

支付渠道实际扣款成功,但是返回响应时网络断开:

Order Service Payment Service | | |--------- Pay ------------>| | | | 扣款成功 | | |<-------- Timeout ---------X | 不知道支付结果

这时候 Order Service 能不能直接认为支付失败,然后释放库存、取消订单?

不能。

因为真实支付可能已经成功。

如果立即取消订单,甚至不加判断地再次执行支付,就可能造成更严重的问题。

正确的处理方向是:

支付调用超时 | v 状态设为 PAYMENT_UNKNOWN | v 根据 payment_id 查询实际状态 | +--+--+ | | v v 成功 明确失败 | | v v 继续 执行补偿

如果暂时无法确定结果,就应该保留待确认状态,通过后续查询、回调或对账等机制继续恢复。

不能把“不知道”强行当成“失败”。

Saga 为什么必须有状态记录?

假设服务在释放库存的过程中崩溃了。

如果没有记录当前进行到哪一步,系统恢复后就可能不知道:

库存到底释放了没有? 订单取消了吗? 退款是否已经申请?

因此,可靠的 Saga 通常需要保存业务执行进度。

例如:

订单状态含义
PENDING已创建,等待后续处理
STOCK_RESERVED库存预留完成
PAYMENT_PENDING等待支付结果
PAYMENT_UNKNOWN支付结果暂时无法确认
COMPENSATING正在执行补偿
PAID支付完成
CANCELLED取消流程完成

这些只是示例状态,真实项目应根据业务含义设计。

这里真正重要的是:

系统不仅需要知道当前最终状态,还需要能够识别尚未完成的步骤和补偿任务。

否则,一旦某个服务崩溃,就很难自动恢复。

Saga 的两种组织方式

Saga 通常可以通过两种方式组织服务协作。

一种是事件驱动:

OrderCreated | v Inventory Service | v StockReserved | v Payment Service

不同服务监听事件,并发布自己的执行结果。

这种方式通常称为 Choreography(事件编排或协同)。

另一种是设置一个协调组件,明确控制下一步调用哪个服务、失败后执行哪个补偿操作。

Saga Coordinator | +-- CreateOrder | +-- ReserveStock | +-- RequestPayment | +-- Compensate

这种方式称为 Orchestration(集中编排)。

两者没有绝对优劣。

事件协作减少了集中流程控制,但业务流程可能分散在多个服务中,不容易追踪。

集中编排让流程和补偿路径更直观,但协调器自身需要可靠地保存状态并支持故障恢复。


七、Saga 解决了补偿,为什么还需要 Transactional Outbox?

现在假设我们已经采用 Saga。

Order Service 创建订单之后,需要向 Kafka 发布事件:

OrderCreated

Inventory Service 订阅这个事件,并预留库存。

Order Service | | OrderCreated v Kafka | v Inventory Service

看起来已经解决了跨服务协作问题。

但现在出现一个更基础的故障。

Order Service 要完成两件事:

第一件:MySQL 创建订单 第二件:Kafka 发布 OrderCreated

如果程序这样执行:

MySQL INSERT order | v MySQL COMMIT 成功 | v 准备发送 Kafka 消息 | X Order Service 崩溃

结果就是:

MySQL: 订单已经创建

但:

Kafka: 没有 OrderCreated

库存服务根本不知道需要处理这笔订单。

于是订单可能一直停留在待处理状态。

这时即使 Kafka 本身非常可靠,也没有办法消费一条从未发送进去的消息。

先发送 Kafka,再提交 MySQL 呢?

我们把顺序反过来:

发送 OrderCreated | v Kafka 确认成功 | v 准备提交 MySQL | X 事务失败

此时:

Kafka: 已经存在 OrderCreated

但:

MySQL: 订单创建失败

Inventory Service 可能收到一个没有对应有效订单的事件。

所以:

先写 MySQL 再写 Kafka,以及先写 Kafka 再写 MySQL,都无法自动保证两项操作原子成功。

这不是简单修改顺序能够解决的问题。

我们需要把“订单状态变更”和“需要发布的事件”先纳入同一个可靠的提交边界。

这就是 Transactional Outbox 的核心思想。


八、Transactional Outbox:把事件先写进同一个数据库事务

Transactional Outbox(事务发件箱)并不要求在 MySQL 事务中直接完成 Kafka 发送。

它采用的思路是:

业务数据和待发布事件都先写入同一个 MySQL 数据库,并由同一个本地事务提交。

例如 Order Service 的数据库包含两张表:

order_db | +-- orders | +-- outbox_events

其中:

orders保存订单业务数据。

outbox_events保存需要发布的业务事件记录。

1. 创建 Outbox 表

一个简化设计如下:

CREATETABLEoutbox_events(event_idVARCHAR(64)PRIMARYKEY,aggregate_idBIGINTNOTNULL,event_typeVARCHAR(64)NOTNULL,payload JSONNOTNULL,statusVARCHAR(20)NOTNULLDEFAULT'PENDING',created_atDATETIMENOTNULL,published_atDATETIMENULL);

例如:

event_id = E10001 aggregate_id = 10001 event_type = OrderCreated status = PENDING

2. 业务修改与事件记录放进一个事务

创建订单时:

STARTTRANSACTION;INSERTINTOorders(id,user_id,status)VALUES(10001,888,'PENDING');INSERTINTOoutbox_events(event_id,aggregate_id,event_type,payload,status,created_at)VALUES('E10001',10001,'OrderCreated',JSON_OBJECT('event_id','E10001','order_id',10001,'user_id',888),'PENDING',NOW());COMMIT;

这两个 INSERT 属于同一个 MySQL 本地事务。

因此,在事务正常执行的前提下:

订单写入成功 + Outbox 事件写入成功 | v 一起 COMMIT

如果事件写入失败:

Outbox INSERT 失败 | v ROLLBACK | v 订单也不提交

这样,就把原来跨两个系统的写入问题,转换成了 MySQL 内部能够处理的原子提交问题。

但这里还没有直接把事件发送到 Kafka。

3. Relay 负责将 Outbox 事件发布到 Kafka

需要一个独立的事件转发组件,通常称为 Relay。

它不断查找尚未发布的 Outbox 记录,并将消息发送到 Kafka。

Order Service | v MySQL 本地事务 | +------+------+ | | v v orders outbox_events | v Relay | v Kafka | v Inventory Consumer

例如 Relay 读取:

SELECT*FROMoutbox_eventsWHEREstatus='PENDING'ORDERBYcreated_atLIMIT100;

然后把事件发布到 Kafka。

收到相应的 Broker 成功确认后,再更新 Outbox 记录:

UPDATEoutbox_eventsSETstatus='PUBLISHED',published_at=NOW()WHEREevent_id='E10001';

这里展示的是单个 Relay 的简化流程。

真实部署如果有多个 Relay 实例,还需要通过安全的领取、租约或其他并发控制机制,避免多个工作进程无序地争抢同一批待发布事件。

Outbox 也可以通过读取数据库变更日志等方式向 Kafka 转发事件,但无论采用轮询还是其他转发方式,关键的可靠性问题仍然存在。

Transactional Outbox 保证的是业务修改和待发布事件记录能够在同一个本地事务中提交,而不是让 MySQL 与 Kafka 直接共享一个事务。


九、Outbox 最重要的故障窗口:Kafka ACK 成功,Relay 却崩溃了

前面 Kafka 可靠性专题中,我们学过一个结论:

操作成功,但响应或后续确认没有完成时,重试可能产生重复。

在 Transactional Outbox 中,同样存在这个问题。

场景一:MySQL 已提交,Relay 还没发送

Order Service | v MySQL COMMIT 成功 | v Outbox 状态 = PENDING | X Relay 暂时不可用

这时候事件没有进入 Kafka。

但它已经保存在 MySQL 的 Outbox 表中。

只要这条记录没有丢失,并且 Relay 最终能够恢复处理,它就可以在之后继续发布。

这正是 Outbox 的主要价值。

场景二:Kafka 发布失败

Relay | | Publish E10001 v Kafka | X 发送失败

Relay 没有获得可靠的成功确认,就不应该直接将事件标记成已经发布。

Outbox 记录仍然需要保留,等待之后重试。

场景三:Kafka 已经成功,Relay 在更新 Outbox 前崩溃

这是最值得理解的故障窗口。

Relay | | Publish E10001 v Kafka | v 消息写入成功 | v 返回 ACK | v Relay 收到 ACK | X Relay 崩溃

此时 Kafka 中已经存在:

E10001

但 MySQL 中:

outbox_events.status = PENDING

因为 Relay 还没有来得及将其更新为PUBLISHED。

Relay 重启以后,再次扫描 Outbox:

发现 E10001 仍然是 PENDING | v 再次向 Kafka 发布 E10001

结果:

同一条业务事件可能被发布两次。

这并不意味着 Outbox 实现错了。

而是因为 Kafka 发送成功与 MySQL 中的发布状态更新,仍然是两个独立操作。

为什么不能先标记 PUBLISHED,再发送 Kafka?

如果反过来执行:

先标记 PUBLISHED | v 准备发送 Kafka | X Relay 崩溃

那么 MySQL 认为事件已经发布,Kafka 却可能根本没有收到。

由于 Relay 后续不再处理这条记录,就存在事件永久遗漏的风险。

因此,常见的安全顺序是:

读取待发布事件 | v 向 Kafka 发送 | v 等待成功确认 | v 标记已发布

这个顺序允许重复发布,但降低了事件被错误地提前标记为已发布的风险。

它采用的核心思路是:

宁可让同一条事件在故障恢复时再次发布,也不要因为过早确认而遗漏事件。

因此,Transactional Outbox 通常需要和 Consumer 幂等一起使用。


十、为什么使用 Outbox 之后,Consumer 仍然需要幂等?

继续使用订单创建事件:

{"event_id":"E10001","event_type":"OrderCreated","order_id":10001,"product_id":1001,"quantity":1}

由于 Relay 崩溃或者消息确认丢失,这条事件可能被重复发布。

Kafka: E10001 E10001

Inventory Consumer 如果每收到一次消息就直接扣减库存:

UPDATEinventorySETavailable_stock=available_stock-1WHEREproduct_id=1001;

就可能发生重复扣减。

因此,需要让消费端能够识别:

E10001 是否已经处理过?

一种常见方案是使用 MySQL 唯一约束记录消费事件。

例如:

CREATETABLEprocessed_events(consumer_nameVARCHAR(64)NOTNULL,event_idVARCHAR(64)NOTNULL,processed_atDATETIMENOTNULL,PRIMARYKEY(consumer_name,event_id));

Consumer 处理事件时,在同一个数据库事务中:

尝试登记 E10001 | v 执行库存预留 | v COMMIT

如果是第一次处理,登记成功,并完成库存操作。

如果同一消费业务再次收到E10001,唯一键约束能够识别重复事件。

但这里有两个需要注意的问题。

第一,去重记录与库存操作必须处于同一个本地事务。

否则可能出现去重记录已经成功、库存修改却失败的情况。

第二,消费端仍然需要检查库存是否充足。

例如:

UPDATEinventorySETavailable_stock=available_stock-1,reserved_stock=reserved_stock+1WHEREproduct_id=1001ANDavailable_stock>=1;

如果影响行数为 0,就应根据实际原因处理库存不足,而不是把事件记录成已经成功预留。

完整的简化逻辑可以写成:

BEGIN | v 插入去重记录 | +-- 已成功处理过 | | | v | 跳过重复副作用 | +-- 第一次处理 | v 检查业务条件 | v 预留库存 | v COMMIT

如果事务发生业务失败,需要按状态机记录正确的结果,或回滚并安排安全重试,而不是把所有错误都当作已经成功。

这样即使收到重复消息,也不会简单重复扣减库存。

幂等只能处理重复,不能自动保证整个流程完成

假设 Inventory Consumer 成功预留库存后,还需要发布:

StockReserved

如果库存数据库事务已经提交,但这个事件又没有成功发布,那么 Saga 仍然可能停留在等待状态。

因此,Inventory Service 也可以使用自己的 Outbox,在同一个本地事务中记录:

库存预留成功 + StockReserved 待发布事件

这样,Saga 的每一步不仅需要本地事务正确,还需要让相应结果能够可靠传播给下一步。

这就形成一个完整的闭环:

Order Service | v Order DB + Outbox | v Kafka | v Inventory Consumer | v Inventory DB + Outbox | v Kafka | v 后续服务 / Saga Coordinator

这条链路中:

  • Outbox 减少本地数据库提交后漏发事件的风险。
  • Kafka 保存和传递事件。
  • Consumer 幂等控制重复业务副作用。
  • Saga 状态机决定下一步业务动作和失败补偿。
  • 监控与恢复任务负责处理长期停滞的状态。

每个机制都有明确的职责。


十一、Saga 和 Transactional Outbox 是不是同一种方案?

不是。

虽然它们经常一起出现,但解决的问题不同。

假设用户创建订单,需要预留库存并完成支付。

Saga 主要回答:

订单已经创建 库存已经预留 但是支付失败 下一步该做什么?

答案可能是:

释放库存 取消订单

也就是说:

Saga 关注跨服务业务流程的状态推进和失败补偿。

Transactional Outbox 关注的是另一类问题:

订单已经写入 MySQL 但 OrderCreated 事件还没有进入 Kafka

它要解决:

数据库变更与待发布事件怎样可靠地保持一致。

因此,两者可以这样区分:

机制主要解决的问题
本地事务单个数据库内的原子提交
2PC多个支持协调提交的参与者如何共同决定提交
TCC通过业务预留、确认和取消实现协调
Saga多个本地事务如何通过状态流转和补偿完成业务
Transactional Outbox数据库状态变化后如何可靠记录并发布对应事件
Consumer 幂等消息重复处理时如何避免重复业务副作用

这里要特别强调:

Outbox 不能代替 Saga,Saga 也不能自动保证 Kafka 事件不会遗漏。

一个可靠的异步业务流程,可能同时需要:

Saga + Transactional Outbox + Consumer Idempotency

这不是重复设计,而是因为它们分别解决不同的故障问题。


十二、实际系统中应该怎么选择?

理解了这些机制以后,不应该默认任何跨服务业务都要使用复杂的分布式事务框架。

可以先判断业务是否真的必须跨多个存储边界。

场景一:所有修改都在一个 MySQL 中

例如:

创建订单 | v 扣减订单服务自己的业务额度 | v 更新相关状态

如果所有数据位于同一个可参与事务的 MySQL 数据库中,那么优先使用本地事务。

没有必要额外引入 Saga 或 2PC。

场景二:需要跨服务,但允许补偿

例如:

创建订单 | v 预留库存 | v 支付

可以考虑 Saga。

但前提是能够定义清晰的状态机和补偿操作。

例如库存预留成功后,支付失败可以释放预留库存。

场景三:需要业务层资源预留

例如一个操作必须先锁定多个业务资源,再决定确认还是取消。

可以评估 TCC。

但需要承担 Try、Confirm、Cancel 的实现复杂度。

场景四:数据库提交后必须可靠发布事件

例如:

MySQL 写入 OrderCreated 业务状态 | v 需要 Kafka 通知库存服务

可以考虑 Transactional Outbox。

但不能因此省略 Consumer 幂等。

场景五:跨多个数据库需要原子提交

如果参与者支持相应事务协调协议,而且业务确实需要严格的共同提交语义,可以评估 2PC 等方案。

但要考虑资源锁定、未决事务恢复和故障协调成本。

真正的选型关键不是方案名称,而是:

业务允许哪些中间状态、哪些操作能够补偿、哪些数据不能丢失,以及发生故障后系统能否继续恢复。


十三、如何验证这些机制真的可靠?

分布式事务特别容易出现一种情况:

正常流程测试完全通过,但系统在特定故障窗口下出现严重问题。

因此,测试不能只覆盖:

创建订单成功 库存预留成功 支付成功

还需要人为制造故障。

例如:

故障场景应重点验证什么
订单事务提交前崩溃订单与 Outbox 是否一起回滚
订单事务提交后崩溃待发布事件是否仍保留
Relay 发送 Kafka 前崩溃事件是否可以继续发布
Kafka ACK 成功后 Relay 崩溃是否可能重复发布,Consumer 能否幂等
Consumer 更新 MySQL 后崩溃重新消费是否产生重复副作用
库存预留成功后支付失败库存是否最终释放
支付接口超时是否先确认实际支付状态,而不是盲目重试
补偿操作执行中崩溃系统恢复后能否继续补偿
事件长时间未被处理是否能够发现积压并恢复

这里有一个测试原则很重要:

故障应该发生在关键操作已经完成、但下一个确认步骤尚未完成的位置。

例如 Transactional Outbox 最重要的故障窗口之一,就是:

Kafka ACK 成功 | X Relay Crash | v Outbox 状态尚未更新

如果只测试 Kafka 发送失败,却不测试这个窗口,就可能遗漏最常见的重复发布路径。

同样,对于 Saga,不能只测试支付服务明确返回失败,还应测试支付实际成功但响应超时的场景。

因为明确失败通常比较容易处理,而执行结果未知才是分布式系统最难处理的部分之一。

还需要哪些监控?

除了常规错误率,更值得关注的是:

Outbox PENDING 记录数量 最早待发布事件的等待时间 Relay 发布失败率 Kafka Consumer Lag 重复事件识别数量 Saga 长时间未完成数量 补偿任务失败次数

这些指标能够帮助我们区分:

系统只是正在异步处理

和:

某个业务流程已经卡住,需要恢复

例如订单进入COMPENSATING状态后,如果库存释放一直失败,就不能只靠用户再次发起请求来触发恢复。

系统应该能够主动发现并继续处理这些长期未完成的状态。


十四、总结:分布式事务的核心不是“回滚”,而是可恢复

回顾这一篇,我们从一个订单场景出发,逐步讨论了本地事务、2PC、TCC、Saga 和 Transactional Outbox。

其中最重要的变化,是事务边界从单个数据库扩展到了多个独立系统。

对于 MySQL 本地事务:

BEGIN | v 执行 SQL | v COMMIT / ROLLBACK

数据库可以在自己的事务边界内协调原子提交。

但跨服务之后:

Order DB | v Inventory DB | v Payment System

每个系统都有独立的执行结果和故障窗口。

如果简单认为失败了就能统一ROLLBACK,就很难正确处理已经提交的数据库操作,以及已经发生的真实支付或外部副作用。

Saga 提供了一种不同的思路:

允许多个本地事务依次提交,通过明确的业务状态和补偿操作处理后续失败。

Transactional Outbox 则进一步处理:

数据库变更已经提交,但对应事件可能因进程崩溃而未发布的问题。

不过,Outbox 通常采用至少一次的可靠发布方式,因此可能产生重复消息,消费端仍然需要幂等。

把这些机制放在一起,可以得到一条完整的可靠异步业务链路:

业务请求 | v MySQL 本地事务 | +---- 业务状态修改 | +---- Outbox 事件记录 | v COMMIT | v Relay 可靠发布 | v Kafka | v Consumer 幂等处理 | v 下一个本地事务 | v Saga 状态推进 / 补偿

最后需要记住三个结论。

第一,本地事务只能保证自己的事务边界。

多个数据库各自支持 ACID,并不意味着这些事务自动组成一个全局原子操作。

第二,补偿不等于数据库回滚。

Saga 的补偿可能是取消订单、释放库存或者发起退款。这些都是新的业务操作,本身也可能失败并需要重试。

第三,可靠发布不等于永不重复。

Transactional Outbox 主要防止数据库提交后遗漏事件,但不能简单消除所有重复发送。因此还需要 Consumer 幂等和明确的业务状态约束。

分布式事务设计真正应该关注的是:

当系统在任何关键步骤发生崩溃、超时或重复执行时,是否仍然有足够的信息判断当前状态,并把业务恢复到一个合法结果。

这比单纯追求所有步骤在正常情况下都能够执行成功更加重要。

下一篇会继续讨论分布式系统的另一项基础能力:CAP、Raft 与多数派机制,理解多个节点之间如何形成一致决定,以及为什么三个节点的集群只剩两个节点时,仍然可能继续工作。

返回列表