電商系統中秒殺是一種常見的業務場景需求,其中核心設計之一就是如何扣減庫存。本篇主要分享一些常見庫存扣減技術方案,庫存扣減設計選擇並非一味追求性能更佳,更多的應該考慮根據實際情況來進行架構取捨... ...
電商系統中秒殺是一種常見的業務場景需求,其中核心設計之一就是如何扣減庫存。本篇主要分享一些常見庫存扣減技術方案,庫存扣減設計選擇並非一味追求性能更佳,更多的應該考慮根據實際情況來進行架構取捨。在商品購買的過程中,庫存的抵扣過程通常包括以下步驟:
- 開啟事務:在開始進行庫存抵扣操作前,開啟一個事務。
- 查詢庫存:根據商品ID,使用SELECT語句從庫存表中查詢該商品的當前庫存數量。
- 檢查庫存是否足夠:將查詢到的庫存數量與用戶購買數量進行比較。如果庫存數量大於或等於用戶購買數量,則庫存足夠,可以繼續下單。如果庫存不足,需要採取相應的處理措施,例如提示用戶庫存不足或進行庫存預訂等。
- 扣減庫存:如果庫存足夠,根據用戶購買數量,使用UPDATE語句將庫存表中對應商品的庫存數量減去購買數量,得到最新的庫存剩餘值。
- 記錄交易明細:在購買過程中,通常需要記錄交易明細,例如生成訂單記錄或交易日誌,以便後續查詢和跟蹤。
- 提交事務:以上操作通常會在一個事務中進行,確保操作的原子性。如果所有步驟都成功執行,則提交事務,庫存扣減過程完成。如果在任何步驟中出現錯誤或異常,事務會回滾,恢復到操作前的狀態,確保數據的完整性和一致性。
由於涉及到 SELECT後進行UPDATE,以上步驟中存在多事務併發時寫覆蓋的問題。
悲觀鎖更新庫存
在資料庫併發控制中,防止寫覆蓋是一個重要的問題,特別是在多個會話(事務)同時嘗試修改同一行數據時。如果不進行適當的併發控制,可能會導致數據的不一致性和丟失更新。
為瞭解決這個問題,可以使用 SELECT FOR UPDATE語句。在使用SELECT FOR UPDATE時,資料庫會將目標行的數據加上寫鎖,阻止其他事務在當前事務完成之前修改被鎖定的數據。這樣,其他會話無法同時修改同一行數據,從而避免了併發寫入衝突。
需要註意的是,使用SELECT FOR UPDATE可能會引起一些併發性能問題,因為其他會話需要等待鎖釋放才能繼續執行。因此,在設計併發控制策略時,需要綜合考慮併發性能和數據一致性之間的平衡。
在上述流程中,步驟3 改為:
3. 查詢庫存並鎖定:使用SELECT ... FOR UPDATE語句查詢指定商品的庫存,並將其鎖定。這將確保其他併發事務在當前事務提交或回滾之前無法修改該商品的庫存。
# 開始事務
connection.begin()
# 加鎖(FOR UPDATE)並讀取當前庫存記錄
cursor.execute("SELECT quantity FROM inventory WHERE id = ? FOR UPDATE", item_id)
current_quantity = cursor.fetchone()['quantity']
# 檢查庫存是否足夠
if current_quantity >= requested_quantity:
# 計算更新後的庫存數量
new_quantity = current_quantity - requested_quantity
# 更新庫存
cursor.execute("UPDATE inventory SET quantity = ? WHERE id = ?", (new_quantity, item_id))
#{... 記錄明細等操作}
# 提交事務
connection.commit()
return True
else:
# 庫存不足,回滾事務
connection.rollback()
return False
樂觀鎖更新庫存
除開悲觀鎖,自然也可以想到使用樂觀鎖的方式來進行更新;最常見的設計就是CAS + 版本號的更新來實現庫存更新,在庫存表中新加一個 version的欄位。
# 開始事務
connection.begin()
# 讀取當前庫存記錄和版本號
cursor.execute("SELECT quantity, version FROM inventory WHERE id = ?", item_id)
result = cursor.fetchone()
current_quantity = result['quantity']
current_version = result['version']
# 檢查版本號是否匹配
if current_quantity >= requested_quantity:
# 計算更新後的庫存數量和版本號
new_quantity = current_quantity - requested_quantity
new_version = current_version + 1
# 更新庫存和版本號
cursor.execute("UPDATE inventory SET quantity=%s,version=%s WHERE id=%s AND version=%s"
,(new_quantity, new_version, item_id, current_version))
#{... 記錄明細等操作}
# 檢查是否有更新行數
if cursor.rowcount == 1:
# 提交事務
connection.commit()
return True
else:
# 更新失敗,可能是由於版本號不匹配導致的併發操作問題
connection.rollback()
return False
else:
# 庫存不足,回滾事務
connection.rollback()
return False
可以進一步思考,是否需要版本的概念;扣減庫存流程中,如果將 SELECT 查詢 作為庫存超賣前置檢查的(保障扣減成功率,減少不必要的寫操作)是視角看待,其實需要保障的是扣減後的庫存是否大於等於零。
如何理解前置檢查視角?
用個賣西瓜的例子來說明,假如你今天微信問到樓下水果店老闆有特價5毛一斤西瓜還有10個,這時你立刻下樓去購買。那麼可能兩種結果,結果一 你買到了特價西瓜;結果二 買的人太多,你到店的時候已經賣光了。從結果看,微信詢問的消息只是決定你下不下樓購買,而並非決定真正買到(不影響庫存);這種詢問作用在於減少直接下樓購買花費體力。
# 讀取當前庫存記錄
cursor.execute("SELECT quantity FROM inventory WHERE id = ? ", item_id)
current_quantity = cursor.fetchone()['quantity']
# 檢查庫存是否足夠
if current_quantity >= requested_quantity:
# 開始事務
connection.begin()
# 更新庫存
cursor.execute("UPDATE inventory SET quantity = quantity-? WHERE id = ? and quantity - ?>= 0"
, (requested_quantity, item_id,requested_quantity))
#{... 記錄明細等操作}
# 檢查是否有更新行數
if cursor.rowcount == 1:
# 提交事務
connection.commit()
return True
else:
# 更新失敗
connection.rollback()
return False
else:
return False
庫存讀寫分離
再考慮一個極端的例子:假設有一個最新款的 iPhone 秒殺活動,庫存只有 100 件,活動期間預估峰值每秒查詢請求量(QPS)為 10 萬次。在活動結束後,流水錶最終只會插入 100 條記錄,但是查詢的 QPS 卻接近 10 萬次,導致讀取的壓力非常大。
在這種情況下,查詢壓力主要是由於活動期間大量的用戶查詢商品的秒殺狀態和庫存數量所導致的。雖然流水錶最終只插入了 100 條記錄,但是查詢請求卻非常頻繁,可能會導致資料庫性能問題。
優化首先可以想到是採用讀寫分離架構,通過新增一套從庫來實現。藉助MySQL自帶的數據同步能力,可以將主庫的數據同步到從庫,從而在讀取庫存時可以直接查詢從資料庫。這樣可以將讀取請求分散到從庫,減輕主庫的查詢壓力。
雖然讀寫分離可以提高查詢性能,但需要註意從庫的數據同步可能會有一定的時間延遲,導致從庫的數據新鮮度(實時性)有一定的滯後性。(前置檢查視角)在進行庫存校驗時,從庫的數據並不一定完全準確,但可以攔截大部分無效流量,起到了一定的作用。
最終的購買決策仍然由主庫的UPATE SQL語句來控制,以確保最終扣減的準確性。雖然從庫的數據可能有一定的滯後,但並不會影響最終扣減的結果,因為購買操作仍然在主庫上執行,確保了數據的一致性和準確性。
優點:1. 藉助資料庫的 ACID 特性,確保事務的原子性、一致性、隔離性和持久性,避免了超賣和少買等業務問題。
2. 實現簡單,適用於項目工期緊張或開發資源有限的情況。
不足: 如果參與秒殺的 SKU(庫存量單位)非常多,最終的寫操作都是基於庫存主庫,可能會導致主庫的性能壓力較大。
這裡 犧牲數據實時性(新鮮度) 來提升性能 是一種典型的 技術架構選型的 取捨方向。
庫存分庫分表
為瞭解決上述存在的容量和性能上限問題,庫存分庫分表會是一種優化選擇。
-
將庫存扣減表和扣減明細表根據商品ID進行水平拆分,將不同商品的記錄存儲在不同的分片中。這樣可以將高併發的請求路由到不同的資料庫實例上,分攤資料庫負載。
-
在水平拆分的基礎上,進一步考慮將不同商品的記錄分佈在不同的資料庫實例中,每個實例稱為一個庫。對於每個庫,可以再將表進行分表,將不同商品的記錄分開存儲。例如,可以按照商品ID的哈希值進行分表,或者按照一定的範圍將商品ID進行分段,確保每個表的數據量均衡。
在實際應用中,系統需要根據商品ID來決定將請求路由到哪個資料庫實例上。可以使用一致性哈希演算法、分段路由規則等方式來實現請求的正確路由。這樣可以確保同一商品的庫存扣減和明細記錄在同一個資料庫實例上進行,保證事務的原子性和數據的一致性。
總體來說,通過水平拆分和分庫分表的設計,可以有效地提高系統的吞吐量和性能,並減輕單一實例的容量限制。但是在實際應用中,需要仔細考慮資料庫的選擇、數據路由策略、數據一致性等問題,以確保系統的可用性和性能。同時,還需要合理評估業務需求和數據增長趨勢,以選擇合適的分片和資料庫配置,避免出現過度分片或數據傾斜等問題。
緩存扣減庫存
讀寫分離、分庫分表確實能分攤主庫很大一部分壓力,但是如果面對是 單品萬級QPS 的秒殺流量,MySQL 的千級 TPS 同樣也支撐不了,需要進一步升級性能。
(讀 改為 Redis)此時引入緩存中間件,將 MySQL 的數據定時同步到緩存中(可能存在延遲,庫存顯示不准確)。庫存超賣前置檢查,從 Redis 中查詢剩餘的庫存數據,寫入操作在資料庫校驗不准也不會超賣。 由於緩存基於記憶體操作,性能比資料庫高出幾個數量級,單台 Redis 實例可以達到 10W QPS 的讀性能。
(讀/寫庫存都為 Redis)對於扣減庫存的操作,如果直接執行多個 Redis 命令,無法保證原子性。為了確保原子性,可以採用 Lua 腳本的形式,將多個 Redis 命令打包到一個腳本中,作為一個命令發送給 Redis 執行,從而保證了操作的原子性。
具體步驟如下:
- 使用 Lua 腳本:將扣減庫存的多個 Redis 命令封裝在一個 Lua 腳本中。這樣可以確保這些命令在 Redis 中以原子方式執行,避免併發問題。
- 執行 Lua 腳本:將封裝了扣減庫存邏輯的 Lua 腳本作為一個整體命令發送給 Redis 執行。這樣在 Redis 中執行腳本時,將按照腳本中的邏輯一次性執行多個命令。
- 非同步保存到資料庫:Redis 扣減庫存成功後,將此次扣減操作非同步化保存到資料庫中進行持久化存儲。
單品分桶扣減
在更大規模,針對單一商品的超高併發扣減的庫存集群中,可能基於資料庫內核的改造優化還無法滿足業務需求。單一商品的超高併發扣減可能會影響到同一資料庫實例上的其他商品扣減,同一個資料庫實例上也可能存在多個熱點商品造成互相影響,這時就考慮引入基於緩存的分桶扣減方案。
將商品ID按照一定的規則分成多個桶(Bucket),每個桶對應一個緩存項。例如,可以根據商品ID的哈希值或者取模運算的結果來分桶。分桶的目的是將不同商品的庫存信息均勻地存儲在不同的緩存項中,避免單個緩存項過大導致性能問題。
在進行庫存扣減時,首先根據商品ID找到對應的緩存項。然後,在緩存中讀取當前庫存數量,併進行判斷是否足夠進行扣減操作。如果足夠,更新緩存中的庫存數量,並將扣減後的值存回緩存。如果不足,直接返回扣減失敗。
其他解決方案
-
針對單品較多場景,也可以考慮批量扣減庫存,批量處理庫存的更新操作,這樣可以大量的減少資料庫事務。
-
基於消息的庫存,下單完成後發生訂單相關消息,庫存通過消息消費的方式進行更新;優勢在於庫存的更新速率可控。
-
令牌庫存,可控的時間內進行秒殺庫存,提升用戶秒殺感知。
以上綜述
可以看到庫存扣減方案場景多樣,更多的 應該根據業務要求 以及 具體的流量進行選擇,僅追求性能非好的選擇;性能高的同時 往往意味 著其他方面的取捨,比如:代碼複雜性、庫存精準性、部署複雜性等等。
歡迎關註