如何設計一個秒殺系統

系統要求

高性能

秒殺涉及大量的併發讀和併發寫,因此要求性能必須高;

一致性

秒殺中商品減庫存的實現方式同樣關鍵。可想而知,有限數量的商品同一時刻被很多倍的請求同時來減庫存。在大併發更新數據的過程中保證數據的準確性,難度可想而知;

高可用

出現問題的時候要保證可用;

架構原則

1. 數據要儘量少

所謂“數據要儘量少”,就是指用戶請求的數據能少則少。請求的數據包括上傳給系統的數據和系統返回給用戶的數據(通常就是網頁)。

因為這些數據在網絡上傳輸需要時間,其次不管是請求數據還是返回數據都需要服務器來處理,而服務器在寫網絡時都要做壓縮和編碼,這些都非常消耗CPU,所以減少傳輸的數據量可以顯著的減少CPU的使用。例如,我們可以簡化秒殺頁面的大小,去掉不必要的頁面裝修效果等。

其次,“數據要儘量少”還要求系統依賴的數據能少就少,包括系統完成某些業務邏輯需要讀取和保存的數據,這些數據一般是和後臺服務和數據庫打交道的。調用其他服務會涉及到數據的序列化和反序列化,這也是CPU的一大殺手,同樣也會增加延時。而且,數據庫本身也容易成為一個瓶頸,所以和數據庫打交道越少越好,越簡單越好。

2. 請求數要儘量少

用戶請求的頁面返回後,瀏覽器渲染頁面還包含其他的額外請求,比如說這個頁面依賴的CSS/Javascript,圖片以及Ajax請求等,這些額外請求應該儘量少。因為瀏覽器每發出一個請求都多少會有一些消耗,例如建立連接要做三次握手,另外不同請求訪問的域名不一樣,還需要做DNS域名解析,可能會耗時更久。

3. 路徑要儘量短

所謂“路徑”,就是用戶發出請求到返回數據這個過程中,需求經過的中間的節點數。

4. 依賴要儘量少

所謂依賴,指的是要完成一次用戶請求必須依賴的系統或者服務,這裡的依賴指的是強依賴。

5. 不要有單點

系統中的單點可以說是系統架構上的一個大忌,因為單點意味著沒有備份,風險不可控,我們設計分佈式系統最重要的原則就是“消除單點”。

做好動靜分離

何為動靜數據

簡單來說,“靜態數據”和“動態數據”的主要區別就是看頁面中輸出的數據是否和URL、瀏覽器、時間、地域相關,以及是否含有Cookie等私密信息。

如何做動靜分離的改造

下面我們從5個方面來分離出動態內容:

1. URL唯一化。商品詳情繫統天然地就可以做到 URL 唯一化,比如每個商品都由 ID 來標識,那麼 http://item.xxx.com/item.htm?id=xxxx 就可以作為唯一的 URL 標識。為啥要 URL 唯一呢?前面說了我們是要緩存整個 HTTP 連接,那麼以什麼作為 Key 呢?就以 URL 作為緩存的 Key,例如以 id=xxx 這個格式進行區分。

2. 分離瀏覽者相關的因素。瀏覽者相關的因素包括是否已登錄,以及登錄身份等,這些因素可以單獨拆分出來,通過動態請求來獲取。

3. 分離時間因素。服務器輸出的時間也要動態請求來獲取。

4. 異步化地域因素。詳情頁上與地域相關的因素做成異步獲取。

5. 服務端輸出的頁面包含的 Cookie 可以通過代碼軟件來刪除,如 Web 服務器 Varnish 可以通過 unset req.http.cookie 命令去掉 Cookie。注意,這裡說的去掉 Cookie 並不是用戶端收到的頁面就不含 Cookie 了,而是說,在緩存的靜態數據中不含有 Cookie。

靜態數據如何緩存

第一,你應該把靜態數據緩存到離用戶最近的地方。靜態數據就是那些相對不會變化的數據,因此我們可以把它們緩存起來。緩存到哪裡呢?常見的就三種,用戶瀏覽器裡、CDN 上或者在服務端的 Cache 中。你應該根據情況,把它們儘量緩存到離用戶最近的地方。

