返回文章列表

Rust 微服务里真正难的,是“这件事到底做没做过”

319·3 分钟阅读
Rust微服务

用户点了提交,前端等到超时;后端可能已经写库,也可能还没写;消息可能发出去了,也可能卡在发送前。

微服务里很多问题不是“失败”本身,而是失败之后的不确定。

请求超时以后,调用方会重试;消息投递失败以后,后台任务会重试;消费者处理失败以后,队列也会重试。每一层都在努力提高成功率,但如果业务没有回答一个问题,重试越努力,系统越危险:

这件事到底做没做过?

这篇接着前面的 超时、取消和重试HTTP Client 治理gRPC 生产实践 往下讲。重试不是单独存在的能力,它必须和幂等、事务、Outbox 放在一起看。

本文代码环境:

# Cargo.toml
[dependencies]
serde = { version = "1", features = ["derive"] }
uuid = { version = "1", features = ["v4", "serde"] }

超时之后,系统最怕的是不确定

下单接口最容易说明问题。调用方发起请求:

use serde::{Deserialize, Serialize};
use uuid::Uuid;
 
#[derive(Debug, Deserialize, Serialize)]
struct CreateOrderRequest {
    user_id: u64,
    sku_ids: Vec<u64>,
    idempotency_key: Uuid,
}

这个 idempotency_key 不是装饰字段。它表达的是一句业务承诺:

同一个用户、同一个幂等键,只能产生同一个下单结果。

如果服务端处理到一半,调用方超时了,调用方可以拿同一个 key 再请求一次。服务端不能说“我不知道”,它必须返回已经创建的订单,或者明确告诉调用方这次操作没有成功。

没有这个 key,重试就会变成猜谜。

客户端:刚才那次下单成功了吗?
服务端:不知道,要不你再试一次?
客户端:再试一次会不会生成两个订单?
服务端:也不好说。

这种系统不是不可靠,是没有定义可靠性的边界。


幂等键不是 request_id

很多团队会把 request_id 当幂等键用,这个习惯要改。

request_id 代表一次网络请求。它适合查日志、串链路、定位错误。

idempotency_key 代表一次业务意图。它适合去重、恢复结果、防止重复提交。

两者生命周期完全不同:

字段 含义 重试时是否变化
request_id 这一次 HTTP/gRPC 调用 通常变化
trace_id 一条调用链路 可能继承
idempotency_key 一次业务操作 必须不变

我个人更偏向把幂等键放在业务请求体里,而不是藏在基础设施 header 里。原因很简单:幂等是业务语义,不是网关语义。

创建订单、发券、扣库存、提交审批,它们对“同一次操作”的定义都不一样。基础设施最多负责透传,不能替业务决定。


唯一约束是数据库防线

只在代码里判断“有没有处理过”是不够的。

真正的防线应该落到数据库唯一约束上:

create table orders (
    id bigserial primary key,
    user_id bigint not null,
    idempotency_key uuid not null,
    status text not null,
    created_at timestamptz not null default now(),
    unique (user_id, idempotency_key)
);

这条唯一约束的意义很直接:并发重试、网关重复转发、客户端连点,都不可能插出两条订单。

Rust 代码里可以先查再写,但不要把“先查”当安全保证:

#[derive(Debug, Clone)]
struct Order {
    id: u64,
    user_id: u64,
}
 
fn existing_order(user_id: u64, order_id: u64) -> Order {
    Order { id: order_id, user_id }
}

真实实现里,insert 遇到唯一冲突后,应该回查已有订单并返回。

这不是为了优雅,而是为了面对并发现实:两个请求可能同时看到“还没创建”,然后一起插入。能裁决这个竞争的不是你的 if 判断,是数据库。


只写数据库不发消息,会丢事件

订单写完以后,通常要发一条事件:

OrderCreated -> 库存服务扣减
OrderCreated -> 优惠券服务核销
OrderCreated -> 风控服务检查

最直觉的写法是:

begin transaction
insert orders
commit
send message

问题就在 commitsend message 中间。

如果数据库提交成功,服务进程在发消息前崩了,订单已经存在,但下游永远不知道。这类 bug 很难靠重试补回来,因为重试入口在 HTTP 请求上,而 HTTP 请求可能早就结束了。

反过来,如果先发消息再提交数据库,也会出现下游看到了订单事件,但订单记录还不存在的问题。

业务写入和事件写入必须在同一个事务里。

这就是 Outbox。


Outbox 把“要发的消息”先写进数据库

Outbox 的核心并不复杂:不要在业务事务里直接调用 MQ,而是把“将来要发的消息”写进一张表。

create table outbox_events (
    id uuid primary key,
    aggregate_type text not null,
    aggregate_id bigint not null,
    event_type text not null,
    payload jsonb not null,
    sent_at timestamptz
);

创建订单时,同一个事务里写两份东西:

begin;
 
insert into orders (user_id, idempotency_key, status)
values ($1, $2, 'created');
 
insert into outbox_events (id, aggregate_type, aggregate_id, event_type, payload)
values ($3, 'order', $4, 'OrderCreated', $5);
 
commit;

现在故障边界清楚了:

  • 事务失败:订单和事件都没有
  • 事务成功:订单和待发送事件一定都在

Outbox 不保证消息立刻发出,但它保证不会凭空丢掉“应该发消息”这件事。


后台投递可以重试,但消费者也要幂等

Outbox worker 做的事情很朴素:扫描未发送事件,投递,成功后标记 sent_at

use uuid::Uuid;
 
#[derive(Debug, Clone)]
struct OutboxEvent {
    id: Uuid,
    event_type: String,
    payload: String,
}
 
fn should_publish(event: &OutboxEvent) -> bool {
    !event.event_type.is_empty() && !event.payload.is_empty()
}

真实代码会把 should_publish 换成 MQ 调用,但关键策略不变:

select unsent events
publish event
mark event as sent

这里仍然有一个缝隙:消息已经发出,但 mark sent 之前进程崩了。下一轮 worker 会再次投递同一条事件。

所以 Outbox 只能保证“至少一次投递”,不能保证“只投递一次”。

消费者必须用 event_id 去重:

create table processed_events (
    event_id uuid primary key,
    processed_at timestamptz not null default now()
);

消费者处理时,先插入 processed_events。插入成功才处理业务;遇到唯一冲突就说明已经处理过,直接确认消息。

生产系统里,Exactly Once 大多不是 MQ 给你的,而是业务用唯一约束和幂等语义拼出来的。


哪些操作不能靠幂等键糊过去

幂等不是万能胶。

有些操作天然不适合简单幂等:

操作 风险 更合理的设计
扣款 重复扣钱 支付流水唯一号 + 状态机
发券 重复发放 用户券唯一约束
扣库存 超卖 库存流水 + 条件更新
外部 API 调用 对方不支持幂等 本地状态机 + 人工补偿入口

我的偏好是:幂等键只解决入口重复提交,不负责掩盖业务状态机设计不足。

比如支付,不要只想着“同一个 key 只扣一次”。你还要关心:

  • 已创建,未支付
  • 支付中
  • 支付成功
  • 支付失败
  • 已退款

幂等键能帮你找到同一笔业务操作,但状态机才决定下一步该做什么。


结论

Rust 不会自动让微服务变可靠。它能帮你把数据结构、错误类型、并发边界写得更清楚,但“重试以后会不会重复做业务”这件事,仍然要靠工程设计。

只要一个接口可能被重试,它就必须定义幂等语义;只要一个业务写入要通知下游,它就应该考虑 Outbox;只要消息可能重复投递,消费者就必须能去重。

重试不是可靠性的起点。幂等才是。