#TypeScript#DevOps

API 幂等设计:让重试安全

网络超时、用户双击、自动重试——同一请求可能发多次。幂等设计让"同一操作执行 N 次结果一致"。本文讲清幂等的本质(GET 天然幂等 vs POST 不幂等)、实现手段(Idempotency Key + 去重表)、支付/下单等关键场景的幂等保证,以及幂等与并发的配合。

本文要点

  • 主题聚焦:TypeScript、DevOps
  • 适合人群:独立开发者、全栈工程师、AI 应用开发者
  • 核心价值:快速理解背景、实践路径、常见误区和工程建议
作者 @admin·发布于 2026年1月28日·更新于 2026年7月23日

API 幂等设计:让重试安全

"网络是不可靠的,用户是焦虑的,重试是自动的——这三件事加在一起,你的接口就必须幂等。"

作为一个后端工程师,你一定经历过这种事故复盘:

  • 用户点了一下"支付",扣了两次款。
  • 网关超时,客户端自动重试,结果下了两单。
  • 消息队列消息重复投递,同一个积分加了两次。

这些问题的根都是同一个:同一个操作被执行了多次,而你的系统没有保证"多次执行等于一次执行"。这就是幂等(Idempotency)要解决的事。

幂等不是锦上添花,是分布式系统的底线。任何一个对外暴露的写接口,如果没有幂等保证,就是在裸奔。本文把幂等的本质、实现手段、关键场景和并发配合一次讲透。


一、什么是幂等:从"重复"这个麻烦说起

数学上,幂等(idempotent)的定义很朴素:

如果一个操作执行一次和执行多次产生的效果相同,这个操作就是幂等的。

形式化一点:对于函数 f,如果 f(f(x)) = f(x),那么 f 是幂等的。

映射到 API 世界,这句话翻译过来就是:同样的请求,你发一次和发十次,系统状态的变化是一样的,返回给客户端的结果也是一致的(或者说至少不会产生新的副作用)。

注意这里的两个关键词:

  1. 系统状态的变化是一样的:不是"返回相同的结果",而是"对系统的副作用一致"。一个查询请求每次返回的内容可能因为时间不同而不同,但它不改变状态,所以是幂等的。
  2. 副作用的边界要清晰:扣款 100 元执行两次,扣 200 元——这不叫幂等。扣 100 元执行两次,仍然只扣 100 元——这才叫幂等。

为什么分布式系统对幂等如此执着?因为"重复"在这个世界里是常态,而不是异常:

  • 网络不可靠:客户端发请求,服务端处理完了,但响应在回去路上丢了。客户端没收到响应,以为失败了,自动重试。
  • 用户不可靠:用户等了 3 秒没看到反馈,又点了一次"提交"。
  • 中间件不可靠:消息队列的 At-Least-Once 语义、网关的超时重试、服务网格的重试策略,都会让同一个请求出现多次。

一个成熟的系统必须假设:任何请求都可能被重复发送。基于这个假设去做设计,而不是寄希望于"客户端会做好"。

一句话:幂等不是把"重复"消灭掉,而是让"重复"变得无害。


二、HTTP 方法的天然分野

讨论 API 幂等,绕不开 HTTP 方法本身。RFC 7231 给每个方法规定了语义,其中一部分方法天然就是幂等的,另一部分天然不是。

方法安全幂等说明
GET是是只读,无副作用
HEAD是是同 GET,只返回 header
OPTIONS是是预检/查询支持的方法
PUT否是用请求体替换目标资源,多次替换结果一致
DELETE否是删除一个资源,删一次和删十次结果都是"不存在"
POST否否创建资源/触发动作,每次都可能产生新副作用
PATCH否否部分更新,语义上不保证幂等(取决于实现)

很多人会困惑:PUT 和 DELETE 凭什么是幂等的?它们明明会改状态。

