Rust 微服务别幻想分布式事务,补偿流程才是现实
下单成功,库存扣了,优惠券核销了,支付却失败了。这个时候系统要做的不是“回滚整个世界”,而是把已经发生的动作补回来。
跨服务事务最容易让人产生错觉。
单体里一个数据库事务可以解决很多问题。拆成微服务以后,订单、库存、优惠券、支付分散在不同服务里,就很难再靠一个事务包住。
这时更现实的模型是 Saga:把一个大流程拆成多个本地事务,每个本地事务成功后,记录下一步;如果后面失败,执行补偿动作。
Saga 不是强一致魔法,它是可恢复的业务流程。
跨服务流程不是一个大事务
下单流程通常长这样:
创建订单
锁定库存
核销优惠券
发起支付
确认订单如果每个动作都在不同服务里,想用一个事务全部包住,代价会非常高。网络会失败,服务会重启,调用方会超时,参与者状态也未必同步。
更适合的建模是流程状态:
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum OrderSagaState {
Created,
StockReserved,
CouponUsed,
PaymentStarted,
Completed,
Compensating,
Failed,
}这个状态不是订单状态本身,而是“下单流程走到哪里了”。
订单状态回答用户看到什么,Saga 状态回答系统还欠哪些动作。
每个动作都要有对应补偿
不是所有动作都能回滚,但关键动作都要想清楚失败后怎么办。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SagaAction {
ReserveStock,
UseCoupon,
StartPayment,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Compensation {
ReleaseStock,
RestoreCoupon,
RefundPayment,
}映射关系要明确:
fn compensation_for(action: SagaAction) -> Compensation {
match action {
SagaAction::ReserveStock => Compensation::ReleaseStock,
SagaAction::UseCoupon => Compensation::RestoreCoupon,
SagaAction::StartPayment => Compensation::RefundPayment,
}
}这段代码看起来简单,但它逼你回答一个业务问题:每个成功动作,失败时怎么补。
如果某个动作没有补偿,那它就不应该随便放在流程前面。
补偿不是撤销,而是新的业务动作
很多人把补偿理解成 rollback,这不准确。
释放库存、退券、退款,都是新的业务动作。它们也可能失败,也需要幂等,也需要记录状态。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CompensationStatus {
Pending,
Running,
Succeeded,
Failed,
}
fn can_retry(status: CompensationStatus) -> bool {
matches!(status, CompensationStatus::Pending | CompensationStatus::Failed)
}补偿动作不能依赖“肯定一次成功”。
如果退款失败,你需要重试、告警、人工处理入口。否则 Saga 只是把不一致从主流程移到了补偿流程里。
补偿流程必须和主流程一样可观测、可重试、可幂等。
Saga 状态要持久化
Saga 不能只存在内存里。
服务进程可能在任何位置重启:
库存已锁定
优惠券已核销
进程崩溃如果流程状态没有持久化,系统重启后就不知道该继续支付,还是该释放库存和退券。
表结构可以很朴素:
create table order_sagas (
id bigserial primary key,
order_id bigint not null,
state text not null,
attempts int not null default 0,
last_error text
);每完成一个本地事务,就更新 Saga 状态,并通过 Outbox 发出下一步命令。
Saga 和 Outbox 经常一起出现,因为流程推进也不能丢。
编排式和协同式各有代价
Saga 有两种常见风格。
编排式:一个 orchestrator 决定下一步调用谁。
协同式:每个服务监听事件,自己决定下一步。
| 风格 | 好处 | 代价 |
|---|---|---|
| 编排式 | 流程清楚,容易追踪 | 中心流程服务更重 |
| 协同式 | 服务解耦,事件驱动自然 | 全局流程难看清 |
我更偏向核心交易流程用编排式。订单、支付、库存这种链路,排查时需要清楚看到流程卡在哪里。
协同式适合低风险扩展,比如发通知、同步搜索索引、积分累计。
不要为了“解耦”把核心流程拆成一堆没人能看懂的事件反应。
幂等键贯穿整个 Saga
Saga 里的每个动作都可能重复。
库存服务可能收到两次锁库存命令,优惠券服务可能收到两次退券命令,支付服务可能收到两次退款命令。
所以每个命令都要有业务唯一标识:
#[derive(Debug, Clone)]
struct SagaCommand {
saga_id: u64,
action: SagaAction,
idempotency_key: String,
}
fn command_key(saga_id: u64, action: SagaAction) -> String {
format!("saga:{saga_id}:{action:?}")
}这个 key 应该被下游用来去重,而不是只停留在调用方日志里。
Saga 没有幂等,补偿就会变成新的风险来源。
结论
微服务里的跨服务一致性,不能靠幻想一个全局事务解决。现实做法是把流程拆成可持久化、可重试、可补偿的本地事务序列。
Saga 状态要持久化;每个成功动作要有补偿策略;补偿是新的业务动作,不是数据库 rollback;核心交易流程更适合编排式;每个命令和补偿都必须幂等。
Saga 不会让一致性变简单,但它至少让失败有路可走。