原文链接:stripe.com 作者:Brandur Leach
网络是不可靠的。我们都有连不上 Wi-Fi 或者电话突然中断的经历(译者注:看来还是国内运营商靠谱)。
服务器间的网络连接一般来说相对用户端的最后一公里网络(如:手机网络、家用宽带网络)要好很多,但是传输大量数据的时候还是会出各种奇葩的问题。停电、路由问题还有其他各种间歇性的网络失败从整体统计角度来说不是经常性的,但在固有的发生率下(原文:ambient background rate)一定会发生。
在这种本质上不可靠的环境下,设计一套失败时足够健壮,而且能保证复杂状态一致性的 API 和客户端就非常重要了。我们来看几种实现这个目标的方式。
为故障做打算(原文:Planning for failure)
仅考虑两个节点之间的远程调用,就有多种可能出现的故障:
- 客户端尝试连接服务端的时候可能失败
- 请求可能在服务端处理过程中失败。导致请求处理工作处在不确定的状态。
- 请求处理成功了,但是服务端返回给客户端处理成功结果时网络连接中断了。
上面这些情况都会导致发出请求的客户端处于不确定的状态。有些情况下失败很明确,客户端可以放心的进行重试。比如无法建立链接的失败。但是很多其它情况下重试是否成功对于客户端来说是有歧义的。它不知道重试是否是安全的。一个例子就是请求处理过程中的链接断开。
这是分布式系统的经典问题。如果把「分布式系统」描述为最少两台计算机组成的通过网络互相连接并传递消息的系统,那么这里「分布式系统」的定义是很宽泛的。Stripe API 和另外一台给它发请求的服务器就能组成一个分布式系统。
灵活运用幂等性
要对付失败造成的分布式状态不一致,最简单的方法就是把服务器节点实现成幂等的。也就是说不管调用多少次,都能保证副作用(译者注:实体状态变化)只生效一次。
这样不管客户端遇到什么样的错误,都能通过不断重试来保证自己的状态和服务端的状态最终收敛一致。这样彻底解决未决失败的问题,因为客户端知道仅用一个简单的技术(译者注:重试)就能安全地处理失败。
下面给出一个例子。比如一个向某域名服务商发出的调用添加子域名 API 的 HTTP 请求:
curl https://example.com/domains/stripe.com/records/s3.stripe.com \
-X PUT \
-d type=CNAME \
-d value="stripe.s3.amazonaws.com" \
-d ttl=3600
这个请求包含了创建记录的所有信息,而且客户端可以绝对安全地多次调用。如果服务端收到请求时发现要创建的域名域名已经存在,是重复调用,那服务端就简单的忽略掉请求,然后返回操作成功的响应就好了。
按照 HTTP 的语义,PUT
andDELETE
动词是幂等的。并且PUT
动词专门用来表示目标资源需要用请求负载(payload)来创建或完全替换。(现代 RESTful 语境中部分修改用PATCH
来表示)
保证有且仅有一次的语义
尽管 HTTP 的 PUT
和 DELETE
这种本身就幂等的语义很好地支持了很多 API 调用,那如果我们有一个需要执行一次且只能执行一次的操作呢?例如我们要设计一个向客户收款的 API,如果不小心调用了两次导致客户被收了两次款,那就太糟了。
这种时候就需要幂等键(idempotency keys)登场了。客户端发送请求时先产生一个唯一的 ID 来标识这次请求,然后和常规负载一起发送给服务端。服务端收到这个 ID 后和把它和这个请求在服务端的状态关联起来。如果客户端发现请求失败了,它会带上同一个 ID 重新请求,然后由服务端来决定怎么来处理这个请求。
我们来考虑之前给出的网络故障的例子:
如果是创建连接失败,服务端收到第二个请求时发现这个 ID 是第一次收到,正常处理这个请求就好了。
如果是请求处理过程中的失败,服务端需要继续处理过程。具体行为取决于系统实现。如果之前的操作被 ACID 数据库成功回滚了,那把处理过程完整重试就是安全的。否则就要把状态恢复,然后继续调用过程。
如果是响应时失败(比如操作已经成功执行了,但是客户端没能收到结果),服务端就把缓存的操作成功结果返回就好了。
Stripe API 在变更节点(如我们的例子里所有的 POST
请求)上实现了幂等键。实现方式是让客户端用特殊的 Idempotency-Key
header 来传一个唯一的值,以确保分布式操作的安全性:
curl https://api.stripe.com/v1/charges \
-u sk_test_BQokikJOvBiI2HlWgH4olfQ2: \
-H "Idempotency-Key: AGJ6FJMkGQIpHUTX" \
-d amount=2000 \
-d currency=usd \
-d description="Charge for Brandur" \
-d customer=cus_A8Z5MHwQS7jUmZ
如果上面所说的 Stripe 请求因为网络问题失败了,然后用相同的幂等键来重试,那个客户只会被收一次款。
做一个好的分布式市民
安全地处理失败是非常重要的。不仅如此,最好还要体贴细致地处理。客户端碰到网络请求失败的时候很可能是因为偶然的失败,重试一下就好了。但也能是因为更严重的问题,没那么好恢复,比如服务器因为故障停机了。这时候重试可能不仅没有效果,反而可能让情况更糟,引起进一步的降级。
客户端遇到错误时一般建议采用类似 指数延时(exponential backoff)算法的方法。客户端第一次重试前等待一个初始的时间长度,随后每次重试前等待按照 2^n 递增的时间长度,其中 n 为失败次数。通过这种方法我们就能保证客户端不会给自身难保的服务器火上浇油。
指数延时在计算机网络领域有一段很长很有趣的历史。除此之外为延时加入一些随机元素也是个好主意。如果大量客户端在相近的时间点一起出现故障,延时重试会导致它们在某些时间点集中进行重试,进而对深陷困境服务器造成很大的冲击。这个问题被称为雷暴问题。
我们可以通过给客户端的重试等待时间长度加一个随机的‘抖动’来对付雷暴问题。这样客户端的重试请求可以被排布开,以给服务端一些喘息的空间。
制订健壮的 API 设计
构建健壮且可预期的 API 过程中,极其重要的一点就是要考虑分布式系统中可能出现的各种失败以及怎么处理失败。客户端加入重试逻辑以及在服务端实现幂等性对于实现这个目标是行之有效的。同时这两种技巧在各种技术栈中都是比较好实现的。
下面给出几条设计客户端和 API 的核心原则:
确保一致地处理失败。客户端向远程服务进行重试请求时, 不这么做会导致数据的不一致,进而跟多问题随之而来。
确保安全地处理失败。通过幂等性和幂等键让客户端在重试时可以传递一个唯一的标识。
确保负责任地处理失败。使用类似指数延时和随机抖动的技巧。要考虑到服务端可能已经陷入降级状态。