关键在于"幂等"看的是多次执行后的最终状态,而不是"有没有副作用"。

  • PUT /users/123 {name: "Alice"}:无论你执行多少次,user 123 的名字最终都是 Alice。第一次执行改变了状态,后续执行是"覆盖为同样的值",不再产生新的状态变化。所以幂等。
  • DELETE /users/123:第一次执行把用户删了,后续执行发现用户已经不存在,什么都不做。最终状态都是"用户不存在"。所以幂等。
  • POST /users {name: "Alice"}:每执行一次就创建一个新用户,执行十次就有十个 Alice。状态不断变化,所以不幂等。

理解了这一点,API 设计的第一条原则就清晰了:

用对 HTTP 方法,就免费拿到了一半的幂等。

能用 PUT 表达"替换"语义的,不要用 POST。能用 DELETE 表达"删除"的,不要用 POST 模拟。很多人喜欢万物皆 POST,理由是"灵活",代价就是放弃了 HTTP 方法自带的幂等保证,所有重试逻辑都得自己重新实现一遍。

但现实是残酷的:业务里大量操作本质上就是不幂等的 POST——下单、支付、转账、发券。这些才是幂等设计真正的战场。HTTP 方法帮不了你,你得自己上。

PATCH 是个灰色地带。如果 PATCH 的实现是"把字段设为某值"({op: "replace"}),它是幂等的;如果是"把字段增加某值"({op: "increment"}),就不幂等。所以 RFC 说 PATCH "不保证幂等",具体看你的语义。设计 PATCH API 时要心里有数。


三、Idempotency Key:让 POST 也能安全重试

既然 POST 天然不幂等,那要怎么让它"幂等"?业界给出的标准答案是:Idempotency Key(幂等键)。

核心思路一句话:客户端为每一次"逻辑请求"生成一个唯一 ID,带着这个 ID 发请求;服务端记住这个 ID 和它对应的结果,下次同一个 ID 再来,直接返回记住的结果,不再执行真正的副作用。

这里有几个细节值得展开:

第一,ID 由谁生成?

必须是客户端生成,而且要在"重试的是同一个请求"这件事上有语义。如果客户端发"下单"请求,超时了,客户端重试——这两次必须带同一个 ID,服务端才能识别出"这是同一个下单,别重复处理"。如果每次重试都换一个 ID,那这个机制就形同虚设。

typescript
// 客户端:同一笔业务,始终用同一个 key
async function submitOrder(order: Order) {
  const idempotencyKey = order.clientOrderId // 用业务上的唯一 ID
  // 或 crypto.randomUUID(),但要保存在客户端,重试时复用

  const response = await fetch('/api/orders', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Idempotency-Key': idempotencyKey,
    },
    body: JSON.stringify(order),
  })

  // 超时重试时,仍然用同一个 idempotencyKey
  return withRetry(() => submitOrder(order), { key: idempotencyKey })
}

第二,服务端怎么"记住"?

要么放数据库(去重表),要么放缓存(Redis)。两者各有利弊,后面详细讲。

第三,记住的到底是"结果"还是"正在处理"?

这是个容易被忽视的点。如果一个请求处理需要 2 秒,客户端 1 秒就超时重试了,第二个请求到达时第一个还没处理完——这时候服务端不能简单地"返回缓存结果",因为还没有结果可返回。所以幂等记录需要有一个状态机:processing → done。第二个请求看到 processing 时,要么等(同步阻塞)、要么直接返回"处理中"(异步轮询),但绝对不能再启动一次真正的处理。

typescript
type IdempotencyRecord = {
  key: string
  status: 'processing' | 'done' | 'error'
  result?: unknown
  createdAt: number
  expiresAt: number
}

这三点合起来,就是 Stripe、PayPal、AWS 等所有成熟 API 平台采用的幂等模型。它的核心是:把"请求"和"副作用"解耦——请求可以来 N 次,但副作用只发生一次。


四、去重表:幂等的落地实现

