接口設計的那些事

接口的一般性問題

缺乏經驗的程序員開發接口的時候,往往僅關注功能實現,但決定接口質量的恰恰是非功能性方面——遺憾的是,這一點在大部分公司,從項目到產品到研發,甚至到測試,都未得到應有的重視。

接口的非功能性要素主要體現在如下幾個方面:

  1. 冪等性;
  2. 魯棒性;
  3. 安全性;

冪等性

如果某一天你在超市消費了 1000 元,而你的銀行卡被扣了 2000 元,你是什麼感受?

(當然你我幾乎不會遇到這種問題,因爲金融級別軟件出現這種低級錯誤,估計是不想在市面上混了。)

重複扣款涉及到接口的冪等性問題。

冪等性是指寫型接口必須保證重複調用時的數據正確性,一般出現在添加數據的場景,以及一些非冪等修改的場景(如扣減餘額)。刪除場景一般具備冪等性。

我們無法預期接口調用方如何調接口,可能由於調用超時,或者調用方實現問題(比如前端用戶可短時間內高頻點擊),接口設計必須將重複調用作爲常態考慮——因接口被重複調用而導致數據問題,責任應歸於接口實現者而不是調用者。

處理冪等性的手段一般分業務邏輯層面數據庫層面


業務邏輯層面:select + insert:

這種方式應用得很多,實現方式是在添加或修改數據之前先根據請求參數(如用戶編號、訂單編號)查一下相關數據,以決定該請求是否已經處理過了,防止重複處理(如重複加積分、重複扣款)。

這種處理方式的優點是它本身屬於業務邏輯的一部分,產品和開發人員畫流程圖時往往會自然而然地包括這些邏輯,因而也是最容易想到的實現方式——容易想到就意味着現實中大部分的系統已經實現了這種基本的冪等性處理。

但這種 select + insert 解決不了併發問題:在極短的時間內發生的重複請求,比如用戶瘋狂地點擊按鈕(假如按鈕沒做任何限制)、羊毛黨薅羊毛等。

在高併發時,同一個用戶的兩個請求幾乎同時到達,此時兩個請求幾乎同時 select,都發現數據庫沒有相關記錄,於是都能執行後續業務邏輯。

所以對於重要場景(如發券、積分等),請求必須在用戶級別具有排他性:同一時間同一個用戶只能有一個請求在處理,多個同樣的請求必須串行處理。

我們可以藉助 Redis 來實現分佈式請求鎖。根據相關請求參數生成 redis key,比如在增加積分場景,可以根據“用戶 id + 場景 id” 生成 key 作爲鎖,請求到來時先檢查鎖是否存在,如果存在則直接拒絕處理,不存在的話才進入下一步。這樣就保證了請求的排它性。流程圖如下:

image-20220321164004286

然而,當你的數據庫使用讀寫分離時,你會發現請求鎖方案有時還是會出現漏網之魚。業務系統處理完成後會解除請求鎖,此時同一個用戶的重複請求就可以進來,但此時新數據可能還沒有同步到從庫,因而 select 仍然查不到,於是業務邏輯又被執行了一遍(如加了兩次積分)。你可能覺得這種延遲在毫秒級,問題不大,但如果對方是腳本薅羊毛,這可能就是不容忽視的問題。

這種情況必須結合數據庫層面的約束來解決。

Redis 分佈式鎖:

Redis 的高性能、高併發和單線程處理(命令的原子性)很適合做分佈式鎖。有些細節值得注意。

我們一般使用 Redis 的 set 帶 nx 選項實現分佈式鎖:

> set lock_key private_val ex 20 nx

(其中 lock_key 和 private_val 是程序生成的。)

上面設置鎖 lock_key,過期時間是 20 秒。其中關鍵在 nx 選項,它表示當 lock_key 不存在時才設置。這條指令是 setnx 的增強版,在 setnx 基礎上增加了對過期時間的支持。

那麼我們如何釋放鎖呢?直接執行 del lock_key?不行的,程序只能釋放由自己加的鎖,如果直接 del,那麼有可能會刪除掉別的進程加的鎖(比如當前進程執行超時,原來的鎖過期了,而此時另一個進程剛好也加了個 lock_key 的鎖,此時會把另一個進程的鎖刪了)。

所以刪除前必須判斷 private_val 是不是當前進程生成的,所以必須先判斷再比較:

> get lock_key

> del lock_key

這樣實現有沒有問題呢?還是有那麼一點小問題的:這裏執行了兩條 Redis 命令,不具備原子性,可能出現第一條執行成功了第二條失敗的情況(雖然概率很低),另外需要兩次網絡開銷。有沒有優化空間呢,可以使用 Redis 的 eval 命令執行 Lua 腳本來保證原子性(相關語言 SDK 都有支持):

> eval 'if (redis.call('get', KEYS[1]) == ARGV[1]) then redis.call('del', KEYS[1]);end return 1;' lock_key private_val

(Lua 語言很簡單,自行百度, 1 小時學會。)


數據庫層面:

我們可以通過數據庫提供的唯一鍵約束來實現冪等性。

我們看看儲值卡扣費場景。電商的儲值卡支付場景中,儲值卡扣費環節至少要發生兩個操作:

  1. 產生一筆流水,至少包含訂單號和支付金額;
  2. 儲值卡賬戶扣除相應金額;

如果儲值卡支付接口不做任何冪等性處理,那就有可能同一筆訂單會產生兩筆支付流水且卡賬戶被重複扣款,造成客訴。

