[分散式]高併發案例---庫存超發問題

加瓦一枚發表於2019-02-12

1. 庫存超發的原因是什麼?

在執行商品購買操作時,有一個基本流程:

 

例如初始庫存有3個。

第一個購買請求來了,想買2個,從資料庫中讀取到庫存有3個,數量夠,可以買,減庫存後,更新庫存為1個。

接下來第二個購買請求來了,想買2個,發現庫存為1,不夠,不可以買了。

這樣是沒問題的,但在高併發情況下,這2個購買請求很可能是一起來的,他們都讀到庫存是3,都可以買,就都去減庫存,這時超發就發生了,結果庫存變成 -1了。

 

有多種方案來解決這個問題,我們主要看3種方案:

  • 悲觀鎖

  • 樂觀鎖

  • Redis + Lua

下面分別看一下各個方案的實現思路,和優缺點。

2. 解決方案

2.1 悲觀鎖

出現超發現象的根本在於共享的資料被多個執行緒所修改,多個執行緒交織在一起。

如果一個執行緒讀庫存時就將資料鎖定,不允許別的執行緒進行讀寫操作,直到庫存修改完成才釋放鎖,那麼就不會出現超發問題了。

例如:

// 事務開始
select id, product_name, stock, ... 
from t_product 
where id=#{id} for update

update t_product 
set stock = stock - #{quantity} 
where id = #{id}
// 事務結束

在查詢庫存時使用了 for update,這樣在事務執行的過程中,就會鎖定查詢出來的資料,其他事務不能對其進行讀寫(注意,讀也不行),這就避免了資料的不一致,直至事務完成釋放鎖。

  • 優點

思路簡單,程式碼實現也非常簡單,從資料庫層面解決了超發問題。

  • 缺點

這種獨佔鎖的方式對效能的影響是比較大的。

2.2 樂觀鎖

悲觀鎖有效但不高效,為了提高效能,出現了樂觀鎖方案,不使用資料庫鎖,不阻塞執行緒併發。

思路:

給商品記錄新增一個 version 欄位,讀取庫存時拿到這個 version 版本,更新庫存時要對比這個 version 值,如果版本相同,說明庫存沒被別人改過,可以更新,同時把 version 值加1,如果版本不同,說明被別人改過了,則取消庫存修改操作,購買失敗。

 

update t_product 
set stock = stock - #{quantity}, 
   version = version +1    
where id = #{id} and version = #{version}

通過 version 版本號,就可以知道自己讀取的資料在更新時是不是舊的,如果是舊資料,就不能更新了。

這種方式有點像碰運氣,運氣好,沒人和我一起更新,那麼就成功;如果運氣不好,被別人搶先修改了,那麼就失敗。

從而可以知道,在併發量很大的時候,失敗的概率會比較高。

為了提升成功率,可以引入重試機制,當更新失敗後,再走一遍流程(讀取、更新),具體重走幾遍比較好呢?可以規定一個次數,例如3次,如果重試了3次還是失敗,就放棄;還可以規定一個時間段,比如在 100ms 內迴圈操作,期間如果某次成功了就退出,否則一直重試到時間到為止。

  • 優點

沒有阻塞,效能優於悲觀鎖。

  • 缺點

實現思路較悲觀鎖複雜,增加了 version 的控制,還需要新增重試機制。

2.3 Redis + Lua

在高併發環境中,資料庫的方案較慢,如果寫入記憶體的 Redis 就會快很多。

此方案思路與悲觀鎖類似,都是把查詢庫存的操作與更新庫存的操作繫結在一起,不被其他執行緒影響,區別在於儲存介質,從資料庫換為Redis。

Lua 指令碼中可以編寫邏輯(取庫存、判斷是否夠用、更新庫存),Redis 中執行 Lua 時可以保證原子性,所以能夠滿足我們的需求,而且記憶體操作非常快,我們也不用擔心效能。

Lua 指令碼的邏輯:

 

示例程式碼:

-- 獲取當前庫存
local stock = tonumber(redis.call('hget', product, 'stock')) 

-- 如果庫存小於購買數量,說明庫存不足,返回0(失敗)
if stock < quantity then return 0 end

-- 減少庫存,得到新的庫存數量
stock = stock - quantity 

-- 更新庫存
redis.call('hset', product, 'stock', tostring(stock)) 

-- 字串拼接,生成購買記錄
local purchaseRecord = ... 

-- 把購買記錄儲存到 redis
redis.call('rpush', purchaseList, purchaseRecord)

-- 返回1(成功)
return 1

我們的程式接收到使用者的購買請求時,就呼叫 Lua 進行處理。

上面的處理流程中有一步”把購買記錄寫入 redis“,這是因為 redis 不適合做持久化,我們還是需要把資料同步到資料庫中,可以使用一個定時程式,把 redis 中的記錄寫入資料庫。購買記錄也可以不放在 redis 中,寫入訊息佇列,然後通過消費者同步到資料庫。

  • 優點

效能最優,實現簡單。

  • 缺點

增加了輔助工作,需要額外處理資料庫的同步,還要保證 redis 本身是高可用的。

3. 小結

 

內容參考自《深入淺出 Spring Boot 2.x》。

相關文章