讲清楚了原理,我们来看最经典的实现:去重表(deduplication table)。

思路非常直白:建一张表,每处理一个请求,把它的幂等键和处理结果存进去。下次同一个键再来,先查表,命中就返回缓存的结果。

sql
CREATE TABLE idempotency_records (
  idempotency_key VARCHAR(128) PRIMARY KEY,
  request_method  VARCHAR(10) NOT NULL,
  request_path    VARCHAR(255) NOT NULL,
  status          VARCHAR(20)  NOT NULL,  -- processing / done / error
  response_status INT,
  response_body   JSONB,
  created_at      TIMESTAMPTZ  NOT NULL DEFAULT now(),
  expires_at      TIMESTAMPTZ  NOT NULL
);

CREATE INDEX idx_idempotency_expires ON idempotency_records(expires_at);

几个设计要点:

  1. 主键就是 idempotency_key:用数据库的唯一约束直接兜底并发,后面会讲。
  2. 记录请求方法和路径:同一个 key 在不同接口的语义不同,要区分。Stripe 的做法是 key + method + path + params 一起作为去重维度。
  3. status 字段:支持异步处理的状态机,避免"处理中"被重复触发。
  4. expires_at:幂等记录不能永久保留,要定期清理。

服务端的处理逻辑:

typescript
async function withIdempotency<T>(
  req: Request,
  handler: () => Promise<T>
): Promise<{ status: number; body: T | { error: string } }> {
  const key = req.headers.get('Idempotency-Key')
  if (!key) {
    // 没带 key 的请求,按普通请求处理(不享受幂等保护)
    return { status: 200, body: await handler() }
  }

  const repo = new IdempotencyRepository(db)

  // 1. 尝试插入一条 processing 记录
  //    利用唯一约束,并发下只有一个请求能成功插入
  const inserted = await repo.tryInsert({
    key,
    status: 'processing',
    requestMethod: req.method,
    requestPath: new URL(req.url).pathname,
    expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24h TTL
  })

  if (!inserted) {
    // 2. 已存在记录,读取状态
    const existing = await repo.findByKey(key)
    if (!existing) {
      // 极端情况:刚被并发删掉,提示客户端换 key 重试
      return { status: 409, body: { error: 'conflict, please retry with a new key' } }
    }

    if (existing.status === 'processing') {
      // 3. 还在处理中:返回 409,让客户端稍后重试(或轮询)
      return { status: 409, body: { error: 'request is being processed' } }
    }

    if (existing.status === 'done') {
      // 4. 已完成:直接返回缓存的结果(关键!)
      return { status: existing.responseStatus!, body: existing.responseBody as T }
    }

    // 5. 上次失败:允许用同一个 key 重新尝试
    //    重新置为 processing,继续往下走
    await repo.renewAsProcessing(key)
  }

  // 6. 真正执行业务逻辑
  try {
    const result = await handler()
    await repo.markDone(key, 200, result)
    return { status: 200, body: result }
  } catch (err) {
    await repo.markError(key)
    throw err
  }
}

这段代码体现了去重表的精髓:插入成功 = 拿到"执行权",插入失败 = 走缓存或排队。这是用数据库的原子性来协调并发,比应用层加锁更稳。

去重表 vs Redis 缓存

很多人会问:为什么不直接用 Redis,要写一张表?

我的观点是:关键业务的幂等,优先用数据库表;非关键业务可以缓存。理由:

  • 持久性:数据库表即使服务重启也不会丢,Redis 默认可能丢数据。幂等一旦失效就是重复扣款,代价极高。
  • 事务一致性:去重记录可以和业务写入放在同一个事务里(扣款 + 写幂等记录一起 commit),从根本上杜绝"扣了款但幂等记录没写"的不一致。Redis 做不到这一点。
  • 可观测:表里的记录天然就是审计日志,排查问题方便。

