不喜欢广告? 无广告 今天

API 中的幂等性 — 为什么你的 POST 请求被触发了两次以及如何修复

更新于

重复收费、重复订单或用户被创建三次——这些都是相同缺失API合同的症状。这里解释了“幂等性”是什么,为什么POST请求会破坏它,以及如何通过幂等性键来解决这个问题。

API中的幂等性——为什么你的POST被调用了两次以及如何修复它 1
广告 移除?

用户点击了“立即支付”。加载动画开始旋转。网络超时。重试逻辑被触发。扣款成功。随后,原始请求也在服务器端完成——扣款再次成功。从客户账户中扣除$200,早上还有一张支持工单等待你处理。

这不是一个罕见的边缘情况。这是在你需要它时才想到幂等性的结果。让我们来解决这个问题。

幂等性实际上意味着什么

这个词来自数学。如果一个操作多次执行的结果与执行一次的结果相同,那么这个操作就是幂等的。 f(f(x)) = f(x).

在API术语中:使用相同的端点和相同的意图调用N次,系统应处于与调用一次相同的状态。响应可以是缓存的结果,但副作用——数据库写入、扣款、邮件发送——应只发生一次。

这是关于你的服务器 并不会将Cookie限制在仅,而不仅仅是关于它返回的内容。

HTTP方法:哪些是安全的,哪些不是

HTTP规范将某些方法定义为幂等。以下是实际的分类:

方法幂等性?为什么
GET✅ 是只读操作。根据定义,不会产生副作用。
HEAD✅ 是与GET相同,不返回请求体。
PUT✅ 是“将此资源设置为精确的这种状态。”调用两次的结果相同。
DELETE✅ 是资源在第一次调用后被删除。后续调用找不到需要删除的内容。(状态码可能不同——204与404——但服务器状态不会改变。)
POST❌ 否“处理这个数据负载。”具体含义由服务器决定。通常,两次POST会创建两个资源或触发两次副作用。
PATCH⚠️ 依赖于具体实现一个相对更新("increment count by 1")不是幂等的。一个绝对更新("set count to 5")是。具体实现由你决定。

问题在于,大多数实际业务操作——扣款、创建订单、发送通知——自然映射到POST。而POST本身没有任何幂等性保证。

导致问题的序列

以下是经典失败模式,逐步说明:

Client                    Network                   Server
  |
  |--- POST /payments ------>|                         |
  |                          |--- (delivered) -------->|
  |                          |                  processing...
  |                          |                  card charged ✓
  |<-- (connection drops) ---|                  response queued
  |
  |  [retry logic kicks in]
  |
  |--- POST /payments ------>|                         |
  |                          |--- (delivered) -------->|
  |                          |                  processing...
  |                          |                  card charged ✓ (again)
  |<------- 200 OK ----------|<------------------------|
  |
  [client sees: one charge. server did: two charges]

客户端从未看到第一次响应。从客户端角度看,请求失败了。从服务器角度看,请求成功了两次。这是一个分布式系统中的经典问题——客户端和服务器对发生了什么产生了分歧。

这不仅仅是支付处理器的问题。订单创建、用户注册、邮件发送、库存预留——任何“重复执行”会产生实际后果的操作都存在这个问题。

幂等性密钥:Stripe模式

Stripe推广了目前大多数支付API所采用的方法。客户端在发送请求前生成一个唯一的密钥,并将其作为头部附加。服务器使用该密钥进行去重。

客户端端:

// Generate once, before any retry loop
const idempotencyKey = crypto.randomUUID();

async function chargeWithRetry(amount, retries = 3) {
  for (let attempt = 0; attempt < retries; attempt++) {
    try {
      const response = await fetch('/api/payments', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Idempotency-Key': idempotencyKey, // same key on every retry
        },
        body: JSON.stringify({ amount, currency: 'usd' }),
      });

      if (response.ok) return await response.json();

      // Don't retry on client errors (4xx)
      if (response.status < 500) throw new Error(`Client error: ${response.status}`);

    } catch (err) {
      if (attempt === retries - 1) throw err;
      await sleep(Math.pow(2, attempt) * 1000); // exponential backoff
    }
  }
}

密钥必须在发送请求前生成 通知团队完成之前 任何重试循环——这才是整个机制的核心。如果你在每次尝试时都生成新的UUID,你就彻底破坏了该机制。

一个UUID v4在这里表现良好。如果你需要快速生成一个用于测试的UUID, IO Tools' UUID生成器 可以让你在不引入库的情况下批量生成它们。

服务器端:存储响应,重复请求时返回它

服务器的职责在概念上很简单:检查该密钥是否已被见过;如果已见过,则返回已存储的响应;如果未见过,则处理并存储。

// Express + Redis
app.post('/api/payments', async (req, res) => {
  const idempotencyKey = req.headers['idempotency-key'];

  if (idempotencyKey) {
    const cached = await redis.get(`idem:${idempotencyKey}`);
    if (cached) {
      const { status, body } = JSON.parse(cached);
      return res.status(status).json(body);
    }
  }

  const result = await paymentProcessor.charge(req.body);
  const responseBody = { id: result.id, status: result.status };

  if (idempotencyKey) {
    // 24-hour TTL — same window Stripe uses
    await redis.setex(
      `idem:${idempotencyKey}`,
      86400,
      JSON.stringify({ status: 200, body: responseBody })
    );
  }

  res.json(responseBody);
});

