【案例分析】分佈式系統的接口冪等性設計!


概念

冪等性, Idempotence, 這個詞來源自數學領域, 百科 上一元運算的冪等性解釋如下:設 f 爲一由 {x} 映射至 {x} 的一元運算, 則 f 爲冪等的, 當對於所有在 {x} 內的 x:  f(f(x)) = f(x) 。特別的是,恆等函數一定是冪等的,且任一常數函數也都是冪等的。

冪等性衍生到軟件工程中, 它的語義是指: 函數 / 接口可以使用相同的參數重複執行, 不應該影響系統狀態, 也不會對系統造成改變 .

一個簡答的例子: 查詢接口 GetFoo(), 不管調用多少次, 都不會破壞當前的系統 / 內存, 這就是一個冪等操作. 當然, 系統內部產生的日誌這些細節不要在意.

在 HTTP/1.1 規範中, 冪等性有類似的明確定義:  Methods can also have the property of “idempotence” in that (aside from error or expiration issues) the side-effects of N > 0 identical requests is the same as for a single request.

從語義上不難看出, HTTP GET 是一個清晰的冪等操作, HTTP DELETE/POST 是非冪等的, HTTP PUT 也是冪等的, 因爲對同一個 URI 進行多次 PUT 的 side-effetcs 是一致的.

在分佈式系統中, 由於分佈式天然特性的時序問題, 以及網絡的不可靠性 (機器、機架、機房故障, 電纜被挖斷等等), 重複請求很常見, 接口冪等性設計就顯得尤爲重要 .

案例分析

舉一個遊戲領域中的案例:

玩家 Jack 花費點券購買道具, 調用後端 shop_svr 集羣的 rpc 接口 buy_commodity(commodity_id) .

由於網絡延遲, 或者系統負載比較高, shop_svr 沒來得及返回, 總之, 第一次調用超時了沒返回.

Jack 見一直木有反映, 又點了一次購買按鈕.

網絡恢復了, shop_svr 連續收到兩次 buy_commodity(commodity_id) 請求.

好吧, Jack 本來只想花 100 點券買個小喇叭, 系統硬是讓他買了倆, 難怪都說 XX 遊戲坑錢了……

上面錯誤的示例只是扯個蛋, 咳咳…… 從這個問題中可以折射出幾點系統設計的問題:

buy_commodity() 接口不符合冪等性 , 當重複操作時, 對整個系統產生了影響, 玩家 A 被多扣了點券, 在網遊業務中, 一旦涉及到錢這種敏感數據, 往往就不妙了.

shop_svr 的消息處理做的不夠完善, 當它收到延遲了許久的消息時, 應該及早拒絕, 返回失敗, 不僅是爲了避免重複調用, 更重要的是保證 shop_svr 不會過載而導致整個系統雪崩 (不過這又是另外個話題, 不在此贅述).

那麼,怎麼完善 buy_commodity() 接口的冪等性呢?

借鑑銀行等金融系統的做法, 引入 票據 (token) 是個不錯的主意:

Jack 花費點券購買道具, 先到 shop_svr 中去申請交易票據 token.

shop_svr 生成唯一 token, 並記錄到 DB.

Jack 拿到 token, 調用接口 buy_commodity(token, commodity_id) 購買.

由於網絡延遲, 或者系統負載比較高, shop_svr 沒來得及返回, 總之, 第一次調用超時了沒返回.

Jack 重試購買, 仍然調用接口 buy_commodity(token, commodity_id) .

shop_svr 收到第一次 buy_commodity() 請求, 驗證 token 之後完成購買行爲,再將 token 標記爲已執行, 這是個 原子行爲 .

shop_svr 收到第二次 buy_commodity() 請求, 驗證 token 失敗, 丟棄消息.

票據 (token) 機制, 保證了 buy_commodity() 接口的冪等性 , 同樣的請求, 並不會對系統造成額外的 side-effects, 即多次調用預期保持一致, 問題解決!

PS: 按照上面的描述, DB 層保證 “驗證 token”, “加道具扣點券”, “標記 token” 這三步操作的原子性, 這並不是一個很容易的事情

所以實際中往往妥協爲: 先 “驗證並標記 token” , 再 “加道具扣點券” 這兩步操作:

第一步操作可以通過 SQL 的條件更新, 或者帶版本號寫 (部分 NoSQL 支持) 來實現, 這是冪等性操作.

如果第一步成功, 第二步失敗, 可以直接認爲操作失敗, 但並不會破壞接口的冪等性.

大部分的網遊服務器, 是極其注重數據強一致性的, 但能容忍一定的可用性缺失.

例如: 玩家能接受每週的例行停服維護時間, 能接受某次點擊服務器返回失敗, 但是很難接受數據被篡改乃至回檔, 這也是上面 DB 操作可以妥協的根本原因.

擴展

But, 問題真的完美解決了麼?

再擴展一下上面的例子, 現在遊戲火了, 爲了響應迅速增大的併發請求, 遊戲服務都做了擴展, 無狀態的 shop_svr 也平行擴展爲一個集羣

玩家的每次 buy_commodity() 請求都被負載均衡器路由到不同的 shop_svr 處理, 以 平攤系統負載 , 一切都看上去很好.

Jack 吃了一個禮拜泡麪終於攢了 20000 點券, 準備買個” 趙雲 - 子龍” 的皮膚, Jack 滿心期待的點下了” 購買” 按鈕, 額, 居然又沒反應… 點了幾下都如此

納悶兒的 Jack 順手點了下隔壁的” 閉月之顏 - 貂蟬” 皮膚, 彈窗提示:” 購買成功”, 這…… Jack 哭了.

我們來回顧一下, 應該是如此的流程: 託分佈式系統的福, 第二個請求 buy_commodity(token_2, “閉月之顏 - 貂蟬”) 後發而先至, 被優先處理

當第一個請求 buy_commodity(pay_token_1, “趙雲 - 子龍”) 在之後到達時, Jack 的點券已經被扣完了,扣完了……

這個問題跟冪等性本身無關, 從系統的行爲來看, 也是符合強一致性 的, 只是在時序上沒能符合 Jack 的預期, 帶來了體驗上的心理落差.

解決之道:

  1. 配置 shop_svr 集羣前端的 負載均衡器 , 通過一定的 路由算法 保證 Jack 的請求消息路由到固定某個 shop_svr_j 上處理.

  2. 同時, 請求消息的傳遞通過 消息隊列 (TCP 也是個樸素的實現) 來 保證順序 , 這樣, Jack 先發的請求 request 1 一定在後發的請求 request 2 之前到達, 並被處理, 從而避免時序的影響.

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/bH6KtVpv5MHZ-ETCKLuHeQ