Redis 的优势是性能和TTL 自动过期。适合那些"短时间内可能重复,但不需要长期追溯"的场景,比如限流、防抖。如果是支付级别的幂等,老老实实上表。

当然,两者也可以结合:Redis 做第一层快速去重(挡住大部分重复),数据库表做第二层持久兜底。这是大型系统的常见做法。


五、支付/下单:幂等的主战场

讲了通用方案,来看业务里最敏感的场景:下单和支付。这里的幂等一旦失效,直接是资损,所以是重中之重。

下单:用业务唯一 ID + 数据库唯一约束

下单的本质是"创建一笔订单"。我们要保证:用户因为网络问题或手抖提交了两次,只产生一笔订单。

最稳妥的做法不是依赖额外的幂等表,而是让订单本身具有天然唯一性。客户端在提交前生成一个 client_order_id(比如 UUID),作为订单的一部分。服务端在订单表上对 client_order_id + user_id 建唯一约束:

sql
CREATE TABLE orders (
  id              BIGSERIAL PRIMARY KEY,
  user_id         BIGINT NOT NULL,
  client_order_id VARCHAR(64) NOT NULL,  -- 客户端生成的业务唯一 ID
  amount          DECIMAL(10,2) NOT NULL,
  status          VARCHAR(20) NOT NULL,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (user_id, client_order_id)      -- 关键:数据库唯一约束兜底
);

服务端逻辑:

typescript
async function createOrder(userId: number, clientOrderId: string, items: Item[]) {
  return await db.transaction(async (tx) => {
    try {
      const order = await tx.orders.create({
        data: { userId, clientOrderId, amount: calcAmount(items), status: 'pending' },
      })
      // 扣库存、生成快照等
      await tx.inventory.decrement(items)
      return { status: 'created', order }
    } catch (err) {
      if (isUniqueViolation(err)) {
        // 唯一约束触发:说明已存在同一 clientOrderId 的订单
        const existing = await tx.orders.findFirst({
          where: { userId, clientOrderId },
        })
        return { status: 'already_exists', order: existing }
      }
      throw err
    }
  })
}

注意返回 { status: 'already_exists' } 而不是抛错——这是幂等设计的细节:重复请求对客户端应该是透明的,告诉它"你的订单已经在了",而不是报错让它崩溃。

为什么不用前面讲的通用幂等表?因为下单这件事,订单表自己就能充当"去重表",没必要多维护一张表。能用业务唯一性解决的问题,不要引入额外的机制。

支付:Idempotency Key + 去重表

支付比下单更复杂,因为:

  1. 支付操作不创建实体:支付是"触发一个外部副作用"(调用银行/第三方渠道),没有自己的表来当去重表。
  2. 结果可能延迟:支付渠道回调可能几秒到几分钟才到。
  3. 金额必须精确:重复支付直接是钱的事。

所以支付场景必须用独立的幂等记录:

typescript
async function charge(req: ChargeRequest): Promise<ChargeResult> {
  return await withIdempotency(req, async () => {
    // 1. 业务校验
    const order = await db.orders.findById(req.orderId)
    if (order.status === 'paid') {
      return { code: 'already_paid', orderId: order.id }
    }

    // 2. 调用支付渠道(外部副作用)
    const channelResp = await paymentChannel.createCharge({
      orderId: order.id,
      amount: order.amount,
    })

    // 3. 更新订单状态
    await db.orders.update(order.id, { status: 'paying', channelChargeId: channelResp.id })

    return { code: 'charging', orderId: order.id, channelChargeId: channelResp.id }
  })
}

withIdempotency 就是上一节那套去重表逻辑。这里它的价值是:支付渠道只会被调用一次。哪怕客户端把同一个 Idempotency-Key 重试十次,真正打到第三方渠道的请求只有一次——这正是我们要的。

支付的幂等铁律:外部副作用的调用,必须包在幂等保护里。 你调银行接口扣款,这一步没有重试余地,重复一次就是重复扣款。

