返回文章列表

Rust 微服务别幻想分布式事务,补偿流程才是现实

185·2 分钟阅读
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 不会让一致性变简单,但它至少让失败有路可走。