幂等设计:分布式系统里最被低估的约定
接口能重试,才算真正可用。用一个支付扣款场景讲清楚幂等键、状态机与并发边界该怎么配合,以及 90% 的实现错在哪。
如果只能给分布式系统加一条规矩,我会选这条:
任何会被重试的写操作,都必须幂等。
因为重试不是异常情况,而是常态。超时、连接重置、上游负载均衡切换、用户连点两次 —— 这些事件在生产环境的频率远高于大多数人的直觉。
一个真实的失败案例
一个扣款接口,逻辑长这样:
async function charge(orderId: string, amount: number) {
const order = await orders.find(orderId);
if (order.paid) return; // ← 看起来有幂等保护
await payments.create({ orderId, amount });
await orders.markPaid(orderId);
}
上线三个月后出现资损。原因很朴素:两个请求同时进来了。它们都在对方把 paid 置为 true 之前读到了 paid = false,于是钱扣了两次。
先检查再写入,不是幂等,是竞态。
正确的做法:幂等键 + 唯一约束
第一步,让客户端为每次「业务意图」生成一个唯一的幂等键。注意是每次意图,而不是每次请求 —— 重试沿用同一个键。
POST /payments
Idempotency-Key: 8f2c1d9a-...
第二步,把幂等键做成数据库的唯一约束,让数据库来裁决唯一性,而不是让应用代码来检查。只有数据库能在并发下提供这个保证。
create table payment_requests (
idempotency_key text primary key,
order_id text not null,
amount bigint not null,
status text not null default 'pending',
response_body jsonb,
created_at timestamptz not null default now()
);
第三步,把处理流程改写成「抢占式」:
async function handle(key: string, orderId: string, amount: number) {
// 1. 抢占:插入失败说明已有同键请求在处理或已完成
const inserted = await db.query(
`insert into payment_requests (idempotency_key, order_id, amount)
values ($1, $2, $3)
on conflict (idempotency_key) do nothing
returning id`,
[key, orderId, amount],
);
if (inserted.rowCount === 0) {
// 2. 已完成 → 直接返回存档结果;处理中 → 让客户端稍后重试
const existing = await db.query(
`select status, response_body from payment_requests where idempotency_key = $1`,
[key],
);
if (existing.rows[0].status === 'succeeded') return existing.rows[0].response_body;
return { status: 'processing' };
}
// 3. 首次请求:执行副作用,写回结果
const result = await doCharge(orderId, amount);
await db.query(
`update payment_requests set status = 'succeeded', response_body = $2
where idempotency_key = $1`,
[key, result],
);
return result;
}
关键在于第一步的 on conflict do nothing:它是一次原子的占位操作。并发下的两个请求,只有一个能拿到 rowCount === 1。
三个容易做错的地方
幂等键的作用域没想清楚。 如果键在整个系统里唯一,那么一个客户端的 UUID 生成器坏了(比如容器快照导致种子重复),两个不同用户就可能撞键。我的做法是键作用域绑定到 tenant:
unique (tenant_id, idempotency_key)。
只对成功结果做幂等。 失败的请求如果不记录,客户端重试时会重新执行一次,而那次失败可能是「部分成功」的 —— 比如扣款成功、记账失败。所以 pending 状态必须落库,并且要有超时回收。
没有过期策略。 幂等键表会无限增长。我给这类表的策略是 保留 24 小时,之后归档到冷表。业务上,超过 24 小时的重试没有任何意义。
什么时候可以不做
不是所有写操作都值得这个成本。我的判断标准:
| 场景 | 是否幂等 | 理由 |
|---|---|---|
| 扣款、转账、下单 | 必须 | 重复执行有直接资损 |
| 创建幂等资源(如 SCIM 用户) | 必须 | 客户端会盲目重试 |
更新为固定值(set status = 'x') | 天然幂等 | 执行两次结果一样 |
| 追加日志、埋点 | 可以不做 | 接受重复,成本低于收益 |
计数递增(count = count + 1) | 必须改造 | 典型非幂等 |
最后一行值得展开:把 update t set count = count + 1 改成
insert into events ... on conflict do nothing,再由后台物化计数,是这类问题最省事的通用解法。
一条经验
每次写一个会改变状态的接口,我都会问自己一句: 如果这个请求被原样发三次,会发生什么?
答不上来的接口,就不要上线。