高併發下資料冪等問題的9種解決方案

小松聊PHP进阶發表於2024-03-23

置頂說明

嚴格來說,所謂人云亦云的介面冪等性,大部分場景是要求介面防重或資料冪等,而不是介面冪等,很多人都搞混了。
舉例:後端做了支付防重,使用者對單一訂單重複支付,再次支付不是提示支付成功(介面冪等是要求多次請求返回的結果一致),而是提示請勿重複支付。
很多時候是防重是保證MySQL表資料的冪等,而不是介面冪等。

介面冪等與介面防重

  • 介面冪等:對於一個介面進行多次請求,伺服器響應的結果一致。
  • 介面防重:對於一個介面進行多次請求,伺服器不會產生額外的副作用,或產生業務邏輯意料之外的情況。

常規方案

前端實現方案(多使用者防重)

耳熟能詳的防抖:使用者點選按鈕後,可將按鈕置灰幾秒鐘,一方面提示使用者不能點了,一方面只讓介面請求了一次,減輕伺服器的壓力。

驗證碼方案(常見於搶購的流量削峰)(多使用者防重)

秒殺場景需要極致的效能最佳化,秒殺開始時的搶購按鈕點選後,新增驗證碼功能,不同使用者輸入速度不一樣。
一方面用於流量削峰,防止服務端瞬時負載過大掛掉(報502等)。
一方面可以防止使用者狂點,影響介面冪等性,只要點按鈕就蹦出來驗證碼。

但是這裡小心有業務邏輯安全漏洞,驗證碼是否正確或被繞過(駭客直接請求下單介面),都需要與下游的業務邏輯保持線性關聯。

低併發時,資料庫的判斷問題(單使用者防重)

低併發也不要小看,有時就會陰溝裡翻船。

我之前寫過,郵政銀行的內部員工營銷專案(用的PostgreSQL資料庫),當時考慮到請求量不大,於是就沒用redis去抗。
模糊邏輯如下:表中查不到部分資料就新增,查到資料就提示,當時的業務場景還加不了唯一索引。
但是出現了相同資料的情況。
後來一推理,是同一個人狂點(存在表中的create_at欄位時間相同),此時多個請求都沒有查詢到表中有這個資料,就是所謂的趁PgSQL不注意,於是同一組資料都進行了新增操作,出現了bug。

也很好解決:
在使用者寫操作成功邏輯程式碼區的下游中,新增,用redis的setex命令,將模組名拼接使用者id作為key,設定3秒過期,1作為value,用不上value,所以隨便嘗試。
上游程式碼:只要檢測到有值,則給提示。

因為3秒的時間,足夠資料庫的insert操作了,還不用手動刪除這個key。
虛擬碼如下:

if(Redis::exists('模組名:' . $user_id)) {
	return '操作頻繁,請勿重複操作';
}

if(判斷表中是否存在資料sql) {
	return '您已提交,請明天再來';
}

寫操作SQL,將資料入庫操作...

if(寫操作SQL執行失敗) {
	return '操作失敗,請稍後重試';
}

Redis::setex('模組名:' . $user_id, 3, 1);

return '操作成功';

基於請求頭資料(Token)的前置判斷方案(單使用者防重)

這種方式,適用於快速解決並全域性解決冪等性的專案,高明手段。
但是覆蓋率太廣,需要根據請求的url,新增黑白名單的策略,就是說哪些介面要防重,哪些介面不能防重。

  • 在不用登入的業務場景下防重:
    請求頭可以獲取,客戶端的IP、UA資料,兩者結合,基本可以區分不同使用者,但是有誤差。
    在提交介面時,讓前端生成一個隨機字串,並儲存到LocalStorage,跟隨介面提交,後端無需驗證字串,但3者一結合,基本能確定是一個使用者。
    將3者拼接後計算hash雜湊值(省空間)作為redis的key
    將使用者提交的資料的hash雜湊值(省空間)作為key對應的value。
    將redis過期時間設定為3秒。
    若使用者重複提交就能檢測出這個值,並且3秒過期,不用維護這塊的資料。
    虛擬碼如下

if(當前請求的介面需要防重) {
	$server = $_SERVER;
	$user_temp_key = md5($server['REMOTE_ADDR'] . $server['User-Agent'] . $server['HTTP_RAND_STR']);
	$user_temp_value = md5($_POST['post_data']);
	
	$cache = Redis::get($user_temp_key)
	if($cache && ($cache === $user_temp_value)) {
		return '操作頻繁';
	}
	
	Redis::setex($user_temp_key, 3, $user_temp_value);
}
  • 在用登入的業務場景下防重:
    有使用者的令牌,介面能獲取到,在專案中的前置中介軟體新增類似以上的邏輯,把redis的key換成token即可,也是一種方案。

資料庫唯一索引兜底方案(多使用者防重)

新增唯一索引做兜底,就算併發繞過了業務邏輯,但使用會在唯一索引那裡報錯,然後返回給使用者此次操作失敗,從而保證介面冪等。
缺點是有些場景不能加唯一索引。

狀態機判斷方案(單使用者防重)

