API 中的幂等性 — 为什么你的 POST 请求被触发了两次以及如何修复
重复收费、重复订单或用户被创建三次——这些都是相同缺失API合同的症状。这里解释了“幂等性”是什么,为什么POST请求会破坏它,以及如何通过幂等性键来解决这个问题。
用户点击了“立即支付”。加载动画开始旋转。网络超时。重试逻辑被触发。扣款成功。随后,原始请求也在服务器端完成——扣款再次成功。从客户账户中扣除$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——但核心模式简单到可以添加到任何重要的端点中。
在首次生产流量到来之前添加幂等性密钥支持。在发生双重扣款事件后才添加是明显更糟糕的学习时机。