這裏我們除了可以採用前面的“請求鎖+select+insert”方案,還可以在數據庫層面增加唯一鍵約束。假如一筆訂單僅支持支付一次,那麼就可以用訂單號做唯一鍵約束,當同一筆訂單進行多次支付(插入流水)時就會因唯一鍵衝突而插入失敗(賬戶餘額變更操作和增加流水在一個數據庫事務中,自然也不會成功)。

有些場景的唯一性約束體現在組合鍵上,比如簽到,用戶一天只能簽到一次,那麼就可以用“用戶id+日期”這樣的組合唯一鍵。

當然,有些場景可能壓根就不存在這樣的唯一約束字段,比如增減積分、發券,此時必須創造出單獨的約束字段來實現唯一性約束,比如給表增加一個 uniqid 並建立唯一鍵索引。現在的問題是 uniqid 從哪裏來?

這種情況下基本上接口提供方無法根據接口請求參數生成唯一標識,必須由接口調用方提供這個 uniqid。接口提供方(如券系統)在寫入數據的時候(如給用戶發券)會將該 uniqid 存入,如果之前已經寫入過,則會發生唯一鍵衝突,數據寫入失敗。

那麼現在的問題是,如何保證接口調用方生成的標識是唯一的呢?如果調用方生成的標識和其他請求的標識衝突了,就會導致本次接口調用永遠會失敗。

一般有兩種方案:1. 調用方根據某種規則自行生成標識;2. 由接口提供方提供單獨的生成標識的接口。

調用方自行生成,可以採用 uuid 算法生成(一般編程語言都有相應的庫)。uuid 能很好地保證唯一性,但缺點一方面是比較長(至少佔用 16 字節),另外它是無序的,對 MySQL 這樣的 B+ 樹索引不是很友好,可以採用 twitter 開源的雪花算法(snowflake,網上也有現成的實現庫)方案來生成 64 bit 整型(long)標識。

如果系統併發量不是特別高,而且也不想讓客戶端去生成唯一標識,可以由業務系統或者獨立的發號器系統提供唯一標識接口來獲取唯一標識。

發號器系統(有可能就是相關業務系統自身)可以採用現成的 uuid 或 snowflake 方案,也可以自行實現。此處提供一種實現思路。

假如我們要生成的唯一標識格式是 xxxxxxxxyyyyyyyyyyyyzzzz,其中 x 是當前日期,y 是 12 位十進制(千億),每天從 1 開始自增,z 是四位隨機數,主要防止萬一 y 位出現異常重複的情況下降低標識符重複概率。該唯一標識在不考慮隨機位 z 的情況下,每天能生成約 9 千億個標識。

發號器服務器一般不止一臺,所以需要保證多臺服務器生成的 y 部分不會重複,我們採用中間服務 Redis 來分配 y 部分。

那麼,是不是每次生成標識符都要請求 Redis 呢?如此 Redis 的壓力可就大了。所以 y 部分我們要採用批量分配策略,即發號器系統一次向 Redis 申請一個號段,比如一次申請包含 1 萬個值的 y 號段,將號段的起止值記錄在本地內存中,生成標識符的時候先從本地號段中取 y 值,只有本地號段用完了才向 Redis 申請新號段。

發號器系統的本地號段是記錄在內存中的(進程的全局變量),服務退出重啓後會重新向 Redis 申請號段。所以號段範圍建議不能太大,否則如果服務重啓次數較多可能會耗盡 y 號段。

流程如下:

image-20220322113956369


總結一下如何用數據唯一鍵實現接口冪等性:

  1. 適用於插入數據的場景,典型的如“流水+總賬”模式的業務(如儲值、積分、點贊等)。
  2. 優先使用業務字段本身實現唯一性約束,比如儲值卡消費流水中的訂單號。或者是若干字段(2、3 個)的組合鍵唯一約束,如點贊場景。
  3. 當沒有業務字段做唯一約束時,可創建單獨標識字段做唯一約束,此時由調用方提供唯一標識符。
  4. 需保證調用方標識符的唯一性,可採用業界標準的 uuid、snowflake 算法,也可以自己實現。標識符可以由調用端自行生成,也可以由發號器統一生成,根據自己的實際情況和併發量做決策。
  5. 發號器的實現必須考慮其可擴展性,需保證發號器集羣生成的標識具有唯一性。
  6. 數據庫唯一鍵約束可能會和請求鎖、“select+insert”方案一起使用。

關於接口冪等性還有個需要關注的問題:當服務提供方發現本次調用已被處理(本次可能是調用方超時重試,也可能是其它異常調用),應該返回什麼?

有些開發者想當然地從業務判重角度將重複操作作爲異常場景看待,不假思索地返回個錯誤碼,這會給調用端帶來困擾,很可能帶來數據完整性問題。

此時最簡單的做法是直接返回 OK——如果開發團隊中只有一種狀態碼錶示“成功”的話(如 code=200)。

有些開發團隊借鑑 HTTP 狀態碼的定義,將 20X 狀態碼段定義爲成功碼,此時可以就“操作成功”和“該操作已處理過”定義不同的狀態碼(如 200 表示成功,201 表示該操作已處理過),這樣既不干擾調用端的業務處理,也能讓業務端確切知道本次調用的實際處理情況。


前後端的冪等性:

考慮下面的場景:

張三在管理後臺創建券,點擊“創建”按鈕後半天沒響應(網絡較慢),於是張三又連續點了若干次,結果去列表一看,創建了三四張券。