一个隐蔽的陷阱:幂等不是只在外层

新手常犯的错误:只在最外层 Controller 包了一层 withIdempotency,但内部又调用了另一个不幂等的服务。结果外层去重挡住了重复请求,内部服务却被多次触发(比如内部有另一个重试机制、另一个消费者)。

正确的心智模型是:每一层都要想"如果我这一层被重复调用,会发生什么"。外层挡得住客户端重试,挡不住内部消息队列的重投递、定时任务的重复执行、回调的重复到达。每一处关键副作用,都要有自己的幂等保护。


六、并发下的幂等:锁与唯一约束

幂等设计里最容易被低估的难点是并发。

"同一个请求来两次"听上去是顺序的:第一次处理完了,第二次来,查表命中,返回。但真实世界里的"两次"经常是同时到达——客户端重试和原始请求几乎同时打到服务端的不同实例上。这时候如果你只是"先查表,没有就处理",两个请求都会查到"没有",然后都去处理——幂等彻底失效。

看一个错误的实现:

typescript
// ❌ 反面教材:先查后写,典型的 TOCTOU 漏洞
async function badIdempotency(key: string, handler: () => Promise<void>) {
  const exists = await repo.findByKey(key)
  if (exists) {
    return exists.result
  }
  // 两个并发请求都会走到这里!
  const result = await handler()
  await repo.save(key, result)
  return result
}

这是经典的 TOCTOU(Time of Check to Time of Use)问题。在高并发下必挂。

正确的做法有两条路:

方案 A:数据库唯一约束(推荐)

把"插入"作为获取执行权的原子操作。利用 INSERT ... ON CONFLICT 或捕获唯一约束冲突:

typescript
async function safeIdempotency(key: string, handler: () => Promise<Result>) {
  try {
    // 原子插入 processing 记录。成功 = 拿到执行权
    await db.none(
      `INSERT INTO idempotency_records(key, status, expires_at)
       VALUES($1, 'processing', now() + interval '24 hours')`,
      [key]
    )
  } catch (err) {
    if (isUniqueViolation(err)) {
      // 已有记录:走缓存/排队逻辑(同上一节)
      return await handleExistingRecord(key)
    }
    throw err
  }

  // 拿到执行权,执行业务
  const result = await handler()
  await db.none(`UPDATE idempotency_records SET status='done', result=$2 WHERE key=$1`, [key, result])
  return result
}

这条路的精髓:不用应用层锁,直接靠数据库的原子约束来当裁判。数据库天然串行化唯一约束的写入,无论并发多少个请求,只有一个能 INSERT 成功。简单、可靠、跨实例(多个服务节点也成立)。

方案 B:分布式锁

如果业务逻辑不能简单靠数据库约束表达(比如要调用一长串外部服务,中间状态复杂),可以用 Redis 分布式锁:

typescript
async function lockBasedIdempotency(key: string, handler: () => Promise<Result>) {
  const lockKey = `idempotency:${key}`
  const lock = await redis.set(lockKey, '1', 'PX', 30000, 'NX') // SETNX, 30s TTL
  if (!lock) {
    // 没拿到锁:说明其他请求正在处理同一个 key
    return await waitForResult(key) // 轮询幂等记录,等它变 done
  }

  try {
    // 双重保险:再查一次,因为可能是"上一个持有者刚释放锁但记录已 done"
    const existing = await repo.findByKey(key)
    if (existing?.status === 'done') return existing.result

    const result = await handler()
    await repo.save(key, result)
    return result
  } finally {
    await redis.del(lockKey)
  }
}

锁方案要注意锁的释放问题(避免锁泄露),以及"锁内执行时间长于锁 TTL"的边界。生产环境建议用 Redlock 或基于 etcd/Zookeeper 的强一致锁。

我的观点

