電商庫存系統(tǒng)的防超賣和高并發(fā)扣減方案
摘要:如果你要開發(fā)一個(gè)電商庫存系統(tǒng),最擔(dān)心的是什么?閉上眼睛想下,當(dāng)然是高并發(fā)和防超賣了!本文給出一個(gè)統(tǒng)籌考慮如何高并發(fā)和防超賣數(shù)據(jù)準(zhǔn)確性的方案。讀者可以直接借鑒本設(shè)計(jì),或在此基礎(chǔ)上做出更切合使用場景的設(shè)計(jì)。
下面用電商庫存為示例,來說明如何高并發(fā)扣減庫存,原理同樣適用于其他需要并發(fā)寫和數(shù)據(jù)一致性的場景。
庫存數(shù)量模型示例
為了描述方便,我們使用簡化的庫存數(shù)量模型,真實(shí)場景中庫存數(shù)據(jù)項(xiàng)會(huì)比我的示例多很多,但已經(jīng)夠說明原理。如下表,庫存數(shù)量表(stockNum)包含商品標(biāo)識(shí)和庫存數(shù)量兩個(gè)字段,庫存數(shù)量代表有多少貨可以賣。
字段名 |
英文名 |
字段類型 |
商品標(biāo)識(shí) |
skuId |
長整型 |
庫存數(shù)量 |
num |
整數(shù) |
傳統(tǒng)通過數(shù)據(jù)庫保證不超賣
庫存管理的傳統(tǒng)方案為了保證不超賣,都是使用數(shù)據(jù)庫的事務(wù)來保證的:通過Sql判斷剩余的庫存數(shù)夠用,多個(gè)并發(fā)執(zhí)行update語句只有一個(gè)能執(zhí)行成功;為了保證扣減不重復(fù),會(huì)配合一個(gè)防重表來防止重復(fù)的提交,做到冪等性,防重表示例(antiRe)設(shè)計(jì)如下:
字段名 |
英文名 |
字段類型 |
標(biāo)識(shí) |
id |
長整型 |
防重碼 |
code |
字符串(唯一索引) |
比如一個(gè)下單過程的扣減過程示例如下:
事務(wù)開始
Insert into antiRe(code) value (‘訂單號+Sku’)
Update stockNum set num=num-下單數(shù)量 where skuId=商品ID and num-下單數(shù)量>0
事務(wù)結(jié)束
復(fù)制代碼
面臨系統(tǒng)流量越來越大,數(shù)據(jù)庫的性能瓶頸就會(huì)暴露出來:就算分庫分表也是沒用的,促銷的時(shí)候高并發(fā)都是針對少量商品的,最終并發(fā)流量會(huì)打向少數(shù)表,只能去提升單分片的抗量能力。我們接下來設(shè)計(jì)一種使用Redis緩存做庫存扣減的方案。
綜合使用數(shù)據(jù)庫和Redis滿足高并發(fā)扣減的原理
扣減庫存其實(shí)包含兩個(gè)過程:第一步是超賣校驗(yàn),第二步是扣減數(shù)據(jù)的持久化;在傳統(tǒng)數(shù)據(jù)庫扣減中,兩步是一起完成的。抗寫的實(shí)現(xiàn)原理其實(shí)是巧妙的利用了分離的思想,分離開防超賣和數(shù)據(jù)持久化;首先防超賣是由Redis來完成的;通過Redis防超賣后,只要落庫就可以;落庫通過任務(wù)引擎,業(yè)務(wù)數(shù)據(jù)庫使用商品分庫分表,任務(wù)引擎任務(wù)通過單據(jù)號分庫分表,熱點(diǎn)商品的落庫會(huì)被狀態(tài)機(jī)分散開,消除熱點(diǎn)。
整體架構(gòu)如下:

第一關(guān)解決超賣檢驗(yàn):我們可以把數(shù)據(jù)放入Redis中,每次扣減庫存,都對Redis中的數(shù)據(jù)進(jìn)行incryby 扣減,如果返回的數(shù)量大于0,說明庫存夠,因?yàn)镽edis是單線程,可以信任返回結(jié)果。第一關(guān)是Redis,可以抗高并發(fā),性能Ok。超賣校驗(yàn)通過后,進(jìn)入第二關(guān)。
第二關(guān)解決庫存扣減:經(jīng)過第一關(guān)后,第二關(guān)不需要再判斷數(shù)量是否足夠,只需要傻瓜扣減庫存就行,對數(shù)據(jù)庫執(zhí)行如下語句,當(dāng)然還是需要處理防重冪等的,不需要判斷數(shù)量是否大于0了,扣減SQL只要如下寫就可以。
事務(wù)開始
Insert into antiRe(code) value (‘訂單號+Sku’)
Update stockNum set num=num-下單數(shù)量 where skuId=商品ID
事務(wù)結(jié)束
復(fù)制代碼
要點(diǎn):最終還是要使用數(shù)據(jù)庫,熱點(diǎn)怎么解決的呢?任務(wù)庫使用訂單號進(jìn)行分庫分表,這樣針對同一個(gè)商品的不同訂單會(huì)散列在任務(wù)庫的不同庫存,雖然還是數(shù)據(jù)庫抗量,但已經(jīng)消除了數(shù)據(jù)庫熱點(diǎn)。
整體交互序列圖如下:

