高并發(fā)業(yè)務(wù)下的庫(kù)存扣減方案
扣減庫(kù)存需要查詢(xún)庫(kù)存是否足夠:
- 足夠就占用庫(kù)存
- 不夠則返回庫(kù)存不足(這里不區(qū)分庫(kù)存可用、占用、已消耗等狀態(tài),統(tǒng)一成扣減庫(kù)存數(shù)量,簡(jiǎn)化場(chǎng)景)
并發(fā)場(chǎng)景,若 查詢(xún)庫(kù)存和扣減庫(kù)存不具備原子性,就可能超賣(mài),而高并發(fā)場(chǎng)景超賣(mài)概率會(huì)增高,超賣(mài)數(shù)額也會(huì)增高。處理超賣(mài)的確麻煩:
- 系統(tǒng)全鏈路刷數(shù)會(huì)很麻煩(多團(tuán)隊(duì)協(xié)作),客服外呼也有額外成本
- 最主要原因,客戶(hù)搶到訂單又被取消,嚴(yán)重影響客戶(hù)體驗(yàn),甚至引發(fā)客訴產(chǎn)生公關(guān)危機(jī)
實(shí)現(xiàn)邏輯
常用方案redis+lua,借助redis單線程執(zhí)行+lua腳本中的邏輯,可在一次執(zhí)行中順序完成的特性達(dá)到原子性(叫排它性更準(zhǔn)確,因?yàn)椴痪邆浠貪L動(dòng)作,異常情況需自己手動(dòng)編碼回滾)。
lua腳本基本實(shí)現(xiàn)
圖片
-- 1. 獲取庫(kù)存緩存key KYES[1] = hot_{itemCode-skuCode}_stock
local hot_item_stock = KYES[1]
-- 2. 獲取剩余庫(kù)存數(shù)量
local stock = tonumber(redis.call('get', hot_item_stock))
-- 3. 購(gòu)買(mǎi)數(shù)量
local buy_qty = tonumber(ARGV[1])
-- 4. 如果庫(kù)存小于購(gòu)買(mǎi)數(shù)量,則返回1,表達(dá)庫(kù)存不足
if stock < buy_qty then
return 1
end
-- 5. 庫(kù)存足夠,更新庫(kù)存數(shù)量
stock = stock - buy_qty
redis.call('set', hot_item_stock, tostring(stock))
-- 6. 扣減成功則返回2,表達(dá)庫(kù)存扣減成功
return 2
但腳本還有一些問(wèn)題:
- 不具備冪等性,同個(gè)訂單多次執(zhí)行會(huì)導(dǎo)致重復(fù)扣減,手動(dòng)回滾也無(wú)法判斷是否會(huì)回滾過(guò),會(huì)出現(xiàn)重復(fù)增加的問(wèn)題
- 不具備可追溯性,不知道庫(kù)存被誰(shuí)被哪個(gè)訂單扣減了
增強(qiáng)后的lua腳本:
-- 1. 獲取庫(kù)存扣減記錄緩存 key KYES[2] = hot_{itemCode-skuCode}_deduction_history
local hot_deduction_history = KYES[2]
-- 2. 使用 Redis Cluster hash tag 保證 stock 和 history 在同一個(gè)槽
local exist = redis.call('hexists', hot_deduction_history, ARGV[2])
-- 3. 請(qǐng)求冪等判斷,存在返回0,表達(dá)已扣減過(guò)庫(kù)存
if exist == 1 then return 0 end
-- 4. 獲取庫(kù)存緩存key KYES[1] = hot_{itemCode-skuCode}_stock
local hot_item_stock = KYES[1]
-- 5. 獲取剩余庫(kù)存數(shù)量
local stock = tonumber(redis.call('get', hot_item_stock))
-- 6. 購(gòu)買(mǎi)數(shù)量
local buy_qty = tonumber(ARGV[1])
-- 7. 如果庫(kù)存小于購(gòu)買(mǎi)數(shù)量 則返回1,表達(dá)庫(kù)存不足
if stock < buy_qty then return 1 end
-- 8. 庫(kù)存足夠
-- 9. 1.更新庫(kù)存數(shù)量
-- 10. 2.插入扣減記錄 ARGV[2] = ${扣減請(qǐng)求唯一key} - ${扣減類(lèi)型} 值為 buy_qty
stock = stock - buy_qty
redis.call('set', hot_item_stock, tostring(stock))
redis.call('hset', hot_deduction_history, ARGV[2], buy_qty)
-- 11. 如果剩余庫(kù)存等于0則返回2,表達(dá)庫(kù)存已為0
if stock == 0 then return 2 end
-- 12. 剩余庫(kù)存不為0返回 3 表達(dá)還有剩余庫(kù)存
return 3 end
利用Redis Cluster hash tag保證stock和history在同個(gè)槽,這樣lua腳本才能正常執(zhí)行。
★
因?yàn)檎R?Lua 腳本操作的鍵必須在同一個(gè) slot 中。
@Override
public <T, R> RFuture<R> evalReadAsync(String key, Codec codec, RedisCommand<T> evalCommandType, String script, List<Object> keys, Object... params) {
NodeSource source = getNodeSource(key);
return evalAsync(source, true, codec, evalCommandType, script, keys, false, params);
}
private NodeSource getNodeSource(String key) {
int slot = connectionManager.calcSlot(key);
return new NodeSource(slot);
}”
利用hot_deduction_history,判斷扣減請(qǐng)求是否執(zhí)行過(guò),以實(shí)現(xiàn)冪等性。
借助hot_deduction_history的V值判斷追溯扣減來(lái)源,如:用戶(hù)A的交易訂單A的扣減請(qǐng)求,或用戶(hù)B的借出單B的扣減請(qǐng)求。
回滾邏輯先判斷hot_deduction_history里有沒(méi)有 ${扣減請(qǐng)求唯一key}:
- 有,則執(zhí)行回補(bǔ)邏輯
- 沒(méi)有,則認(rèn)定回補(bǔ)成功
但該邏輯依舊有漏洞,如(消息亂序消費(fèi)),訂單扣減庫(kù)存超時(shí)成功觸發(fā)了重新扣減庫(kù)存,但同時(shí)訂單取消觸發(fā)了庫(kù)存扣減回滾,回滾邏輯先成功,超時(shí)成功的重新扣減庫(kù)存就會(huì)成為臟數(shù)據(jù)留在redis里。
處理方案
有兩種:
- 追加對(duì)賬,定期校驗(yàn)hot_deduction_history中數(shù)據(jù)對(duì)應(yīng)單據(jù)的狀態(tài),對(duì)于已經(jīng)取消的單據(jù)追加一次回滾請(qǐng)求,存在時(shí)延(業(yè)務(wù)不一定接受)以及額外計(jì)算資源開(kāi)銷(xiāo)
- 使用順序消息,讓扣減庫(kù)存、回滾庫(kù)存都走同一個(gè)MQ topic的有序隊(duì)列,借助MQ消息的有序性保證回滾動(dòng)作一定在扣減動(dòng)作后面執(zhí)行,但有序串行必然帶來(lái)性能下降
高可用
Redis終究是內(nèi)存,一旦服務(wù)中斷,數(shù)據(jù)就消失。所以需要追加保護(hù)數(shù)據(jù)不丟失的方案。
運(yùn)用Redis部署的高可用方案:
- 采用Redis Cluster(數(shù)據(jù)分片+ 多副本 + 同步多寫(xiě) + 主從自動(dòng)選舉)
- 多寫(xiě)節(jié)點(diǎn)分(同城異地)多中心防止意外災(zāi)害
定期歸檔冷數(shù)據(jù)。定期 + 庫(kù)存為0觸發(fā)redis數(shù)據(jù)往DB同步,流程如下:
圖片
CDC分發(fā)數(shù)據(jù)時(shí),秒殺商品,hot_deduction_history的數(shù)據(jù)量不高,可以一次全量同步。但如果是普通大促商品,就需要再追加一個(gè)map動(dòng)作分批處理,以保證每次執(zhí)行CDC的數(shù)據(jù)量恒定,不至于一次性數(shù)據(jù)量太大出現(xiàn)OOM。代碼如下:
/**
* 對(duì)任務(wù)做分發(fā)
* @param stockKey 目標(biāo)庫(kù)存的key值
*/
public void distribute(String stockKey) {
final String historyKey = StrUtil.format("hot_{}_deduction_history", stockKey);
// 獲取指定庫(kù)存key 所有扣減記錄的key(生產(chǎn)請(qǐng)分頁(yè)獲取,防止數(shù)據(jù)量太多)
final List<String> keys = RedisUtil.hkeys(historyKey, stockKey);
// 以 100 為大小,分片所有記錄key
final List<List<String>> splitKeys = CollUtil.split(keys, 100);
// 將集合分發(fā)給各個(gè)節(jié)點(diǎn)執(zhí)行
map(historyKey, splitKeys);
}
/**
* 對(duì)單頁(yè)任務(wù)做執(zhí)行
* @param historyKey 目標(biāo)庫(kù)存的key值
* @param stockKeys 要執(zhí)行的頁(yè)面大小
*/
public void mapExec(String historyKey, List<String> stockKeys) {
// 獲取指定庫(kù)存key 指定扣減記錄 的map
final Map<String, String> keys = RedisUtil.HmgetToMap(historyKey, stockKeys);
keys.entrySet()
.stream()
.map(stockRecordFactory::of)
.forEach(stockRecord -> {
// (冪等 + 去重) 扣減 + 保存記錄
stockConsumer.exec(stockRecord);
// 刪除redis中的 key 釋放空間
RedisUtil.hdel(historyKey, stockRecord.getRecordRedisKey());
});
}
為啥不走DB
商品庫(kù)存數(shù)據(jù)在DB最終會(huì)落到單庫(kù)單表的一行數(shù)據(jù)。無(wú)法通過(guò)分庫(kù)分表提高請(qǐng)求的并行度。而在單節(jié)點(diǎn)場(chǎng)景,數(shù)據(jù)庫(kù)吞吐遠(yuǎn)不如Redis。最基礎(chǔ)的原因:IO效率不是一個(gè)量級(jí),DB是磁盤(pán)操作,而且還可能要多次讀盤(pán),Redis是一步到位的內(nèi)存操作。
同時(shí),一般DB都是提交讀隔離級(jí)別,為保證原子性,執(zhí)行庫(kù)存扣減,得加鎖,無(wú)論悲觀樂(lè)觀。不僅性能差(搶不到鎖要等待),而且因?yàn)榉枪礁?jìng)爭(zhēng),易出現(xiàn)線程饑餓。而redis是單線程操作,不存在共享變量競(jìng)爭(zhēng)。
有些優(yōu)化思路,如合并扣減,走批降低請(qǐng)求的并行連接數(shù)。但伴隨的集單的時(shí)延,以及按庫(kù)分批的訴求;還有拆庫(kù)存行,商品A100個(gè)庫(kù)存拆成2行商品A50庫(kù)存,然后扣減時(shí)分發(fā)請(qǐng)求,以提高并行連接數(shù)(多行可落在不同庫(kù)來(lái)提高并行連接數(shù))。但伴隨的:
- 復(fù)雜的庫(kù)存行拆分管理(把什么庫(kù)存行在什么時(shí)候拆分到哪些庫(kù))
- 部分庫(kù)存行超賣(mài)的問(wèn)題(加鎖優(yōu)化就又串行了,不加總量還有庫(kù)存,個(gè)別庫(kù)存行不足是允許一定系數(shù)超賣(mài)還是返回庫(kù)存不足就是一個(gè)要決策的問(wèn)題)
部分頭部電商采用弱緩存抗讀(非庫(kù)存不足,不實(shí)時(shí)更新),DB抗寫(xiě)的方案。該方案前提在于,通過(guò)一系列技術(shù)方案,流量落到庫(kù)存已相對(duì)低且平滑了(扛得住,不用再自己實(shí)現(xiàn)操作原子性)。
作者簡(jiǎn)介:魔都架構(gòu)師,多家大廠后端一線研發(fā)經(jīng)驗(yàn),在分布式系統(tǒng)設(shè)計(jì)、數(shù)據(jù)平臺(tái)架構(gòu)和AI應(yīng)用開(kāi)發(fā)等領(lǐng)域都有豐富實(shí)踐經(jīng)驗(yàn)。
各大技術(shù)社區(qū)頭部專(zhuān)家博主。具有豐富的引領(lǐng)團(tuán)隊(duì)經(jīng)驗(yàn),深厚業(yè)務(wù)架構(gòu)和解決方案的積累。
負(fù)責(zé):
- 中央/分銷(xiāo)預(yù)訂系統(tǒng)性能優(yōu)化
- 活動(dòng)&券等營(yíng)銷(xiāo)中臺(tái)建設(shè)
- 交易平臺(tái)及數(shù)據(jù)中臺(tái)等架構(gòu)和開(kāi)發(fā)設(shè)計(jì)
- 車(chē)聯(lián)網(wǎng)核心平臺(tái)-物聯(lián)網(wǎng)連接平臺(tái)、大數(shù)據(jù)平臺(tái)架構(gòu)設(shè)計(jì)及優(yōu)化
- LLM Agent應(yīng)用開(kāi)發(fā)
- 區(qū)塊鏈應(yīng)用開(kāi)發(fā)
- 大數(shù)據(jù)開(kāi)發(fā)挖掘經(jīng)驗(yàn)
- 推薦系統(tǒng)項(xiàng)目
目前主攻市級(jí)軟件項(xiàng)目設(shè)計(jì)、構(gòu)建服務(wù)全社會(huì)的應(yīng)用系統(tǒng)。
參考:編程嚴(yán)選網(wǎng)