天天看點

接口設計的那些事

接口設計的那些事

很多人開發接口的時候,往往僅關注功能實作,但接口品質恰恰取決于非功能性方面,主要包括:幂等性、魯棒性和安全性。本文詳細講解開發接口(以及系統)時在這三方面需要考慮的細節問題。

接口的一般性問題

很多程式員開發接口的時候,往往僅關注功能實作,但決定接口品質的恰恰是非功能性方面——遺憾的是,這一點在很多公司,從項目到産品到研發,甚至到測試,都未得到應有的重視。

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

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

幂等性

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

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

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

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

我們無法預期接口調用方如何調接口,可能由于調用逾時,或者調用方實作問題(比如前端使用者可短時間内高頻點選),接口設計必須将重複調用作為常态考慮——因接口被重複調用而導緻資料問題,責任應歸于接口實作者而不是調用者。

處理幂等性的手段一般分業務邏輯層面和資料庫層面。

業務邏輯層面:select + insert:

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

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

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

在高并發時,同一個使用者的兩個請求幾乎同時到達,此時兩個請求幾乎同時 select,都發現資料庫沒有相關記錄,于是都能執行後續業務邏輯。

是以對于重要場景(如發券、積分等),請求必須在使用者級别具有排他性:同一時間同一個使用者隻能有一個請求在處理,多個同樣的請求必須串行處理。

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

接口設計的那些事

然而,當你的資料庫使用讀寫分離時,你會發現請求鎖方案有時還是會出現漏網之魚。業務系統處理完成後會解除請求鎖,此時同一個使用者的重複請求就可以進來,但此時新資料可能還沒有同步到從庫,因而 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 号段。

流程如下:

接口設計的那些事

總結一下如何用資料唯一鍵實作接口幂等性:

  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. 前端在使用該辨別符請求後端,後端由于檢測不到該辨別符,會直接傳回錯誤;

流程如下:

接口設計的那些事

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

它确實能阻止一部分重複送出,但不是全部。

試想前端請求後端接口,後端接口逾時了(但實際上後端事務仍然在執行中),此時前端會讓使用者重試,使用者再次送出,這第二次接口請求仍然會帶上剛才的 flag,那這次 flag 校驗是否會通過呢?可能會,也可能不會,取決于第二次請求到達時,前一次的事務有沒有處理完(進而删除掉 flag)。假如前一次的事務(這裡的事務不是說資料庫事務,而是指該接口要做的事情)還沒有處理完,那麼這個 flag 就仍然是合法的,那麼第二次請求仍然會被處理。如下圖:

接口設計的那些事

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

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

要想前後端互動真正的實作幂等性,必須借助資料庫的唯一鍵限制。和前面的一樣,我們給資料表增加一個專門字段(假如就叫 flag)做唯一性限制,我們以券為例,資料表大緻長這樣:

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

這裡的 flag 就是上面我們生成并存儲到 Redis 的那個唯一辨別,我們在資料庫插入券資料的時候一并寫進去。由于 flag 字段是唯一鍵,如果先前已經寫入過了,再寫入就會報唯一鍵沖突錯誤,寫入失敗,進而保證了接口的幂等性。如此,上圖中使用者再次點選送出,雖然flag 校驗仍然會成功,但兩次處理隻有一次會真正成功,另一次在寫資料庫時會失敗(不能保證一定是第一次請求寫入成功,網絡調用不具備時序性)。

加上資料庫限制後兩次請求的處理過程如下:

接口設計的那些事

圖中橙色和藍色部分代表兩次請求的處理流程(省略了 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 的合法性,然後執行後續操作。流程如下:

接口設計的那些事

前端調後端接口時由浏覽器自動将 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 注入、格式校驗、簽名、隊列、排程等的支援情況和上手難易度,以及團隊成員的熟悉度——如果一部分人不熟悉,則要組織教育訓練。

本文來自部落格園,作者:林子er,轉載請注明原文連結:https://www.cnblogs.com/linvanda/p/16053236.html

繼續閱讀