怎麼設計一個秒殺系統

程式設計師自由之路發表於2020-10-16

什麼是秒殺

百度百科對秒殺這個詞的解釋有多個,第一種是:

在某些領域以壓倒性的優勢超越其他人,或者是在極短時間(比如一秒鐘)內解決對手,該種語言通常使用在網路遊戲中。

還有一種解釋語義用在網購場景中,通常是指:

網路商家一個非常優惠,極具吸引力的價格釋出一款商品,並限定在一段非常短的時間內開放給消費者購買。由於價格十分實惠,往往會吸引很多消費者爭相購買,商品會在很短時間內被一搶而空,有時甚至在一秒鐘之內商品就被搶完了。因此將這種電商的限時低價搶購活動形象的稱為秒殺。

當然,我們今天的主題肯定是第二個了。下面就先來看看網購秒殺的場景有哪些特點。

秒殺場景的特點

生活中最常見的秒殺場景有雙十一的電商促銷活動,節假日12306的搶票場景等。這些場景的特點就是:

  • 瞬間系統的併發請求特別高;
  • 商品數量有限,往往供不應求。

因此我們在針對秒殺場景設計系統時就要充分考慮以下問題:

  • 系統怎麼扛住高併發的請求;
  • 怎麼防止商品超賣; ---不考慮秒殺的話,就是一個普通的下單流程,樂觀鎖釦減庫存
  • 惡意軟體刷單秒殺; ---針對IP過濾限流?
  • 秒殺的介面需要到指定時間才放開,到指定時間失效; --- 後臺判定
  • 訂單長時間沒有支付,應該及時釋放該商品,補充到庫存中。 ---死信佇列
  • 秒殺業務併發量大,是否會對其業務造成影響; ---單獨部署秒殺系統?
  • 惡意DDos攻擊; ---高防IP

從系統架構的角度優化

在秒殺進行的瞬間,會有大量的請求湧進系統。如果系統不具備高併發能力的話,那麼系統會立馬進入癱瘓狀態。這對於大公司是不能接受的,因為不僅會影響業務的正常開展,還會給使用者留下這個公司技術能力差的映像。所以讓秒殺系統具備高併發能力是我們在設計系統時首先需要考慮的問題。

讓系統具備高併發的能力是一個很大的話題,這邊限於篇幅,不會深入展開每個細節點。

負載均衡提高系統水平擴充套件能力

高併發的系統架構都會採用分散式叢集部署,服務上層有著層層負載均衡,並提供各種容災手段 (雙活機房、節點容錯、伺服器災備等)來保證系統的高可用,流量也會根據不同的負載能力和配置策略均衡到不同的伺服器上。下邊是一個簡單的負載均衡示意圖:

img

上面的三層負載均衡的架構,能大大提升了系統的水平擴充套件能力。可以根據秒殺的併發量來靈活地調整機器的數量。如果預估請求量比較大的話,可以同步往上加機器。

LVS和Nginx都是和負載均衡相關的技術,一個是四層負載,一個是七層負載。LVS的吞吐量比Nginx要高很多,可以達到幾十萬,Nginx的吞吐量也相對較高,可以達到幾萬的量級。關於兩者更深入的知識,大家可以自己學習。

介面限流減少不必要的流量

秒殺的商品庫存只有100件,但是一秒鐘內可能會有10000個,甚至更多的使用者來搶購這100件商品。很顯然其中的大多數是搶不到的,那麼就很有必要做一下限制——一定時間內不要讓這麼多使用者進來搶,比如說一秒內我只放行5000個使用者進來秒殺。這就是限流措施,在秒殺系統中引進限流措施可以大大較少系統資源的浪費,減少無意義的爭搶。

限流可以分為前端限流後端限流。

前端限流的措施有:

  • 首先第一步就是通過前端限流,使用者在秒殺按鈕點選以後發起請求,那麼在接下來的5秒是無法點選(通過設定按鈕為disable)。這一小舉措開發起來成本很小,但是很有效。