能用唯一约束的,别用分布式锁。 唯一约束是声明式的、事务安全的、几乎零运维成本的;分布式锁是命令式的、容易出 bug 的、需要小心处理各种边界条件的。我见过太多线上事故的根因都是"分布式锁用错了"。

只有当业务流程涉及多个外部系统的长事务、确实无法靠数据库约束表达时,才考虑分布式锁。即便如此,也建议锁 + 唯一约束双保险:锁挡住大部分并发,唯一约束兜底防止锁失效时的极端情况。


七、过期策略:幂等记录不是永远

幂等记录不能永久保留,原因有三:

  1. 存储成本:所有写请求都要留痕,表会无限膨胀。
  2. 语义失效:两周前的请求,业务上下文可能早就变了(比如订单已退款、商品已下架),用老 key 重试反而会触发"恢复一个本不该恢复的状态",引入新 bug。
  3. 安全边界:无限期保留幂等记录,意味着攻击者只要拿到一个 key 就能长期复现响应。

所以幂等记录必须有 TTL(Time To Live)。

TTL 设多长?

没有标准答案,要看业务的重试特征:

  • 普通 API:24 小时是 Stripe 的默认值,也是大部分平台的事实标准。覆盖了一天内所有正常的重试、人工排查、客户端 bug 修复周期。
  • 支付/对账类:可以延长到 7-30 天,因为对账、补单这类操作可能在几天后才发生。
  • 配置更新类:几分钟到几小时即可,因为配置类重试窗口短。

不要无限期。 有人觉得"保留越久越安全",实际上超过一定窗口后,保留更久带来的边际收益几乎为零,而存储和复杂度的边际成本是线性增长的。

怎么清?

两种方式:

方式一:Redis TTL(自动过期)

如果幂等记录放在 Redis,直接用 EXPIRE 即可,自动清理。

typescript
await redis.set(key, JSON.stringify(record), 'EX', 24 * 60 * 60) // 24h

方式二:数据库定时任务(定期删)

如果放在数据库,靠一个定时任务清理过期记录:

typescript
// 每天凌晨执行
async function cleanupExpiredIdempotencyRecords() {
  const deleted = await db.result(
    `DELETE FROM idempotency_records WHERE expires_at < now()`
  )
  log.info(`cleaned up ${deleted.rowCount} expired idempotency records`)
}

配合 expires_at 上的索引,这个清理是高效的。

查询时也要带过期判断——避免一个 key 在"已过期但还没被清理"的窗口内被错误命中:

typescript
async function findByKey(key: string) {
  return await db.oneOrNone(
    `SELECT * FROM idempotency_records
     WHERE key = $1 AND expires_at > now()`,
    [key]
  )
}

key 复用的问题

TTL 过期后,同一个 key 又能被用来发新请求了。这里有一个坑:客户端不能复用过期的 key。

如果客户端的代码是把 key 硬编码或者用自增 ID 生成,过一段时间后,同一个 key 可能被赋给两个语义上不同的请求,造成"用旧 key 提了新请求,结果返回了旧结果"的诡异 bug。所以客户端生成 key 的最佳实践是:每次新的逻辑请求都用一个新的随机 UUID,只在自己的"重试"流程内复用。


八、实战:向 Stripe 学幂等设计

讲了这么多理论和自己的实现,我们来看看行业标杆是怎么做的。Stripe 的 Idempotency-Key 设计是公认的教科书级实践,几乎所有后来的支付平台都在模仿它。值得逐点拆解。

Stripe 的规则

  1. 客户端通过 Idempotency-Key HTTP header 传一个不超过 255 字符的字符串,建议用 UUID。
  2. 服务端把 key + 请求参数(method、path、body)一起作为去重维度。同一个 key 配不同的请求参数会被拒绝(返回 400),防止误用。
  3. 结果会缓存 24 小时,24 小时后同一个 key 可以复用。
  4. 支持自动重试:Stripe 官方 SDK 内置了自动重试,会自动复用同一个 key,开发者完全无感。
  5. 覆盖所有写接口:不只支付,包括退款、创建 customer、转账等,所有 POST 都支持。