當然你我第一反應很可能是在前端做交互優化:點擊按鈕後將按鈕置灰,並提示“正在創建中...”,直到後端返回數據後按鈕纔可以再次點擊。

上面的前端交互優化確實可以解決絕大部分重複創建的問題。

不過,試想一下這樣的場景:

用戶點擊創建按鈕後,後端服務處理較慢(如服務器負載高了),前端按鈕置灰,用戶不可點擊。

過了一會(如 5 秒鐘),前端接口等待時間超過閾值,前端 js 直接報超時錯誤,告知用戶“服務處理超時,請稍後重試”。

於是用戶再次點擊“創建”按鈕。

然後,用戶去券列表頁面,很可能會發現自己創建了兩張券。

問題出在當前端發現後端接口超時後,會認爲事務處理失敗,於是提示用戶重試,但後端事務實際上仍在執行(甚至有可能後端事務其實早都執行完了,但在返回數據時出現了網絡問題而超時),此時用戶再次點擊“創建”按鈕實際上會執行兩次事務(創建兩張券)。

所以在前後端調用的場景中(主要是創建型事務的場景),同樣需要通過唯一標識(如 uuid)來保證接口調用的冪等性。

首先我們想到用類似前面“請求鎖”方案(但這次不是加鎖):

  1. 在渲染創建頁面的時候,後端生成一個唯一標識符 X,將其保存到 Redis 中(設置一個合理的有效期),並將該標識符返回給前端;
  2. 前端請求後端“創建優惠券”接口時,帶上該標識符;
  3. 後端先比較該標識符是否和 Redis 中的一致,標識符沒問題才進行後續的事務處理;
  4. 後端事務處理成功後,刪除掉 Redis 中的標識符;
  5. 前端在使用該標識符請求後端,後端由於檢測不到該標識符,會直接返回錯誤;

流程如下:

image-20220322175508756

上面的流程有沒有問題呢?

它確實能阻止一部分重複提交,但不是全部。

試想前端請求後端接口,後端接口超時了(但實際上後端事務仍然在執行中),此時前端會讓用戶重試,用戶再次提交,這第二次接口請求仍然會帶上剛纔的 flag,那這次 flag 校驗是否會通過呢?可能會,也可能不會,取決於第二次請求到達時,前一次的事務有沒有處理完(從而刪除掉 flag)。假如前一次的事務(這裏的事務不是說數據庫事務,而是指該接口要做的事情)還沒有處理完,那麼這個 flag 就仍然是合法的,那麼第二次請求仍然會被處理。如下圖:

image-20220322220950452

圖中橙色和藍色部分代表兩次請求的處理流程(省略了 Redis 部分)

我們也不能在接口處理完之前刪除掉 Redis 中的 flag,因爲如果事務處理失敗,是需要前端重新提交的。

要想前後端交互真正的實現冪等性,必須藉助數據庫的唯一鍵約束。和前面的一樣,我們給數據表增加一個專門字段(假如就叫 flag)做唯一性約束,我們以券爲例,數據表大致長這樣:

	id		|		name		|		...		|			flag
   -----------------------------------------------------------------------------------------------------------------------------
       122			     5元優惠券                             ...			         122174813112

這裏的 flag 就是上面我們生成並存儲到 Redis 的那個唯一標識,我們在數據庫插入券數據的時候一併寫進去。由於 flag 字段是唯一鍵,如果先前已經寫入過了,再寫入就會報唯一鍵衝突錯誤,寫入失敗,從而保證了接口的冪等性。如此,上圖中用戶再次點擊提交,雖然flag 校驗仍然會成功,但兩次處理只有一次會真正成功,另一次在寫數據庫時會失敗(不能保證一定是第一次請求寫入成功,網絡調用不具備時序性)。

加上數據庫約束後兩次請求的處理過程如下:

image-20220322223331381

圖中橙色和藍色部分代表兩次請求的處理流程(省略了 Redis 部分)

有人可能覺得有了數據庫層的唯一性校驗,就可以去掉 Redis 那一層的校驗。這是不行的,如果去掉 Redis 這層校驗,我們便無法保證前端傳的這個 flag 是我們自己生成的,也就是說前端隨便傳個 flag 就能寫庫了。

總結一下前後端接口調用的冪等性實現:

  1. 通過前端 js 限制用戶高頻次點擊導致的重複提交,這是成本最低、最快見效的實現方式;
  2. 通過 Redis 實現標識符校驗,結合前端 js 控制,能夠滿足大部分的冪等性要求;
  3. 再加上數據庫層面的唯一鍵約束,能夠真正實現前後端交互的冪等性;

講完冪等性,我們看看第二個接口設計原則:魯棒性。


魯棒性

“魯棒”這個詞真的誤人子弟,反正我第一次聽到這個詞時腦海中冒出的是一個粗魯的大漢揮舞着棒子不知在幹啥。

“魯棒”是音譯,英文叫 Robustness,翻譯過來是“堅固性,健壯性”的意思,所以接口的魯棒性是指接口的健壯性如何。

接口的魯棒性取決於它對異常場景的承載能力

什麼樣的接口不具備魯棒性呢?如果一個接口嚴重依賴於外部輸入的合法性以及第三方服務的正確性,一旦外部輸入非預期內容(如含有 SQL 注入的字符串),或者所依賴的第三方服務(接口)崩潰了(如超時),該接口就會出現各種未知問題(最典型的是數據一致性問題,如卡賬扣款了但訂單還是未支付狀態),那麼我們說該接口是脆弱的,不具備魯棒性。