第二,靜態化改造就是要直接緩存 HTTP 連接。相較於普通的數據緩存而言,你肯定還聽過系統的靜態化改造。靜態化改造是直接緩存 HTTP 連接而不是僅僅緩存數據,如下圖所示,Web 代理服務器根據請求 URL,直接取出對應的 HTTP 響應頭和響應體然後直接返回,這個響應過程簡單得連 HTTP 協議都不用重新組裝,甚至連 HTTP 請求頭也不需要解析。

動態數據如何處理

1. ESI 方案(或者 SSI):即在 Web 代理服務器上做動態內容請求,並將請求插入到靜態頁面中,當用戶拿到頁面時已經是一個完整的頁面了。這種方式對服務端性能有些影響,但是用戶體驗較好。

2. CSI 方案。即單獨發起一個異步 JavaScript 請求,以向服務端獲取動態內容。這種方式服務端性能更佳,但是用戶端頁面可能會延時,體驗稍差。

動靜分離的幾種架構方案

1. 實體機單機部署

2. 統一Cache層

3. 上CDN

如何處理熱點數據

處理熱點數據的通常幾個思路:一是優化,二是限制,三是隔離。

先來說說優化。優化熱點數據最有效的辦法就是緩存熱點數據,如果熱點數據做了動靜分離,那麼可以長期緩存靜態數據。但是,緩存熱點數據更多的是“臨時”緩存,即不管是靜態數據還是動態數據,都用一個隊列短暫地緩存數秒鐘,由於隊列長度有限,可以採用 LRU 淘汰算法替換。

再來說說限制。限制更多的是一種保護機制,限制的辦法也有很多,例如對被訪問商品的 ID 做一致性 Hash,然後根據 Hash 做分桶,每個分桶設置一個處理隊列,這樣可以把熱點商品限制在一個請求隊列裡,防止因某些熱點商品佔用太多的服務器資源,而使其他請求始終得不到服務器的處理資源。

最後介紹一下隔離。秒殺系統設計的第一個原則就是將這種熱點數據隔離出來,不要讓 1% 的請求影響到另外的 99%,隔離出來後也更方便對這 1% 的請求做針對性的優化。

具體到“秒殺”業務,我們可以在以下幾個層次實現隔離。

  • 業務隔離。把秒殺做成一種營銷活動,賣家要參加秒殺這種營銷活動需要單獨報名,從技術上來說,賣家報名後對我們來說就有了已知熱點,因此可以提前做好預熱。
  • 系統隔離。系統隔離更多的是運行時的隔離,可以通過分組部署的方式和另外 99% 分開。秒殺可以申請單獨的域名,目的也是讓請求落到不同的集群中。
  • 數據隔離。秒殺所調用的數據大部分都是熱點數據,比如會啟用單獨的 Cache 集群或者 MySQL 數據庫來放熱點數據,目的也是不想 0.01% 的數據有機會影響 99.99% 數據。

當然了,實現隔離有很多種辦法。比如,你可以按照用戶來區分,給不同的用戶分配不同的 Cookie,在接入層,路由到不同的服務接口中;再比如,你還可以在接入層針對 URL 中的不同 Path 來設置限流策略。服務層調用不同的服務接口,以及數據層通過給數據打標來區分等等這些措施,其目的都是把已經識別出來的熱點請求和普通的請求區分開。

流量削峰該怎麼做

常用的一些思路:排隊,答題,分層過濾。

排隊

要對流量進行削峰,最容易想到的解決方案就是用消息隊列來緩衝瞬時流量,把同步的直接調用轉換成異步的間接推送,中間通過一個隊列在一端承接瞬時的流量洪峰,在另一端平滑地將消息推送出去。在這裡,消息隊列就像“水庫”一樣,攔蓄上游的洪水,削減進入下游河道的洪峰流量,從而達到減免洪水災害的目的。

如何設計一個秒殺系統

答題

答題的目的有兩個:

1. 防止用戶作弊;

2. 延緩用戶請求;

如何設計一個秒殺系統

分層過濾

分層校驗的目的是:在讀系統中,儘量減少由於一致性校驗帶來的系統瓶頸,但是儘量將不影響性能的檢查條件提前,如用戶是否具有秒殺資格、商品狀態是否正常、用戶答題是否正確、秒殺是否已經結束、是否非法請求、營銷等價物是否充足等;在寫數據系統中,主要對寫的數據(如“庫存”)做一致性檢查,最後在數據庫層保證數據的最終準確性(如“庫存”不能減為負數)。