例如訂單狀態,可能是1手動取消訂單、2被動取消訂單、3待支付,4待發貨,5已發貨,6代簽收,7待評價,5已評價。
狀態機的更新,如果不是遞增的、不連續的、或者不變,也有可能是併發過來,或者是駭客攻擊。
也可以在這一步做一些驗證。

在支付回撥等場景,根據訂單狀態的判斷,在防止重複改狀態,或者防止變更為不符合事務發展規律的狀態時,很 重要。

高併發的方案

MySQL 可重複讀的隔離別引發的幻讀問題

場景:有些操作需要insert的事務,請求A中的事務a還未提交,此時又過來一個請求B,也就有了事務b,兩者算是相同的資料進行insert,表中新增了唯一索引。

分析:為了保證防重,事務b insert時需要先查詢有沒有相同的資料,如果沒有再進行插入,此時事務a還沒有提交,事務b也就查詢不到資料(能查到就是髒讀,MySQL RR的隔離級別不會出現),於是進行了inset操作,結果導致事務b被阻塞(受事務a的行級X鎖排斥),等事務a提交後,事務b插入失敗。

幻讀:同一個事務裡前後查詢兩次相同範圍的資料,後一次查詢查詢到了前一次看不到的東西,這叫幻讀。MySQL的機制,select沒辦法直接幻讀,只能透過insert 插入相同的資料,達到唯一索引衝突的錯誤來證明。

解決:幻讀的問題可以透過間隙鎖或臨鍵鎖去阻塞,但是無法解決唯一約束衝突的報錯問題。

唯一約束衝突的問題,看業務也是一項,如果重複是小機率事件,可以忽略。
如果機率挺大,儘量不要讓MySQL頻繁報錯,新增一個redis元件,在上次事務提交成功後,快取提交資料的md5的值,與這次提交資料的md5的做個對比,如果一致,說明有重複,避免了併發情況下,下游唯一約束衝突的報錯問題。
用空間換時間的方式。這樣可以把問題引到上游,減輕MySQL伺服器的壓力,和報錯數量。提升性
能。

可按照以下虛擬碼思路去最佳化(注意是最佳化,不是解決)

$post_data = 'md5加密後的介面資料';
$cache_data = Redis::get('key');

if(($cache_data != null) && ($post_data === $cache_data) ) {
	return '請勿重複提交';
}

查詢是否存在的防重提交SQL... //這一步是資料庫防重的兜底策略。
if(有重複) {
	return '請勿重複提交';
}



事務sql...

if(事務回滾) {
	return '操作失敗,請稍後重試';
}

if(事務提交) {
	Redis::setex('key', 3, 'md5加密提交的資料'); //3秒後過期,不用考慮佔空間和維護問題。
}

return '操作成功';

擴充套件:MySQL事務(4種事務隔離級別、髒寫、髒讀、不可重複讀、幻讀、當前讀、快照讀、MVCC、事務指標監控)

分散式鎖(多使用者防重)

分散式鎖對於PHP而言,不常用。用的相對沒有Java的多,並且PHP實現分散式鎖缺少了一些機制,顯得雞肋。
用分散式鎖,也可以解決上面的問題,但是會降低效能。
除非因為重複插入的報錯非常多,否則不推薦用。

但是有幾點要注意:

  • 一定要在事務提交後在釋放分散式鎖,如果在否則事務提交前釋放,那其它請求就可以拿到分散式鎖,進而提交事務,仍舊可能遇到上面一樣的問題。
  • 不要讓事務包含分散式鎖,否則事務因為分散式鎖的阻塞,而阻塞當前事務。其它事務過來也會因為這個問題,阻塞到那裡佔用MySQL連線資源。應當反過來,分散式鎖包含事務。
  • 不要鎖錯物件了,分散式鎖鎖的是事務,是查詢。

悲觀鎖(多使用者防重)

  • 原理:就是同一時間,MySQL只允許一個寫請求改某個部位的資料。
  • 補充:加X鎖獲得最新的資料,防止被改動,然後去更新它,其它的請求被阻塞(等待)。
  • 注意:加鎖最好根據主鍵或者唯一索引列,避免鎖住更多的資料,要降低鎖的粒度。並且有效能問題。

請閱讀之前寫過的文章:
MySQL鎖(讀鎖、共享鎖、寫鎖、S鎖、排它鎖、獨佔鎖、X鎖、表鎖、意向鎖、自增鎖、MDL鎖、RL鎖、GL鎖、NKL鎖、插入意向鎖、間隙鎖、頁鎖、悲觀鎖、樂觀鎖、隱式鎖、顯示鎖、全域性鎖、死鎖)

樂觀鎖(多使用者)

  • 原理:先查詢出版本號,並將版本號作為where條件,聯合其它where條件去更新(並更新版本欄位),如果受影響函式為0,就說明資料沒被改動,需要再次查詢後更新,如此往復,直到有重試次數的干預,或者受影響行數>0。
  • 補充:如果select語句,查不到資料,可能是資料被刪除了,後面的update也就沒必要執行了。
  • 注意:謹防樂觀鎖的ABA問題。至於update受影響行數為0,是否正常返回或者重試,看業務。

請閱讀之前寫過的文章。
MySQL樂觀鎖與悲觀鎖

相關文章