我第一次因为这个 bug 亏钱的时候,其实没搞懂发生了什么。一个支付请求超时了,我的代码重试了它,于是客户被扣了两次款。日志里没有任何异常:两个请求都成功了。服务器老老实实照着指令做了两遍——因为我从来没告诉它,这两个请求本来就是同一个。这就是幂等键存在的全部意义:一个幂等 API 把「我可能会发两次」变成「发两次和发一次的效果相同」。没有它,重试安全就是幻觉,而至少一次投递会悄无声息地变成重复写入。
这一类 bug:重试的请求被扣两次款
- 支付超时,客户端重试,客户被扣两次。
- 注册表单重复提交,同一个邮箱留下了两条用户记录。
- 任务队列重新投递了一条消息,于是同一个订单发了两遍货。
每一次,请求本身格式正确,服务器逻辑也没错,结果却仍然是错的。问题不在任何一个请求里,而在两个请求之间的那段空隙——系统忘了「两个一模一样的请求本该是一次操作」。而且它只在网络打嗝 之后 才浮现,那恰恰是你最承受不起重复的时候。
为什么网络必然带来重试——以及为什么重试安全必须落在服务端
让这个问题无法回避的真相有点扎心:客户端无法可靠地知道一个请求到底成功了没有。
从调用方的角度看,结果有三种,其中两种长得完全一样:
- 服务器收到了请求、处理完,你拿到了
200。 - 服务器收到了请求、处理完了,但响应在回来的路上丢了。
- 服务器压根没收到请求。
第 2 种和第 3 种无法区分:你发了字节,没收到任何响应。面对「没有响应」,唯一合理的反应就是重试——如果是第 3 种,重试是对的;如果是第 2 种,重试恰好就是双重扣款。你分辨不出来,所以你重试,于是第 2 种变成了重复。
这不是 HTTP 的 bug,而是一个随时可能丢包的网络必然导致的后果。TCP 会替你重传报文段,但 TCP 完全不理解你的应用语义:它能告诉你一串字节到了,却没法告诉你「扣款」这件事只发生了一次。超时、连接重置、负载均衡器掐掉空闲连接,都会制造出同样的客户端歧义。
重试不是可选项。客户端会重试,队列会重投,负载均衡会重放——而且正如我在逆向一个私有 API 时发现的,快速重放请求很快就能教你认出:哪些接口已经把你正要创建的东西创建出来了。
幂等键模式:如何做一个幂等 API
解法是一份约定:客户端给一次操作贴上标签,服务器记住它。
客户端生成键
在客户端发出一条不能被重复的请求之前,它生成一个唯一键——通常是 UUIDv4——放进请求头:
POST /v1/charges
Idempotency-Key: 8f14e45f-ea0b-4c1d-9a2e-2f3b6c7d8e9f
Content-Type: application/json
{"amount": 4200, "currency": "usd", "customer": "cus_123"}
这个键标识的是这一次尝试,而不是这个请求。重试时发的是同一个键;想要一笔真正的新扣款,就发一个新键。整个接口就这么点东西:客户端改一个请求头,不用动重试逻辑。但键必须在第一次尝试之前生成一次,并在所有重试中保持不变——每次尝试都重新生成一个键,看着像一道防线,其实什么都保护不了。
服务器存下 键 → 结果 并重放它
在服务器端:
- 从请求头读出键。
- 到存储里查它。
- 如果已存在且操作已经结束,原样返回存下来的响应——同样的状态码,同样的响应体。不要再跑一遍操作。
- 如果已存在且操作还在进行中,返回
409 Conflict(或者等待),让调用方不要往上堆任务。 - 如果不存在,把键记成
in_progress,执行操作,存下最终状态码和响应体,再返回。
第 3 步是核心:这次重试不是被处理了,而是被回答了。
给键划定作用域
一个键孤立地看没有意义。把它限定在账户或商户(这样一个租户不会和另一个撞车,也不会把结果泄露到别的租户)、接口(POST /charges 和 POST /refunds 即使拿到同一个键,也不该共用一个命名空间),以及一个时间窗口。我用的是 (account_id, endpoint, key)。如果只用键本身做作用域,两个不同的客户就会撞车,第二个被重放了一个属于第一个的结果——一个伪装成特性的数据泄露。
处理并发重复
人们最容易忘的情况是:两个请求带着同一个键同时到达——重复提交,或者客户端在第一个请求还在飞行途中就重试了。如果逻辑是「先查、再插」,两个请求都没查到、都执行了,于是你又回到了双扣款。让插入本身成为那把锁:用唯一约束(或者 INSERT ... ON CONFLICT DO NOTHING,或者 Redis 的 SET NX),保证只有一个请求能占住这个键。输的那个发现键已存在、状态是 in_progress,就返回冲突,而不是去干活。这里的并发是正确性问题,不是性能问题——一个「先检查再执行」的幂等实现,就是一个多绕了几步的竞态条件。
幂等既不是重试,也不是至少一次
这三个词经常被混着用,但它们不是一回事。
- 重试是客户端行为,被不可靠的网络逼出来的。它本身恰好制造了重复问题。
- 至少一次是投递保证。队列和 webhook 承诺每条消息至少到达一次——也就是说,它们可能投递不止一次。它们对网络是诚实的,但没承诺没有重复。
- 幂等是服务器属性:同一个操作应用两次,效果等于应用一次。正是它让至少一次投递变得可用。
那精确一次呢?作为一个跨越不可靠网络的端到端保证,它不存在。你可以通过「至少一次投递 + 幂等消费者」构造出精确一次的效果——也就是至多一次的副作用。这才是诚实的版本:不是一种传输保证,而是叠加在至少一次之上的去重层。谁要是卖给你一个真正的网络端到端精确一次投递,他卖的其实是「至少一次加幂等」的另一个名字。别再想着阻止重试了,把操作本身变得可以安全重复。
键存在哪,存多久
存储必须在所有实例之间共享、在重启之后仍然存在,并且能强制上一节提到的唯一性。
- 关系型数据库表是默认选项:它顺带给了你唯一约束,还能和业务写入放进同一个事务。
- Redis适合用
SET key value NX EX ttl做占位,再加一份存下来的结果——它快,但它是缓存,所以要假设数据可能丢。 - 进程内内存是错的:跑两个实例它就崩,部署一次它就没了。
保留时长是一个权衡。太短,迟到的重试——队列过了一个小时又重投、手机客户端一夜之后重连——会在键已经消失之后到达,于是把活儿重复做一遍。太长,表会无限增长。一个合理的默认值是 24 小时,和任何客户端被允许的最长重试窗口对齐。Stripe 的公开行为在这里是常被引用的参考点:键只在一个有界窗口内被记住,而且用不同的请求体复用同一个键是错误,不是静默重放。这条规则逮住客户端 bug,而不是把 bug 藏起来。同一个请求得到同一个答案;同一个键下面是不同的请求,就值得被拒绝。
让我真正浪费过时间的几个错误
下面每一个,我都上线过。
- 键不唯一。 用请求体推导键(
hash(payload))看着挺聪明,直到两笔合法的、完全相同的请求——两杯咖啡,同样的金额,同一秒——被压成了一次扣款。键必须标识这次尝试,而不是内容。 - 什么都没存。 发送键只是简单的那一半。如果服务器从不持久化它,这个键就是装饰品——我见过有的客户端往一个完全忽略它的接口上发了几个月的
Idempotency-Key头。 - 把 GET 当成不安全的。
GET和HEAD按定义就是安全且幂等的,不该有副作用。如果你的GET /orders/42会创建东西,那这个方法本身在撒谎,什么键也救不了你。反过来,也别硬给读请求塞键——那是徒增仪式感。 - 忘了在响应里带上键。 如果存下的响应不回显这个键(或者资源 ID),客户端就没法把一次重试和原始结果对应起来。把它回显出去——这能让调试一次重复提交变得轻而易举,而不是像做取证。
一份小小的服务端实现
下面是我用的形式,Python。存储是一张带唯一约束的表;占位就是那把锁。
import json, uuid, hashlib
from datetime import datetime, timedelta
class IdempotencyStore:
"""One row per (account, endpoint, key). Unique constraint = the lock."""
def claim(self, account_id, endpoint, key, request_fingerprint):
# INSERT ... ON CONFLICT DO NOTHING RETURNING id
# Returns a row only if WE won the race; None if someone else holds it.
row = self.db.execute(
"""
INSERT INTO idempotency_keys
(account_id, endpoint, key, fingerprint, state, created_at)
VALUES (%s, %s, %s, %s, 'in_progress', now())
ON CONFLICT (account_id, endpoint, key) DO NOTHING
RETURNING id
""",
(account_id, endpoint, key, request_fingerprint),
).fetchone()
return row is not None # True = we own the operation
def load(self, account_id, endpoint, key):
return self.db.execute(
"""SELECT state, fingerprint, status_code, body
FROM idempotency_keys
WHERE account_id=%s AND endpoint=%s AND key=%s""",
(account_id, endpoint, key),
).fetchone()
def finish(self, account_id, endpoint, key, status_code, body):
self.db.execute(
"""UPDATE idempotency_keys
SET state='done', status_code=%s, body=%s, finished_at=now()
WHERE account_id=%s AND endpoint=%s AND key=%s""",
(status_code, json.dumps(body), account_id, endpoint, key),
)
以及使用它的请求处理器:
def create_charge(req):
key = req.headers.get("Idempotency-Key")
if not key:
return error(400, "Idempotency-Key required")
account_id = req.account.id
endpoint = "POST /v1/charges"
fingerprint = hashlib.sha256(req.raw_body).hexdigest()
# 1. Is this key already known?
existing = store.load(account_id, endpoint, key)
if existing:
if existing.fingerprint != fingerprint:
return error(422, "Idempotency-Key reused with different body")
if existing.state == "in_progress":
return error(409, "Request with this key is still in progress")
# Replay the original result. Do NOT run the charge again.
return Response(existing.status_code, existing.body)
# 2. Try to claim it. If we lose the race, someone else is doing the work.
if not store.claim(account_id, endpoint, key, fingerprint):
return error(409, "Request with this key is already being processed")
# 3. We own it. Run the operation exactly once, inside the same
# transaction as the business write where possible.
try:
result = charge_card(req.json) # the side effect
store.finish(account_id, endpoint, key, 201, result)
return Response(201, result)
except Exception:
# A failure that did not commit should free the key so a retry
# can make progress. A failure *after* commit must not.
store.release(account_id, endpoint, key)
raise
安全性主要靠两个细节。ON CONFLICT DO NOTHING 让并发的重复收敛成一个赢家,而不是两次扣款。而指纹检查把「同一个键、不同请求」这种客户端 bug 变成一个响亮的 422,而不是一次静默的错误重放。
我现在实际会做的事
- 在客户端生成键,一次,在第一次尝试之前,并在所有重试中保持不变。
- 在服务器上存下
键 → (状态码, 响应体),按账户和接口划定作用域,用唯一约束当锁。 - 遇到重复时重放已存的结果,而不是重跑操作。
- 对还在进行中的键返回
409;绝不在同一个键上堆任务。 - 键只保留一个有界的窗口——我用 24 小时——并且拒绝用不同请求体复用同一个键。
- 接受至少一次才是投递模型,在此之上构建幂等消费者,而不是去追逐一个网络层并不存在的精确一次。
这些都不聪明——它只是一点点纪律,把「被扣了两次款」这一整类事故从「可能发生」挪到「不可能发生」。无论那个接口是一笔支付还是一个自托管的服务,道理都一样,而后者我是这样维持的,见2026 年自建了哪些服务。网络一定会把你的请求复制一遍。就按你知道这一点来设计。