強(qiáng)一致鎖:如何解決高并發(fā)下的庫存爭(zhēng)搶問題?
由于秒殺場(chǎng)景是庫存爭(zhēng)搶非常經(jīng)典的一個(gè)應(yīng)用場(chǎng)景,接下來我會(huì)結(jié)合秒殺需求,帶你看看如何實(shí)現(xiàn)高并發(fā)下的庫存爭(zhēng)搶,相信在這一過程中你會(huì)對(duì)鎖有更深入的認(rèn)識(shí)。
鎖爭(zhēng)搶的錯(cuò)誤做法
在開始介紹庫存爭(zhēng)搶的具體方案之前,我們先來了解一個(gè)小知識(shí)——并發(fā)庫存鎖。還記得在我學(xué)計(jì)算機(jī)的時(shí)候,老師曾演示過一段代碼:
public class ThreadCounter {
private static int count = 0;
public static void main(String[] args) throws Exception {
Runnable task = new Runnable() {
public void run() {
for (int i = 0; i < 1000; ++i) {
count += 1;
}
}
};
Thread t1 = new Thread(task);
t1.start();
Thread t2 = new Thread(task);
t2.start();
t1.join();
t2.join();
cout << "count = " << count << endl;
}
}
從代碼來看,我們運(yùn)行后結(jié)果預(yù)期是 2000,但是實(shí)際運(yùn)行后并不是。為什么會(huì)這樣呢?當(dāng)多線程并行對(duì)同一個(gè)公共變量讀寫時(shí),由于沒有互斥,多線程的 set 會(huì)相互覆蓋或讀取時(shí)容易讀到其他線程剛寫一半的數(shù)據(jù),這就導(dǎo)致變量數(shù)據(jù)被損壞。反過來說,我們要想保證一個(gè)變量在多線程并發(fā)情況下的準(zhǔn)確性,就需要這個(gè)變量在修改期間不會(huì)被其他線程更改或讀取。對(duì)于這個(gè)情況,我們一般都會(huì)用到鎖或原子操作來保護(hù)庫存變量:如果是簡(jiǎn)單 int 類型數(shù)據(jù),可以使用原子操作保證數(shù)據(jù)準(zhǔn)確;如果是復(fù)雜的數(shù)據(jù)結(jié)構(gòu)或多步操作,可以加鎖來保證數(shù)據(jù)完整性。
考慮到我們之前的習(xí)慣會(huì)有一定慣性,為了讓你更好地理解爭(zhēng)搶,這里我再舉一個(gè)我們常會(huì)犯錯(cuò)的例子。因?yàn)榭蹘齑娴牟僮餍枰⒁庠有?,我們?shí)踐的時(shí)候常常碰到后面這種方式:
redis> get prod_1475_stock_1
15
redis> set prod_1475_stock_1 14
OK
也就是先將變量從緩存中取出,對(duì)其做 -1 操作,再放回到緩存當(dāng)中,這是個(gè)錯(cuò)誤做法。
圖片
如上圖,原因是多個(gè)線程一起讀取的時(shí)候,多個(gè)線程同時(shí)讀到的是 5,set 回去時(shí)都是 6,實(shí)際每個(gè)線程都拿到了庫存,但是庫存的實(shí)際數(shù)值并沒有累計(jì)改變,這會(huì)導(dǎo)致庫存超賣。如果你需要用這種方式去做,一般建議加一個(gè)自旋互斥鎖,互斥其他線程做類似的操作。
原子操作
當(dāng)大量用戶并發(fā)修改某個(gè)變量時(shí),使用互斥鎖雖然能保證數(shù)據(jù)修改的正確性,但性能非常低。假設(shè)有一萬個(gè)用戶爭(zhēng)搶一個(gè)鎖,排隊(duì)等待修改服務(wù)器中的變量,這樣的設(shè)計(jì)效率極差。鎖在獲取過程中需要自旋,反復(fù)嘗試才能搶到鎖,用戶越多,爭(zhēng)搶越激烈,系統(tǒng)資源的消耗就越大,可能導(dǎo)致系統(tǒng)不穩(wěn)定。
為了解決這個(gè)問題,我會(huì)選擇將庫存數(shù)據(jù)存放在高性能的內(nèi)存緩存服務(wù)(比如 Redis)中集中管理。這樣可以避免用戶爭(zhēng)搶鎖時(shí)影響到其他服務(wù),同時(shí)也能提高響應(yīng)速度。Redis 本身支持原子操作,并且通過它可以更好地應(yīng)對(duì)高并發(fā)場(chǎng)景。這也是目前互聯(lián)網(wǎng)行業(yè)普遍采用的庫存保護(hù)方案。
相比之下,不建議通過數(shù)據(jù)庫行鎖來保證庫存的修改。數(shù)據(jù)庫資源非常寶貴,如果使用行鎖管理庫存,不僅性能會(huì)很差,系統(tǒng)也容易變得不穩(wěn)定。
為了減少鎖爭(zhēng)搶和提升系統(tǒng)效率,我們可以采取降低鎖粒度的方式,或者引入其他優(yōu)化方案。
圖片
實(shí)際上,我們可以通過將熱門商品的庫存進(jìn)行拆分,存儲(chǔ)到多個(gè) key 中,從而顯著減少鎖的競(jìng)爭(zhēng)。比如,假設(shè)當(dāng)前商品的庫存為 100 個(gè),可以把庫存分成 10 個(gè) key,每個(gè) key 保存 10 個(gè)庫存,并將這些 key 分布在不同的 Redis 實(shí)例中。當(dāng)用戶下單時(shí),可以隨機(jī)選擇一個(gè) key 扣減庫存。如果某個(gè) key 中的庫存不足,再記錄該 key 并隨機(jī)挑選剩余的 key 進(jìn)行扣減,直到成功完成一次庫存的扣除。
不過,除了這種方法之外,我更推薦使用 Redis 的原子操作來處理庫存問題。原子操作的粒度更小,并且 Redis 本質(zhì)上是單線程運(yùn)行,能夠提供全局唯一的決策。很多原子操作的底層實(shí)現(xiàn)甚至是通過硬件實(shí)現(xiàn)的,性能非常優(yōu)異,且不會(huì)產(chǎn)生鎖競(jìng)爭(zhēng)問題。
以 Redis 的 INCR 和 DECR 操作為例,它們可以在不加鎖的情況下實(shí)現(xiàn)對(duì)庫存的精確修改,這樣能同時(shí)保證高性能和數(shù)據(jù)的一致性。
redis> decr prod_1475_stock_1
14
incr、decr 這類操作就是原子的,我們可以根據(jù)返回值是否大于 0 來判斷是否扣庫存成功。但是這里你要注意,如果當(dāng)前值已經(jīng)為負(fù)數(shù),我們需要考慮一下是否將之前扣除的補(bǔ)償回來。并且為了減少修改操作,我們可以在扣減之前做一次值檢測(cè),整體操作如下:
//讀取當(dāng)前庫存,確認(rèn)是否大于零
//如大于零則繼續(xù)操作,小于等于拒絕后續(xù)
redis> get prod_1475_stock_1
1
//開始扣減庫存、如返回值大于或等于0那么代表扣減成功,小于0代表當(dāng)前已經(jīng)沒有庫存
//可以看到返回-2,這可以理解成同時(shí)兩個(gè)線程都在操作扣庫存,并且都沒拿到庫存
redis> decr prod_1475_stock_1
-2
//扣減失敗、補(bǔ)償多扣的庫存
//這里返回0是因?yàn)橥瑫r(shí)兩個(gè)線程都在做補(bǔ)償,最終恢復(fù)0庫存
redis> incr prod_1475_stock
0
這個(gè)庫存保護(hù)方案確實(shí)很有價(jià)值,但也有其局限性。庫存的準(zhǔn)確性依賴于業(yè)務(wù)是否能成功“返還”之前扣減的庫存。如果在服務(wù)運(yùn)行過程中“返還”操作被中斷,人工修復(fù)將變得非常困難,因?yàn)闊o法確定當(dāng)前還有多少庫存正在處理中。通常,我們需要等到活動(dòng)結(jié)束后再查看最終庫存。
為了完全保證庫存不丟失,通常會(huì)依賴事務(wù)和回滾機(jī)制,但 Redis 作為外置的庫存服務(wù),并不屬于數(shù)據(jù)庫的緩存范疇。這就要求我們?cè)诿總€(gè)可能出現(xiàn)故障的業(yè)務(wù)環(huán)節(jié)中都能夠妥善處理庫存問題。因此,許多秒殺系統(tǒng)在出現(xiàn)故障時(shí)往往選擇不返還庫存,并不是因?yàn)椴幌?,而是因?yàn)楹芏嘁馔鈭?chǎng)景無法做到。
至于使用 SETNX 指令或數(shù)據(jù)庫的 CAS(比較并交換)機(jī)制來實(shí)現(xiàn)互斥鎖,盡管這能解決庫存問題,但這種鎖機(jī)制會(huì)引入自旋等待。在并發(fā)高的情況下,用戶服務(wù)需要反復(fù)嘗試才能獲取鎖,這樣會(huì)浪費(fèi)系統(tǒng)資源,并對(duì)數(shù)據(jù)服務(wù)造成較大壓力。因此,這種方法并不推薦使用。
令牌庫存
除了這種用數(shù)值記錄庫存的方式外,還有一種比較科學(xué)的方式就是“發(fā)令牌”方式,通過這個(gè)方式可以避免出現(xiàn)之前因?yàn)閾寧齑娑寧齑娉霈F(xiàn)負(fù)數(shù)的情況。
圖片
具體是使用 Redis 中的 list 保存多張令牌來代表庫存,一張令牌就是一個(gè)庫存,用戶搶庫存時(shí)拿到令牌的用戶可以繼續(xù)支付:
//放入三個(gè)庫存
redis> lpush prod_1475_stock_queue_1 stock_1
redis> lpush prod_1475_stock_queue_1 stock_2
redis> lpush prod_1475_stock_queue_1 stock_3
//取出一個(gè),超過0.5秒沒有返回,那么搶庫存失敗
redis> brpop prod_1475_stock_queue_1 0.5
在庫存搶購失敗時(shí),用戶只會(huì)收到 nil,這種實(shí)現(xiàn)方式確實(shí)能避免失敗后還要補(bǔ)償庫存的問題。不過,即便如此,如果我們的業(yè)務(wù)代碼在異常處理上不夠完善,依然可能發(fā)生庫存丟失的情況。同時(shí),如果需要從隊(duì)列中取出令牌,使用 brpop 可以阻塞等待,而使用 rpop 則在壓測(cè)場(chǎng)景下性能表現(xiàn)更好,因?yàn)椴恍枰却?/p>
然而,當(dāng)庫存數(shù)量非常龐大時(shí),令牌方式可能并不適用。比如,如果有1萬個(gè)庫存,就需要插入1萬個(gè)令牌到 Redis 的 list 中;如果庫存有10萬,就得連續(xù)插入10萬個(gè)字符串,這會(huì)導(dǎo)致 Redis 在入庫期間發(fā)生大量卡頓。
到這里,庫存設(shè)計(jì)似乎已經(jīng)比較完善了,但如果產(chǎn)品團(tuán)隊(duì)提出“一個(gè)商品可以一次搶多個(gè)庫存”的需求(比如一次秒殺兩袋大米),這個(gè)方案可能就無法滿足了。因?yàn)槲覀円蕾囉诙鄠€(gè)鎖來降低競(jìng)爭(zhēng),而一次性扣減多個(gè)庫存會(huì)讓這個(gè)設(shè)計(jì)變得復(fù)雜,鎖爭(zhēng)搶問題依舊存在。
多庫存秒殺
其實(shí)這種情況經(jīng)常出現(xiàn),這讓我們對(duì)之前的優(yōu)化有了更多的想法。對(duì)于一次秒殺多個(gè)庫存,我們的設(shè)計(jì)需要做一些調(diào)整。
圖片
之前,我們?yōu)榱藴p少鎖競(jìng)爭(zhēng),將庫存拆分成了 10 個(gè)不同的 key 并隨機(jī)獲取。但如果到了庫存只剩下最后幾個(gè)商品的極端情況,用戶一次秒殺三件商品(如上例所示),可能需要嘗試所有的庫存 key。最終,在嘗試了 10 個(gè) key 后,可能只成功扣減了兩個(gè)庫存。這個(gè)時(shí)候,我們就面臨選擇:是拒絕用戶的訂單,還是返還庫存?
這就取決于產(chǎn)品的設(shè)計(jì)思路了。同時(shí),我們還需要增加一個(gè)檢測(cè)機(jī)制:如果庫存已經(jīng)售罄,就不要再去嘗試獲取 10 個(gè)庫存 key 了。畢竟,在沒庫存的情況下,每次請(qǐng)求都會(huì)刷 10 次 Redis,而這會(huì)對(duì) Redis 服務(wù)帶來較大壓力。雖然 Redis 的 O(1) 操作理論上可以達(dá)到每秒 10 萬次操作(OPS),但一次請(qǐng)求刷 10 次,理想情況下?lián)屬弾齑娴慕涌谛阅艽蠹s為每秒 1 萬次請(qǐng)求(QPS)。實(shí)際壓測(cè)后,建議按照實(shí)測(cè)性能的 70% 來進(jìn)行漏斗式限流。
你應(yīng)該注意到了,在“一個(gè)商品可以搶多個(gè)庫存”的場(chǎng)景下,庫存拆分方案并沒有減少鎖的爭(zhēng)搶次數(shù),反而增加了維護(hù)的復(fù)雜性。當(dāng)庫存越來越少時(shí),系統(tǒng)性能會(huì)顯著下降,這樣的設(shè)計(jì)已經(jīng)不符合我們最初的目的(這種由業(yè)務(wù)需求引發(fā)的設(shè)計(jì)不適配問題在實(shí)際項(xiàng)目中非常常見,需要我們?cè)谠O(shè)計(jì)之初更深入地理解產(chǎn)品需求)。
那么,應(yīng)該怎么優(yōu)化呢?我們可以考慮將原來拆分的 10 個(gè) key 合并為 1 個(gè),然后使用 rpop 來實(shí)現(xiàn)多個(gè)庫存的扣減操作。對(duì)于庫存不足的情況(例如,用戶想買 3 件但只剩 2 件庫存),需要產(chǎn)品側(cè)給出明確的建議,看是否繼續(xù)處理交易。同時(shí),在每次操作開始時(shí),可以使用 LLEN(O(1) 操作)檢查 list 中是否有足夠的庫存支持 rpop。
//取之前看一眼庫存是否空了,空了不繼續(xù)了(llen O(1))
redis> llen prod_1475_stock_queue
3
//取出庫存3個(gè),實(shí)際搶到倆
redis> rpop prod_1475_stock_queue 3
"stock_1"
"stock_2"
//產(chǎn)品說數(shù)量不夠,不允許繼續(xù)交易,將庫存返還
redis> lpush prod_1475_stock_queue stock_1
redis> lpush prod_1475_stock_queue stock_2
通過這個(gè)設(shè)計(jì),我們已經(jīng)大大降低了下單系統(tǒng)鎖爭(zhēng)搶壓力。要知道,Redis 是一個(gè)性能很好的緩存服務(wù),其 O(1) 類復(fù)雜度的指令在使用長(zhǎng)鏈接的情況下多線程壓測(cè),5.0 版本的 Redis 就能夠跑到 10w OPS,而 6.0 版本的網(wǎng)絡(luò)性能會(huì)更好。這種利用 Redis 原子操作減少鎖沖突的方式,對(duì)各個(gè)語言來說是通用且簡(jiǎn)單的。不過你要注意,不要把 Redis 服務(wù)和復(fù)雜業(yè)務(wù)邏輯混用,否則會(huì)影響我們的庫存接口效率。
自旋互斥超時(shí)鎖
如果我們?cè)趲齑鏍?zhēng)搶時(shí)需要操作多個(gè)決策 key 才能夠完成爭(zhēng)搶,那么原子這種方式是不適合的。因?yàn)樵硬僮鞯牧6冗^小,無法做到事務(wù)性地維持多個(gè)數(shù)據(jù)的 ACID。這種多步操作,適合用自旋互斥鎖的方式去實(shí)現(xiàn),但流量大的時(shí)候不推薦這個(gè)方式,因?yàn)樗暮诵脑谟谌绻覀円WC用戶的體驗(yàn),我們需要邏輯代碼多次循環(huán)搶鎖,直到拿到鎖為止,如下:
//業(yè)務(wù)邏輯需要循環(huán)搶鎖,如循環(huán)10次,每次sleep 10ms,10次失敗后返回失敗給用戶
//獲取鎖后設(shè)置超時(shí)時(shí)間,防止進(jìn)程崩潰后沒有釋放鎖導(dǎo)致問題
//如果獲取鎖失敗會(huì)返回nil
redis> set prod_1475_stock_lock EX 60 NX
OK
//搶鎖成功,扣減庫存
redis> rpop prod_1475_stock_queue 1
"stock_1"
//扣減數(shù)字庫存,用于展示
redis> decr prod_1475_stock_1
3
// 釋放鎖
redis> del prod_1475_stock_lock
圖片
這種方式的缺點(diǎn)在于,在搶鎖階段如果排隊(duì)搶的線程越多,等待時(shí)間就越長(zhǎng),并且由于多線程一起循環(huán) check 的緣故,在高并發(fā)期間 Redis 的壓力會(huì)非常大,如果有 100 人下單,那么有 100 個(gè)線程每隔 10ms 就會(huì) check 一次,此時(shí) Redis 的操作次數(shù)就是:
100線程×(1000ms÷10ms)次=10000ops
CAS 樂觀鎖:鎖操作后置
另外一個(gè)推薦的方式是使用 CAS(Compare and Swap)樂觀鎖。與自旋互斥鎖相比,在并發(fā)搶庫存的線程較少時(shí),CAS 樂觀鎖效率更高。傳統(tǒng)的鎖機(jī)制是通過先獲取鎖,再對(duì)數(shù)據(jù)進(jìn)行操作,這個(gè)過程中搶鎖本身會(huì)消耗性能,哪怕沒有其他線程參與爭(zhēng)搶,搶鎖的開銷依然存在。
而 CAS 樂觀鎖 的核心在于記錄或監(jiān)控當(dāng)前的庫存信息或版本號(hào),在此基礎(chǔ)上進(jìn)行預(yù)操作。當(dāng)線程準(zhǔn)備修改數(shù)據(jù)時(shí),系統(tǒng)會(huì)先檢查當(dāng)前的庫存版本號(hào)是否和預(yù)期的一致。如果一致,則修改成功;否則,重新讀取最新的數(shù)據(jù)并重試。這種方式可以避免鎖競(jìng)爭(zhēng)帶來的性能損耗,在并發(fā)較低的場(chǎng)景下更具優(yōu)勢(shì)。
圖片
如上圖,在操作期間如果發(fā)現(xiàn)監(jiān)控的數(shù)值有變化,那么就回滾之前操作;如果期間沒有變化,就提交事務(wù)的完成操作,操作期間的所有動(dòng)作都是事務(wù)的。
//開啟事務(wù)
redis> multi
OK
// watch 修改值
// 在exec期間如果出現(xiàn)其他線程修改,那么會(huì)自動(dòng)失敗回滾執(zhí)行discard
redis> watch prod_1475_stock_queue prod_1475_stock_1
//事務(wù)內(nèi)對(duì)數(shù)據(jù)進(jìn)行操作
redis> rpop prod_1475_stock_queue 1
QUEUED
//操作步驟2
redis> decr prod_1475_stock_1
QUEUED
//執(zhí)行之前所有操作步驟
//multi 期間 watch有數(shù)值有變化則會(huì)回滾
redis> exec
3
以看到,通過這個(gè)方式我們可以批量地快速實(shí)現(xiàn)庫存扣減,并且能大幅減少鎖爭(zhēng)搶時(shí)間。它的好處我們剛才說過,就是爭(zhēng)搶線程少時(shí)效率特別好,但爭(zhēng)搶線程多時(shí)會(huì)需要大量重試,不過即便如此,CAS 樂觀鎖也會(huì)比用自旋鎖實(shí)現(xiàn)的性能要好。當(dāng)采用這個(gè)方式的時(shí)候,我建議內(nèi)部的操作步驟盡量少一些。同時(shí)要注意,如果 Redis 是 Cluster 模式,使用 multi 時(shí)必須在一個(gè) slot 內(nèi)才能保證原子性。
Redis Lua 方式實(shí)現(xiàn) Redis 鎖
與“事務(wù) + 樂觀鎖”類似的另一種實(shí)現(xiàn)方式是使用 Redis 的 Lua 腳本 來進(jìn)行多步驟的庫存操作。由于 Lua 腳本在 Redis 內(nèi)部的執(zhí)行是連續(xù)且原子的,因此所有操作不會(huì)被其他操作打斷,避免了鎖爭(zhēng)搶的問題。
此外,Lua 腳本可以根據(jù)不同情況靈活處理不同的操作,業(yè)務(wù)只需調(diào)用指定的 Lua 腳本并傳遞相關(guān)參數(shù),就可以實(shí)現(xiàn)高性能的庫存扣減。這樣做不僅提高了操作的原子性,也顯著減少了由于多次請(qǐng)求等待所帶來的 RTT(往返時(shí)間),提升了整體系統(tǒng)的響應(yīng)速度和并發(fā)處理能力。
為了方便演示怎么執(zhí)行 Lua 腳本,我使用了 PHP 實(shí)現(xiàn):
<?php
$script = <<<EOF
// 獲取當(dāng)前庫存?zhèn)€數(shù)
local stock=tonumber(redis.call('GET',KEYS[1]));
//沒找到返回-1
if stock==nil
then
return -1;
end
//找到了扣減庫存?zhèn)€數(shù)
local result=stock-ARGV[1];
//如扣減后少于指定個(gè)數(shù),那么返回0
if result<0
then
return 0;
else
//如果扣減后仍舊大于0,那么將結(jié)果放回Redis內(nèi),并返回1
redis.call('SET',KEYS[1],result);
return 1;
end
EOF;
$redis = new \Redis();
$redis->connect('127.0.0.1', 6379);
$result = $redis->eval($script, array("prod_stock", 3), 1);
echo $result;
通過這個(gè)方式,我們可以遠(yuǎn)程注入各種連貫帶邏輯的操作,并且可以實(shí)現(xiàn)一些補(bǔ)庫存的操作。
總結(jié)