強一致鎖:如何解決高併發下的庫存爭搶問題?
由於秒殺場景是庫存爭搶非常經典的一個應用場景,接下來我會結合秒殺需求,帶你看看如何實現高併發下的庫存爭搶,相信在這一過程中你會對鎖有更深入的認識。
鎖爭搶的錯誤做法
在開始介紹庫存爭搶的具體方案之前,我們先來了解一個小知識——併發庫存鎖。還記得在我學計算機的時候,老師曾演示過一段代碼:
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;
}
}
從代碼來看,我們運行後結果預期是 2000,但是實際運行後並不是。爲什麼會這樣呢?當多線程並行對同一個公共變量讀寫時,由於沒有互斥,多線程的 set 會相互覆蓋或讀取時容易讀到其他線程剛寫一半的數據,這就導致變量數據被損壞。反過來說,我們要想保證一個變量在多線程併發情況下的準確性,就需要這個變量在修改期間不會被其他線程更改或讀取。對於這個情況,我們一般都會用到鎖或原子操作來保護庫存變量:如果是簡單 int 類型數據,可以使用原子操作保證數據準確;如果是複雜的數據結構或多步操作,可以加鎖來保證數據完整性。
考慮到我們之前的習慣會有一定慣性,爲了讓你更好地理解爭搶,這裏我再舉一個我們常會犯錯的例子。因爲扣庫存的操作需要注意原子性,我們實踐的時候常常碰到後面這種方式:
redis> get prod_1475_stock_1
15
redis> set prod_1475_stock_1 14
OK
也就是先將變量從緩存中取出,對其做 -1 操作,再放回到緩存當中,這是個錯誤做法。
如上圖,原因是多個線程一起讀取的時候,多個線程同時讀到的是 5,set 回去時都是 6,實際每個線程都拿到了庫存,但是庫存的實際數值並沒有累計改變,這會導致庫存超賣。如果你需要用這種方式去做,一般建議加一個自旋互斥鎖,互斥其他線程做類似的操作。
原子操作
當大量用戶併發修改某個變量時,使用互斥鎖雖然能保證數據修改的正確性,但性能非常低。假設有一萬個用戶爭搶一個鎖,排隊等待修改服務器中的變量,這樣的設計效率極差。鎖在獲取過程中需要自旋,反覆嘗試才能搶到鎖,用戶越多,爭搶越激烈,系統資源的消耗就越大,可能導致系統不穩定。
爲了解決這個問題,我會選擇將庫存數據存放在高性能的內存緩存服務(比如 Redis)中集中管理。這樣可以避免用戶爭搶鎖時影響到其他服務,同時也能提高響應速度。Redis 本身支持原子操作,並且通過它可以更好地應對高併發場景。這也是目前互聯網行業普遍採用的庫存保護方案。
相比之下,不建議通過數據庫行鎖來保證庫存的修改。數據庫資源非常寶貴,如果使用行鎖管理庫存,不僅性能會很差,系統也容易變得不穩定。
爲了減少鎖爭搶和提升系統效率,我們可以採取降低鎖粒度的方式,或者引入其他優化方案。
實際上,我們可以通過將熱門商品的庫存進行拆分,存儲到多個 key 中,從而顯著減少鎖的競爭。比如,假設當前商品的庫存爲 100 個,可以把庫存分成 10 個 key,每個 key 保存 10 個庫存,並將這些 key 分佈在不同的 Redis 實例中。當用戶下單時,可以隨機選擇一個 key 扣減庫存。如果某個 key 中的庫存不足,再記錄該 key 並隨機挑選剩餘的 key 進行扣減,直到成功完成一次庫存的扣除。
不過,除了這種方法之外,我更推薦使用 Redis 的原子操作來處理庫存問題。原子操作的粒度更小,並且 Redis 本質上是單線程運行,能夠提供全局唯一的決策。很多原子操作的底層實現甚至是通過硬件實現的,性能非常優異,且不會產生鎖競爭問題。
以 Redis 的 INCR
和 DECR
操作爲例,它們可以在不加鎖的情況下實現對庫存的精確修改,這樣能同時保證高性能和數據的一致性。
redis> decr prod_1475_stock_1
14
incr、decr 這類操作就是原子的,我們可以根據返回值是否大於 0 來判斷是否扣庫存成功。但是這裏你要注意,如果當前值已經爲負數,我們需要考慮一下是否將之前扣除的補償回來。並且爲了減少修改操作,我們可以在扣減之前做一次值檢測,整體操作如下:
//讀取當前庫存,確認是否大於零
//如大於零則繼續操作,小於等於拒絕後續
redis> get prod_1475_stock_1
1
//開始扣減庫存、如返回值大於或等於0那麼代表扣減成功,小於0代表當前已經沒有庫存
//可以看到返回-2,這可以理解成同時兩個線程都在操作扣庫存,並且都沒拿到庫存
redis> decr prod_1475_stock_1
-2
//扣減失敗、補償多扣的庫存
//這裏返回0是因爲同時兩個線程都在做補償,最終恢復0庫存
redis> incr prod_1475_stock
0
這個庫存保護方案確實很有價值,但也有其侷限性。庫存的準確性依賴於業務是否能成功 “返還” 之前扣減的庫存。如果在服務運行過程中 “返還” 操作被中斷,人工修復將變得非常困難,因爲無法確定當前還有多少庫存正在處理中。通常,我們需要等到活動結束後再查看最終庫存。
爲了完全保證庫存不丟失,通常會依賴事務和回滾機制,但 Redis 作爲外置的庫存服務,並不屬於數據庫的緩存範疇。這就要求我們在每個可能出現故障的業務環節中都能夠妥善處理庫存問題。因此,許多秒殺系統在出現故障時往往選擇不返還庫存,並不是因爲不想,而是因爲很多意外場景無法做到。
至於使用 SETNX
指令或數據庫的 CAS(比較並交換)機制來實現互斥鎖,儘管這能解決庫存問題,但這種鎖機制會引入自旋等待。在併發高的情況下,用戶服務需要反覆嘗試才能獲取鎖,這樣會浪費系統資源,並對數據服務造成較大壓力。因此,這種方法並不推薦使用。
令牌庫存
除了這種用數值記錄庫存的方式外,還有一種比較科學的方式就是 “發令牌” 方式,通過這個方式可以避免出現之前因爲搶庫存而讓庫存出現負數的情況。
具體是使用 Redis 中的 list 保存多張令牌來代表庫存,一張令牌就是一個庫存,用戶搶庫存時拿到令牌的用戶可以繼續支付:
//放入三個庫存
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
//取出一個,超過0.5秒沒有返回,那麼搶庫存失敗
redis> brpop prod_1475_stock_queue_1 0.5
在庫存搶購失敗時,用戶只會收到 nil
,這種實現方式確實能避免失敗後還要補償庫存的問題。不過,即便如此,如果我們的業務代碼在異常處理上不夠完善,依然可能發生庫存丟失的情況。同時,如果需要從隊列中取出令牌,使用 brpop
可以阻塞等待,而使用 rpop
則在壓測場景下性能表現更好,因爲不需要等待。
然而,當庫存數量非常龐大時,令牌方式可能並不適用。比如,如果有 1 萬個庫存,就需要插入 1 萬個令牌到 Redis 的 list 中;如果庫存有 10 萬,就得連續插入 10 萬個字符串,這會導致 Redis 在入庫期間發生大量卡頓。
到這裏,庫存設計似乎已經比較完善了,但如果產品團隊提出 “一個商品可以一次搶多個庫存” 的需求(比如一次秒殺兩袋大米),這個方案可能就無法滿足了。因爲我們依賴於多個鎖來降低競爭,而一次性扣減多個庫存會讓這個設計變得複雜,鎖爭搶問題依舊存在。
多庫存秒殺
其實這種情況經常出現,這讓我們對之前的優化有了更多的想法。對於一次秒殺多個庫存,我們的設計需要做一些調整。
之前,我們爲了減少鎖競爭,將庫存拆分成了 10 個不同的 key 並隨機獲取。但如果到了庫存只剩下最後幾個商品的極端情況,用戶一次秒殺三件商品(如上例所示),可能需要嘗試所有的庫存 key。最終,在嘗試了 10 個 key 後,可能只成功扣減了兩個庫存。這個時候,我們就面臨選擇:是拒絕用戶的訂單,還是返還庫存?
這就取決於產品的設計思路了。同時,我們還需要增加一個檢測機制:如果庫存已經售罄,就不要再去嘗試獲取 10 個庫存 key 了。畢竟,在沒庫存的情況下,每次請求都會刷 10 次 Redis,而這會對 Redis 服務帶來較大壓力。雖然 Redis 的 O(1) 操作理論上可以達到每秒 10 萬次操作(OPS),但一次請求刷 10 次,理想情況下搶購庫存的接口性能大約爲每秒 1 萬次請求(QPS)。實際壓測後,建議按照實測性能的 70% 來進行漏斗式限流。
你應該注意到了,在 “一個商品可以搶多個庫存” 的場景下,庫存拆分方案並沒有減少鎖的爭搶次數,反而增加了維護的複雜性。當庫存越來越少時,系統性能會顯著下降,這樣的設計已經不符合我們最初的目的(這種由業務需求引發的設計不適配問題在實際項目中非常常見,需要我們在設計之初更深入地理解產品需求)。
那麼,應該怎麼優化呢?我們可以考慮將原來拆分的 10 個 key 合併爲 1 個,然後使用 rpop
來實現多個庫存的扣減操作。對於庫存不足的情況(例如,用戶想買 3 件但只剩 2 件庫存),需要產品側給出明確的建議,看是否繼續處理交易。同時,在每次操作開始時,可以使用 LLEN
(O(1) 操作)檢查 list 中是否有足夠的庫存支持 rpop
。
//取之前看一眼庫存是否空了,空了不繼續了(llen O(1))
redis> llen prod_1475_stock_queue
3
//取出庫存3個,實際搶到倆
redis> rpop prod_1475_stock_queue 3
"stock_1"
"stock_2"
//產品說數量不夠,不允許繼續交易,將庫存返還
redis> lpush prod_1475_stock_queue stock_1
redis> lpush prod_1475_stock_queue stock_2
通過這個設計,我們已經大大降低了下單系統鎖爭搶壓力。要知道,Redis 是一個性能很好的緩存服務,其 O(1) 類複雜度的指令在使用長鏈接的情況下多線程壓測,5.0 版本的 Redis 就能夠跑到 10w OPS,而 6.0 版本的網絡性能會更好。這種利用 Redis 原子操作減少鎖衝突的方式,對各個語言來說是通用且簡單的。不過你要注意,不要把 Redis 服務和複雜業務邏輯混用,否則會影響我們的庫存接口效率。
自旋互斥超時鎖
如果我們在庫存爭搶時需要操作多個決策 key 才能夠完成爭搶,那麼原子這種方式是不適合的。因爲原子操作的粒度過小,無法做到事務性地維持多個數據的 ACID。這種多步操作,適合用自旋互斥鎖的方式去實現,但流量大的時候不推薦這個方式,因爲它的核心在於如果我們要保證用戶的體驗,我們需要邏輯代碼多次循環搶鎖,直到拿到鎖爲止,如下:
//業務邏輯需要循環搶鎖,如循環10次,每次sleep 10ms,10次失敗後返回失敗給用戶
//獲取鎖後設置超時時間,防止進程崩潰後沒有釋放鎖導致問題
//如果獲取鎖失敗會返回nil
redis> set prod_1475_stock_lock EX 60 NX
OK
//搶鎖成功,扣減庫存
redis> rpop prod_1475_stock_queue 1
"stock_1"
//扣減數字庫存,用於展示
redis> decr prod_1475_stock_1
3
// 釋放鎖
redis> del prod_1475_stock_lock
這種方式的缺點在於,在搶鎖階段如果排隊搶的線程越多,等待時間就越長,並且由於多線程一起循環 check 的緣故,在高併發期間 Redis 的壓力會非常大,如果有 100 人下單,那麼有 100 個線程每隔 10ms 就會 check 一次,此時 Redis 的操作次數就是:
100 線程 ×(1000ms÷10ms)次 = 10000ops
CAS 樂觀鎖:鎖操作後置
另外一個推薦的方式是使用 CAS(Compare and Swap)樂觀鎖。與自旋互斥鎖相比,在併發搶庫存的線程較少時,CAS 樂觀鎖效率更高。傳統的鎖機制是通過先獲取鎖,再對數據進行操作,這個過程中搶鎖本身會消耗性能,哪怕沒有其他線程參與爭搶,搶鎖的開銷依然存在。
而 CAS 樂觀鎖 的核心在於記錄或監控當前的庫存信息或版本號,在此基礎上進行預操作。當線程準備修改數據時,系統會先檢查當前的庫存版本號是否和預期的一致。如果一致,則修改成功;否則,重新讀取最新的數據並重試。這種方式可以避免鎖競爭帶來的性能損耗,在併發較低的場景下更具優勢。
如上圖,在操作期間如果發現監控的數值有變化,那麼就回滾之前操作;如果期間沒有變化,就提交事務的完成操作,操作期間的所有動作都是事務的。
//開啓事務
redis> multi
OK
// watch 修改值
// 在exec期間如果出現其他線程修改,那麼會自動失敗回滾執行discard
redis> watch prod_1475_stock_queue prod_1475_stock_1
//事務內對數據進行操作
redis> rpop prod_1475_stock_queue 1
QUEUED
//操作步驟2
redis> decr prod_1475_stock_1
QUEUED
//執行之前所有操作步驟
//multi 期間 watch有數值有變化則會回滾
redis> exec
3
以看到,通過這個方式我們可以批量地快速實現庫存扣減,並且能大幅減少鎖爭搶時間。它的好處我們剛纔說過,就是爭搶線程少時效率特別好,但爭搶線程多時會需要大量重試,不過即便如此,CAS 樂觀鎖也會比用自旋鎖實現的性能要好。當採用這個方式的時候,我建議內部的操作步驟儘量少一些。同時要注意,如果 Redis 是 Cluster 模式,使用 multi 時必須在一個 slot 內才能保證原子性。
Redis Lua 方式實現 Redis 鎖
與 “事務 + 樂觀鎖” 類似的另一種實現方式是使用 Redis 的 Lua 腳本 來進行多步驟的庫存操作。由於 Lua 腳本在 Redis 內部的執行是連續且原子的,因此所有操作不會被其他操作打斷,避免了鎖爭搶的問題。
此外,Lua 腳本可以根據不同情況靈活處理不同的操作,業務只需調用指定的 Lua 腳本並傳遞相關參數,就可以實現高性能的庫存扣減。這樣做不僅提高了操作的原子性,也顯著減少了由於多次請求等待所帶來的 RTT(往返時間),提升了整體系統的響應速度和併發處理能力。
爲了方便演示怎麼執行 Lua 腳本,我使用了 PHP 實現:
<?php
$script = <<<EOF
// 獲取當前庫存個數
local stock=tonumber(redis.call('GET',KEYS[1]));
//沒找到返回-1
if stock==nil
then
return -1;
end
//找到了扣減庫存個數
local result=stock-ARGV[1];
//如扣減後少於指定個數,那麼返回0
if result<0
then
return 0;
else
//如果扣減後仍舊大於0,那麼將結果放回Redis內,並返回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;
通過這個方式,我們可以遠程注入各種連貫帶邏輯的操作,並且可以實現一些補庫存的操作。
總結
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/1NOYbMKTDQ-SkCdFp-9n1g