
原文鏈接:stripe.com 作者:Brandur Leach
網(wǎng)絡(luò)是不可靠的。我們都有連不上 Wi-Fi 或者電話突然中斷的經(jīng)歷(譯者注:看來(lái)還是國(guó)內(nèi)運(yùn)營(yíng)商靠譜)。
服務(wù)器間的網(wǎng)絡(luò)連接一般來(lái)說(shuō)相對(duì)用戶端的最后一公里網(wǎng)絡(luò)(如:手機(jī)網(wǎng)絡(luò)、家用寬帶網(wǎng)絡(luò))要好很多,但是傳輸大量數(shù)據(jù)的時(shí)候還是會(huì)出各種奇葩的問(wèn)題。停電、路由問(wèn)題還有其他各種間歇性的網(wǎng)絡(luò)失敗從整體統(tǒng)計(jì)角度來(lái)說(shuō)不是經(jīng)常性的,但在固有的發(fā)生率下(原文:ambient background rate)一定會(huì)發(fā)生。
在這種本質(zhì)上不可靠的環(huán)境下,設(shè)計(jì)一套失敗時(shí)足夠健壯,而且能保證復(fù)雜狀態(tài)一致性的 API 和客戶端就非常重要了。我們來(lái)看幾種實(shí)現(xiàn)這個(gè)目標(biāo)的方式。
為故障做打算(原文:Planning for failure)
僅考慮兩個(gè)節(jié)點(diǎn)之間的遠(yuǎn)程調(diào)用,就有多種可能出現(xiàn)的故障:
- 客戶端嘗試連接服務(wù)端的時(shí)候可能失敗
- 請(qǐng)求可能在服務(wù)端處理過(guò)程中失敗。導(dǎo)致請(qǐng)求處理工作處在不確定的狀態(tài)。
- 請(qǐng)求處理成功了,但是服務(wù)端返回給客戶端處理成功結(jié)果時(shí)網(wǎng)絡(luò)連接中斷了。
上面這些情況都會(huì)導(dǎo)致發(fā)出請(qǐng)求的客戶端處于不確定的狀態(tài)。有些情況下失敗很明確,客戶端可以放心的進(jìn)行重試。比如無(wú)法建立鏈接的失敗。但是很多其它情況下重試是否成功對(duì)于客戶端來(lái)說(shuō)是有歧義的。它不知道重試是否是安全的。一個(gè)例子就是請(qǐng)求處理過(guò)程中的鏈接斷開。
這是分布式系統(tǒng)的經(jīng)典問(wèn)題。如果把「分布式系統(tǒng)」描述為最少兩臺(tái)計(jì)算機(jī)組成的通過(guò)網(wǎng)絡(luò)互相連接并傳遞消息的系統(tǒng),那么這里「分布式系統(tǒng)」的定義是很寬泛的。Stripe API 和另外一臺(tái)給它發(fā)請(qǐng)求的服務(wù)器就能組成一個(gè)分布式系統(tǒng)。
靈活運(yùn)用冪等性
要對(duì)付失敗造成的分布式狀態(tài)不一致,最簡(jiǎn)單的方法就是把服務(wù)器節(jié)點(diǎn)實(shí)現(xiàn)成冪等的。也就是說(shuō)不管調(diào)用多少次,都能保證副作用(譯者注:實(shí)體狀態(tài)變化)只生效一次。
這樣不管客戶端遇到什么樣的錯(cuò)誤,都能通過(guò)不斷重試來(lái)保證自己的狀態(tài)和服務(wù)端的狀態(tài)最終收斂一致。這樣徹底解決未決失敗的問(wèn)題,因?yàn)榭蛻舳酥纼H用一個(gè)簡(jiǎn)單的技術(shù)(譯者注:重試)就能安全地處理失敗。
下面給出一個(gè)例子。比如一個(gè)向某域名服務(wù)商發(fā)出的調(diào)用添加子域名 API 的 HTTP 請(qǐng)求:
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
這個(gè)請(qǐng)求包含了創(chuàng)建記錄的所有信息,而且客戶端可以絕對(duì)安全地多次調(diào)用。如果服務(wù)端收到請(qǐng)求時(shí)發(fā)現(xiàn)要?jiǎng)?chuàng)建的域名域名已經(jīng)存在,是重復(fù)調(diào)用,那服務(wù)端就簡(jiǎn)單的忽略掉請(qǐng)求,然后返回操作成功的響應(yīng)就好了。
按照 HTTP 的語(yǔ)義,PUTandDELETE動(dòng)詞是冪等的。并且PUT 動(dòng)詞專門用來(lái)表示目標(biāo)資源需要用請(qǐng)求負(fù)載(payload)來(lái)創(chuàng)建或完全替換。(現(xiàn)代 RESTful 語(yǔ)境中部分修改用PATCH來(lái)表示)
保證有且僅有一次的語(yǔ)義
盡管 HTTP 的 PUT 和 DELETE 這種本身就冪等的語(yǔ)義很好地支持了很多 API 調(diào)用,那如果我們有一個(gè)需要執(zhí)行一次且只能執(zhí)行一次的操作呢?例如我們要設(shè)計(jì)一個(gè)向客戶收款的 API,如果不小心調(diào)用了兩次導(dǎo)致客戶被收了兩次款,那就太糟了。
這種時(shí)候就需要冪等鍵(idempotency keys)登場(chǎng)了??蛻舳税l(fā)送請(qǐng)求時(shí)先產(chǎn)生一個(gè)唯一的 ID 來(lái)標(biāo)識(shí)這次請(qǐng)求,然后和常規(guī)負(fù)載一起發(fā)送給服務(wù)端。服務(wù)端收到這個(gè) ID 后和把它和這個(gè)請(qǐng)求在服務(wù)端的狀態(tài)關(guān)聯(lián)起來(lái)。如果客戶端發(fā)現(xiàn)請(qǐng)求失敗了,它會(huì)帶上同一個(gè) ID 重新請(qǐng)求,然后由服務(wù)端來(lái)決定怎么來(lái)處理這個(gè)請(qǐng)求。
我們來(lái)考慮之前給出的網(wǎng)絡(luò)故障的例子:
如果是創(chuàng)建連接失敗,服務(wù)端收到第二個(gè)請(qǐng)求時(shí)發(fā)現(xiàn)這個(gè) ID 是第一次收到,正常處理這個(gè)請(qǐng)求就好了。
如果是請(qǐng)求處理過(guò)程中的失敗,服務(wù)端需要繼續(xù)處理過(guò)程。具體行為取決于系統(tǒng)實(shí)現(xiàn)。如果之前的操作被 ACID 數(shù)據(jù)庫(kù)成功回滾了,那把處理過(guò)程完整重試就是安全的。否則就要把狀態(tài)恢復(fù),然后繼續(xù)調(diào)用過(guò)程。
如果是響應(yīng)時(shí)失?。ū热绮僮饕呀?jīng)成功執(zhí)行了,但是客戶端沒(méi)能收到結(jié)果),服務(wù)端就把緩存的操作成功結(jié)果返回就好了。
Stripe API 在變更節(jié)點(diǎn)(如我們的例子里所有的 POST 請(qǐng)求)上實(shí)現(xiàn)了冪等鍵。實(shí)現(xiàn)方式是讓客戶端用特殊的 Idempotency-Key header 來(lái)傳一個(gè)唯一的值,以確保分布式操作的安全性:
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
如果上面所說(shuō)的 Stripe 請(qǐng)求因?yàn)榫W(wǎng)絡(luò)問(wèn)題失敗了,然后用相同的冪等鍵來(lái)重試,那個(gè)客戶只會(huì)被收一次款。
做一個(gè)好的分布式市民
安全地處理失敗是非常重要的。不僅如此,最好還要體貼細(xì)致地處理??蛻舳伺龅骄W(wǎng)絡(luò)請(qǐng)求失敗的時(shí)候很可能是因?yàn)榕既坏氖?,重試一下就好了。但也能是因?yàn)楦鼑?yán)重的問(wèn)題,沒(méi)那么好恢復(fù),比如服務(wù)器因?yàn)楣收贤C(jī)了。這時(shí)候重試可能不僅沒(méi)有效果,反而可能讓情況更糟,引起進(jìn)一步的降級(jí)。
客戶端遇到錯(cuò)誤時(shí)一般建議采用類似 指數(shù)延時(shí)(exponential backoff)算法的方法。客戶端第一次重試前等待一個(gè)初始的時(shí)間長(zhǎng)度,隨后每次重試前等待按照 2^n 遞增的時(shí)間長(zhǎng)度,其中 n 為失敗次數(shù)。通過(guò)這種方法我們就能保證客戶端不會(huì)給自身難保的服務(wù)器火上澆油。
指數(shù)延時(shí)在計(jì)算機(jī)網(wǎng)絡(luò)領(lǐng)域有一段很長(zhǎng)很有趣的歷史。除此之外為延時(shí)加入一些隨機(jī)元素也是個(gè)好主意。如果大量客戶端在相近的時(shí)間點(diǎn)一起出現(xiàn)故障,延時(shí)重試會(huì)導(dǎo)致它們?cè)谀承r(shí)間點(diǎn)集中進(jìn)行重試,進(jìn)而對(duì)深陷困境服務(wù)器造成很大的沖擊。這個(gè)問(wèn)題被稱為雷暴問(wèn)題。
我們可以通過(guò)給客戶端的重試等待時(shí)間長(zhǎng)度加一個(gè)隨機(jī)的‘抖動(dòng)’來(lái)對(duì)付雷暴問(wèn)題。這樣客戶端的重試請(qǐng)求可以被排布開,以給服務(wù)端一些喘息的空間。

