併發扣款,如何保證一致性?

有朋友問我:

沈老師,我們有個業務,同一個用戶在併發 “查詢,邏輯計算,扣款” 的情況下,餘額可能出現不一致,請問有什麼優化方法麼?

今天和大家聊一聊這個問題。

問題一:用戶扣款的業務場景是怎樣的?

用戶購買商品的過程中,要對餘額進行查詢與修改,大致的業務流程如下:

第一步,從數據庫查詢用戶現有餘額:

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 底層,調用超時是默認重試的,這個設計不好;

因此,在有重試的架構體系裏,冪等性是需要考慮的一個問題

因此,扣款和充值業務,一般使用:

而不使用:

問題五:CAS 方案,會不會存在 ABA 問題?

什麼是 ABA 問題?

CAS 樂觀鎖機制確實能夠提升吞吐,並保證一致性,但在極端情況下可能會出現 ABA 問題。

考慮如下操作:

上述併發環境下,併發 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 舉例而已,實際上本例可以用餘額 “值” 比對。

總結

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/Mp1SH33BCabrsQrkHrTwow