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问题就在 commit 和 send 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;只要消息可能重复投递,消费者就必须能去重。
重试不是可靠性的起点。幂等才是。