熱點(diǎn)防刷
但Redis也是有瓶頸的,如果出現(xiàn)過熱SKU就會(huì)打向Redis單片,會(huì)造成單片性能抖動(dòng)。庫存防刷有個(gè)前提是不能卡單的。可以定制設(shè)計(jì)JVM內(nèi)毫秒級時(shí)間窗的限流,限流的目的是保護(hù)Redis,盡可能的不限流。限流的極端情況就是商品本來應(yīng)該在一秒內(nèi)賣完,但實(shí)際花了兩秒,正常并不會(huì)發(fā)生延遲銷售,之所以選擇JVM是因?yàn)槿绻捎眠h(yuǎn)端集中緩存限流,還未來得及收集數(shù)據(jù)就已經(jīng)把Redis打死。
實(shí)現(xiàn)方案可以通過guava之類的框架,每10ms一個(gè)時(shí)間窗,每個(gè)時(shí)間窗進(jìn)行計(jì)數(shù),單臺(tái)服務(wù)器超過計(jì)數(shù)進(jìn)行限流。比如10ms超過2個(gè)就限流,那么一秒一臺(tái)服務(wù)器就是200個(gè),50臺(tái)服務(wù)器一秒就可以賣出1萬個(gè)貨,自己根據(jù)實(shí)際情況調(diào)整閾值就可以。