後端限流:

  • 每個使用者一定時間內只能秒殺一次:具體多少秒需要根據實際業務和秒殺的人數而定,一般限定為10秒。具體的做法就是通過redis的鍵過期策略,首先對每個請求都從String value = redis.get(userId);如果獲取到這個value為空或者為null,表示它是有效的請求,然後放行這個請求。如果不為空表示它是重複性請求,直接丟掉這個請求。如果有效,採用redis.setexpire(userId,value,10).value可以是任意值,一般放業務屬性比較好,這個是設定以userId為key,10秒的過期時間(10秒後,key對應的值自動為null)。
  • 限流演算法限流:到這步已經將很多無效的爭搶流量限制住了,但是極限併發下還是會有很多請求進來。這時我們就可以使用限流演算法來進一步限制流量,比如1秒內最多放行1000個請求。

比較成熟的限流演算法有:

  • 令牌桶演算法
  • 漏桶演算法

當然,已經有很多的限流演算法實現了。比如Guava、Nignx和Spring等,都可以讓我們實現限流措施。大家可以自己去查詢使用。(後面會寫專門的文章來介紹限流的實現)

從秒殺流程的角度優化

從上面的介紹我們知道使用者秒殺流量通過層層的負載均衡,均勻到了不同的伺服器上,但即使如此,叢集中的單機所承受的 QPS 也是非常高的,因此我們還需要儘可能地優化單機的效能。

其實如果我們拋開秒殺場景的大併發特點的話,秒殺就是一個普通的電商下單流程。一個普通的電商下單流程包括以下幾步:

  • 查詢庫存,庫存不足的話就不能購買;
  • 生成訂單並扣減庫存(生成訂單和庫存扣減的順序有講究,下面會討論);
  • 使用者支付;
  • 長時間沒支付的訂單處理成失效,並釋放庫存。

為了將整個流程說清楚,這邊簡單建兩個表:商品表和訂單表,用簡單的程式碼描述下大體的過程。