它做对了什么?

第一,把幂等变成协议,而不是约定。

很多团队的幂等是"我们尽量保证"这种口头约定,真出问题就甩锅。Stripe 把它变成了 HTTP header 这种标准协议,客户端必须带、服务端必须处理,白纸黑字写进 API 文档。这才叫工程化。

第二,SDK 内置,对开发者透明。

typescript
// Stripe SDK 自动处理幂等
const charge = await stripe.charges.create({
  amount: 1000,
  currency: 'usd',
  source: 'tok_visa',
}, {
  idempotencyKey: 'client-generated-uuid', // 只需要提供 key
})

开发者只需要提供一个 idempotencyKey,SDK 自己处理超时、重试、key 复用。这才是好的开发者体验。把复杂度收敛到框架层,别让每个业务开发者都重复实现一遍。

第三,key + 参数 一起校验。

这一步很多人会漏。如果客户端 bug 导致同一个 key 被用在了两个不同的请求上(比如先用来下单,又误用为退款),没有这个校验就会出现"用下单的结果返回给了退款请求"。Stripe 通过校验参数一致性,在源头拦截这种误用。

typescript
// 服务端校验:key 命中后,比较当前请求参数与缓存参数
async function verifyParams(key: string, req: Request) {
  const cached = await repo.findByKey(key)
  if (cached && !paramsEqual(cached.params, await serialize(req))) {
    throw new BadRequestError(
      `idempotency key was used for a different request`
    )
  }
}

第四,清晰的失败语义。

Stripe 规定:如果原始请求最终失败了,这个 key 不会"锁定失败结果",客户端可以用同一个 key 安全地重试。失败状态的记录会在短时间后被清理,允许重试。这点很反直觉(很多实现是把失败结果也缓存住),但更符合开发者心智:"失败了当然要能重试"。

抄作业的正确姿势

如果你的系统对外提供 API,或者内部有跨服务的写调用,直接照着 Stripe 的模式抄一份:

  • 定义一个 Idempotency-Key header,要求客户端生成 UUID。
  • 服务端建去重表,记录 key + 参数 + 状态 + 结果。
  • TTL 24 小时(根据业务调整)。
  • 提供 SDK / 中间件,让上层开发者不用关心细节。
  • 写进 API 文档,明确告诉用户"这是支持的"。

不要小看"写进文档"这一步。很多团队内部有幂等机制,但没有人知道,业务开发还是各写各的。没有被广泛采用的机制,等于没有机制。


小结

  • 幂等的本质:同一个操作执行多次,系统状态的变化和单次执行一致。它不是消灭重复,而是让重复无害。
  • HTTP 方法自带一半的幂等:GET/PUT/DELETE 天然幂等,POST/PATCH 不保证。用对方法,是设计的第一步。
  • POST 类操作的幂等靠 Idempotency Key:客户端生成唯一 key,服务端去重表/缓存,重复请求返回缓存结果。这是行业标配。
  • 去重表 vs Redis:关键业务(支付/下单)优先数据库表——持久、可审计、可事务;非关键场景用 Redis 提速。
  • 并发幂等的两条路:优先用数据库唯一约束(声明式、可靠),业务复杂时才上分布式锁,且建议双保险。
  • 过期策略必须有:24 小时是常见 TTL,数据库定时清理 + 查询带过期判断。客户端禁止跨逻辑请求复用 key。
  • 向 Stripe 学:把幂等做成协议(标准 header + 参数校验)、做进 SDK、写进文档。工程化的机制才靠谱。

金句:在网络、用户、中间件都不可信的世界里,幂等不是优化项,是接口的及格线。每一个写接口,都要默认假设它会被重试十次——然后让它扛得住。

能扛住重复的接口,才是生产可用的接口。

Related

相关推荐