幾乎所有的程序員都能寫出可用的接口(實現正常流程),但至少有一半(其實不止)的程序員寫不出健壯的接口。

這裏的異常主要包括:

  1. 輸入異常;
  2. 流程異常;
  3. 性能異常;

輸入異常:

“不要信任外部輸入”是常識,但不是所有人都正確處理這塊。這裏主要包括以下幾塊:

  1. 參數類型限制;
  2. 缺省參數處理;
  3. 惡意輸入的攔截;

考慮到接口調用方編程語言的異構性以及其他複雜因素,參數類型儘量只使用數值類型和字符串,儘量不要用 bool 型(true、false)、Null——有些情況下對方可能給你傳的是字符串“true”而不是 bool 值 true,如果你打算用這些類型,請在接口內部消化掉字符串 "true"、"false"。

接口參數應遵循”最小化輸入“原則,即調用端只需要關心他關心的參數,接口自身應能正確處理參數缺省值。我見過有些接口有二三十個參數,每個參數都是必填的——調用端對不需要的參數必須傳缺省值(0 或空字符串),對接的人一邊對接一邊崩潰,還經常因某個參數傳入錯誤導致接口報錯。

異常輸入這塊重點在字符串類型上。

字符串的第一個威脅是 XSS 攻擊。企盼每個開發人員對每個入參都做脫敏處理是不現實的,所以這一步必須在開發框架層面提供支持,控制器中拿到的參數應該是已經做過處理了的。雖然這是件很基礎(基礎到不值得拿出來一說)的事情,但我敢保證,市面上有一半的系統都沒有做嚴格的參數處理——因爲保證這點的唯一手段是將滲透測試作爲測試的一個環節納入到工作流程中,但大部分中小公司的產品並沒有做滲透測試。退而求次,保證接口入參健壯性的次要手段(但對於大部分中小公司是最實用的)是將參數處理納入到框架層面(有些框架天然支持這點,有些則需要定製開發)。

XSS:跨站腳本攻擊(Cross Site Scripting,爲了不和層疊樣式表的縮寫衝突而寫成 XSS),是指惡意用戶通過在網站中注入 javascript 腳本實現攻擊(如獲取 Cookie 信息)。

比如我們網站有個輸入框(普通文本框或者富文本),用戶在裏面輸入”<script>alert(document.cookie)</script>“,如果後端接口沒有對該輸入做任何處理就存入數據庫,那麼當這段文本在前端頁面渲染時該腳本就會被執行獲取到 Cookie 信息。

那是不是把代碼裏面 <script> 都去掉就行了呢?沒那麼簡單的,比如用戶輸入 <img onerror="alert(document.cookie)" src="http://aaa"> 照樣能執行。所以最好使用對應語言現成的開源庫來過濾 XSS 腳本。

XSS 的威脅在於其生成的 js 腳本是在受信任環境執行的(處於受信任域名下,而且是在合法的登錄會話中),它可以獲取 Cookie(如果沒有做 HttpOnly 防護)、localStorage,以及調後端接口,其威脅甚至大於 CSRF(後面會提到)。

字符串的第二個威脅是 SQL 注入。這同樣是一個老掉牙的問題,老到幾乎所有框架都提供了直接支持,只要你不在代碼裏面寫原生 SQL 幾乎就不會出現 SQL 注入問題——問題恰恰出在很多開發人員就是喜歡寫原生 SQL,各種參數拼接,一滲透一堆問題,甚至表都讓人給刪了。開發人員寫原生 SQL 的原因有很多,可能是開發人員對框架的數據庫操作模塊不熟悉,又懶得去看文檔;也可能是開發人員寫的 SQL 比較複雜,用框架提供的方法實現起來比較彆扭;或者僅僅是個人偏好。

想要杜絕代碼中的原生 SQL,最直接的方法是代碼審查。代碼審查的一個環節專門審查 Model 層(或倉儲層)的 SQL 規範性——什麼,你說你的 SQL 寫在控制器裏面?

一種更加自動化的方式是開發個審查工具,自動檢查 Model 層出現的字符串拼接,或者對某特定方法的調用。

字符串的第三個威脅是格式。強制對每個輸入字符串都做長度限制是個好習慣,它能防止一些不必要的麻煩——你的接口產生的數據會被別的地方用到,不能保證別的地方都能正確處理這些超長數據。對特定字段做格式限制是必要的,比如郵件、手機號、身份證號、性別,防止用戶隨意輸入產生無效數據。

和前兩者一樣,指望開發人員在代碼中對入參格式做合理處理是困難的——瞅瞅自己公司數據庫中有多少無效的手機號、身份證號、車牌號就知道了。參數格式需要在產品策劃階段加以定義,並納入到測試用例中;開發框架需要提供常見格式校驗的能力(如郵箱、URL、身份證號等),開發人員只需要簡單的配置就可以實現參數格式校驗——不是所有的開發人員都會寫郵箱驗證的正則表達式的。

字符串的第四個威脅是空格。你沒看錯,就是這麼小小的空格,困擾了無數運營和開發。反正我是遇到過多次因小小的空格造成的血案。對於開發來說,去空格這件事卑微到不屑去做;對於運營來說,檢查空格不但卑微而且無趣。空格的威脅力在於其本身極其沒有存在感,開發很難關注,運營很難發現,但出現問題時很難排查。