Redis扣減原理
Redis的incrby 命令可以用做庫存扣減,扣減項(xiàng)可能多個(gè),我們使用Hash結(jié)構(gòu)的hincrby命令,先用Reids原生命令模擬整個(gè)過程,為了簡化模型我們演示一個(gè)數(shù)據(jù)項(xiàng)的操作,多個(gè)數(shù)據(jù)項(xiàng)原理完全等同。
127.0.0.1:6379> hset iphone inStock 1 #設(shè)置蘋果手機(jī)有一個(gè)可售庫存
(integer) 1
127.0.0.1:6379> hget iphone inStock #查看蘋果手機(jī)可售庫存為1
"1"
127.0.0.1:6379> hincrby iphone inStock -1 #賣出扣減一個(gè),返回剩余0,下單成功
(integer) 0
127.0.0.1:6379> hget iphone inStock #驗(yàn)證剩余0
"0"
127.0.0.1:6379> hincrby iphone inStock -1 #應(yīng)用并發(fā)超賣但Redis單線程返回剩余-1,下單失敗
(integer) -1
127.0.0.1:6379> hincrby iphone inStock 1 #識(shí)別-1,回滾庫存加一,剩余0
(integer) 0
127.0.0.1:6379> hget iphone inStock #庫存恢復(fù)正常
"0"
復(fù)制代碼
扣減的冪等性保證
如果應(yīng)用調(diào)用Redis扣減后,不知道是否成功,可以針對批量扣減命令增加一個(gè)防重碼,對防重碼執(zhí)行setnx命令,當(dāng)發(fā)生異常的時(shí)候,可以根據(jù)防重碼是否存在來決定是否扣減成功,針對批量命名可以使用pipeline提高成功率。
// 初始化庫存
127.0.0.1:6379> hset iphone inStock 1 #設(shè)置蘋果手機(jī)有一個(gè)可售庫存
(integer) 1
127.0.0.1:6379> hget iphone inStock #查看蘋果手機(jī)可售庫存為1
"1"
// 應(yīng)用線程一扣減庫存,訂單號a100,jedis開啟pipeline
127.0.0.1:6379> set a100_iphone "1" NX EX 10 #通過訂單號和商品防重碼
OK
127.0.0.1:6379> hincrby iphone inStock -1 #賣出扣減一個(gè),返回剩余0,下單成功
(integer) 0
//結(jié)束pipeline,執(zhí)行結(jié)果OK和0會(huì)一起返回
復(fù)制代碼
防止并發(fā)扣減后校驗(yàn):為了防止并發(fā)扣減,需要對Redis的hincrby命令返回值是否為負(fù)數(shù),來判斷是否發(fā)生高并發(fā)超賣,如果扣減后的結(jié)果為負(fù)數(shù),需要反向執(zhí)行hincrby,把數(shù)據(jù)進(jìn)行加回。
如果調(diào)用中發(fā)生網(wǎng)絡(luò)抖動(dòng),調(diào)用Redis超時(shí),應(yīng)用不知道操作結(jié)果,可以通過get命令來查看防重碼是否存在來判斷是否扣減成功。
127.0.0.1:6379> get a100_iphone #扣減成功
"1"
127.0.0.1:6379> get a100_iphone #扣減失敗
(nil)
復(fù)制代碼
單向保證
在很多場景中,因?yàn)闆]有使用事務(wù),你很那做到不超賣,并且不少賣,所以在極端情況下,我的抉擇是選擇不超賣,但有可能少賣。當(dāng)然還是應(yīng)該盡量保證數(shù)據(jù)準(zhǔn)確,不超賣,也不少賣;不能完全保證的前提下,選擇不超賣單向保證,也要通過手段來盡可能減少少賣的概率。
比如如果扣減Redis過程中,命令編排是先設(shè)置防重碼,再執(zhí)行扣減命令失敗;如果執(zhí)行過程網(wǎng)絡(luò)抖動(dòng)可能放重碼成功,而扣減失敗,重試的時(shí)候就會(huì)認(rèn)為已經(jīng)成功,造成超賣,所以上面的命令順序是錯(cuò)誤的,正確寫法應(yīng)該是:
如果是扣減庫存,順序?yàn)椋?.扣減庫存 2.寫入放重碼。
如果是回滾庫存,順序?yàn)?1.寫入放重碼 2.扣減庫存。
為什么使用PiPeline
在上面命令中,我們使用了Redis的Pipeline,來看下Pipeline的原理。
非pipeline模式 request–>執(zhí)行 –>response request–>執(zhí)行 –>response pipeline模式 request–>執(zhí)行 server將響應(yīng)結(jié)果隊(duì)列化 request–>執(zhí)行 server將響應(yīng)結(jié)果隊(duì)列化 –>response –>response
使用Pipeline,能盡量保證多條命令返回結(jié)果的完整性,讀者可以考慮使用Redis事務(wù)來代替Pipeline,實(shí)際項(xiàng)目中,個(gè)人有過Pipeline的成功抗量經(jīng)驗(yàn),并沒有使用Redis事務(wù),正常情況下事務(wù)比pipeline慢一些,所以沒有采用。
Redis事務(wù) 1)mutil:開啟事務(wù),此后的所有操作將被添加到當(dāng)前鏈接事務(wù)的“操作隊(duì)列”中 2)exec:提交事務(wù) 3)discard:取消隊(duì)列執(zhí)行 4)watch:如果watch的key被修改,觸發(fā)dicard。
通過任務(wù)引擎實(shí)現(xiàn)數(shù)據(jù)庫的最終一致性
前面通過任務(wù)引擎來保證數(shù)據(jù)一定持久化數(shù)據(jù)庫,「任務(wù)引擎」的設(shè)計(jì)如下,我們把任務(wù)調(diào)度抽象為業(yè)務(wù)無關(guān)的框架。「任務(wù)引擎」可以支持簡單的流程編排,并保證至少成功一次。「任務(wù)引擎」也可以作為狀態(tài)機(jī)的引擎出現(xiàn),支持狀態(tài)機(jī)的調(diào)度,所以「任務(wù)引擎」也可以稱為「狀態(tài)機(jī)引擎」,在此文是同一個(gè)概念。
**任務(wù)引擎設(shè)計(jì)核心原理:**先把任務(wù)落庫,通過數(shù)據(jù)庫事務(wù)保證子任務(wù)拆分和父任務(wù)完成的事務(wù)一致性。
**任務(wù)庫分庫分表:**任務(wù)庫使用分庫分表,可以支撐水平擴(kuò)展,通過設(shè)計(jì)分庫字段和業(yè)務(wù)庫字段不同,無數(shù)據(jù)熱點(diǎn)。
任務(wù)引擎的核心處理流程:

