【案例分析】分佈式系統的接口冪等性設計!
概念
冪等性, 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 的預期, 帶來了體驗上的心理落差.
解決之道:
-
配置 shop_svr 集羣前端的 負載均衡器 , 通過一定的 路由算法 保證 Jack 的請求消息路由到固定某個 shop_svr_j 上處理.
-
同時, 請求消息的傳遞通過 消息隊列 (TCP 也是個樸素的實現) 來 保證順序 , 這樣, Jack 先發的請求 request 1 一定在後發的請求 request 2 之前到達, 並被處理, 從而避免時序的影響.
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/bH6KtVpv5MHZ-ETCKLuHeQ