我們就遇到過一次支付失敗的問題,兩邊團隊查日誌、查配置,眼睛都瞎了還找不出問題所在,最後一個偶然的機會,某人發現運營在填 appid 時末尾多了個空格!

不能指望開發人員能自覺地對所有字符串參數去首尾空格,必須在框架層面統一處理。


流程異常:

這裏的流程異常不是說代碼沒有正確實現業務邏輯——那屬於功能異常,不屬於魯棒性考慮的範圍。這裏說的流程異常是指在正常執行流中出現了不可控的異常。

想想我們過去開發的接口,有沒有出現過以下情況:

  • 讀取磁盤中的文件——有沒有考慮讀取失敗會怎樣?
  • 寫入磁盤文件——有沒有考慮寫入失敗會怎樣(如目錄不存在)?
  • 讀取系統時間——有沒有考慮如果系統時間錯誤會怎樣?
  • 計算某個比率(如中獎率)——有沒有考慮除數是 0 的情況(如壓根沒人抽獎)?
  • 調某個外部接口——有沒有考慮接口調用失敗(如超時)的情況?
  • 更重要的,當流程中的某一步失敗了,其他步該如何處理(以及已經產生的數據如何處理)?

以上異常有兩個特徵:

  1. 大部分是不可控的(無法通過程序自身避免問題發生);
  2. 只要系統運行時間足夠長,就一定會發生(除非系統自身沒有涉及到這些方面,如壓根沒有涉及到遠程調用);

健壯的程序要能夠正確地處理這些異常,保證數據的一致性。這裏有兩層含義:

  1. 程序要處理(而不是忽略)這些異常;
  2. 程序能正確地處理這些異常,讓程序在發生異常時的行爲符合預期;

作爲開發人員我們不能有”幸運兒“思想:我的系統不會發生這些問題。但這不代表我們的程序一定能夠消化掉這些異常並讓流程繼續進行下去——有時候讓流程終止纔是唯一正確的方式,但由於程序沒有處理這些異常(或者處理不當)導致流程繼續進行,進而導致數據一致性問題(比如在儲值卡充值場景中,調支付接口失敗,但程序沒有判斷該異常,仍然往下執行,給用戶卡賬充了錢)。

處理這些異常的方式主要有以下幾種:

  1. 終止執行流。比如儲值卡消費場景,如果儲值卡扣款接口調失敗了,則要終止執行流,防止出現扣款失敗但訂單狀態變成已支付的數據一致性問題(實際上儲值卡消費的異常場景遠比這裏說的複雜,後面我會在單獨的文章中分析該場景);
  2. 預處理。比如寫文件的場景,可以先判斷一下目錄是否存在,不存在則先創建目錄然後再寫文件;計算比率時可先判斷分母(如抽獎次數)是否爲 0,如果爲 0 則比率直接爲 0,不再執行除法運算。
  3. 重試。這在遠程調用時用得比較多,當接口超時時,一段時間後(如 1 秒)重試一次,還不行則終止執行流。但需要注意,一般接口超時往往意味着對方系統負載高(或者網絡擁塞),大量的重試會加重對方系統負擔,最終崩潰掉;另外重試也會導致本次請求長時間佔用本服務器資源,如果對方系統長時間無法恢復,本系統則會產生大量的請求進程(大家都在那重試),最終引發雪崩。如果決定引入重試機制,則需要合理設置超時時間(比如 2 秒。時間越長請求佔用資源越久,越容易導致雪崩),重試次數也不能太多,可能還要結合熔斷和限流一起使用。
  4. 異步補償。對於執行流中的非核心節點出現的異常(主要是遠程調用失敗的場景),我們可以先做異常登記,然後執行流繼續往下執行。而後我們通過異步任務去重試這些異常節點。比如用戶消費返券的場景,在支付回調的處理流程中會調券接口給用戶發券,如果該接口調用失敗(超時),我們除了可採用重試機制,還可以在數據庫中(或消息隊列中)寫一條失敗待重試的記錄,由異步處理程序稍後重試。
    相比同步重試機制,異步重試不會導致本次請求佔用太久服務器資源,本次請求的後續流程仍然能夠快速執行完成;另外異步重試的時間間隔可以更長(如 10 秒一次,或者隨着重試次數而增加時間間隔),這樣對被調用系統的壓力也更小。
    不過異步重試也是有限制條件的。首先相關節點可以異步化,後續節點不需要依賴該節點的輸出結果;其次業務對該節點的時效性具有較寬的容忍度(如消費返券的場景,即使延遲幾秒鐘發券也無所謂)。

性能異常:

健壯的接口應具備一定的性能承諾能力——即併發處理能力(在一定併發量——比如 1000 qps——的情況下每個請求的平均處理時間)。

性能問題來自三個方面:

  1. 自身代碼質量導致的性能問題;
  2. 所依賴的服務出現性能問題而造成的連鎖反應;
  3. 異常調用量造成的額外壓力(如大促);

大部分接口的性能問題來自接口自身的實現缺陷——如從不使用緩存、很少創建索引。所以優化接口性能總是要先從緩存和索引着手,這是成本最低、最立竿見影的做法。

有很大一部分的性能問題來自所依賴的服務(接口)。一般有兩種解決辦法:

  1. 找到對方,讓對方優化接口性能(如果是部門內部團隊,該方案比較可行);
  2. 將調用異步化;

在接口自身已經達到優化極限的情況下,還承受不了併發壓力,說明需要水平擴容了——往集羣中再加幾臺服務器。但現實往往沒那麼簡單,因爲性能瓶頸往往出現在存儲上而非業務服上,而存儲恰恰是最難擴展的部分。

