介面設計的那些事

林子er發表於2022-03-25

介面的一般性問題

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

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

  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 注入、格式校驗、簽名、佇列、排程等的支援情況和上手難易度,以及團隊成員的熟悉度——如果一部分人不熟悉,則要組織培訓。





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

相關文章