一些需要注意的细节:

  • 缓存失败,而不仅仅是成功。 如果扣款失败(信用卡被拒),也要存储该失败响应。否则,重试会再次尝试扣款,即使之前已经返回了错误——而这正是你试图避免的情况。
  • 验证请求体的一致性。 Stripe在密钥重复使用且请求参数不同时返回422错误。这可以捕获你在不同操作中意外重复使用密钥的错误。
  • 处理并发重复请求。 两个具有相同密钥的请求同时到达时,需要协调——处理一个,对另一个返回409错误,或使用分布式锁。Redis SETNX是常见的实现方式。
  • 设置合理的过期时间。 过期后,密钥可以被回收,具有相同密钥的新请求被视为新的请求。不要无限存储它们——否则你的缓存会膨胀。

客户端错误导致POST被调用两次而没有重试

幂等性密钥可以防止网络层重试带来的问题。但当客户端发出两个独立的请求时,它无法提供帮助。常见原因包括:

  • 双击提交按钮。 用户点击后没有看到任何变化,于是再次点击。必须在第一次点击时立即禁用按钮——而不是在响应到达后。点击与响应之间的间隙正是第二次点击发生的时机。
  • React StrictMode的双重调用。 React 18 Strict Mode在开发环境中会运行两次效果以暴露错误。如果你在 useEffect 中触发POST操作且没有清理,你将在开发环境中看到重复请求。这在生产环境中不会发生,但可能会掩盖真正的错误。
  • 浏览器表单重新提交。 提交 → 导航 → 返回 → 前进。一些浏览器会提示“是否重新提交?”,有些则直接执行。POST-Redirect-GET模式(POST后返回重定向)可以完全消除这种情况。
  • 事件处理器中的竞争条件。 一次点击和一次快速的回车键都触发了提交处理器,在第一次响应返回前就禁用了表单。
  • 移动应用后台重试。 iOS和Android的后台获取或网络层重试逻辑可能会重复一个应用已经发出的请求。应在用户意图发生时生成幂等性密钥,必要时本地持久化,并在确认成功后清除。

一个值得考虑的替代方案:使用UUID URL发起PUT请求

如果你控制API的两端,有一个结构更清晰的资源创建方案:让客户端分配资源ID,并使用PUT而不是POST。

# Instead of this (POST, not idempotent):
POST /orders
{"amount": 99.00, "items": [...]}

# Do this (PUT to client-generated UUID, idempotent by spec):
PUT /orders/7f3b9c2a-4e5d-4f8b-9a1c-2d3e4f5a6b7c
{"amount": 99.00, "items": [...]}

PUT根据HTTP规范是幂等的——“将此资源设置为这种状态”意味着一旦资源存在,重复执行PUT不会产生额外效果。服务器通过 INSERT ... ON CONFLICT DO NOTHING 或类似方式处理重复请求。

该模式适用于订单创建、草稿管理,以及任何资源在重试时ID一致性至关重要的场景。当第三方(如支付处理器)分配ID,或副作用必须在知道ID之前在服务器端执行时,该模式不适用。

Stripe实际实现的细节

Stripe的 幂等性密钥文档 值得完整阅读。一些实际中重要的细节如下:

  • 密钥格式: 最多255个字符的字符串,限定在你的Stripe账户内。两个不同账户使用相同字符串不会相互干扰。
  • 24小时窗口: 密钥在24小时后过期。过期后重试被视为新请求——这对于构建长时间运行的工作流非常重要。
  • 请求体不一致 = 422: 相同密钥,不同参数 → Stripe返回422不可处理实体。这是正确的行为;它能捕获你在不同操作中意外重复使用密钥的错误。
  • 并发去重: 如果两个具有相同密钥的请求同时到达,Stripe会处理一个请求,对另一个返回409冲突。在短延迟后重试409响应。
  • 缓存失败: 如果扣款失败(信用卡被拒),Stripe会缓存该失败。使用相同密钥重试时会返回相同的拒绝。你需要一个新的密钥来尝试另一张卡——这是正确的,因为之前的尝试是完整且有意的操作。

在没有完整集成测试套件的情况下测试幂等性

最快验证行为的方法是使用相同的密钥两次调用你的端点,并检查第二次调用是否返回缓存响应而不会再次触发副作用。 IO Tools' cURL命令构建器 使其轻松构造带有自定义头部(包括 Idempotency-Key )的请求——而无需记忆curl标志语法。

需要覆盖的场景:

  • 相同密钥 + 相同请求体,在TTL内 → 返回缓存响应,副作用仅触发一次
  • 相同密钥 + 不同请求体 → 返回422(或你选择的冲突状态)
  • TTL过期后的相同密钥 → 视为新请求
  • 未提供密钥 → 按正常流程处理(提前决定密钥是必需还是可选)
  • 两个具有相同密钥的并发请求 → 一个处理,一个返回409

简要版本

POST不是幂等的,而“请求发送”和“响应接收”之间的间隙正是重复副作用存在的地方。解决方法并不复杂:在任何重试循环之前生成一个UUID,将其作为头部发送,服务器端以该头部为键缓存响应。复杂之处在于细节——缓存失败、处理并发重复、选择合适的TTL——但核心模式简单到可以添加到任何重要的端点中。

在首次生产流量到来之前添加幂等性密钥支持。在发生双重扣款事件后才添加是明显更糟糕的学习时机。

想要享受无广告的体验吗? 立即无广告

安装我们的扩展

将 IO 工具添加到您最喜欢的浏览器,以便即时访问和更快地搜索

添加 Chrome 扩展程序 添加 边缘延伸 添加 Firefox 扩展 添加 Opera 扩展

记分板已到达!

记分板 是一种有趣的跟踪您游戏的方式,所有数据都存储在您的浏览器中。更多功能即将推出!

广告 移除?
广告 移除?
广告 移除?

新闻角 包含技术亮点

参与其中

帮助我们继续提供有价值的免费工具

给我买杯咖啡
广告 移除?