這裏不會去討論怎麼設計高併發系統,也不會去討論熔斷限流這些”高級“的話題(其實一點都不高級)——這裏要強調的是,在”言必高併發“的今天,對於大部分公司來說,性能優化性價比最高的三劍客仍然是:緩存、索引、異步化

除了這三種異常,其實前面討論的冪等性也屬於魯棒性範疇,它說的是接口在異常調用的情況下對數據一致性的保障能力。


安全性

前面講的 XSS 攻擊和 SQL 注入也屬於安全範疇,不過此處說的安全性是指防止接口被非法調用。

主要有兩種類型的接口調用:

  1. 前後端接口調用;
  2. 後端之間的接口調用;

兩種調用者的區別是,前端完全暴露在外部(相當於裸體),而後端調用者本身是處於各種保護之中的(相當於穿了羽絨服)。


前後端調用:

前後端的信任是基於登錄的(賬號密碼登錄、手機號驗證碼登錄、微信/支付寶 Oauth 授權登錄等),用戶登錄成功後,後端會生成一個登錄標識給到前端,前端後續請求後端都會帶上該標識。登錄標識有兩層含義:

  1. 驗證前後端交互的合法性:該前端此時能否調該接口。
  2. 驗證操作的合法性:本次接口調用是否有權操作其指定的數據(只能操作登錄用戶權限範圍內的數據)。

常用的登錄標識有 session 和 token 兩種方案。


session 方案:

傳統的基於瀏覽器的 Web 應用多采用 session 方案。用戶登錄成功後後端生成一個隨機串(sessionId),通過 Cookie 傳遞給前端;前端調後端接口時同樣通過 Cookie 將 sessionId 傳遞給後端,後端校驗 sessionId 的合法性,然後執行後續操作。流程如下:

image-20220324161101160

前端調後端接口時由瀏覽器自動將 Cookie 攜帶入 HTTP Header 中,而後端 sessionId 的生成與維護一般也由框架底層支持——就是說 session 方案基本是個開箱即用的方案,實在是太方便了(方便到以至於很多人並不清楚 session 的運作機制)。

方便是有代價的。session 方案存在以下幾個問題:

  1. 跨域問題。Cookie 默認是不支持跨域的,這對需要跨域訪問的站點可能是個問題。當然解決方案也有多種,如將 Cookie 的 domain

屬性設置爲一級域名;採用 sso。

  1. 分佈式訪問問題。一般框架默認的 session 存儲方案是本地文件存儲,這會導致在集羣環境登錄失效——用戶登錄的時候在 A 服務器生成的 session,自然存儲在 A 服務器本地,用戶後續的請求如果打到 B 服務器,由於 B 服務器沒有該用戶的 session,就會報錯。解決方案也有很多種,如採用集中式存儲方案(一般採用 Redis,大多數框架也支持一鍵配置 Redis 作爲 session 存儲方案);配置負載均衡規則,讓同一個客戶端的請求都打到同一臺服務器。
  2. CSRF 攻擊。由於 sessionId 是通過 Cookie 傳輸的,”瀏覽器自動將 Cookie 寫入 HTTP Header 頭“這一做法帶來方便的同時也帶來了危險——CSRF(跨站請求僞造攻擊)利用這一特性可以在別的網站上僞裝成合法用戶請求實施非法操作。當然我們可以通過 CSRF Token 來防範 CSRF 攻擊。
  3. 狀態保持。由於 sessionId 本身並不攜帶用戶信息(如 userId),所以服務器端必須將用戶基本信息和 sessionId 一同存儲起來,如此才能知道該登錄會話是由哪個用戶發起的。當登錄量很大時,這是一筆不小的存儲開銷。
  4. 移動端環境。有些移動端環境不支持 Cookie,此時開發人員不得不自行實現 Cookie 存儲與傳輸。

上面的情況都是可以解決的——問題在於是不是所有人都解決了這些問題呢?肯定不是的,現實中大量的網站沒有做 CSRF 防護,沒有將 Cookie 設置成 HttpOnly,沒有做 XSS 注入和 SQL 注入過濾。

所以有沒有其它方案能夠規避掉 session 方案的這些問題呢?

方案是有的,也就是目前業界非常青睞的 Token 方案。


Token 方案:

既然 session 方案的問題都出現在 Cookie 上(具體是 Cookie 的客戶端存儲和傳輸機制上),那我們可以對原先的方案稍作改造,讓它不依賴於 Cookie。

後端生成登錄標識(爲了和 session 方案區分,此處我們叫它 token)後,通過自定義響應頭(如就叫 Login-Token)將 token 返回給前端,前端將該 token 以適當的方式存儲起來(如 localStorage);前端對後端的後續請求都在 HTTP 請求頭中帶上該 token,後端先校驗 token 的合法性,並通過 token 拿到登錄用戶信息,然後執行後續流程。

和 session 方案一樣,Token 也是通過 HTTP Header 傳輸的(Cookie 也是在 HTTP Header 中),只不過 Token 的存儲和傳輸都是由應用層程序自己控制的,沒有利用瀏覽器的自動機制,CSRF 僞造請求時自然帶不上該參數。

由於不需要依賴 Cookie,token 方案也就不存在跨域問題,並且在移動端環境也很好使用。

此 token 方案在服務器端的行爲和 session 幾乎是完全一致的:它也需要生成一個隨機串(token),並且要將 token 串和用戶基本信息以適當的方式保存起來以供後續使用。