如何設計一個秒殺系統

減庫存的核心邏輯

減庫存的幾種方式

  • 下單減庫存,即當買家下單後,在商品的總庫存中減去買家購買數量。下單減庫存是最簡單的減庫存方式,也是控制最精確的一種,下單時直接通過數據庫的事務機制控制商品庫存,這樣一定不會出現超賣的情況。但是你要知道,有些人下完單可能並不會付款。
  • 付款減庫存,即買家下單後,並不立即減庫存,而是等到有用戶付款後才真正減庫存,否則庫存一直保留給其他買家。但因為付款時才減庫存,如果併發比較高,有可能出現買家下單後付不了款的情況,因為可能商品已經被其他人買走了。
  • 預扣庫存,這種方式相對複雜一些,買家下單後,庫存為其保留一定的時間(如 10 分鐘),超過這個時間,庫存將會自動釋放,釋放後其他買家就可以繼續購買。在買家付款前,系統會校驗該訂單的庫存是否還有保留:如果沒有保留,則再次嘗試預扣;如果庫存不足(也就是預扣失敗)則不允許繼續付款;如果預扣成功,則完成付款並實際地減去庫存。

減庫存可能存在的問題

假如我們採用“下單減庫存”的方式,即用戶下單後就減去庫存,正常情況下,買家下單後付款的概率會很高,所以不會有太大問題。但是有一種場景例外,就是當賣家參加某個活動時,此時活動的有效時間是商品的黃金售賣時間,如果有競爭對手通過惡意下單的方式將該賣家的商品全部下單,讓這款商品的庫存減為零,那麼這款商品就不能正常售賣了。要知道,這些惡意下單的人是不會真正付款的,這正是“下單減庫存”方式的不足之處。

既然“下單減庫存”可能導致惡意下單,從而影響賣家的商品銷售,那麼有沒有辦法解決呢?你可能會想,採用“付款減庫存”的方式是不是就可以了?的確可以。但是,“付款減庫存”又會導致另外一個問題:庫存超賣。

假如有 100 件商品,就可能出現 300 人下單成功的情況,因為下單時不會減庫存,所以也就可能出現下單成功數遠遠超過真正庫存數的情況,這尤其會發生在做活動的熱門商品上。這樣一來,就會導致很多買家下單成功但是付不了款,買家的購物體驗自然比較差。

那麼,既然“下單減庫存”和“付款減庫存”都有缺點,我們能否把兩者相結合,將兩次操作進行前後關聯起來,下單時先預扣,在規定時間內不付款再釋放庫存,即採用“預扣庫存”這種方式呢?

這種方案確實可以在一定程度上緩解上面的問題。但是否就徹底解決了呢?其實沒有!針對惡意下單這種情況,雖然把有效的付款時間設置為 10 分鐘,但是惡意買家完全可以在 10 分鐘後再次下單,或者採用一次下單很多件的方式把庫存減完。針對這種情況,解決辦法還是要結合安全和反作弊的措施來制止。

例如,給經常下單不付款的買家進行識別打標(可以在被打標的買家下單時不減庫存)、給某些類目設置最大購買件數(例如,參加活動的商品一人最多隻能買 3 件),以及對重複下單不付款的操作進行次數限制等。

針對“庫存超賣”這種情況,在 10 分鐘時間內下單的數量仍然有可能超過庫存數量,遇到這種情況我們只能區別對待:對普通的商品下單數量超過庫存數量的情況,可以通過補貨來解決;但是有些賣家完全不允許庫存為負數的情況,那隻能在買家付款時提示庫存不足。

大型秒殺中如何減庫存

目前來看,業務系統中最常見的就是預扣庫存方案,像你在買機票、買電影票時,下單後一般都有個“有效付款時間”,超過這個時間訂單自動釋放,這都是典型的預扣庫存方案。而具體到秒殺這個場景,應該採用哪種方案比較好呢?

