并發(fā)扣款,如何保證一致性?
精選?有朋友問我:
沈老師,我們有個(gè)業(yè)務(wù),同一個(gè)用戶在并發(fā)“查詢,邏輯計(jì)算,扣款”的情況下,余額可能出現(xiàn)不一致,請(qǐng)問有什么優(yōu)化方法么?
今天和大家聊一聊這個(gè)問題。
畫外音:文章較長,建議提前收藏。

問題一:用戶扣款的業(yè)務(wù)場(chǎng)景是怎樣的??
用戶購買商品的過程中,要對(duì)余額進(jìn)行查詢與修改,大致的業(yè)務(wù)流程如下:
第一步,從數(shù)據(jù)庫查詢用戶現(xiàn)有余額:
不妨設(shè)查詢出來的$old_money=100元。
第二步,業(yè)務(wù)層實(shí)施業(yè)務(wù)邏輯計(jì)算,比如:
(1)先查詢購買商品的價(jià)格,例如是80元;
(2)再查詢產(chǎn)品是否有活動(dòng),以及活動(dòng)折扣,例如是9折;
(3)比對(duì)余額是否足夠,足夠時(shí)才往下走;
第三步,將數(shù)據(jù)庫中的余額進(jìn)行修改。
在并發(fā)量低的情況下,這個(gè)流程沒有任何問題,原有金額100元,購買了80元的九折商品(72元),剩余28元。
問題二:同一個(gè)用戶,并發(fā)扣款可能出現(xiàn)什么問題?
在分布式環(huán)境中,如果并發(fā)量很大,這種“查詢+修改”的業(yè)務(wù)有一定概率出現(xiàn)數(shù)據(jù)不一致。
極限情況下,可能出現(xiàn)這樣的異常流程:
步驟一,業(yè)務(wù)1和業(yè)務(wù)2并發(fā)查詢余額,是100元。

畫外音:這些并發(fā)查詢,是在不同的站點(diǎn)實(shí)例/服務(wù)實(shí)例上完成的,進(jìn)程內(nèi)互斥鎖肯定解決不了。
步驟二,業(yè)務(wù)1和業(yè)務(wù)2并發(fā)進(jìn)行邏輯計(jì)算,算出各自業(yè)務(wù)的余額,假設(shè)業(yè)務(wù)1算出的余額是28元,業(yè)務(wù)2算出的余額是38元。

步驟三,業(yè)務(wù)1對(duì)數(shù)據(jù)庫中的余額先進(jìn)行修改,設(shè)置成28元。
業(yè)務(wù)2對(duì)數(shù)據(jù)庫中的余額后進(jìn)行修改,設(shè)置成38元。

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

并發(fā)1(上):讀取棧頂?shù)脑貫椤癆1”

并發(fā)2:進(jìn)行了2次出棧

并發(fā)3:又進(jìn)行了1次出棧
并發(fā)1(下):實(shí)施CAS樂觀鎖,發(fā)現(xiàn)棧頂還是“A1”,于是修改為A2

此時(shí)會(huì)出現(xiàn)系統(tǒng)錯(cuò)誤,因?yàn)榇恕癆1”非彼“A1”
ABA問題可以怎么優(yōu)化?
ABA問題導(dǎo)致的原因,是CAS過程中只簡單進(jìn)行了“值”的校驗(yàn),在有些情況下,“值”相同不會(huì)引入錯(cuò)誤的業(yè)務(wù)邏輯(例如余額),有些情況下,“值”雖然相同,卻已經(jīng)不是原來的數(shù)據(jù)了(例如堆棧)。
因此,CAS不能只比對(duì)“值”,還必須確保是原來的數(shù)據(jù),才能修改成功。
常見的實(shí)踐是,將“值”比對(duì),升級(jí)為“版本號(hào)”的比對(duì),一個(gè)數(shù)據(jù)一個(gè)版本,版本變化,即使值相同,也不應(yīng)該修改成功。
余額并發(fā)讀寫例子,引入版本號(hào)的具體實(shí)踐如下:
(1) 余額表要升級(jí)。
升級(jí)為:
(2) 查詢余額時(shí),同時(shí)查詢版本號(hào)。
升級(jí)為:
假設(shè)有并發(fā)操作,都會(huì)將版本號(hào)查詢出來
(3) 設(shè)置余額時(shí),必須版本號(hào)相同,并且版本號(hào)要修改。
舊版本“值”比對(duì):
升級(jí)為“版本號(hào)”比對(duì):
此時(shí)假設(shè)有并發(fā)操作,首先操作的請(qǐng)求會(huì)修改版本號(hào),并發(fā)操作會(huì)執(zhí)行失敗。
畫外音:version通用,本例是強(qiáng)行用version舉例而已,實(shí)際上本例可以用余額“值”比對(duì)。
總結(jié)?
- select&set業(yè)務(wù)場(chǎng)景,在并發(fā)時(shí)會(huì)出現(xiàn)一致性問題
 - 冪等性是一個(gè)需要考慮的問題
 - 基于“值”的CAS樂觀鎖,可能導(dǎo)致ABA問題
 - CAS樂觀鎖,必須保證修改時(shí)的“此數(shù)據(jù)”就是“彼數(shù)據(jù)”,應(yīng)該由“值”比對(duì),優(yōu)化為“版本號(hào)”比對(duì)
 
思路比結(jié)論重要。?















 
 
 













 
 
 
 