返回文章列表

Rust 微服务里,很多 bug 不是并发问题,而是状态机没设计好

213·2 分钟阅读
Rust微服务

订单已经取消了,支付回调又把它改成已支付;退款已经成功,补偿任务又发起了一次退款;任务失败了,重试却把它从终态拉回处理中。

这些问题表面看像并发、重试、消息乱序,往深处看,往往是状态机没有设计好。

微服务里最危险的状态字段,不是数据库里的 status,而是代码里那句随手写的:

update orders set status = 'paid' where id = ?

如果没有定义“哪些状态可以流向哪些状态”,系统迟早会出现非法流转。

状态机不是架构图里的装饰,它是业务一致性的骨架。


status 字符串很方便,也很危险

很多订单表一开始都会这样设计:

create table orders (
    id bigserial primary key,
    status text not null
);

然后代码里到处出现字符串:

fn is_finished(status: &str) -> bool {
    status == "paid" || status == "cancelled"
}

这段代码能跑,但它没有边界。

"paying""PAYING""paid " 都是字符串。更麻烦的是,任何地方都能把状态改成任何值。

我更偏向先在 Rust 里把状态收窄:

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum OrderStatus {
    Created,
    Paying,
    Paid,
    Cancelled,
    Failed,
}

这不是为了“Rust 味”更浓,而是为了让编译器帮你看住状态集合。

业务状态一旦是开放字符串,状态机就很难存在。


状态迁移要显式表达

有了枚举还不够。真正关键的是迁移规则。

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum OrderEvent {
    StartPayment,
    PaymentSucceeded,
    PaymentFailed,
    Cancel,
}

状态和事件合起来,才能决定下一个状态:

fn transition(
    status: OrderStatus,
    event: OrderEvent,
) -> Option<OrderStatus> {
    match (status, event) {
        (OrderStatus::Created, OrderEvent::StartPayment) => Some(OrderStatus::Paying),
        (OrderStatus::Created, OrderEvent::Cancel) => Some(OrderStatus::Cancelled),
        (OrderStatus::Paying, OrderEvent::PaymentSucceeded) => Some(OrderStatus::Paid),
        (OrderStatus::Paying, OrderEvent::PaymentFailed) => Some(OrderStatus::Failed),
        _ => None,
    }
}

None 很重要。它表达的是:这次流转非法。

比如 Cancelled -> PaidPaid -> Failed 都不应该悄悄成功。它们应该被拒绝、记录、报警,至少要让调用方知道“当前状态不允许这个动作”。

状态机里最有价值的不是能走的路径,而是不能走的路径。


数据库也要参与状态机

只在 Rust 里判断还不够。并发请求可能同时读到旧状态,然后各自判断通过。

状态更新应该带上当前状态条件:

update orders
set status = 'paid'
where id = $1
  and status = 'paying';

应用层根据影响行数判断是否成功:

#[derive(Debug, PartialEq, Eq)]
enum UpdateResult {
    Changed,
    Conflict,
}
 
fn map_rows_affected(rows: u64) -> UpdateResult {
    if rows == 1 {
        UpdateResult::Changed
    } else {
        UpdateResult::Conflict
    }
}

这类条件更新比“先查状态,再无条件 update”可靠得多。

Rust 负责表达迁移规则,数据库负责裁决并发竞争。两边缺一边,状态机都会漏风。


终态不要被后台任务拉回来

后台任务和消息回调最容易破坏状态机。

比如支付回调晚到了:

订单已取消
支付平台回调支付成功
服务把订单改成已支付

这时正确行为不一定是“忽略回调”。它可能需要触发退款、人工处理、或者进入异常状态。但它不应该直接覆盖终态。

可以先定义终态:

fn is_terminal(status: OrderStatus) -> bool {
    matches!(
        status,
        OrderStatus::Paid | OrderStatus::Cancelled | OrderStatus::Failed
    )
}

终态不是永远不能产生后续动作,而是不能被普通业务流转随意改写。

如果取消后支付成功,应该是一个新流程:

Cancelled + PaymentSucceeded -> RefundRequired

而不是:

Cancelled -> Paid

终态保护的是用户和系统对结果的共同认知。


状态机不是只能写在代码里

有些状态机规则可以写在 Rust 里,有些更适合写在表里。

如果状态很多、运营经常调整流程,可以用迁移表:

create table state_transitions (
    from_status text not null,
    event text not null,
    to_status text not null,
    primary key (from_status, event)
);

但我不会一开始就上配置化状态机。

配置化的代价很明确:规则更灵活,类型安全更弱,测试更依赖数据准备,排查时也更绕。

我的偏好是:

场景 适合方式
核心支付、订单、退款 Rust enum + 明确迁移函数
审批流、运营流程 数据库迁移表
低风险展示状态 简单枚举

越靠近资金、库存、权限,越应该让状态机写得硬一点。


状态变化要产生日志和事件

状态机不是只改一列字段。

状态变化通常要留下历史:

#[derive(Debug)]
struct StateChange {
    order_id: u64,
    from: OrderStatus,
    to: OrderStatus,
    reason: &'static str,
}
 
fn describe_change(change: &StateChange) -> String {
    format!("{:?}->{:?}: {}", change.from, change.to, change.reason)
}

真实系统里,这份历史可以写入 order_status_logs,也可以通过 Outbox 发事件。

原因很简单:状态错了以后,你需要知道它是怎么错的。

只保留当前状态,排查时只能看到结果;保留状态变化,才能看到路径。


结论

很多微服务 bug 不是因为 Rust 不够安全,也不是因为数据库不够强,而是业务状态没有被认真建模。

状态字段不能只是字符串;状态迁移要显式表达;数据库更新要带当前状态条件;终态不能被普通重试和回调随意改写;关键状态变化要留下历史和事件。

状态机设计清楚以后,重试、补偿、Outbox、幂等才有稳定的落点。