也就是說該 token 方案仍然需要保存狀態信息。如果該狀態信息存儲在服務器本地,則同樣會存在分佈式訪問問題。

我們並沒有解決 session 方案的第 2、4 兩點問題。

兵來將擋,水來土掩。

服務器端之所以要存儲狀態信息,是因爲 token 自身沒有攜帶狀態(用戶)信息——那如果我們讓 token 自身攜帶這些信息呢?

好像可行。比如我們這樣生成 token:

// 狀態信息(用戶信息)
stat_info = 'userid=12345&name=張三';
// 將狀態信息 base64 編碼後得到 token
token = base64_encode(stat_info);

如此,服務器後續從前端拿到 token 後 base64_decode 就能拿到用戶信息了。

可行嗎?

當然不行!

服務器端之所以存儲 token 相關信息,一方面是爲了後面能拿到登錄用戶信息,另外一方面是爲了能夠校驗客戶端傳過來的 token 是不是服務器端生成的,而不是客戶端自己僞造的(回想一下前面提到的”登錄標識“的兩層含義)。

現在服務器端沒存 token 了,怎麼檢驗前端傳過來的 token 是否有效?

彆氣餒。如果我們能夠讓前端僞造不了呢?

所謂僞造,跟”篡改“是一個意思。業界防篡改的常用手段是簽名——對,我們給剛纔生成的 token 加上私鑰簽名:

// 簽名祕鑰(從配置中心獲取,或者腳本定期動態生成)
key = 'ajdhru4837%^#!kj78d';
// 狀態信息(用戶信息、登錄過期時間)
stat_info = 'userid=12345&name=張三&expire=2022-03-25 12:00:00';
// 將狀態信息 base64 編碼
encode_info = base64_encode(stat_info);
// 簽名(此處用 HMACSHA256)
sign = hmac_sha256(encode_info, key);
// 將 encode_info 和 sign 簽名拼在一起生成 token
token = encode_info + "." + sign;

如上,我們得到的 token 串長這樣子:xxxxxxxxxx.yyyy,其中 x 部分是用戶信息 base64 編碼後的值,y 部分是對 x 部分的簽名。

有了 y 部分的簽名,外部由於沒有簽名祕鑰,便無法修改或者僞造 x 部分的內容了。

這個帶簽名的無狀態的 token 業界有個標準方案叫 JWT。


JWT:

JWT 是 JSON Web Token 的縮寫,是 RFC 7519 定義的鑑權和信息交互標準。

從名字可知,它是用 json 格式存儲信息,主要用於 web 接口交互(但不限於前後端交互的場景),在系統間(前後端、後端之間)接口交互時實現鑑權和非敏感信息傳輸。

先看看 JWT token 到底長什麼樣子:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

看到這串”亂碼“中兩個小小的點(.)沒?它將這段字符串分成三個部分:

xxxxx.yyyyy.zzzzz

第一部分(x)和第二部分(y)都是 json 字符串的 base64 編碼(JWT 的 J 就是 json 的意思)。具體地,第一部分叫首部(Header),放一些元數據(簽名算法等);第二部分叫有效載荷(Payload),放的是具體要傳輸的信息;第三部分(z)是第一部分和第二部分的簽名串,防止前兩部分被篡改。

我們對上面 token 的前兩部分 base64_decode 看看裏面是什麼東西:

// 第一部分 decode 後
{
  "alg": "HS256",
  "typ": "JWT"
}

// 第二部分 decode 後
{
  "sub": "1234567890",
  "name": "John Doe",
  "iat": 1516239022
}

第一部分(首部)包含了類型(typ,此處是 JWT)和簽名算法(alg,即用什麼算法生成第三部分簽名串,此處用的是 HMAC_SHA256);第二部分(有效載荷)可以自己定義(如上面的 name),RFC 標準定義了一些通用的字段(如上面的 sub、iat)。

你有沒有發現,任何人都可以查看前兩部分的內容?

是的,JWT 前兩部分是明文,所以不要放敏感信息(你也可以對前兩部分加密,但一般我們不這麼搞)。JWT 的真正用途是簽名而不是加密。

現在我們用 JWT 來實現前後端無狀態交互。

JWT token 生成過程如下:

// header
// 簽名算法也用 HS256(HMAC_SHA256,編程語言一般都提供了相應的算法庫)
header = '{"alg": "HS256","typ": "JWT"}';

// payload
// 定義了三個非敏感信息:用戶編號、姓名、token 過期時間
payload = '{"user_id": 123456,"name": "張三","exp": "2022-03-25 12:00:00"}';

// header base64 後
base_header = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
// payload base64 後
base_payload = "eyJ1c2VyX2lkIjoxMjM0NTYsIm5hbWUiOiLlvKDkuIkiLCJleHAiOiIyMDIyLTAzLTI1IDEyOjAwOjAwIn0";
// 兩者拼接
content = base_header + "." + base_payload;

// 簽名祕鑰(從配置中心獲取,或者後臺腳本定期刷新)
key = "ajdhru4837%^#!kj78d";
// 用 HMAC_SHA256 簽名
sign = hmac_sha256(content);

// 得到最終的 token
token = content + "." + sign;

張三登錄成功後,後端將上面生成的 JWT token 通過 HTTP 響應頭(假如叫 Authorization)返回給前端,而後前端請求後端都會帶上如下 HTTP Header:

Authorization:<jwt_token>

