併發扣款,如何保證一致性?
有朋友問我:
沈老師,我們有個業務,同一個用戶在併發 “查詢,邏輯計算,扣款” 的情況下,餘額可能出現不一致,請問有什麼優化方法麼?
今天和大家聊一聊這個問題。
問題一:用戶扣款的業務場景是怎樣的?
用戶購買商品的過程中,要對餘額進行查詢與修改,大致的業務流程如下:
第一步,從數據庫查詢用戶現有餘額:
SELECT money FROM t_yue WHERE uid=$uid;
不妨設查詢出來的 $old_money=100 元。
第二步,業務層實施業務邏輯計算,比如:
(1)先查詢購買商品的價格,例如是 80 元;
(2)再查詢產品是否有活動,以及活動折扣,例如是 9 折;
(3)比對餘額是否足夠,足夠時才往下走;
if($old_money> 80*0.9){
$new_money=$old_money-80*0.9=28
} else {
return "Not enough minerals";
}
第三步,將數據庫中的餘額進行修改。
UPDATE t_yue SET money=$new_money
WHERE uid=$uid;
在併發量低的情況下,這個流程沒有任何問題,原有金額 100 元,購買了 80 元的九折商品(72 元),剩餘 28 元。
問題二:同一個用戶,併發扣款可能出現什麼問題?
在分佈式環境中,如果併發量很大,這種 “查詢 + 修改” 的業務有一定概率出現數據不一致。
極限情況下,可能出現這樣的異常流程:
步驟一,業務 1 和業務 2 併發查詢餘額,是 100 元。
畫外音:這些併發查詢,是在不同的站點實例 / 服務實例上完成的,進程內互斥鎖肯定解決不了。
步驟二,業務 1 和業務 2 併發進行邏輯計算,算出各自業務的餘額,假設業務 1 算出的餘額是 28 元,業務 2 算出的餘額是 38 元。
步驟三,業務 1 對數據庫中的餘額先進行修改,設置成 28 元。
業務 2 對數據庫中的餘額後進行修改,設置成 38 元。
此時異常出現了,原有金額 100 元,業務 1 扣除了 72 元,業務 2 扣除了 62 元,最後剩餘 38 元。
畫外音:假設業務 1 先寫回餘額,業務 2 再寫回餘額。
問題三:有什麼常見的解決方案?
對於此案例,同一個用戶,併發扣款時,有小概率會出現異常,可以對每一個用戶進行分佈式鎖互斥,例如:在 redis/zk 裏搶到一個 key 才能繼續操作,否則禁止操作。
這種悲觀鎖方案確實可行,但要引入額外的組件 (redis/zk),並且會降低吞吐量。
對於小概率的不一致,有沒有樂觀鎖的方案呢?
對併發扣款進行進一步的分析發現:
(1)業務 1 寫回時,舊餘額 100,這是一個初始狀態;新餘額 28,這是一個結束狀態。理論上只有在舊餘額爲 100 時,新餘額才應該寫回成功。
而業務 1 併發寫回時,舊餘額確實是 100,理應寫回成功。
(2)業務 2 寫回時,舊餘額 100,這是一個初始狀態;新餘額 28,這是一個結束狀態。理論上只有在舊餘額爲 100 時,新餘額才應該寫回成功。
可實際上,這個時候數據庫中的金額已經變爲 28 了,所以業務 2 的併發寫回,不應該成功。
如何低成本實施樂觀鎖?
在 set 寫回的時候,加上初始狀態的條件 compare,只有初始狀態不變時,才允許 set 寫回成功,Compare And Set(CAS),是一種常見的降低讀寫鎖衝突,保證數據一致性的方法。
此時業務要怎麼改?
使用 CAS 解決高併發時數據一致性問題,只需要在進行 set 操作時,compare 初始值,如果初始值變換,不允許 set 成功。
具體到這個 case,只需要將:
UPDATE t_yue SET money=$new_money
WHERE uid=$uid;
升級爲:
UPDATE t_yue SET money=$new_money
WHERE uid=$uid AND money=$old_money;
即可。
併發操作發生時:
業務 1 執行:
UPDATE t_yue SET money=28
WHERE uid=$uid AND money=100;
業務 2 執行:
UPDATE t_yue SET money=38
WHERE uid=$uid AND money=100;
這兩個操作同時進行時,只可能有一個執行成功。
怎麼判斷哪個併發執行成功,哪個併發執行失敗呢?
set 操作,其實無所謂成功或者失敗,業務能通過 affect rows 來判斷:
(1)寫回成功的,affect rows 爲 1;
(2)寫回失敗的,affect rows 爲 0;
高併發 “查詢並修改” 的場景,可以用 CAS(Compare and Set)的方式解決數據一致性問題。對應到業務,即在 set 的時候,加上初始條件的比對即可。
優化不難,只改了半行 SQL,但確實能解決問題。
問題四:能不能使用直接扣減的方法
UPDATE t_yue SET money=money-$diff
WHERE uid=$uid;
來進行餘額扣減?
明顯不行,在併發情況下,會將 money 扣成負數。
問題五:爲了保證餘額不被扣成負數,再加一個 where 條件:
UPDATE t_yue SET money=money-$diff
WHERE uid=$uid AND money-$diff>0;
這樣是否可行?
很遺憾,仍然不行。
這個方案不冪等。
那什麼是冪等性?
聊冪等性之前,先看另一個測試用例的 case。
假設有一個服務接口,註冊新用戶:
bool RegisterUser($uid, $name){
// **查看 uid 是否已經存在**
select uid from t_user where uid=$uid;
// **不是新用戶,返回失敗**
if(rows>0)return false;
else{
// **把新用戶插入用戶表**
insert into t_user values($uid, $name);
// **返回成功**
return true;
}
}
有一個測試工程師,對該接口寫了一個測試用例:
bool TestCase_RegisterUser(){
// **造一些假數據**
long uid=123;
String name='shenjian';
// **調用被測試的接口**
bool result= RegisterUser(uid,name);
// 預期註冊成功,**對結果進行斷言判斷**
Assert(result,true);
// **返回測試結果**
return result;
}
這是不是一個好的測試用例?這個用例存在什麼問題?
你會發現,相同條件下,這個測試用例執行兩次,得到的結果不一樣:
(1)第一次執行,第一次造數據,調用接口,註冊成功;
(2)第二次執行,又造了一次相同的數據,調用接口,註冊會失敗;
這不是一個好的測試用例,多次執行結果不同。
什麼是冪等性?
相同條件下,執行同一請求,得到的結果相同,才符合冪等性。
畫外音:Google 一下,比我解釋得更好,但意思應該說清楚了。
如何將上面的測試用例改爲符合 “冪等性” 的測試用例呢?
只需要加一行代碼:
bool TestCase_RegisterUser(){
// 造一些假數據
long uid=123;
String name=’shenjian’;
// **先刪除這個僞造的用戶**
DeleteUser(uid);
// 調用被測試的接口
bool result= RegisterUser(uid,name);
// 預期註冊成功,對結果進行斷言判斷
Assert(result,true);
// 返回測試結果
return result;
}
這樣,在相同條件下,不管這個用例執行多少次,得到的測試結果都是相同的。
讀請求,一般是冪等的。
寫請求,視情況而定:
(1)insert x,一般來說不是冪等的,重複插入得到的結果不一定一樣;
(2)delete x,一般來說是冪等的,刪除多次得到的結果仍相同;
(3)set a=x 是冪等的;
(4)set a=a-x 不是冪等的;
(5)…
因此,這麼扣減餘額:
UPDATE t_yue SET money=$new_money
WHERE uid=$uid AND money=$old_money;
是冪等操作。
要是這麼扣減餘額:
UPDATE t_yue SET money=money-$diff
WHERE uid=$uid AND money-$diff>0;
不是冪等操作。
聊到這裏,或許有朋友要擡槓了,測試用例會重複執行,扣款怎麼會重複執行呢?
重試。
重試,是異常處理裏很常見的手段。
你在寫業務的時候有沒有寫過這樣的代碼:
result = DoSomething();
if(false==result || TIMEOUT){
// **錯誤,或者超時,重試一次**
result= DoSomething();
}
return result;
當然,又會有朋友擡槓了,我從來不重試!!!
畫外音:額,這是合格,還是不合格呢?
你可以決定業務代碼怎麼寫,你不能決定底層框架代碼怎麼寫:
(1)站點框架有沒有自動重試?
(2)服務框架有沒有自動重試?
(3)服務連接池,數據庫連接池有沒有自動重試?
畫外音:
(1)服務化分層的架構中,建議只入口層重試,服務層不要重試,防止雪崩;
(2)dubbo 底層,調用超時是默認重試的,這個設計不好;
因此,在有重試的架構體系裏,冪等性是需要考慮的一個問題。
因此,扣款和充值業務,一般使用:
- select&set,配合 CAS 方案
而不使用:
- set money-=X 方案
問題五:CAS 方案,會不會存在 ABA 問題?
什麼是 ABA 問題?
CAS 樂觀鎖機制確實能夠提升吞吐,並保證一致性,但在極端情況下可能會出現 ABA 問題。
考慮如下操作:
-
併發 1(上):獲取出數據的初始值是 A,後續計劃實施 CAS 樂觀鎖,期望數據仍是 A 的時候,修改才能成功
-
併發 2:將數據修改成 B
-
併發 3:將數據修改回 A
-
併發 1(下):CAS 樂觀鎖,檢測發現初始值還是 A,進行數據修改
上述併發環境下,併發 1 在修改數據時,雖然還是 A,但已經不是初始條件的 A 了,中間發生了 A 變 B,B 又變 A 的變化,此 A 已經非彼 A,數據卻成功修改,可能導致錯誤,這就是 CAS 引發的所謂的 ABA 問題。
餘額操作,出現 ABA 問題並不會對業務產生影響,因爲對於 “餘額” 屬性來說,前一個 A 爲 100 餘額,與後一個 A 爲 100 餘額,本質是相同的。
但其他場景未必是這樣,舉一個堆棧操作的例子:
併發 1(上):讀取棧頂的元素爲 “A1”
併發 2:進行了 2 次出棧
併發 3:又進行了 1 次出棧
併發 1(下):實施 CAS 樂觀鎖,發現棧頂還是 “A1”,於是修改爲 A2
此時會出現系統錯誤,因爲此 “A1” 非彼“A1”
ABA 問題可以怎麼優化?
ABA 問題導致的原因,是 CAS 過程中只簡單進行了 “值” 的校驗,在有些情況下,“值”相同不會引入錯誤的業務邏輯(例如餘額),有些情況下,“值”雖然相同,卻已經不是原來的數據了(例如堆棧)。
因此,CAS 不能只比對 “值”,還必須確保是原來的數據,才能修改成功。
常見的實踐是,將 “值” 比對,升級爲 “版本號” 的比對,一個數據一個版本,版本變化,即使值相同,也不應該修改成功。
餘額併發讀寫例子,引入版本號的具體實踐如下:
(1)餘額表要升級。
t_yue(uid, money)
升級爲:
t_yue(uid, money, version)
(2)查詢餘額時,同時查詢版本號。
SELECT money FROM t_yue WHERE sid=$sid
升級爲:
SELECT money,version FROM t_yue WHERE sid=$sid
假設有併發操作,都會將版本號查詢出來
(3)設置餘額時,必須版本號相同,並且版本號要修改。
舊版本 “值” 比對:
UPDATE t_yue SET money=38
WHERE uid=$uid AND money=100
升級爲 “版本號” 比對:
UPDATE t_yue SET money=38, version=$version_new
WHERE uid=$uid AND version=$version_old
此時假設有併發操作,首先操作的請求會修改版本號,併發操作會執行失敗。
畫外音:version 通用,本例是強行用 version 舉例而已,實際上本例可以用餘額 “值” 比對。
總結
-
select&set 業務場景,在併發時會出現一致性問題
-
冪等性是一個需要考慮的問題
-
基於 “值” 的 CAS 樂觀鎖,可能導致 ABA 問題
-
CAS 樂觀鎖,必須保證修改時的 “此數據” 就是 “彼數據”,應該由“值” 比對,優化爲 “版本號” 比對
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/Mp1SH33BCabrsQrkHrTwow