由於參加秒殺的商品,一般都是“搶到就是賺到”,所以成功下單後卻不付款的情況比較少,再加上賣家對秒殺商品的庫存有嚴格限制,所以秒殺商品採用“下單減庫存”更加合理。另外,理論上由於“下單減庫存”比“預扣庫存”以及涉及第三方支付的“付款減庫存”在邏輯上更為簡單,所以性能上更佔優勢。

"下單減庫存"在數據一致性上,主要就是保證大併發請求時庫存數據不能為負數,也就是要保證數據庫中的庫存字段值不能為負數,一般我們有多種解決方案:一種是在應用程序中通過事務來判斷,即保證減後庫存不能為負數,否則就回滾;另一種辦法是直接設置數據庫的字段數據為無符號整數,這樣減後庫存字段值小於零時會直接執行 SQL 語句來報錯;再有一種就是使用 CASE WHEN 判斷語句,例如這樣的 SQL 語句:

UPDATE item SET inventory = CASE WHEN inventory >= xxx THEN inventory-xxx ELSE inventory END

秒殺減庫存的極致優化

在交易環節中,“庫存”是個關鍵數據,也是個熱點數據,因為交易的各個環節中都可能涉及對庫存的查詢。但是,我在前面介紹分層過濾時提到過,秒殺中並不需要對庫存有精確的一致性讀,把庫存數據放到緩存(Cache)中,可以大大提升讀性能。

解決大併發讀問題,可以採用 LocalCache(即在秒殺系統的單機上緩存商品相關的數據)和對數據進行分層過濾的方式,但是像減庫存這種大併發寫無論如何還是避免不了,這也是秒殺場景下最為核心的一個技術難題。

因此,這裡我想專門來說一下秒殺場景下減庫存的極致優化思路,包括如何在緩存中減庫存以及如何在數據庫中減庫存。

秒殺商品和普通商品的減庫存還是有些差異的,例如商品數量比較少,交易時間段也比較短,因此這裡有一個大膽的假設,即能否把秒殺商品減庫存直接放到緩存系統中實現,也就是直接在緩存中減庫存或者在一個帶有持久化功能的緩存系統(如 Redis)中完成呢?

如果你的秒殺商品的減庫存邏輯非常單一,比如沒有複雜的 SKU 庫存和總庫存這種聯動關係的話,我覺得完全可以。但是如果有比較複雜的減庫存邏輯,或者需要使用事務,你還是必須在數據庫中完成減庫存。

由於 MySQL 存儲數據的特點,同一數據在數據庫裡肯定是一行存儲(MySQL),因此會有大量線程來競爭 InnoDB 行鎖,而併發度越高時等待線程會越多,TPS(Transaction Per Second,即每秒處理的消息數)會下降,響應時間(RT)會上升,數據庫的吞吐量就會嚴重受影響。

這就可能引發一個問題,就是單個熱點商品會影響整個數據庫的性能, 導致 0.01% 的商品影響 99.99% 的商品的售賣,這是我們不願意看到的情況。一個解決思路是遵循前面介紹的原則進行隔離,把熱點商品放到單獨的熱點庫中。但是這無疑會帶來維護上的麻煩,比如要做熱點數據的動態遷移以及單獨的數據庫等。

而分離熱點商品到單獨的數據庫還是沒有解決併發鎖的問題,我們應該怎麼辦呢?要解決併發鎖的問題,有兩種辦法:

  • 應用層做排隊。按照商品維度設置隊列順序執行,這樣能減少同一臺機器對數據庫同一行記錄進行操作的併發度,同時也能控制單個商品佔用數據庫連接的數量,防止熱點商品佔用太多的數據庫連接。
  • 數據庫層做排隊。應用層只能做到單機的排隊,但是應用機器數本身很多,這種排隊方式控制併發的能力仍然有限,所以如果能在數據庫層做全局排隊是最理想的。阿里的數據庫團隊開發了針對這種 MySQL 的 InnoDB 層上的補丁程序(patch),可以在數據庫層上對單行記錄做到併發排隊。

另外,數據更新問題除了前面介紹的熱點隔離和排隊處理之外,還有些場景(如對商品的 lastmodifytime 字段的)更新會非常頻繁,在某些場景下這些多條 SQL 是可以合併的,一定時間內只要執行最後一條 SQL 就行了,以便減少對數據庫的更新操作。

全文:https://time.geekbang.org/column/intro/127

相關推薦

推薦中...