關(guān)于秒殺系統(tǒng)中訂單庫存扣減的最佳實(shí)踐

一、背景

一般在日常開發(fā)中經(jīng)常會(huì)遇到打折促銷,秒殺活動(dòng),就如拼多多最近的4999搶券買愛瘋11促銷活動(dòng),畢竟誰的錢也不是大風(fēng)刮來的,有秒殺有促銷必定帶來大量用戶,而這類活動(dòng)往往支撐著公司重要營銷策略,所以保證系統(tǒng)在高并發(fā)下不出異常非常關(guān)鍵,這其中棘手的便是如何在高并發(fā)下高效的處理庫存數(shù)據(jù)。

現(xiàn)在處理這種場景存在多種方案。但是要保證高性能和高可用,大部分方案并不滿足,今天就來聊聊高并發(fā)下庫存加減那些事兒。

二、方案

1. 歷史數(shù)據(jù)庫的事務(wù)特性和唯一主鍵

基于數(shù)據(jù)庫的事務(wù),扣減庫存的操作方法同一個(gè)事務(wù)中進(jìn)行庫存扣減,事務(wù)中任何操作失敗,執(zhí)行回滾操作。從而保證原子性。單純靠數(shù)據(jù)庫的事務(wù),只能在單體的項(xiàng)目中。如何要分布式的項(xiàng)目中,就無法保證單線程操作了。

那如何在多進(jìn)程中實(shí)現(xiàn)單線程扣減庫存呢?我們可以利用數(shù)據(jù)庫的唯一索引。具體操作步驟:

? 1. 新建立一張表:t_lock_order,同時(shí)將商品ID作為唯一索引;

? 2.?進(jìn)行扣減庫存之前在表中插入商品ID,然后進(jìn)行數(shù)據(jù)庫更新;

? 3. 更新結(jié)束后刪除剛才插入數(shù)據(jù)庫中的記錄。

A線程進(jìn)程扣減庫存時(shí)候,插入了該商品的ID,當(dāng)B線程扣減該商品的庫存的時(shí)候,同樣也會(huì)在數(shù)據(jù)庫中插入該商品ID,A線程沒有執(zhí)行完B線程插入同一個(gè)商品ID就會(huì)報(bào)主鍵重復(fù)的錯(cuò)誤,這樣就扣減庫存失敗。

這種方案,功能上是可以實(shí)現(xiàn);但是過分依賴數(shù)據(jù)庫,無法滿足其性能要求,而且存在很多獲取鎖失敗的情況,用戶體驗(yàn)差。

2. 分布式鎖

Redis?或者?ZooKeeper?來實(shí)現(xiàn)一個(gè)分布式鎖,以商品維度來加鎖,在獲取到鎖的線程中,按順序去執(zhí)行商品庫存的查詢和扣減,這樣就同時(shí)實(shí)現(xiàn)了順序性和原子性。其實(shí)這個(gè)思路是可以的,只是不管通過哪種方式實(shí)現(xiàn)的分布式鎖,都是有弊端的。

以?Redis?的實(shí)現(xiàn)來說,通過超時(shí)時(shí)間來控制鎖的失效時(shí)間,不太靠譜,比如在有些場景中,一個(gè)線程 A 獲取到了鎖之后,由于業(yè)務(wù)代碼執(zhí)行時(shí)間可能比較長,導(dǎo)致超過了鎖的超時(shí)時(shí)間,自動(dòng)失效,后續(xù)線程 B 又意外的持有了鎖,當(dāng)線程 A 再次恢復(fù)后,通過 del 命令釋放鎖,就錯(cuò)誤的將線程 B 中同樣 key 的鎖誤刪除了。

所以,如果鎖的超時(shí)時(shí)間設(shè)置過長,會(huì)影響性能,如果設(shè)置的超時(shí)時(shí)間過短,有可能業(yè)務(wù)阻塞沒有處理完成,能否合理設(shè)置超時(shí)時(shí)間,是基于緩存實(shí)現(xiàn)分布式鎖很難解決的一個(gè)問題。

