- 引言 — 一笔没收到响应的支付
- 什么是幂等性
- 安全方法与幂等方法
- POST 的幂等性键
- 「恰好一次」这个神话
- 正确地做重试 — 指数退避
- 惊群效应 — 为什么需要抖动
- 服务端要做的事 — 设计成能扛住重试
- 常见陷阱小结
- 结语
- 参考资料
引言 — 一笔没收到响应的支付
我们从一个场景说起。用户按下支付按钮。你的服务器收到请求,向卡组织发起扣款,创建订单,并把响应发回去。可就在响应到达用户之前,网络断了。用户屏幕上显示「请求失败」。用户理所当然地又按了一次按钮。
于是一个危险的问题出现了。这笔钱会被扣两次吗?
这个问题就是这篇文章的全部。在分布式系统里,网络不可靠,而在不可靠的网络之上我们又必须重试,重试又必然会造成「已经处理完了,只是响应丢了」这种局面。让系统在这种局面下依然正确运行的核心概念,就是幂等性(idempotency)。本文从幂等性是什么讲起,依次梳理安全方法与不安全方法、让 POST 变安全的幂等性键、「恰好一次」这个常见误解,以及如何正确地重试。
什么是幂等性
幂等性这个词借自数学。如果对某个操作多次应用得到的结果与应用一次相同,这个操作就是幂等的。绝对值函数是个好例子:对一个数取一次绝对值,还是取十次,结果都一样。
放到 API 的语境里,幂等性可以这样翻译:如果把同一个请求发送一次和发送多次,服务器最终的状态都相同,这个请求就是幂等的。这里的重点不是「响应每次都必须完全一样」,而是「副作用只能发生一次」。
举几个例子。
- 「把用户 42 的邮箱设置为
a@b.com」是幂等的。不管发几次,结果都只有邮箱是a@b.com这一个状态。 - 「把用户 42 的余额增加100」不是幂等的。发两次,余额就多涨 200。
这个区别正是支付那个例子的核心。「发起支付」本质上更接近增加操作,没有任何机制保护的话就不是幂等的。重试会直接变成重复扣款。我们的目标,就是把这个非幂等操作变成幂等的。
安全方法与幂等方法
HTTP 已经在方法这一层规定了这个概念。要区分两种性质。
安全(safe)方法不改变服务器状态,也就是只读的方法。GET、HEAD、OPTIONS 属于这一类。安全方法无论调用多少次都不会改变数据,所以可以放心地重试。
幂等(idempotent)方法是无论调用一次还是多次,服务器状态都相同的方法。安全方法自然是幂等的,但幂等不代表安全。
把 HTTP 规范为每个方法定义的性质整理成表格:
| 方法 | 安全 | 幂等 | 典型含义 |
|---|---|---|---|
| GET | 是 | 是 | 读取资源 |
| HEAD | 是 | 是 | 仅读取响应头 |
| OPTIONS | 是 | 是 | 查询通信选项 |
| PUT | 否 | 是 | 整体替换(设置)资源 |
| DELETE | 否 | 是 | 删除资源 |
| POST | 否 | 否 | 创建新资源等 |
| PATCH | 否 | 视情况而定 | 部分修改 |
这里值得说说为什么 PUT 和 DELETE 是幂等的。PUT 是一个设置操作:「把这个资源设为这个值」。同一个 PUT 发多少次,资源都停在那一个值上。DELETE 也一样。对一个已经被删除的东西再删一次,最终状态依然是「不存在」(响应码可能不同,比如变成 404,但状态是一样的——幂等性说的是状态,不是响应码)。
问题永远出在 POST 身上。POST 常常意味着「创建一个新东西」,这从本质上就不是幂等的。每重试一次,就会多出一个新订单、一笔新支付、一条新评论。如果你分不清各个 HTTP 状态码分别代表什么,可以在本站的HTTP 状态码工具里查一查每个状态码的含义。
POST 的幂等性键
让 POST 变得幂等的标准做法是幂等性键(idempotency key)。思路很简单。客户端为每个请求生成一个唯一的键(通常是 UUID),放进请求头里发出去。服务器记住这个键,如果同一个键的请求又来了,就不再重新处理,而是直接把之前存下来的第一次响应原样返回。
第一次请求
客户端 --(Idempotency-Key: abc-123, 支付请求)--> 服务器
|
从未见过的键
-> 真正执行扣款
-> 把结果存到 abc-123 下
客户端 <----------(200 OK, 订单 #500)------------ 服务器
(响应丢失,客户端重试)
第二次请求 (同一个键)
客户端 --(Idempotency-Key: abc-123, 支付请求)--> 服务器
|
已经见过的键
-> 不再重新扣款
-> 返回存好的结果
客户端 <----------(200 OK, 订单 #500)------------ 服务器
关键在于第二次请求时不会再次真正扣款。用户按了两次,但只被收了一次钱。Stripe、PayPal 这类支付 API 用的正是这套机制。
实现时有几个细节要留意。
- 键的存储与过期。 得把键到响应的映射存在某个地方(比如 Redis 或数据库)。不能永远保留,通常会设一个过期时间,比如 24 小时。
- 并发处理。 同一个键的两个请求几乎同时到达时会出问题。如果第一个请求还在处理,第二个就进来了,两个都可能误以为自己是「从未见过的键」而各自处理一遍。所以必须在收到键的那一刻就加锁,或者用数据库的唯一约束原子地标记「这个键已经在处理中」。
- 校验键与请求内容是否一致。 键相同但请求体不同,这要么是客户端的错误,要么是攻击。服务器最好能检测出来并拒绝。
- 响应的存储。 不只是成功响应,失败的性质也得慎重对待。临时性失败(比如数据库超时)应该真正被重试,而确定性失败(比如余额不足)最好缓存下来,返回同样的答案。
「恰好一次」这个神话
这里必须正面处理分布式系统里最常见的一个误解。很多人把「恰好一次(exactly-once)投递」当作目标,或者相信某个系统能提供这种保证。先说结论:跨越网络的投递,纯粹意义上的「恰好一次」几乎是不可能的。
为什么?发送方只有两种策略。
- 至多一次(at-most-once):发出去就不管了,不等确认。可能丢失,但不会重复。
- 至少一次(at-least-once):一直重试直到收到确认。不会丢失,但可能重复。
问题的根源和前面那个支付场景一样。发送方「没收到确认」的时候,没有办法分辨这是「消息根本没送到」,还是「消息送到了,只是确认丢了」。所以为了避免丢失就必须重试(至少一次),而重试又会造成重复。
那我们常说的「表现得像恰好一次」的系统,到底是怎么做到的?答案是这样的:
恰好一次 = 至少一次投递 + 接收方去重(dedup)
也就是说,投递本身依然是「至少一次」。只是接收方会识别出已经处理过的消息,从第二次起就忽略它。让这种去重成为可能的,正是前面讲的幂等性。给每条消息附上一个唯一 ID,记录已处理过的 ID,同一条消息再来一次,结果也不会变。
核心的教训是这样的。不要指望基础设施能像变魔法一样保证「恰好一次」,而应该让你的消费者变得幂等。 这样一来,不管投递发生多少次,结果都等同于处理了一次。这条原则不只适用于 API,也适用于消息队列整体。如果想直观地看看队列系统是怎么处理「至少一次」和重复的,可以在本站的消息队列演示场里,把各种投递保证方式可视化出来看看。
正确地做重试 — 指数退避
接下来讲怎样把重试本身做好。「失败了就再发一次」听起来简单,但如果实现得很朴素,反而会把系统拖垮。
最糟糕的做法是立即、固定间隔、无限次地重试。当服务器因为短暂过载而变慢时,如果所有客户端都检测到失败并立刻、反复地再打过去,原本勉强撑住的服务器会彻底垮掉。重试非但治不好故障,反而会加重它。
第一个改进是指数退避(exponential backoff)。每次都把重试间隔翻倍:1 秒、2 秒、4 秒、8 秒……这样一来,失败持续得越久,重试的压力就以指数速度下降,给苦苦支撑的服务器留出喘息的空间。
第 1 次尝试失败 --> 等待 1 秒
第 2 次尝试失败 --> 等待 2 秒
第 3 次尝试失败 --> 等待 4 秒
第 4 次尝试失败 --> 等待 8 秒
...在上限处停住(比如 30 秒),达到最大重试次数就放弃
这里必须再加上两点。
- 上限(cap):设一个最大等待时间,别让间隔无限增长(比如 30 秒)。
- 最大重试次数与放弃:不能永远重试下去。到达一定次数后就放弃,把失败发进死信队列(dead-letter queue),或者通知用户。
而且并不是所有失败都值得重试。重试只对临时性(transient)错误有意义。网络超时、503 Service Unavailable、429 Too Many Requests 这类错误,再试一次可能就成功了。相反,400 Bad Request、401 Unauthorized、404 Not Found 这类确定性错误,不管重发多少次结果都一样,重试只是浪费。
惊群效应 — 为什么需要抖动
只靠指数退避还不够。还留着一个微妙但致命的问题,那就是惊群效应(thundering herd)。
设想一下这个情形。某个服务器短暂宕机。那一刻,一万个客户端同时检测到失败。它们全都遵循同一套指数退避规则:1 秒后重试,失败就 2 秒后,再失败就 4 秒后。问题在于所有人都在完全相同的时刻重试。服务器刚要恢复的一瞬间,一万个请求同时涌来,服务器又倒下了。而这一波,在 2 秒、4 秒、8 秒后还会一模一样地重演。同步的重试正周期性地把服务器打垮。
解决办法是抖动(jitter),也就是加入随机性。如果每个客户端在计算出来的等待时间上混入一点随机值,重试的时刻就会在时间轴上均匀散开。一万个请求不再挤在一个点上,而是广泛分散,让服务器有机会恢复。
无抖动: 所有人在同一瞬间重试
||||||||| |||||||||
---+---------+---------+------ (服务器不断被浪潮打垮)
有抖动: 重试分散开来
| | || | | || | | |
---+---------+---------+------ (负载均匀分摊)
加入抖动的方式有好几种。最广受推荐的是完全抖动(full jitter),即等待「0 到计算出的退避值之间的一个随机时间」。用伪代码表示是这样的。
import random
def backoff_with_jitter(attempt, base=1.0, cap=30.0):
# 指数增长,但不超过上限
exp = min(cap, base * (2 ** attempt))
# 0 ~ exp 之间的随机值 (完全抖动)
return random.uniform(0, exp)
# 示例: 每次失败尝试的等待时间都不同
for attempt in range(5):
wait = backoff_with_jitter(attempt)
print(f"attempt {attempt}: wait {wait:.2f}s")
这个简单的随机化,对大规模系统稳定性的影响之大,令人吃惊。自从 AWS Architecture Blog 那篇著名的文章把「指数退避 + 抖动」这个组合确立为标准处方以来,它几乎已经成为所有可靠客户端的默认模式。
服务端要做的事 — 设计成能扛住重试
到目前为止,视角主要是客户端的。要打造一个可靠的 API,服务端也必须被设计成能够承受重试。
让每一个写入端点都幂等。 考虑把前面讲的幂等性键,不只用在支付上,而是应用到所有会改变状态的重要 POST 请求上。这样客户端就能放心地重试。
用 Retry-After 告知重试时机。 当服务器过载或者在限流(429)时,可以用 Retry-After 响应头告诉客户端「什么时候再来」。懂事的客户端会尊重这个信号,不做不必要的过早重试。
限流(rate limiting)与负载削减(load shedding)。 即使重试的风暴涌来,服务器要保护自己,最好只接受它能承受的量,把超出的部分尽快用 429 拒绝掉。与其把所有请求都接下来慢慢处理、最后大家一起垮掉,不如快速拒绝一部分、保住剩下的部分,这样对整体可用性更有利。
熔断器(circuit breaker)。 如果依赖的下游服务持续失败,与其每次请求都去打它、让情况更糟,不如暂时切换到「断开」状态,快速返回失败。过一段时间后再小心地重试,看看它是否恢复了。这是一种防止故障蔓延到整个系统的机制。
常见陷阱小结
最后,把实践中经常踩的坑压缩一下。
- 不加任何保护就重试非幂等操作——这是重复扣款、重复下单的典型原因。用幂等性键把它包起来。
- 重试所有错误——像 400、401、404 这类确定性错误,重试没有用,只会浪费资源。要区分哪些错误值得重试。
- 没有抖动的固定退避——会招来惊群效应。一定要加入随机性。
- 无限次重试——没有上限和最大次数地重试,失败的请求会在系统里永远堆积。要设置放弃点和死信队列。
- 忽视幂等性键的并发问题——如果不能原子地处理几乎同时到达的同一个键,重复处理就会漏过去。
- 对「恰好一次」盲目信任——不要指望基础设施会保证它,而要让消费者自己变得幂等,由自己来保证。
结语
可靠 API 的秘诀不是「永不失败」。网络迟早会失败,失败了我们就必须重试。真正的秘诀是让重试发生之后依然能得到正确的结果。幂等性正处在这一切的中心。把写操作变得幂等,那个棘手的「已经处理完了,只是响应丢了」的局面,就不再是一场灾难,而只是又一次无害地到达的请求而已。
在此之上,用指数退避与抖动驾驭重试的节奏,再在服务端用限流和熔断器扛住风暴,你的系统就能在不稳定的网络之上依然站得稳。与其追逐「恰好一次」这个神话,不如选择「至少一次 + 幂等处理」这个坚实的现实——这才是可靠 API 真正的基石。
参考资料
- MDN, HTTP 幂等方法: https://developer.mozilla.org/en-US/docs/Glossary/Idempotent
- RFC 9110 (HTTP Semantics) — 安全与幂等方法: https://www.rfc-editor.org/rfc/rfc9110#name-method-properties
- AWS Architecture Blog, "Exponential Backoff and Jitter": https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/
- Stripe API, Idempotent Requests: https://docs.stripe.com/api/idempotent_requests
- Google Cloud, "Retry strategy" (指数退避): https://cloud.google.com/storage/docs/retry-strategy
- 本站的消息队列演示场: /tools/message-queue-playground
- 本站的 HTTP 状态码工具: /tools/http-status-codes
현재 단락 (1/104)
我们从一个场景说起。用户按下支付按钮。你的服务器收到请求,向卡组织发起扣款,创建订单,并把响应发回去。可就在响应到达用户之前,网络断了。用户屏幕上显示「请求失败」。用户理所当然地又按了一次按钮。