關於訂單庫存扣減的最佳實踐

一懶眾衫小QAQ發表於2021-11-02

關於訂單庫存扣減的最佳實踐

一: 背景

​ 在電商的業務場景中每個商品都是有庫存的,而且可能存在很多限售的運營策略。我們團隊面臨社群電商的業務場景更為複雜。不僅僅是庫存限售,存在區域,門店,使用者,運營分組,物流等的限售策略。如何面對日單量千萬級別(未來更多),和多個維度的限售策略而不超賣,少賣是一個必須解決的問題。

​ 下面就是庫存扣減的流程圖。衝圖種我們可以看出,要保證整個扣減庫存不出問題,限購查詢和庫存的扣減必須是原子性的而且要單執行緒執行。

關於訂單庫存扣減的最佳實踐

​ 現在處理這種場景存在多種方案。但是要保證高效能和高可用,大部分方案並不滿足。

二:探索

1. 歷史資料庫的事務特性和唯一主鍵的實現原子操作和單執行緒操作

​ 基於資料庫的事務,扣減庫存的操作方法同一個事務中進行庫存扣減,事務中任何操作失敗,執行回滾操作。從而保證原子性。單純靠資料庫的事務,只能在單體的專案中。如何要分散式的專案中,就無法保證單執行緒操作了。

​ 那如何在多程式中實現單執行緒扣減庫存呢?我們可以利用資料庫的唯一索引。具體操作步驟:

  • 新建立一張表:t_lock_tbl,同時將商品ID作為唯一索引。
  • 進行扣減庫存之前在表中插入商品ID,然後進行資料庫更新。
  • 更新結束後上次剛才插入資料庫中的記錄,釋放鎖。

A執行緒程式扣減庫存時候,插入了該商品的id,當B執行緒扣減該商品的庫存的時候,同樣也會在資料庫中插入該商品ID,A執行緒沒有執行完B執行緒插入同一個商品ID就會報主鍵重複的錯誤,這樣就扣減庫存失敗。

這種方案,功能上是可以實現,但是過分依賴資料庫,無法滿足效能要求,而且存在很多獲取鎖失敗的情況,使用者體驗差。

2. 利用分散式鎖

​ Redis 或者 ZooKeeper 來實現一個分散式鎖,以商品維度來加鎖,在獲取到鎖的執行緒中,按順序去執行商品庫存的查詢和扣減,這樣就同時實現了順序性和原子性。

​ 其實這個思路是可以的,只是不管通過哪種方式實現的分散式鎖,都是有弊端的。以 Redis 的實現來說,僅僅在設定鎖的有效期問題上,就讓人頭大。如果時間太短,那麼業務程式還沒有執行完,鎖就自動釋放了,這就失去了鎖的作用;而如果時間偏長,一旦在釋放鎖的過程中出現異常,沒能及時地釋放,那麼所有的業務執行緒都得阻塞等待直到鎖自動失效,這樣可能導致CPU飆升,系統吞吐量下降。這與我們要實現高效能的系統是相悖的。所以通過分散式鎖的方式可以實現,但不建議使用。

3. Redis + lua 指令碼

​ reids,單執行緒支援順序操作,而且效能優異,但是不支援事務回滾。但是通過redis+lua指令碼可以實現redis操作的原子性。這種方案同時滿足順序性和原子性的要求了。

這裡能幫我們實現 Redis 執行 Lua 指令碼的命令有兩個,一個是 EVAL,另一個是 EVALSHA。

原生 EVAL 方法的使用語法如下:

EVAL script numkeys key [key ...] arg [arg ...]

​ 其中 EVAL 是命令,script 是我們 Lua 指令碼的字串形式,numkeys 是我們要傳入的引數數量,key 是我們的入參,可以傳入多個,arg 是額外的入參。但這種方式需要每次都傳入 Lua 指令碼字串,不僅浪費網路開銷,同時 Redis 需要每次重新編譯 Lua 指令碼,對於我們追求效能極限的系統來說,不是很完美。所以這裡就要說到另一個命令 EVALSHA 了,原生語法如下:

EVALSHA sha1 numkeys key [key ...] arg [arg ...]

​ 可以看到其語法與 EVAL 類似,不同的是這裡傳入的不是指令碼字串,而是一個加密串 sha1。這個 sha1 是從哪來的呢?它是通過另一個命令 SCRIPT LOAD 返回的,該命令是預載入指令碼用的,語法為:

SCRIPT LOAD script

​ 這樣的話,我們通過預載入命令,將 Lua 指令碼先儲存在 Redis 中,並返回一個 sha1,下次要執行對應指令碼時,只需要傳入 sha1 即可執行對應的指令碼。這完美地解決了 EVAL 命令存在的弊端,所以我們這裡也是基於 EVALSHA 方式來實現的。既然有了思路,也有了方案,那我們開始用程式碼實現它吧。首先我們根據以上介紹的庫存扣減核心操作,完成核心 Lua 指令碼的編寫。其主要實現的功能就是查詢庫存並判斷庫存是否充足,如果充足,則做相應的扣減操作,指令碼內容如下:


-- 呼叫Redis的get指令,查詢活動庫存,其中KEYS[1]為傳入的引數1,即庫存key
local c_s = redis.call('get', KEYS[1])
-- 判斷活動庫存是否充足,其中KEYS[2]為傳入的引數2,即當前搶購數量
if not c_s or tonumber(c_s) < tonumber(KEYS[2]) then
   return 0
end
-- 如果活動庫存充足,則進行扣減操作。其中KEYS[2]為傳入的引數2,即當前搶購數量
redis.call('decrby',KEYS[1], KEYS[2])

然後我們將 Lua 指令碼轉成字串,並新增指令碼預載入機制。

​ 預載入可以有多種實現方式,一個是外部預載入好,生成了 sha1 然後配置到配置中心,這樣 Java 程式碼從配置中心拉取最新 sha1 即可。另一種方式是在服務啟動時,來完成指令碼的預載入,並生成單機全域性變數 sha1。我們這裡先採取第二種方式,程式碼結構如下圖所示:

關於訂單庫存扣減的最佳實踐

以上是將 Lua 指令碼轉成字串形式,並通過 @PostConstruct 完成指令碼的預載入。然後新增 EVALSHA 方法,如下圖所示:

方法入參為活動商品庫存 key 以及單次搶購數量,並在內部呼叫 Lua 指令碼執行庫存扣減操作。看起來是不是很簡單?在寫完底層核心方法之後,我們只需要在下單之前,呼叫該方法即可,具體如下圖所示:

三:總結

​ 技術的角度分析了庫存超賣發生的兩個原因。一個是庫存扣減涉及到的兩個核心操作,查詢和扣減不是原子操作;另一個是高併發引起的請求無序。所以我們的應對方案是利用 Redis 的單執行緒原理,以及提供的原生 EVALSHA 和 SCRIPT LOAD 命令來實現庫存扣減的原子性和順序性,並且經過實測也確實能達到我們的預期,且效能良好,從而有效地解決了秒殺系統所面臨的庫存超賣挑戰。以後再遇到類似的問題,你也可以用同樣的解決思路來應對。

相關文章