那么如何合理設(shè)置超時(shí)時(shí)間呢??你可以基于續(xù)約的方式設(shè)置超時(shí)時(shí)間:先給鎖設(shè)置一個(gè)超時(shí)時(shí)間,然后啟動(dòng)一個(gè)守護(hù)線程,讓守護(hù)線程在一段時(shí)間后,重新設(shè)置這個(gè)鎖的超時(shí)時(shí)間。實(shí)現(xiàn)方式就是:寫一個(gè)守護(hù)線程,然后去判斷鎖的情況,當(dāng)鎖快失效的時(shí)候,再次進(jìn)行續(xù)約加鎖,當(dāng)主線程執(zhí)行完成后,銷毀續(xù)約鎖即可。不過這種方式實(shí)現(xiàn)起來相對(duì)復(fù)雜,我建議你結(jié)合業(yè)務(wù)場景,所以針對(duì)超時(shí)時(shí)間的設(shè)置,要站在實(shí)際的業(yè)務(wù)場景中進(jìn)行衡量。

3. Redis + lua 腳本

Redis?單線程支持順序操作,而且性能優(yōu)異,但是不支持事務(wù)回滾。但是通過?Redis + lua?腳本可以實(shí)現(xiàn)?Redis?操作的原子性。這種方案同時(shí)滿足順序性和原子性的要求了。能幫我們實(shí)現(xiàn)?Redis?執(zhí)行?Lua?腳本的命令可以采用EVALSHA,接下來用代碼實(shí)現(xiàn)它。

1) 核心思路

首先我們根據(jù)庫存扣減核心操作,完成核心?Lua?腳本的編寫。其主要實(shí)現(xiàn)的功能就是查詢庫存并判斷庫存是否充足,如果充足,則做相應(yīng)的扣減操作,腳本內(nèi)容如下:

2) 業(yè)務(wù)邏輯

然后我們將?Lua?腳本轉(zhuǎn)成字符串,并添加腳本預(yù)加載機(jī)制。

預(yù)加載可以有多種實(shí)現(xiàn)方式

1. 一個(gè)是外部預(yù)加載好,生成了?sha1?然后配置到配置中心,這樣?Java?代碼從配置中心拉取最新?sha1?即可;

2. 另一種方式是在服務(wù)啟動(dòng)時(shí),來完成腳本的預(yù)加載,并生成單機(jī)全局變量?sha1。

我們這里先采取第二種方式,代碼結(jié)構(gòu)如下圖所示:

以上是將?Lua?腳本轉(zhuǎn)成字符串形式,并通過?@PostConstruct?完成腳本的預(yù)加載。然后新增?EVALSHA?方法,如下圖所示:

方法入?yún)榛顒?dòng)商品庫存?key?以及單次搶購數(shù)量,并在內(nèi)部調(diào)用?Lua?腳本執(zhí)行庫存扣減操作。看起來是不是很簡單?在寫完底層核心方法之后,我們只需要在下單之前,調(diào)用該方法即可,具體如下圖所示:

三、總結(jié)

最后,我們從技術(shù)的角度分析了庫存超賣發(fā)生的兩個(gè)原因:

? ?1. 一個(gè)是庫存扣減涉及到的兩個(gè)核心操作,查詢和扣減不是原子操作;

? ?2. 另一個(gè)是高并發(fā)引起的請(qǐng)求無序。

在秒殺場景下,因?yàn)椴樵兙彺嬉炔樵償?shù)據(jù)庫快,一般將庫存數(shù)放在緩存中,直接在緩存中扣減庫存。在上面的三個(gè)方案中,小編建議是采用redis+lua的方案,即利用Redis的單線程原理,以及提供的原生?EVALSHA?和?SCRIPT LOAD命令來實(shí)現(xiàn)庫存扣減的原子性和順序性,并且經(jīng)過實(shí)測(cè)也確實(shí)能達(dá)到我們的預(yù)期,且性能良好,從而有效地解決了秒殺系統(tǒng)所面臨的庫存超賣挑戰(zhàn)。

最后,如果我的文章對(duì)你有所幫助或者有所啟發(fā),歡迎關(guān)注公眾號(hào)(微信搜索公眾號(hào):首席架構(gòu)師專欄),里面有許多技術(shù)干貨,也有我對(duì)技術(shù)的思考和感悟,還有作為架構(gòu)師的驗(yàn)驗(yàn)分享;關(guān)注后回復(fù) 【面試題】,有我準(zhǔn)備的面試題、架構(gòu)師大型項(xiàng)目實(shí)戰(zhàn)視頻等福利 , 小編會(huì)帶著你一起學(xué)習(xí)、成長,讓我們一起加油?。?!

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容