并發(fā)扣款,如何保證一致性?
精選?有朋友問我:
沈老師,我們有個業(yè)務(wù),同一個用戶在并發(fā)“查詢,邏輯計算,扣款”的情況下,余額可能出現(xiàn)不一致,請問有什么優(yōu)化方法么?
今天和大家聊一聊這個問題。
畫外音:文章較長,建議提前收藏。
問題一:用戶扣款的業(yè)務(wù)場景是怎樣的??
用戶購買商品的過程中,要對余額進行查詢與修改,大致的業(yè)務(wù)流程如下:
第一步,從數(shù)據(jù)庫查詢用戶現(xiàn)有余額:
不妨設(shè)查詢出來的$old_money=100元。
第二步,業(yè)務(wù)層實施業(yè)務(wù)邏輯計算,比如:
(1)先查詢購買商品的價格,例如是80元;
(2)再查詢產(chǎn)品是否有活動,以及活動折扣,例如是9折;
(3)比對余額是否足夠,足夠時才往下走;
第三步,將數(shù)據(jù)庫中的余額進行修改。
在并發(fā)量低的情況下,這個流程沒有任何問題,原有金額100元,購買了80元的九折商品(72元),剩余28元。
問題二:同一個用戶,并發(fā)扣款可能出現(xiàn)什么問題?
在分布式環(huán)境中,如果并發(fā)量很大,這種“查詢+修改”的業(yè)務(wù)有一定概率出現(xiàn)數(shù)據(jù)不一致。
極限情況下,可能出現(xiàn)這樣的異常流程:
步驟一,業(yè)務(wù)1和業(yè)務(wù)2并發(fā)查詢余額,是100元。
畫外音:這些并發(fā)查詢,是在不同的站點實例/服務(wù)實例上完成的,進程內(nèi)互斥鎖肯定解決不了。
步驟二,業(yè)務(wù)1和業(yè)務(wù)2并發(fā)進行邏輯計算,算出各自業(yè)務(wù)的余額,假設(shè)業(yè)務(wù)1算出的余額是28元,業(yè)務(wù)2算出的余額是38元。
步驟三,業(yè)務(wù)1對數(shù)據(jù)庫中的余額先進行修改,設(shè)置成28元。
業(yè)務(wù)2對數(shù)據(jù)庫中的余額后進行修改,設(shè)置成38元。
此時異常出現(xiàn)了,原有金額100元,業(yè)務(wù)1扣除了72元,業(yè)務(wù)2扣除了62元,最后剩余38元。畫外音:假設(shè)業(yè)務(wù)1先寫回余額,業(yè)務(wù)2再寫回余額。
問題三:有什么常見的解決方案?
對于此案例,同一個用戶,并發(fā)扣款時,有小概率會出現(xiàn)異常,可以對每一個用戶進行分布式鎖互斥,例如:在redis/zk里搶到一個key才能繼續(xù)操作,否則禁止操作。
這種悲觀鎖方案確實可行,但要引入額外的組件(redis/zk),并且會降低吞吐量。對于小概率的不一致,有沒有樂觀鎖的方案呢?
對并發(fā)扣款進行進一步的分析發(fā)現(xiàn):
(1) 業(yè)務(wù)1寫回時,舊余額100,這是一個初始狀態(tài);新余額28,這是一個結(jié)束狀態(tài)。理論上只有在舊余額為100時,新余額才應(yīng)該寫回成功。
而業(yè)務(wù)1并發(fā)寫回時,舊余額確實是100,理應(yīng)寫回成功。
(2) 業(yè)務(wù)2寫回時,舊余額100,這是一個初始狀態(tài);新余額28,這是一個結(jié)束狀態(tài)。理論上只有在舊余額為100時,新余額才應(yīng)該寫回成功。
可實際上,這個時候數(shù)據(jù)庫中的金額已經(jīng)變?yōu)?8了,所以業(yè)務(wù)2的并發(fā)寫回,不應(yīng)該成功。
如何低成本實施樂觀鎖?
在set寫回的時候,加上初始狀態(tài)的條件compare,只有初始狀態(tài)不變時,才允許set寫回成功,Compare And Set(CAS),是一種常見的降低讀寫鎖沖突,保證數(shù)據(jù)一致性的方法。
此時業(yè)務(wù)要怎么改?
使用CAS解決高并發(fā)時數(shù)據(jù)一致性問題,只需要在進行set操作時,compare初始值,如果初始值變換,不允許set成功。
具體到這個case,只需要將:
升級為:
即可。
并發(fā)操作發(fā)生時:
業(yè)務(wù)1執(zhí)行:
業(yè)務(wù)2執(zhí)行:
這兩個操作同時進行時,只可能有一個執(zhí)行成功。
怎么判斷哪個并發(fā)執(zhí)行成功,哪個并發(fā)執(zhí)行失敗呢?
set操作,其實無所謂成功或者失敗,業(yè)務(wù)能通過affect rows來判斷:
- 寫回成功的,affect rows為1;
- 寫回失敗的,affect rows為0;
高并發(fā)“查詢并修改”的場景,可以用CAS(Compare and Set)的方式解決數(shù)據(jù)一致性問題。對應(yīng)到業(yè)務(wù),即在set的時候,加上初始條件的比對即可。
優(yōu)化不難,只改了半行SQL,但確實能解決問題。
問題四:能不能使用直接扣減的方法
來進行余額扣減?
明顯不行,在并發(fā)情況下,會將money扣成負數(shù)。
問題五:為了保證余額不被扣成負數(shù),再加一個where條件:
這樣是否可行?
很遺憾,仍然不行。
這個方案不冪等。
那什么是冪等性??
聊冪等性之前,先看另一個測試用例的case。
假設(shè)有一個服務(wù)接口,注冊新用戶:
有一個測試工程師,對該接口寫了一個測試用例:
這是不是一個好的測試用例?這個用例存在什么問題?
你會發(fā)現(xiàn),相同條件下,這個測試用例執(zhí)行兩次,得到的結(jié)果不一樣:
- 第一次執(zhí)行,第一次造數(shù)據(jù),調(diào)用接口,注冊成功;
- 第二次執(zhí)行,又造了一次相同的數(shù)據(jù),調(diào)用接口,注冊會失?。?/li>
這不是一個好的測試用例,多次執(zhí)行結(jié)果不同。
什么是冪等性??
相同條件下,執(zhí)行同一請求,得到的結(jié)果相同,才符合冪等性。
畫外音:Google一下,比我解釋得更好,但意思應(yīng)該說清楚了。
如何將上面的測試用例改為符合“冪等性”的測試用例呢??
只需要加一行代碼:
這樣,在相同條件下,不管這個用例執(zhí)行多少次,得到的測試結(jié)果都是相同的。
讀請求,一般是冪等的。
寫請求,視情況而定:
- insert x,一般來說不是冪等的,重復(fù)插入得到的結(jié)果不一定一樣;
- delete x,一般來說是冪等的,刪除多次得到的結(jié)果仍相同;
- set a=x是冪等的;
- set a=a-x不是冪等的;
- …
因此,這么扣減余額:
是冪等操作。
要是這么扣減余額:
不是冪等操作。
聊到這里,或許有朋友要抬杠了,測試用例會重復(fù)執(zhí)行,扣款怎么會重復(fù)執(zhí)行呢?
重試。?
重試,是異常處理里很常見的手段。
你在寫業(yè)務(wù)的時候有沒有寫過這樣的代碼:
當(dāng)然,又會有朋友抬杠了,我從來不重試!?。?/p>
畫外音:額,這是合格,還是不合格呢?
你可以決定業(yè)務(wù)代碼怎么寫,你不能決定底層框架代碼怎么寫:
- 站點框架有沒有自動重試?
- 服務(wù)框架有沒有自動重試?
- 服務(wù)連接池,數(shù)據(jù)庫連接池有沒有自動重試?
畫外音:
- 服務(wù)化分層的架構(gòu)中,建議只入口層重試,服務(wù)層不要重試,防止雪崩;
- dubbo底層,調(diào)用超時是默認重試的,這個設(shè)計不好;?
因此,在有重試的架構(gòu)體系里,冪等性是需要考慮的一個問題。
因此,扣款和充值業(yè)務(wù),一般使用:
select&set,配合CAS方案
而不使用:
set money-=X方案
問題五:CAS方案,會不會存在ABA問題?
什么是ABA問題?
CAS樂觀鎖機制確實能夠提升吞吐,并保證一致性,但在極端情況下可能會出現(xiàn)ABA問題。
考慮如下操作:
- 并發(fā)1(上):獲取出數(shù)據(jù)的初始值是A,后續(xù)計劃實施CAS樂觀鎖,期望數(shù)據(jù)仍是A的時候,修改才能成功
- 并發(fā)2:將數(shù)據(jù)修改成B
- 并發(fā)3:將數(shù)據(jù)修改回A
- 并發(fā)1(下):CAS樂觀鎖,檢測發(fā)現(xiàn)初始值還是A,進行數(shù)據(jù)修改
上述并發(fā)環(huán)境下,并發(fā)1在修改數(shù)據(jù)時,雖然還是A,但已經(jīng)不是初始條件的A了,中間發(fā)生了A變B,B又變A的變化,此A已經(jīng)非彼A,數(shù)據(jù)卻成功修改,可能導(dǎo)致錯誤,這就是CAS引發(fā)的所謂的ABA問題。
余額操作,出現(xiàn)ABA問題并不會對業(yè)務(wù)產(chǎn)生影響,因為對于“余額”屬性來說,前一個A為100余額,與后一個A為100余額,本質(zhì)是相同的。
但其他場景未必是這樣,舉一個堆棧操作的例子:
并發(fā)1(上):讀取棧頂?shù)脑貫椤癆1”
并發(fā)2:進行了2次出棧
并發(fā)3:又進行了1次出棧
并發(fā)1(下):實施CAS樂觀鎖,發(fā)現(xiàn)棧頂還是“A1”,于是修改為A2
此時會出現(xiàn)系統(tǒng)錯誤,因為此“A1”非彼“A1”
ABA問題可以怎么優(yōu)化?
ABA問題導(dǎo)致的原因,是CAS過程中只簡單進行了“值”的校驗,在有些情況下,“值”相同不會引入錯誤的業(yè)務(wù)邏輯(例如余額),有些情況下,“值”雖然相同,卻已經(jīng)不是原來的數(shù)據(jù)了(例如堆棧)。
因此,CAS不能只比對“值”,還必須確保是原來的數(shù)據(jù),才能修改成功。
常見的實踐是,將“值”比對,升級為“版本號”的比對,一個數(shù)據(jù)一個版本,版本變化,即使值相同,也不應(yīng)該修改成功。
余額并發(fā)讀寫例子,引入版本號的具體實踐如下:
(1) 余額表要升級。
升級為:
(2) 查詢余額時,同時查詢版本號。
升級為:
假設(shè)有并發(fā)操作,都會將版本號查詢出來
(3) 設(shè)置余額時,必須版本號相同,并且版本號要修改。
舊版本“值”比對:
升級為“版本號”比對:
此時假設(shè)有并發(fā)操作,首先操作的請求會修改版本號,并發(fā)操作會執(zhí)行失敗。
畫外音:version通用,本例是強行用version舉例而已,實際上本例可以用余額“值”比對。
總結(jié)?
- select&set業(yè)務(wù)場景,在并發(fā)時會出現(xiàn)一致性問題
- 冪等性是一個需要考慮的問題
- 基于“值”的CAS樂觀鎖,可能導(dǎo)致ABA問題
- CAS樂觀鎖,必須保證修改時的“此數(shù)據(jù)”就是“彼數(shù)據(jù)”,應(yīng)該由“值”比對,優(yōu)化為“版本號”比對
思路比結(jié)論重要。?