後端拿到前端傳的 token,先對前兩部分計算簽名,和第三部分比較,如果一致,說明該 token 合法,並從從有效載荷中解析出用戶信息。

後端並沒有存儲 token,完全是從前端傳過來的 token 中解析出用戶(狀態)信息,一方面避免了後端存儲的開銷,同時也解決了集羣服務的訪問問題,堪稱完美!

我們在有效載荷中增加了過期時間(exp),該 token 只在該時間之前有效。

這裏有個問題。我們假設用戶是在 2022-03-25 11:00:00 登錄的,登錄有效期是 1 個小時,即 token 的過期時間是 2022-03-25 12:00:00。假設用戶在 2022-03-25 11:59:58 訪問某個頁面,此時 token 未過期,能正常訪問;用戶在該頁面停留了 2 秒鐘,然後點擊某個按鈕,此時 token 過期了,後端會返回”登錄過期“錯誤,前端就會跳轉到登錄界面——你能想象此時用戶心裏有多少隻馬在奔騰嗎?

所以和 session 方案一樣,必須要有 token 刷新機制,保證在用戶頻繁操作的情況下,token 不會過期。

JWT 的 token 刷新機制很簡單,我們驗證前端的 token 沒問題後,檢查一下有效期,如果過期了,那自然就返回錯誤;如果沒有過期,我們會根據當前時間生成一個新的 token 給到前端,前端用這個新 token 替換掉原來的 token 即可。後端在每次接口響應頭部都加上:

Refresh-Token: <new token>

如此,用戶只有在 1 小時內沒有任何操作的情況下才會退出登錄。

無論是採用何種方案,有一點需要記住:前後端通信一定要使用 https,否則在登錄之初就已經不安全了。


後端之間的調用:

後端相較於前端的一個優勢是,後端雙方都可以持有祕鑰。根據數據敏感度不同,有兩種不同級別的保障需求:

  1. 防篡改。對於一般的數據,只需要保障數據在傳輸中不會被篡改即可。此種場景可採用 appid + secret 的數字簽名方案;
  2. 防窺視。一些敏感性數據,不但要防篡改,還要防止被非法接受者查看,此時需要採用加解密方案(如採用 RSA 算法);

數字簽名方案需要雙方事先協商祕鑰(secret);非對稱加密方案需要事先協商公鑰私鑰對。這裏不詳細講解兩種方案的具體實現細節,主要提一下很多人在設計接口鑑權時都忽視的一種風險:接口重放攻擊。

比如服務器 A 調服務器 B 接口:

https://www.b.com/somepath?name=lily&age=20

對請求參數使用祕鑰簽名後:

// 簽名算法由 B 決定。如 md5(join(ksort(params)) + secret)
https://www.b.com/somepath?name=lily&age=20&appid=12344&sign=a8d73hakahjj2293asfasd234431sdr

這便是 A 調 B 的完整請求參數。

服務器 B 接收到請求後,使用同樣的祕鑰和簽名算法對請求參數(sign 除外)進行簽名,發現和傳過來的 sign 一致,便認爲是合法請求。

有什麼問題嗎?

一年後,只要雙方的 secret 和簽名算法沒變,上面這個 url 仍然是個合法請求——這是個永不失效的簽名。

一般爲了排查問題,調用雙方一般都會把請求信息記錄日誌,如果日誌內容遭泄露,裏面所有的請求都能被重放。

所以我們必須讓簽名有個有效期,過了一定的時間後原來的簽名就自動失效了。

我們在請求參數中加入請求時間,B 接收到請求後,先判斷該時間跟 B 的本地時間差是否在一定範圍內(如 5 分鐘),超過這個時間範圍則拒絕請求(當然這要求雙方服務器的時間不能錯得離譜)。這樣就相當於簽名只有 5 分鐘的有效期,大大降低被重放的概率。

// 帶上時間戳,服務 B 先檢測 timestamp 值是否過期
// 由於 timestamp 字段也被納入到簽名參數中,調用方無法修改 timestamp 的值
https://www.b.com/somepath?name=lily&age=20&timestamp=1647792000&appid=12344&sign=8judq67kahjj2293asfas5dh1k93

除了簽名和加密,還可以結合其他方面加固接口的安全性,如對外接口(非局域網調用)必須使用 https,採用 IP 白名單機制等。


後記

接口設計除了上面提到的冪等性、魯棒性和安全性,還有其他很多值得探討的東西,包括接口的易用性、返回參數結構的一致性、前後端協作方式等,不一而足。

好的接口設計並不是個人的事,而是團隊的事:

  1. 要儘可能地將保障能力前置(前置到框架、運維層面),讓具體開發者要做的事儘可能少。沒有誰能保證自己寫的所有接口的所有方面都處理得面面俱到——這個參數忘了去空格,那個參數忘了做 XSS 過濾。更何況一個接口往往不是由一個人開發和維護的。
  2. 需要有質量審查機制。如果有可能,由測試團隊給接口做滲透測試和性能測試。代碼審查(以及工具審查)也能發現一部分問題。
  3. 需要強化團隊成員的相關意識。如防禦性編程、充分利用緩存和索引、異步化編程,這些往往是意識問題。
  4. 選擇合適的開發框架。需考察框架對 XSS、CSRF、SQL 注入、格式校驗、簽名、隊列、調度等的支持情況和上手難易度,以及團隊成員的熟悉度——如果一部分人不熟悉,則要組織培訓。





原文出自本人公衆號編碼衚衕,轉載請註明出處。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章