制訂健壯的 API 設(shè)計(jì)
構(gòu)建健壯且可預(yù)期的 API 過(guò)程中,極其重要的一點(diǎn)就是要考慮分布式系統(tǒng)中可能出現(xiàn)的各種失敗以及怎么處理失敗。客戶端加入重試邏輯以及在服務(wù)端實(shí)現(xiàn)冪等性對(duì)于實(shí)現(xiàn)這個(gè)目標(biāo)是行之有效的。同時(shí)這兩種技巧在各種技術(shù)棧中都是比較好實(shí)現(xiàn)的。
下面給出幾條設(shè)計(jì)客戶端和 API 的核心原則:
確保一致地處理失敗??蛻舳讼蜻h(yuǎn)程服務(wù)進(jìn)行重試請(qǐng)求時(shí), 不這么做會(huì)導(dǎo)致數(shù)據(jù)的不一致,進(jìn)而跟多問(wèn)題隨之而來(lái)。
確保安全地處理失敗。通過(guò)冪等性和冪等鍵讓客戶端在重試時(shí)可以傳遞一個(gè)唯一的標(biāo)識(shí)。
確保負(fù)責(zé)任地處理失敗。使用類似指數(shù)延時(shí)和隨機(jī)抖動(dòng)的技巧。要考慮到服務(wù)端可能已經(jīng)陷入降級(jí)狀態(tài)。