Skip to content

필사 모드: 幂等性与重试:构建可靠的 API

中文
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

引言 — 一笔没收到响应的支付

我们从一个场景说起。用户按下支付按钮。你的服务器收到请求,向卡组织发起扣款,创建订单,并把响应发回去。可就在响应到达用户之前,网络断了。用户屏幕上显示「请求失败」。用户理所当然地又按了一次按钮。

于是一个危险的问题出现了。这笔钱会被扣两次吗?

这个问题就是这篇文章的全部。在分布式系统里,网络不可靠,而在不可靠的网络之上我们又必须重试,重试又必然会造成「已经处理完了,只是响应丢了」这种局面。让系统在这种局面下依然正确运行的核心概念,就是幂等性(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 真正的基石。

参考资料

현재 단락 (1/104)

我们从一个场景说起。用户按下支付按钮。你的服务器收到请求,向卡组织发起扣款,创建订单,并把响应发回去。可就在响应到达用户之前,网络断了。用户屏幕上显示「请求失败」。用户理所当然地又按了一次按钮。

작성 글자: 0원문 글자: 6,074작성 단락: 0/104