**第一步:**同步調(diào)用提交任務(wù),先把任務(wù)持久化到數(shù)據(jù)庫,狀態(tài)為「鎖定處理」,保證這件事一定得到處理。
注:原來的最初版本,任務(wù)落庫是待處理,然后由掃描Worker進(jìn)行掃描,為了防止并發(fā)重復(fù)處理,掃描后進(jìn)行單個(gè)任務(wù)鎖定,鎖定成功再進(jìn)行處理。后來優(yōu)化為落庫任務(wù)直接標(biāo)識(shí)狀態(tài)為「鎖定處理」,是為了性能考慮,省去重新掃描再搶占任務(wù),在進(jìn)程內(nèi)直接通過線程異步處理。
鎖定Sql參考:
UPDATE 任務(wù)表_分表號 SET 狀態(tài) = 100,modifyTime = now() WHERE id = #{id} AND 狀態(tài) = 0
復(fù)制代碼
**第二步:**異步線程調(diào)用外部處理過程,調(diào)用外部處理完成后,接收返回子任務(wù)列表。通過數(shù)據(jù)庫事務(wù)把父任務(wù)狀態(tài)設(shè)置為已經(jīng)完成,子任務(wù)落庫。并把子任務(wù)加入線程池。
要點(diǎn):保證子任務(wù)生成和父任務(wù)完成的事務(wù)性
**第三步:**子任務(wù)調(diào)度執(zhí)行,并重新把新子任務(wù)落庫,如果沒有子任務(wù)返回,則整個(gè)流程結(jié)束。
異常處理Worker
異常解鎖Worker來把長時(shí)間未處理完成的任務(wù)解鎖,防止因?yàn)榉?wù)器重啟,或線程池滿造成的任務(wù)一直鎖定無服務(wù)器執(zhí)行。
補(bǔ)漏Worker防止服務(wù)器重啟造成的線程池任務(wù)未執(zhí)行完成,補(bǔ)漏程序重新鎖定,觸發(fā)執(zhí)行。
任務(wù)狀態(tài)轉(zhuǎn)換過程

任務(wù)引擎數(shù)據(jù)庫設(shè)計(jì)
任務(wù)表數(shù)據(jù)庫結(jié)構(gòu)設(shè)計(jì)示例(僅做示例使用,真實(shí)使用需要完善)
字段 |
類型 |
說明 |
任務(wù)ID標(biāo)識(shí) |
Long |
主鍵 |
狀態(tài) |
Int |
0待處理,100鎖定處理,1完成 |
數(shù)據(jù) |
String |
Json格式的業(yè)務(wù)數(shù)據(jù) |
執(zhí)行時(shí)間 |
Date |
執(zhí)行時(shí)間 |
任務(wù)引擎數(shù)據(jù)庫容災(zāi):
任務(wù)庫使用分庫分表,當(dāng)一個(gè)庫宕機(jī),可以把路由到宕機(jī)庫的流量重新散列到其他存活庫中,可以手工配置,或通過系統(tǒng)監(jiān)控來自動(dòng)化容災(zāi)。如下圖,當(dāng)任務(wù)庫2宕機(jī)后,可以通過修改配置,把任務(wù)庫2流量路由到任務(wù)庫1和3。補(bǔ)漏引擎繼續(xù)掃描任務(wù)庫2是因?yàn)楫?dāng)任務(wù)庫2通過主從容災(zāi)恢復(fù)后,任務(wù)庫2宕機(jī)時(shí)未來的及處理的任務(wù)可以得到補(bǔ)充處理。
任務(wù)引擎調(diào)度舉例
比如用戶購買了兩個(gè)手機(jī)和一個(gè)電腦,手機(jī)和電腦分散在兩個(gè)數(shù)據(jù)庫,通過任務(wù)引擎先持久化任務(wù),然后驅(qū)動(dòng)拆分為兩個(gè)子任務(wù),并最終保證兩個(gè)子任務(wù)一定成功,實(shí)現(xiàn)數(shù)據(jù)的最終一致性。整個(gè)執(zhí)行過程的任務(wù)編排如下:

任務(wù)引擎交互流程:

差異對比-異構(gòu)數(shù)據(jù)的終極解決方案
只要有異構(gòu),一定會(huì)有差異的,為了保證差異的影響可控,終極方案還是要靠差異對比來解決。本文篇幅所限,不再展開,后續(xù)再單獨(dú)成文。DB和Redis差異對比的大概過程為:接收庫存變化消息,不斷跟進(jìn)對比Redis和DB的數(shù)據(jù)是否一致,如果連續(xù)穩(wěn)定不一致,則進(jìn)行數(shù)據(jù)修復(fù),用DB數(shù)據(jù)來修改Redis的數(shù)據(jù)。
聲明:本文由網(wǎng)站用戶香香發(fā)表,超夢電商平臺(tái)僅提供信息存儲(chǔ)服務(wù),版權(quán)歸原作者所有。若發(fā)現(xiàn)本站文章存在版權(quán)問題,如發(fā)現(xiàn)文章、圖片等侵權(quán)行為,請聯(lián)系我們刪除。