Rust 微服务里,很多 bug 不是并发问题,而是状态机没设计好
订单已经取消了,支付回调又把它改成已支付;退款已经成功,补偿任务又发起了一次退款;任务失败了,重试却把它从终态拉回处理中。
这些问题表面看像并发、重试、消息乱序,往深处看,往往是状态机没有设计好。
微服务里最危险的状态字段,不是数据库里的 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 -> Paid、Paid -> 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、幂等才有稳定的落点。