原文:Redis架構實戰:高並發情況下並發扣減庫存 - 掘金
https://juejin.cn/post/6995481370269057032?share_token=08d1ede7-872f-40ab-bb59-2d455aa57131
相信大家從網上學習項目大部分人第一個項目都是電商,生活中時時刻刻也會用到電商APP,例如淘寶,京東等。做技術的人都知道,電商的業務邏輯簡單,但是大部分電商都會涉及到高並發高可用,對並發和對數據的處理要求是很高的。這里我今天就講一下高並發情況下是如何扣減庫存的?
我們對扣減庫存所需要關注的技術點如下:
- 當前剩余的數量大於等於當前需要扣減的數量,不允許超賣
- 對於同一個數據的數量存在用戶並發扣減,需要保證並發的一致性
- 需要保證可用性和性能,性能至少是秒級
- 一次的扣減包含多個目標數量
- 當次扣減有多個數量時,其中一個扣減不成功即不成功,需要回滾
- 必須有扣減才能有歸還
- 返還的數量必須要加回,不能丟失
- 一次扣減可以有多次返還
- 返還需要保證冪等性
第一種方案:純MySQL扣減實現
顧名思義,就是扣減業務完全依賴MySQL等數據庫來完成。而不依賴一些其他的中間件或者緩存。純數據庫實現的好處就是邏輯簡單,開發以及部署成本低。(適用於中小型電商)。
純數據庫的實現之所以能夠滿足扣減業務的各項功能要求,主要依賴兩點:
- 基於數據庫的樂觀鎖方式保證並發扣減的強一致性
- 基於數據庫的事務實現批量扣減失敗進行回滾
基於上述方案,它包含一個扣減服務和一個數量數據庫
如果數據量單庫壓力很大,也可以做主從和分庫分表,服務可以做集群等。
一次完整的流程就是先進行數據校驗,在其中做一些參數格式校驗,這里做接口開發的時候,要保持一個原則就是不信任原則,一切數據都不要相信,都需要做校驗判斷。其次,還可以進行庫存扣減的前置校驗。比如當前庫存中的庫存只有8個,而用戶要購買10個,此時的數據校驗中即可前置攔截,減少對於數據庫的寫操作。純讀不會加鎖,性能較高,可以采用此種方式提升並發量。
update xxx set leavedAmount=leavedAmount-currentAmount where skuid='xxx' and leavedAmount>=currentAmount 復制代碼
此SQL采用了類似樂觀鎖的方式實現了原子性。在where后面判斷剩余數量大於等於需要的數量,才能成功,否則失敗。
扣減完成之后,需要記錄流水數據。每一次扣減的時候,都需要外部用戶傳入一個uuid作為流水編號,此編號是全局唯一的。用戶在扣減時傳入唯一的編號有兩個作用:
- 當用戶歸還數量時,需要帶回此編碼,用來標識此次返還屬於歷史上的哪次扣減。
- 進行冪等性控制。當用戶調用扣減接口出現超時時,因為用戶不知道是否成功,用戶可以采用此編號進行重試或反查。在重試時,使用此編號進行標識防重
當用戶只購買某個商品一個的時候,如果校驗時剩余庫存有8個,此時校驗通過。但在后續的實際扣減時,因為其他用戶也在並發的扣減,可能會出現幻讀,此時用戶實際去扣減時不足一個,導致失敗。這種場景會導致多一次數據庫查詢,降低整體的扣減性能。這時候可以對MySQL架構進行升級
MySQL架構升級
多一次查詢,就會增加數據庫的壓力,同時對整體性能也有一定的影響。此外,對外提供的查詢庫存數量的接口也會對數據庫產生壓力,同時讀的請求要遠大於寫。
根據業務場景分析,讀庫存的請求一般是顧客瀏覽商品時產生,而調用扣減庫存的請求基本上是用戶購買時才觸發。用戶購買請求的業務價值比讀請求會更大,因此對於寫需要重點保障。針對上述的問題,可以對MySQL整體架構進行升級
整體的升級策略采用讀寫分離的方式,另外主從復制直接使用MySQL等數據庫已有的功能,改動上非常小,只要在扣減服務里配置兩個數據源。當客戶查詢剩余庫存,扣減服務中的前置校驗時,讀取從數據庫即可。而真正的數據扣減還是使用主數據庫。
讀寫分離之后,根據二八原則,80% 的均為讀流量,主庫的壓力降低了 80%。但采用了讀寫分離也會導致讀取的數據不准確的問題,不過庫存數量本身就在實時變化,短暫的差異業務上是可以容忍的,最終的實際扣減會保證數據的准確性。
在上面基礎上,還可以升級,增加緩存
純數據庫的方案雖然可以避免超賣和少賣的情況,但是並發量實在很低,性能不是很樂觀。所以這里再進行升級
第二種方案:緩存實現扣減
這和前面的扣減庫存其實是一樣的。但是此時扣減服務依賴的是Redis而不是數據庫了。
這里針對Redis的hash結構不支持多個key的批量操作問題,我們可以采用Redis+lua腳本來實現批量扣減單線程請求。
升級成純Redis實現扣減也會有問題
- Redis掛了,如果還沒有執行到扣減Redis里面庫存的操作掛了,只需要返回給客戶端失敗即可。如果已經執行到Redis扣減庫存之后掛了。那這時候就需要有一個對賬程序。通過對比Redis與數據庫中的數據是否一致,並結合扣減服務的日志。當發現數據不一致同時日志記錄扣減失敗時,可以將數據庫比Redis多的庫存數據在Redis進行加回。
- Redis扣減完成,異步刷新數據庫失敗了。此時Redis里面的數據是准的,數據庫的庫存是多的。在結合扣減服務的日志確定是Redis扣減成功到但異步記錄數據失敗后,可以將數據庫比Redis多的庫存數據在數據庫中進行扣減。
雖然使用純Redis方案可以提高並發量,但是因為Redis不具備事務特性,極端情況下會存在Redis的數據無法回滾,導致出現少賣的情況。也可能發生異步寫庫失敗,導致多扣的數據再也無法找回的情況。
第三種方案:數據庫+緩存
順序寫的性能更好
在向磁盤進行數據操作時,向文件末尾不斷追加寫入的性能要遠大於隨機修改的性能。因為對於傳統的機械硬盤來說,每一次的隨機更新都需要機械鍵盤的磁頭在硬盤的盤面上進行尋址,再去更新目標數據,這種方式十分消耗性能。而向文件末尾追加寫入,每一次的寫入只需要磁頭一次尋址,將磁頭定位到文件末尾即可,后續的順序寫入不斷追加即可。
對於固態硬盤來說,雖然避免了磁頭移動,但依然存在一定的尋址過程。此外,對文件內容的隨機更新和數據庫的表更新比較類似,都存在加鎖帶來的性能消耗。
數據庫同樣是插入要比更新的性能好。對於數據庫的更新,為了保證對同一條數據並發更新的一致性,會在更新時增加鎖,但加鎖是十分消耗性能的。此外,對於沒有索引的更新條件,要想找到需要更新的那條數據,需要遍歷整張表,時間復雜度為 O(N)。而插入只在末尾進行追加,性能非常好。
順序寫的架構
通過上面的理論就可以得出一個兼具性能和高可靠的扣減架構
上述的架構和純緩存的架構區別在於,寫入數據庫不是異步寫入,而是在扣減的時候同步寫入。同步寫入數據庫使用的是insert操作,就是順序寫,而不是update做數據庫數量的修改,所以,性能會更好。
insert 的數據庫稱為任務庫,它只存儲每次扣減的原始數據,而不做真實扣減(即不進行 update)。它的表結構大致如下:
create table task{ id bigint not null comment "任務順序編號", task_id bigint not null } 復制代碼
任務表里存儲的內容格式可以為 JSON、XML 等結構化的數據。以 JSON 為例,數據內容大致可以如下:
{
"扣減號":uuid, "skuid1":"數量", "skuid2":"數量", "xxxx":"xxxx" } 復制代碼
這里我們肯定是還有一個記錄業務數據的庫,這里存儲的是真正的扣減名企和SKU的匯總數據。對於另一個庫里面的數據,只需要通過這個表進行異步同步就好了。
扣減流程
這里和純緩存的區別在於增加了事務開啟與回滾的步驟,以及同步的數據庫寫入流程
任務庫里存儲的是純文本的 JSON 數據,無法被直接使用。需要將其中的數據轉儲至實際的業務庫里。業務庫里會存儲兩類數據,一類是每次扣減的流水數據,它與任務表里的數據區別在於它是結構化,而不是 JSON 文本的大字段內容。另外一類是匯總數據,即每一個 SKU 當前總共有多少量,當前還剩余多少量(即從任務庫同步時需要進行扣減的),表結構大致如下:
create table 流水表{ id bigint not null, uuid bigint not null comment '扣減編號', sku_id bigint not null comment '商品編號', num int not null comment '當次扣減的數量' }comment '扣減流水表' 復制代碼
商品的實時數據匯總表,結構如下:
create table 匯總表{ id bitint not null, sku_id unsigned bigint not null comment '商品編號', total_num unsigned int not null comment '總數量', leaved_num unsigned int not null comment '當前剩余的商品數量' }comment '記錄表' 復制代碼
在整體的流程上,還是復用了上一講純緩存的架構流程。當新加入一個商品,或者對已有商品進行補貨時,對應的新增商品數量都會通過 Binlog 同步至緩存里。在扣減時,依然以緩存中的數量為准