-- 商品表
CREATE TABLE `stock` 
(
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(50) NOT NULL DEFAULT '' COMMENT '商品名稱',
`count` int(11) NOT NULL COMMENT '庫存',
`sale` int(11) NOT NULL COMMENT '已售',
`version` int(11) NOT NULL COMMENT '樂觀鎖,版本號',
PRIMARY KEY(`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8;
-- 訂單表
CREATE TABLE `stock_order`
(
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`sid` int(11) NOT NULL COMMENT '庫存ID',
`name` varchar(30) NOT NULL DEFAULT '' COMMENT '商品名稱',
`create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '建立時間',
PRIMARY KEY(`id`)
) ENGINE=InnoDB AUTO_INCREMENT=55 DEFAULT CHARSET=utf8;

庫存扣減的優化

1. 樂觀鎖更新庫存

在高併發系統中存在一個普遍的問題就是多個請求併發修改一條記錄。在秒殺系統中也存在這種情況,就是在扣減庫存的時候。一個請求首先去查商品現在的庫存,發現庫存還充足,準備去扣減相應的庫存並生成訂單。但是這個時候其他請求可能已經在這個請求之前扣減了庫存,導致庫存已經不足了。這個時候如果再繼續去扣減的話就會導致“超賣”現象。

下面是有問題的程式碼邏輯

上面出問題的地方就是更新扣減庫存時沒有檢查當前的庫存是否被人更新過了。解決的辦法也非常簡單,就是在扣減更新庫存時加一個樂觀鎖更新。樂觀鎖我們通常一個時間戳版本號實現,所以將更新庫存的sql改成如下,就可以解決超賣的問題。

update stock set sales = sales + 1, version = version + 1 where id = # and version = version

其實這步還有優化的空間:將查詢庫存和扣減庫存合成一步,可以用這樣的做法:

update stock set sales = sales + 1, version = version + 1 
where id ={id} and 
      version = #{version} and 
      count - sale > 0;

這樣的話,就可以保證庫存不會超賣並且一次更新庫存。當update語句返回更新的資料條數是1的時候就認為還有庫存。

2. 將庫存資訊放入Redis快取

在上面的下單流程中,我們可以發現每次都要去資料庫去查下庫存資訊。這在大併發情況下對資料庫的壓力時相當大的。所以我們可以將庫存資訊放入Redis分散式快取,減少資料庫的壓力。更新庫存時同時將快取中的庫存資料也更新。(這邊有個問題,當併發級別真的非常高時,Redis會不會成為查詢的瓶頸?)

3. 釋放長時間沒有支付的訂單

上面的流程中,一旦訂單建立成功就會佔據一個庫存。如果這個訂單一直沒支付的話就會導致其他想買的使用者不能買到商品。所以我們要想辦法釋放這種長期沒有支付的訂單,將庫存釋放到總庫存中去。

方案一:寫一個定時job, 每分鐘掃描一下資料庫的訂單表,如果訂單超過了15分鐘,那麼訂單狀態改為失效,並且商品表數量要加1,因為剛剛刪除的訂單釋放了一個商品。但是這樣會給資料庫造成很大的壓力,而且如果長時間都沒有過期的訂單,而job依然會每分鐘跑一次,浪費資源。(問題:刪除訂單時如果使用者同時支付了怎麼辦?加樂觀鎖?還有什麼需要注意的?)

方案二:使用延遲佇列處理,建立訂單的時候同步向延遲佇列中傳送相關的訂單資訊。然後消費者在指定的延遲時間後取出訂單ID,去查詢訂單是否已經支付,如果沒有支付則設定成失效。

這邊只是簡單介紹下方案,後面會寫詳細的文章進行分析。

4. 為什麼要先扣庫存再建立訂單

我們上面設計的流程是:扣減庫存 --> 建立訂單 --> 支付。有沒小夥伴想過為什麼這個流程會比較好。能不能是建立訂單-->扣減庫存-->支付;或者是建立訂單-->支付-->扣減庫存呢?

先說建立訂單-->扣減庫存-->支付這個順序,這種流程存在的一個比較大的問題就是:一個使用者會建立很多訂單,但是他只需要買一個,所以會佔據其他使用者的購買名額。

再說建立訂單-->支付-->扣減庫存這種順序,這個流程存在的問題就是:使用者建立了訂單並支付成功了,但是因為存在高併發的情況,其他使用者可能在這個使用者支付的過程中已經提前支付買走了最後的商品,這就會導致“超賣”的現象——錢付了,貨沒了,尷尬。

非同步建立訂單的優化

如果還要繼續提升系統效能的話,可以考慮將最後一步的建立訂單從同步轉為非同步。通過引入訊息佇列,將訂單的資訊發到訊息佇列,消費者負責消費資訊並建立訂單。因為非同步了,所以最終需要採取回撥或者是其他提醒的方式提醒使用者購買完成。當然也可以輪詢訂單的建立情況,主動完成支付。

秒殺頁面靜態化

使用者在秒殺開始前,一般會通過不停重新整理瀏覽器頁面以保證不會錯過秒殺,這些請求如果按照一般的網站應用架構,訪問應用伺服器、連線資料庫,會對應用伺服器和資料庫伺服器造成負載壓力。

重新設計秒殺商品頁面,不使用網站原來的商品詳細頁面,將商品的描述、引數、成交記錄、影像、評價等全部寫入到一個靜態頁面,使用者請求不需要通過訪問後端伺服器。

具體的方法可以使用freemarker模板技術,建立網頁模板,填充資料,然後渲染網頁。

秒殺靜態頁面CDN部署

秒殺的瞬間,傳遞商品靜態頁面需要的貸款可能會超過平時伺服器的貸款,所以可以考慮將 商品靜態頁面部署到CDN來節省貸款。

秒殺系統優化思路總結

  • 儘量將請求攔截在上游。
  • 還可以根據 UID 進行限流。
  • 最大程度的減少請求落到 DB。
  • 多利用快取。
  • 同步操作非同步化。
  • fail fast,儘早失敗,保護應用。

其實不止秒殺系統,個人覺得系統優化都可以參考這幾個維度。這些方面都進行優化過了,可以再考慮其他方面的優化。

秒殺系統的開原始碼

Spring-Boot相關的開源秒殺框架

參考

相關文章