高并發(fā)扣減庫存方案(秒殺扣減庫存)
發(fā)布日期:2022/9/7 10:37:15 瀏覽量:
背景
我們在做電商的時候,經(jīng)常遇到下單之后需要扣減庫存的業(yè)務(wù),那這個業(yè)務(wù)我們怎么來實現(xiàn)呢?
傳統(tǒng)的做法是:
- 用戶下單,執(zhí)行下單服務(wù);
- 同時,扣減庫存;
如果是并發(fā)較高的場景,為了保證可用和性能,那么采用二階段事務(wù)的方式,或者消息隊列的方式,都可以實現(xiàn)。
但是,多個線程同時執(zhí)行下單服務(wù),庫存服務(wù),這樣,會出現(xiàn)什么問題呢?
由于扣減服務(wù)中的邏輯并不是原子操作,必然會出現(xiàn)數(shù)據(jù)不一致的情況,比如,當前庫存數(shù)量為 1,此時多個線程同時校驗庫存符合預(yù)期,但結(jié)果庫存為 1 滿足了多個線程,顯然是不符合預(yù)期的,這樣,就會導(dǎo)致庫存不足的情況。
我們對扣減庫存所需要關(guān)注的技術(shù)點如下:
- 當前剩余的數(shù)量大于等于當前需要扣減的數(shù)量,不允許超賣;
- 對于同一個數(shù)據(jù)的數(shù)量存在用戶并發(fā)扣減,需要保證并發(fā)的一致性;
- 需要保證可用性和性能,性能至少是秒級;
- 一次的扣減包含多個目標數(shù)量;
- 當次扣減有多個數(shù)量時,其中一個扣減不成功即不成功,需要回滾;
- 必須有扣減才能有歸還;
- 返還的數(shù)量必須要加回,不能丟失;
- 一次扣減可以有多次返還;
- 返還需要保證冪等性;
顧名思義,就是扣減業(yè)務(wù)完全依賴 MySQL 等數(shù)據(jù)庫來完成。而不依賴一些其他的中間件或者緩存。純數(shù)據(jù)庫實現(xiàn)的好處就是邏輯簡單,開發(fā)以及部署成本低。(適用于中小型電商)。
純數(shù)據(jù)庫的實現(xiàn)之所以能夠滿足扣減業(yè)務(wù)的各項功能要求,主要依賴兩點:
- 基于數(shù)據(jù)庫的樂觀鎖方式保證并發(fā)扣減的強一致性;
- 基于數(shù)據(jù)庫的事務(wù)實現(xiàn)批量扣減失敗進行回滾。
流程圖如下:
有一句話說的非常好:一次完整的流程就是先進行數(shù)據(jù)校驗,在其中做一些參數(shù)格式校驗,這里做接口開發(fā)的時候,要保持一個原則就是不信任原則,一切數(shù)據(jù)都不要相信,都需要做校驗判斷。
1
update xxx set leavedAmount=leavedAmount-currentAmount where skuid=’xxx’ and leavedAmount>=currentAmount;
此 SQL 采用了類似樂觀鎖的方式實現(xiàn)了原子性。在 where 后面判斷剩余數(shù)量大于等于需要的數(shù)量,才能成功,否則失敗。
扣減完成之后,需要記錄流水數(shù)據(jù)。每一次扣減的時候,都需要外部用戶傳入一個 uuid 作為流水編號,此編號是全局唯一的。用戶在扣減時傳入唯一的編號有兩個作用:
- 當用戶歸還數(shù)量時,需要帶回此編碼,用來標識此次返還屬于歷史上的哪次扣減
- 進行冪等性控制。當用戶調(diào)用扣減接口出現(xiàn)超時時,因為用戶不知道是否成功,用戶可以采用此編號進行重試或反查,在重試時,使用此編號進行標識防重;
庫存校驗這一步涉及到了讀請求,那么必然增加了數(shù)據(jù)庫的壓力,所以需要做一些優(yōu)化。
- 讀寫分離;
整體的升級策略采用讀寫分離的方式,另外主從復(fù)制直接使用 MySQL 等數(shù)據(jù)庫已有的功能,改動上非常小,只要在扣減服務(wù)里配置兩個數(shù)據(jù)源。當客戶查詢剩余庫存,扣減服務(wù)中的前置校驗時,讀取從數(shù)據(jù)庫即可。而真正的數(shù)據(jù)扣減還是使用主數(shù)據(jù)庫。
讀寫分離之后,根據(jù)二八原則,80% 的均為讀流量,主庫的壓力降低了 80%。但采用了讀寫分離也會導(dǎo)致讀取的數(shù)據(jù)不準確的問題,不過庫存數(shù)量本身就在實時變化,短暫的差異業(yè)務(wù)上是可以容忍的,最終的實際扣減會保證數(shù)據(jù)的準確性。
- 增加緩存;
在上面基礎(chǔ)上,還可以升級,增加緩存:
緩存實現(xiàn)扣減
這和前面的扣減庫存其實是一樣的。但是此時扣減服務(wù)依賴的是 Redis 而不是數(shù)據(jù)庫了。
這里針對 Redis 的 hash 結(jié)構(gòu)不支持多個 key 的批量操作問題,我們可以采用Redis+lua 腳本來實現(xiàn)批量扣減單線程請求。
但,升級成純 Redis 實現(xiàn)扣減也會有問題:
- Redis 掛了:如果還沒有執(zhí)行到扣減 Redis 里面庫存的操作掛了,只需要返回給客戶端失敗即可。如果已經(jīng)執(zhí)行到 Redis 扣減庫存之后掛了,那這時候就需要有一個對賬程序。通過對比 Redis 與數(shù)據(jù)庫中的數(shù)據(jù)是否一致,并結(jié)合扣減服務(wù)的日志。當發(fā)現(xiàn)數(shù)據(jù)不一致同時日志記錄扣減失敗時,可以將數(shù)據(jù)庫比 Redis 多的庫存數(shù)據(jù)在Redis 進行加回
- Redis 扣減完成,異步刷新數(shù)據(jù)庫失敗了。此時 Redis 里面的數(shù)據(jù)是準的,數(shù)據(jù)庫的庫存是多的。在結(jié)合扣減服務(wù)的日志確定是 Redis 扣減成功到但異步記錄數(shù)據(jù)失敗后,可以將數(shù)據(jù)庫比 Redis 多的庫存數(shù)據(jù)在數(shù)據(jù)庫中進行扣減
雖然使用純 Redis 方案可以提高并發(fā)量,但是因為 Redis 不具備事務(wù)特性,極端情況下會存在 Redis 的數(shù)據(jù)無法回滾,導(dǎo)致出現(xiàn)少賣的情況。也可能發(fā)生異步寫庫失敗,導(dǎo)致多扣的數(shù)據(jù)再也無法找回的情況。
數(shù)據(jù)庫+緩存
在此之前,先闡述一下順序?qū)懙男阅茌^好。
在向磁盤進行數(shù)據(jù)操作時,向文件末尾不斷追加寫入的性能要遠大于隨機修改的性能。因為對于傳統(tǒng)的機械硬盤來說,每一次的隨機更新都需要機械鍵盤的磁頭在硬盤的盤面上進行尋址,再去更新目標數(shù)據(jù),這種方式十分消耗性能。而向文件末尾追加寫入,每一次的寫入只需要磁頭一次尋址,將磁頭定位到文件末尾即可,后續(xù)的順序?qū)懭氩粩嘧芳蛹纯伞?
對于固態(tài)硬盤來說,雖然避免了磁頭移動,但依然存在一定的尋址過程。此外,對文件內(nèi)容的隨機更新和數(shù)據(jù)庫的表更新比較類似,都存在加鎖帶來的性能消耗。
數(shù)據(jù)庫同樣是插入要比更新的性能好。對于數(shù)據(jù)庫的更新,為了保證對同一條數(shù)據(jù)并發(fā)更新的一致性,會在更新時增加鎖,但加鎖是十分消耗性能的。此外,對于沒有索引的更新條件,要想找到需要更新的那條數(shù)據(jù),需要遍歷整張表,時間復(fù)雜度為 O(N)。而插入只在末尾進行追加,性能非常好。
上述的架構(gòu)和純緩存的架構(gòu)區(qū)別在于,寫入數(shù)據(jù)庫不是異步寫入,而是在扣減的時候同步寫入。同步寫入數(shù)據(jù)庫使用的是 insert 操作,就是順序?qū)?/strong>,而不是 update 做數(shù)據(jù)庫數(shù)量的修改,所以,性能會更好。
insert 的數(shù)據(jù)庫稱為任務(wù)庫,它只存儲每次扣減的原始數(shù)據(jù),而不做真實扣減(即不進行 update)。它的表結(jié)構(gòu)大致如下:
1
2
3
4
create table task{
id bigint not null comment "任務(wù)順序編號",
task_id bigint not null
}
任務(wù)表里存儲的內(nèi)容格式可以為 JSON、XML 等結(jié)構(gòu)化的數(shù)據(jù)。以 JSON 為例,數(shù)據(jù)內(nèi)容大致可以如下:
{
"扣減號":uuid,
"skuid1":"數(shù)量",
"skuid2":"數(shù)量",
"xxxx":"xxxx"
}
這里我們肯定是還有一個記錄業(yè)務(wù)數(shù)據(jù)的庫,這里存儲的是真正的扣減名企和 SKU 的匯總數(shù)據(jù)。對于另一個庫里面的數(shù)據(jù),只需要通過這個表進行異步同步就好了。
扣減流程:
這里和純緩存的區(qū)別在于增加了事務(wù)開啟與回滾的步驟,以及同步的數(shù)據(jù)庫寫入流程。
任務(wù)庫里存儲的是純文本的 JSON 數(shù)據(jù),無法被直接使用。需要將其中的數(shù)據(jù)轉(zhuǎn)儲至實際的業(yè)務(wù)庫里。業(yè)務(wù)庫里會存儲兩類數(shù)據(jù),一類是每次扣減的流水數(shù)據(jù),它與任務(wù)表里的數(shù)據(jù)區(qū)別在于它是結(jié)構(gòu)化,而不是 JSON 文本的大字段內(nèi)容。另外一類是匯總數(shù)據(jù),即每一個 SKU 當前總共有多少量,當前還剩余多少量(即從任務(wù)庫同步時需要進行扣減的),表結(jié)構(gòu)大致如下:
create table 流水表{
id bigint not null,
uuid bigint not null comment ’扣減編號’,
sku_id bigint not null comment ’商品編號’,
num int not null comment ’當次扣減的數(shù)量’
}comment ’扣減流水表’
商品的實時數(shù)據(jù)匯總表,結(jié)構(gòu)如下:
create table 匯總表{
id bitint not null,
sku_id unsigned bigint not null comment ’商品編號’,
total_num unsigned int not null comment ’總數(shù)量’,
leaved_num unsigned int not null comment ’當前剩余的商品數(shù)量’
}comment ’記錄表’
在整體的流程上,還是復(fù)用了上一講純緩存的架構(gòu)流程。當新加入一個商品,或者對已有商品進行補貨時,對應(yīng)的新增商品數(shù)量都會通過 Binlog 同步至緩存里。在扣減時,依然以緩存中的數(shù)量為準:
馬上咨詢: 如果您有業(yè)務(wù)方面的問題或者需求,歡迎您咨詢!我們帶來的不僅僅是技術(shù),還有行業(yè)經(jīng)驗積累。
QQ: 39764417/308460098 Phone: 13 9800 1 9844 / 135 6887 9550 聯(lián)系人:石先生/雷先生