【面試題】電商秒殺如何解決超賣問題?

視頻文稿
問題描述
電商秒殺的超賣問題,其實往大了說主要就是要解決常見的購物網(wǎng)站上經(jīng)常出現(xiàn)的團購、秒殺、特價等活動會出現(xiàn)的一個共同問題:短時間內(nèi)大量并發(fā)請求的情況下,需要保證商品的銷售量不會超過庫存量。類似的問題也可以改成 12306 搶票如何保證不搶超過票數(shù)等,其實解決的思路都是類似的。
產(chǎn)生超賣現(xiàn)象的主要原因,就是購買過程其實最起碼會涉及到兩步:(1)檢查庫存是否已售空或足夠用戶購買。(2)未售空的話,扣除用戶購買的庫存數(shù)量。這就是一個典型的“讀后寫”的情況。
如果沒有對并發(fā)請求進行類似鎖的控制,或者說是原子性地去“讀后寫”訪問庫存數(shù)量的話,就會出現(xiàn)多個請求檢查庫存時是有庫存的,而實際進入扣除庫存邏輯時的請求數(shù)量已經(jīng)超過了庫存的情況,導致最后庫存變?yōu)樨摂?shù)。
舉例就是兩個并發(fā)請求在剩余一個庫存的時候請求服務器,執(zhí)行檢查邏輯的時候都發(fā)現(xiàn)還有一個庫存,可以購買,然后真正扣除的時候,各扣一個導致最后庫存為 -1。
問題解析
作為一個典型的高并發(fā)問題,我們解決問題的關(guān)鍵其實也就是前面提到的:讓并發(fā)請求原子性地去完成對庫存的“讀后寫”,這里可以是用鎖控制并發(fā)請求,也可以是其他使得并發(fā)請求最終順序訪問庫存數(shù)據(jù)的方式。
那么我們可以看看對于不同層級的系統(tǒng),我們有什么方式可以處理。
數(shù)據(jù)庫層
涉及到庫存,必然涉及到數(shù)據(jù)庫,那么我們可以優(yōu)先看看數(shù)據(jù)庫層如何處理。
數(shù)據(jù)庫層我們可以實現(xiàn)鎖的邏輯。首先就是可以基于 MySQL 的行鎖實現(xiàn)悲觀鎖的作用,直接使用 SELECT ... FOR UPDATE 語句來直接原子性的完成讀后寫操作。SQL 代碼類似如下:
select stock from goods_table where id = 1 for update;
update goods_table set stock = stock - 1 where id = 1;
這樣并行請求的訪問數(shù)據(jù)庫的事務就將變成串行訪問,從而避免了超賣問題。
當然,數(shù)據(jù)庫層也可以實現(xiàn)類似 CAS 的樂觀鎖,方法就是在表中增加一個版本號字段,或者直接把庫存作為版本號,然后就可以在查詢和更新時使用類似下面的語句:
select stock from goods_table where id = 1;
update goods_table
set stock = stock - 1
where id = 1 and stock = 123;
但數(shù)據(jù)庫的鎖效率較低,以上兩個方案都很容易導致整個系統(tǒng)性能瓶頸卡在數(shù)據(jù)庫。尤其是樂觀鎖方案在高并發(fā)情況下的大量重試是我們無法接受的,所以真正在較大并發(fā)時,是不會使用這兩種解決方案的。
那么比較現(xiàn)實的處理方式是:我們可以通過引入緩存的思路來優(yōu)化數(shù)據(jù)庫的性能瓶頸。比如使用 Redis 來對庫存數(shù)量進行緩存,每次庫存操作先在 Redis 驗證后再訪問數(shù)據(jù)庫。這里對 Redis 中庫存的讀后寫可以使用 lua 腳本進行實現(xiàn),以保證原子性。這樣利用 Redis 單線程高性能的特性,可以滿足現(xiàn)實場景下基本的業(yè)務需求。這是最常見的一個解決方案。
需要注意的是對于 Redis 集群,機器的增加不會提升我們秒殺業(yè)務支持的并發(fā)量,而僅僅是保證 Redis 的穩(wěn)定性。因為每次存儲庫存的 key 所在的槽的主節(jié)點都是集群中特定的某一臺 Redis 機器,瓶頸依然在這一臺機器上。
單體 Web 服務
接著我們可以看看簡單基礎的單體 Web 服務系統(tǒng)中我們?nèi)绾翁幚恚梢詫竺婕簣鼍暗姆治鲇兴鶈l(fā)。
因為Web 服務層是單體,我們可以直接對多個并發(fā)的請求進程在代碼上使用多線程的鎖進行控制:
(1)使用 notify/wait、synchronized、Lock 等等各種經(jīng)典的多線程悲觀鎖。
(2)使用 CAS 實現(xiàn)樂觀鎖。但同樣大量并發(fā)時的重試請求鎖的問題,也使得這個解決方案不太現(xiàn)實。
除了鎖,還有一個特別的利用異步的思路,就是我們使用一個顯式的并發(fā)隊列,比如 ConcurrentLinkedQueue,每個并發(fā)請求來的時候我們先把請求用戶和購買數(shù)量入隊。真正搶商品的任務工作線程異步執(zhí)行去消費隊列中的記錄,由它真正去數(shù)據(jù)庫扣減庫存。那么只要這個工作線程是單線程的,就不會出現(xiàn)超賣。
但很顯然,我們的單體服務也是很難用于真正的秒殺場景的。畢竟基于單體 Web 服務架構(gòu)的代碼控制鎖的設計很難進行水平擴容(即增加機器數(shù)量以接受更大并發(fā)訪問量)。以上分析主要是讓大家領(lǐng)會到解決問題的核心思路。
Web 服務集群
那么當我們的服務是集群時,我們應該如何處理超賣問題呢?
之前代碼上鎖的思路,我們擴展一下,在當前集群場景下就可以變成使用分布式鎖。這樣邏輯上其實就和使用 Java 代碼的悲觀鎖類似,只是鎖的邏輯使用分布式鎖進行替換。那么即使我們使用 Redis 來實現(xiàn)分布式鎖的話,鎖導致大量請求等待占用系統(tǒng)資源的問題也是存在的,因為這里的瓶頸依然是在數(shù)據(jù)庫訪問速度。此外,同樣需要注意水平擴容 Redis 集群無法提高并發(fā)量的問題。所以其實這個方案應該也是比較少使用。
而代碼通過隊列異步處理的思路,我們可以通過應用消息隊列在集群場景下防止超賣。要做的就是把單體的并發(fā)隊列相關(guān)邏輯改為使用消息隊列。然后保證消費者的單線程性即可防止超賣。
我個人感覺消息隊列的實現(xiàn)應該是在可以在高并發(fā)場景下支持大訪問并發(fā)量的。這樣充分利用了消息隊列異步、解耦、削峰的好處,使得用戶并發(fā)的請求可以盡快返回給前端,并不會像有鎖時在等待訪問數(shù)據(jù)庫的過程中占用服務器資源。但這個方案下,查詢剩余庫存的問題就需要考慮,是同樣引入緩存?還是在單線程的消費者處維護剩余庫存?抑或是在消息隊列先積壓到超過庫存數(shù)量再統(tǒng)一消費處理?設計起來相對復雜。實際實踐中,需要根據(jù)用戶規(guī)模、消息隊列響應時間、設施成本、后續(xù)運維復雜度等因素綜合考慮是否有必要使用該解決方案。
總結(jié)
通過上面的分析,我們可以清楚的體會到,超賣問題的實質(zhì),其實就是解決高并發(fā)下對某個數(shù)據(jù)的讀后寫操作不相互干擾、保證原子性的問題。那么我們就有“使用鎖直接控制請求”和“并發(fā)入隊然后異步單線程處理”兩種思路,對于“數(shù)據(jù)庫層和服務層”、“單體和集群場景”我們也就會有相應的不同實現(xiàn)。基于此,大家應該可以在回答類似問題的時候保持一個清晰的思路進行回答。
各個方法對比的話,最常見的解決方案就是 Redis 緩存的方案了,充分利用 Redis 單線程高性能的特性,足以面對大部分的情況。其次就是消息隊列的方式,應該也是可以比較完美地解決問題,但就是方案相對復雜一些,且需要單獨考慮查詢剩余庫存的問題在這種解決方案下如何實現(xiàn)。
其他單體和基于數(shù)據(jù)庫的方法,在超賣問題上我基本給了不及格的推薦指數(shù),主要就是它們雖然都能防止庫存扣超的問題,但在這個實際的業(yè)務問題上不太適合。原因就是要避免最后瓶頸集中在數(shù)據(jù)庫上,并且要考慮機器的水平擴容問題。之所以介紹,主要還是介紹一下并發(fā)場景上的解決思路,以后遇到其他的問題可以自己自行舉一反三進行分析。
真正面試的話,估計直接回答 Redis 緩存的解決方案即可。