幂等设计:分布式系统里最被低估的约定

接口能重试,才算真正可用。用一个支付扣款场景讲清楚幂等键、状态机与并发边界该怎么配合,以及 90% 的实现错在哪。

4 分钟架构分布式后端

如果只能给分布式系统加一条规矩,我会选这条:

任何会被重试的写操作,都必须幂等。

因为重试不是异常情况,而是常态。超时、连接重置、上游负载均衡切换、用户连点两次 —— 这些事件在生产环境的频率远高于大多数人的直觉。

一个真实的失败案例

一个扣款接口,逻辑长这样:

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,再由后台物化计数,是这类问题最省事的通用解法。

一条经验

每次写一个会改变状态的接口,我都会问自己一句: 如果这个请求被原样发三次,会发生什么?

答不上来的接口,就不要上线。

← 全部文章