如何設計一個優秀的秒殺系統?

程式設計師阿牛發表於2021-08-19

前言

如果問起秒殺系統如何設計,相信都能說出個123來,但是如果細究其中的細節點,很多人估計就無法很快的打上來了。本文從六個方面,來簡要講一下秒殺系統要如何設計,應該主要哪些事情。

01 | 設計秒殺系統時應該注意的5個架構原則

說起秒殺,我想你肯定不陌生,這兩年,從雙十一購物到春節搶紅包,再到 12306 搶火車票,“秒殺”的場景處處可見。簡單來說,秒殺就是在同一個時刻有大量的請求爭搶購買同一個商品並完成交易的過程,用技術的行話來說就是大量的併發讀和併發寫。不管是哪一門語言,併發都是程式設計師們最為頭疼的部分。

同樣,對於一個軟體而言也是這樣,你可以很快增刪改查做出一個秒殺系統,但是要讓它支援高併發訪問就沒那麼容易了。比如說,如何讓系統面對百萬級的請求流量不出故障?如何保證高併發情況下資料的一致性寫?完全靠堆伺服器來解決嗎?這顯然不是最好的解決方案。

在我看來,秒殺系統本質上就是一個滿足大併發、高效能和高可用的分散式系統。今天,我們就來聊聊,如何在滿足一個良好架構的分散式系統基礎上,針對秒殺這種業務做到極致的效能改進。

架構原則:“4 要 1 不要

”如果你是一個架構師,你首先要勾勒出一個輪廓,想一想如何構建一個超大流量併發讀寫、高效能,以及高可用的系統,這其中有哪些要素需要考慮。我把這些要素總結為“4 要 1 不要”。
** 1. 資料要儘量少**
所謂“資料要儘量少”,首先是指使用者請求的資料能少就少。請求的資料包括上傳給系統的資料和系統返回給使用者的資料(通常就是網頁)。為啥“資料要儘量少”呢?因為首先這些資料在網路上傳輸需要時間,其次不管是請求資料還是返回資料都需要伺服器做處理,而伺服器在寫網路時通常都要做壓縮和字元編碼,這些都非常消耗 CPU,所以減少傳輸的資料量可以顯著減少 CPU 的使用。例如,我們可以簡化秒殺頁面的大小,去掉不必要的頁面裝修效果,等等。其次,“資料要儘量少”還要求系統依賴的資料能少就少,包括系統完成某些業務邏輯需要讀取和儲存的資料,這些資料一般是和後臺服務以及資料庫打交道的。呼叫其他服務會涉及資料的序列化和反序列化,而這也是 CPU 的一大殺手,同樣也會增加延時。而且,資料庫本身也容易成為一個瓶頸,所以和資料庫打交道越少越好,資料越簡單、越小則越好。

** 2. 請求數要儘量少**
使用者請求的頁面返回後,瀏覽器渲染這個頁面還要包含其他的額外請求,比如說,這個頁面依賴的 CSS/JavaScript、圖片,以及 Ajax 請求等等都定義為“額外請求”,這些額外請求應該儘量少。因為瀏覽器每發出一個請求都多少會有一些消耗,例如建立連線要做三次握手,有的時候有頁面依賴或者連線數限制,一些請求(例如 JavaScript)還需要序列載入等。另外,如果不同請求的域名不一樣的話,還涉及這些域名的 DNS 解析,可能會耗時更久。所以你要記住的是,減少請求數可以顯著減少以上這些因素導致的資源消耗。例如,減少請求數最常用的一個實踐就是合併 CSS 和 JavaScript 檔案,把多個 JavaScript 檔案合併成一個檔案,在 URL 中用逗號隔開(https://g.xxx.com/tm/xx-b/4.0.94/mods/??module-preview/index.xtpl.js,module-jhs/index.xtpl.js,module-focus/index.xtpl.js)。這種方式在服務端仍然是單個檔案各自存放,只是服務端會有一個元件解析這個 URL,然後動態把這些檔案合併起來一起返回。

3. 路徑要儘量短
所謂“路徑”,就是使用者發出請求到返回資料這個過程中,需求經過的中間的節點數。通常,這些節點可以表示為一個系統或者一個新的 Socket 連線(比如代理伺服器只是建立一個新的 Socket 連線來轉發請求)。每經過一個節點,一般都會產生一個新的 Socket 連線。然而,每增加一個連線都會增加新的不確定性。從概率統計上來說,假如一次請求經過 5 個節點,每個節點的可用性是 99.9% 的話,那麼整個請求的可用性是:99.9% 的 5 次方,約等於 99.5%。所以縮短請求路徑不僅可以增加可用性,同樣可以有效提升效能(減少中間節點可以減少資料的序列化與反序列化),並減少延時(可以減少網路傳輸耗時)。要縮短訪問路徑有一種辦法,就是多個相互強依賴的應用合併部署在一起,把遠端過程呼叫(RPC)變成 JVM 內部之間的方法呼叫。在《大型網站技術架構演進與效能優化》一書中,我也有一章介紹了這種技術的詳細實現。

4. 依賴要儘量少
所謂依賴,指的是要完成一次使用者請求必須依賴的系統或者服務,這裡的依賴指的是強依賴。舉個例子,比如說你要展示秒殺頁面,而這個頁面必須強依賴商品資訊、使用者資訊,還有其他如優惠券、成交列表等這些對秒殺不是非要不可的資訊(弱依賴),這些弱依賴在緊急情況下就可以去掉。要減少依賴,我們可以給系統進行分級,比如 0 級系統、1 級系統、2 級系統、3 級系統,0 級系統如果是最重要的系統,那麼 0 級系統強依賴的系統也同樣是最重要的系統,以此類推。注意,0 級系統要儘量減少對 1 級系統的強依賴,防止重要的系統被不重要的系統拖垮。例如支付系統是 0 級系統,而優惠券是 1 級系統的話,在極端情況下可以把優惠券給降級,防止支付系統被優惠券這個 1 級系統給拖垮。

5. 不要有單點
系統中的單點可以說是系統架構上的一個大忌,因為單點意味著沒有備份,風險不可控,我們設計分散式系統最重要的原則就是“消除單點”。那如何避免單點呢?我認為關鍵點是避免將服務的狀態和機器繫結,即把服務無狀態化,這樣服務就可以在機器中隨意移動。如何那把服務的狀態和機器解耦呢?這裡也有很多實現方式。例如把和機器相關的配置動態化,這些引數可以通過配置中心來動態推送,在服務啟動時動態拉取下來,我們在這些配置中心設定一些規則來方便地改變這些對映關係。應用無狀態化是有效避免單點的一種方式,但是像儲存服務本身很難無狀態化,因為資料要儲存在磁碟上,本身就要和機器繫結,那麼這種場景一般要通過冗餘多個備份的方式來解決單點問題。前面介紹了這些設計上的一些原則,但是你有沒有發現,我一直說的是“儘量”而不是“絕對”?我想你肯定會問是不是請求最少就一定最好,我的答案是“不一定”。我們曾經把有些 CSS 內聯進頁面裡,這樣做可以減少依賴一個 CSS 的請求從而加快首頁的渲染,但是同樣也增大了頁面的大小,又不符合“資料要儘量少”的原則,這種情況下我們為了提升首屏的渲染速度,只把首屏的 HTML 依賴的 CSS 內聯進來,其他 CSS 仍然放到檔案中作為依賴載入,儘量實現首屏的開啟速度與整個頁面載入效能的平衡。所以說,架構是一種平衡的藝術,而最好的架構一旦脫離了它所適應的場景,一切都將是空談。我希望你記住的是,這裡所說的幾點都只是一個個方向,你應該儘量往這些方向上去努力,但也要考慮平衡其他因素。
不同場景下的不同架構案例
前面我說了一些架構上的原則,那麼針對“秒殺”這個場景,怎樣才是一個好的架構呢?下面我以淘寶早期秒殺系統架構的演進為主線,來幫你梳理不同的請求體量下,我認為的最佳秒殺系統架構。

前面我說了一些架構上的原則,那麼針對“秒殺”這個場景,怎樣才是一個好的架構呢?下面我以淘寶早期秒殺系統架構的演進為主線,來幫你梳理不同的請求體量下,我認為的最佳秒殺系統架構。如果你想快速搭建一個簡單的秒殺系統,只需要把你的商品購買頁面增加一個“定時上架”功能,僅在秒殺開始時才讓使用者看到購買按鈕,當商品的庫存賣完了也就結束了。這就是當時第一個版本的秒殺系統實現方式。但隨著請求量的加大(比如從 1w/s 到了 10w/s 的量級),這個簡單的架構很快就遇到了瓶頸,因此需要做架構改造來提升系統效能。
這些架構改造包括:

  • 把秒殺系統獨立出來單獨打造一個系統,這樣可以有針對性地做優化,例如這個獨立出來的系統就減少了店鋪裝修的功能,減少了頁面的複雜度;
  • 在系統部署上也獨立做一個機器叢集,這樣秒殺的大流量就不會影響到正常的商品購買叢集的機器負載;
  • 將熱點資料(如庫存資料)單獨放到一個快取系統中,以提高“讀效能”;
  • 增加秒殺答題,防止有秒殺器搶單。

此時的系統架構變成了下圖這個樣子。最重要的就是,秒殺詳情成為了一個獨立的新系統,另外核心的一些資料放到了快取(Cache)中,其他的關聯絡統也都以獨立叢集的方式進行部署。

file

然而這個架構仍然支援不了超過 100w/s 的請求量,所以為了進一步提升秒殺系統的效能,我們又對架構做進一步升級,
比如:

  • 對頁面進行徹底的動靜分離,使得使用者秒殺時不需要重新整理整個頁面,而只需要點選搶寶按鈕,藉此把頁面重新整理的資料降到最少;
  • 在服務端對秒殺商品進行本地快取,不需要再呼叫依賴系統的後臺服務獲取資料,甚至不需要去公共的快取叢集中查詢資料,這樣不僅可以減少系統呼叫,而且能夠避免壓垮公共快取叢集。
  • 增加系統限流保護,防止最壞情況發生。

經過這些優化,系統架構變成了下圖中的樣子。在這裡,我們對頁面進行了進一步的靜態化,秒殺過程中不需要重新整理整個頁面,而只需要向服務端請求很少的動態資料。而且,最關鍵的詳情和交易系統都增加了本地快取,來提前快取秒殺商品的資訊,熱點資料庫也做了獨立部署,等等。

file

從前面的幾次升級來看,其實越到後面需要定製的地方越多,也就是越“不通用”。例如,把秒殺商品快取在每臺機器的記憶體中,這種方式顯然不適合太多的商品同時進行秒殺的情況,因為單機的記憶體始終有限。所以要取得極致的效能,就要在其他地方(比如,通用性、易用性、成本等方面)有所犧牲。

02 | 如何才能做好動靜分離?有哪些方案可選?

資料的動靜分離。不知道你之前聽過這個解決方案嗎?不管你有沒有聽過,我都建議你先停下來思考動靜分離的價值。如果你的系統還沒有開始應用動靜分離的方案,那你也可以想想為什麼沒有,是之前沒有想到,還是說業務體量根本用不著?不過我可以確信地說,如果你在一個業務飛速發展的公司裡,並且你在深度參與公司內類秒殺類系統的架構或者開發工作,那麼你遲早會想到動靜分離的方案。為什麼?很簡單,秒殺的場景中,對於系統的要求其實就三個字:快、準、穩。

那怎麼才能“快”起來呢?我覺得抽象起來講,就只有兩點,一點是提高單次請求的效率,一點是減少沒必要的請求。今天我們聊到的“動靜分離”其實就是瞄著這個大方向去的。
不知道你是否還記得,最早的秒殺系統其實是要重新整理整體頁面的,但後來秒殺的時候,你只要點選“重新整理搶寶”按鈕就夠了,這種變化的本質就是動靜分離,分離之後,客戶端大幅度減少了請求的資料量。這不自然就“快”了嗎?

何為動靜資料

那到底什麼才是動靜分離呢?所謂“動靜分離”,其實就是把使用者請求的資料(如 HTML 頁面)劃分為“動態資料”和“靜態資料”。

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

很多媒體類的網站,某一篇文章的內容不管是你訪問還是我訪問,它都是一樣的。所以它就是一個典型的靜態資料,但是它是個動態頁面。

我們如果現在訪問淘寶的首頁,每個人看到的頁面可能都是不一樣的,淘寶首頁中包含了很多根據訪問者特徵推薦的資訊,而這些個性化的資料就可以理解為動態資料了。

這裡再強調一下,我們所說的靜態資料,不能僅僅理解為傳統意義上完全存在磁碟上的 HTML 頁面,它也可能是經過 Java 系統產生的頁面,但是它輸出的頁面本身不包含上面所說的那些因素。也就是所謂“動態”還是“靜態”,並不是說資料本身是否動靜,而是資料中是否含有和訪問者相關的個性化資料。

還有一點要注意,就是頁面中“不包含”,指的是“頁面的 HTML 原始碼中不含有”,這一點務必要清楚。理解了靜態資料和動態資料,我估計你很容易就能想明白“動靜分離”這個方案的來龍去脈了。分離了動靜資料,我們就可以對分離出來的靜態資料做快取,有了快取之後,靜態資料的“訪問效率”自然就提高了。
那麼,怎樣對靜態資料做快取呢?我在這裡總結了幾個重點。

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

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

file

第三,讓誰來快取靜態資料也很重要。不同語言寫的 Cache 軟體處理快取資料的效率也各不相同。以 Java 為例,因為 Java 系統本身也有其弱點(比如不擅長處理大量連線請求,每個連線消耗的記憶體較多,Servlet 容器解析 HTTP 協議較慢),所以你可以不在 Java 層做快取,而是直接在 Web 伺服器層上做,這樣你就可以遮蔽 Java 語言層面的一些弱點;而相比起來,Web 伺服器(如 Nginx、Apache、Varnish)也更擅長處理大併發的靜態檔案請求。

如何做動靜分離的改造

理解了動靜態資料的“why”和“what”,接下來我們就要看“how”了。我們如何把動態頁面改造成適合快取的靜態頁面呢?其實也很簡單,就是去除前面所說的那幾個影響因素,把它們單獨分離出來,做動靜分離。

下面,我以典型的商品詳情繫統為例來詳細介紹。這裡,你可以先開啟京東或者淘寶的商品詳情頁,看看這個頁面裡都有哪些動靜資料。我們從以下 5 個方面來分離出動態內容。

- URL 唯一化。商品詳情繫統天然地就可以做到 URL 唯一化,比如每個商品都由 ID 來標識,那麼 http://item.xxx.com/item.htm?id=xxxx 就可以作為唯一的 URL 標識。為啥要 URL 唯一呢?前面說了我們是要快取整個 HTTP 連線,那麼以什麼作為 Key 呢?就以 URL 作為快取的 Key,例如以 id=xxx 這個格式進行區分。
- 分離瀏覽者相關的因素。瀏覽者相關的因素包括是否已登入,以及登入身份等,這些相關因素我們可以單獨拆分出來,通過動態請求來獲取。
- 分離時間因素。服務端輸出的時間也通過動態請求獲取。
- 非同步化地域因素。詳情頁面上與地域相關的因素做成非同步方式獲取,當然你也可以通過動態請求方式獲取,只是這裡通過非同步獲取更合適。
- 去掉 Cookie。服務端輸出的頁面包含的 Cookie 可以通過程式碼軟體來刪除,如 Web 伺服器 Varnish 可以通過 unset req.http.cookie 命令去掉 Cookie。注意,這裡說的去掉 Cookie 並不是使用者端收到的頁面就不含 Cookie 了,而是說,在快取的靜態資料中不含有 Cookie。

分離出動態內容之後,如何組織這些內容頁就變得非常關鍵了。這裡我要提醒你一點,因為這其中很多動態內容都會被頁面中的其他模組用到,如判斷該使用者是否已登入、使用者 ID 是否匹配等,所以這個時候我們應該將這些資訊 JSON 化(用 JSON 格式組織這些資料),以方便前端獲取。

前面我們介紹裡用快取的方式來處理靜態資料。而動態內容的處理通常有兩種方案:ESI(Edge Side Includes)方案和 CSI(Client Side Include)方案。

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

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

動靜分離的幾種架構方案

前面我們通過改造把靜態資料和動態資料做了分離,那麼如何在系統架構上進一步對這些動態和靜態資料重新組合,再完整地輸出給使用者呢?這就涉及對使用者請求路徑進行合理的架構了。根據架構上的複雜度,有 3 種方案可選:實體機單機部署;統一 Cache 層;上 CDN。

方案 1:實體機單機部署

這種方案是將虛擬機器改為實體機,以增大 Cache 的容量,並且採用了一致性 Hash 分組的方式來提升命中率。這裡將 Cache 分成若干組,是希望能達到命中率和訪問熱點的平衡。Hash 分組越少,快取的命中率肯定就會越高,但短板是也會使單個商品集中在一個分組中,容易導致 Cache 被擊穿,所以我們應該適當增加多個相同的分組,來平衡訪問熱點和命中率的問題。這裡我給出了實體機單機部署方案的結構圖,如下:

file

實體機單機部署有以下幾個優點:
沒有網路瓶頸,而且能使用大記憶體;
既能提升命中率,又能減少 Gzip 壓縮;
減少 Cache 失效壓力,因為採用定時失效方式,例如只快取 3 秒鐘,過期即自動失效。
這個方案中,雖然把通常只需要虛擬機器或者容器執行的 Java 應用換成實體機,優勢很明顯,它會增加單機的記憶體容量,但是一定程度上也造成了 CPU 的浪費,因為單個的 Java 程式很難用完整個實體機的 CPU。

另外就是,一個實體機上部署了 Java 應用又作為 Cache 來使用,這造成了運維上的高複雜度,所以這是一個折中的方案。如果你的公司裡,沒有更多的系統有類似需求,那麼這樣做也比較合適,如果你們有多個業務系統都有靜態化改造的需求,那還是建議把 Cache 層單獨抽出來公用比較合理,如下面的方案 2 所示。

方案 2:統一 Cache 層

所謂統一 Cache 層,就是將單機的 Cache 統一分離出來,形成一個單獨的 Cache 叢集。統一 Cache 層是個更理想的可推廣方案,該方案的結構圖如下:

file

**將 Cache 層單獨拿出來統一管理可以減少運維成本,同時也方便接入其他靜態化系統。此外,它還有一些優點。 **

單獨一個 Cache 層,可以減少多個應用接入時使用 Cache 的成本。這樣接入的應用只要維護自己的 Java 系統就好,不需要單獨維護 Cache,而只關心如何使用即可
統一 Cache 的方案更易於維護,如後面加強監控、配置的自動化,只需要一套解決方案就行,統一起來維護升級也比較方便。
可以共享記憶體,最大化利用記憶體,不同系統之間的記憶體可以動態切換,從而能夠有效應對各種攻擊。

這種方案雖然維護上更方便了,但是也帶來了其他一些問題,比如快取更加集中,導致:

Cache 層內部交換網路成為瓶頸;
快取伺服器的網路卡也會是瓶頸;
機器少風險較大,掛掉一臺就會影響很大一部分快取資料。

要解決上面這些問題,可以再對 Cache 做 Hash 分組,即一組 Cache 快取的內容相同,這樣能夠避免熱點資料過度集中導致新的瓶頸產生。

方案 3:上 CDN

在將整個系統做動靜分離後,我們自然會想到更進一步的方案,就是將 Cache 進一步前移到 CDN 上,因為 CDN 離使用者最近,效果會更好。
但是要想這麼做,有以下幾個問題需要解決。

失效問題。前面我們也有提到過快取時效的問題,不知道你有沒有理解,我再來解釋一下。談到靜態資料時,我說過一個關鍵詞叫“相對不變”,它的言外之意是“可能會變化”。比如一篇文章,現在不變,但如果你發現個錯別字,是不是就會變化了?如果你的快取時效很長,那使用者端在很長一段時間內看到的都是錯的。所以,這個方案中也是,我們需要保證 CDN 可以在秒級時間內,讓分佈在全國各地的 Cache 同時失效,這對 CDN 的失效系統要求很高。

命中率問題。Cache 最重要的一個衡量指標就是“高命中率”,不然 Cache 的存在就失去了意義。同樣,如果將資料全部放到全國的 CDN 上,必然導致 Cache 分散,而 Cache 分散又會導致訪問請求命中同一個 Cache 的可能性降低,那麼命中率就成為一個問題。

釋出更新問題。如果一個業務系統每週都有日常業務需要釋出,那麼釋出系統必須足夠簡潔高效,而且你還要考慮有問題時快速回滾和排查問題的簡便性。

從前面的分析來看,將商品詳情繫統放到全國的所有 CDN 節點上是不太現實的,因為存在失效問題、命中率問題以及系統的釋出更新問題。那麼是否可以選擇若干個節點來嘗試實施呢?答案是“可以”,但是這樣的節點**需要滿足幾個條件: **
靠近訪問量比較集中的地區;
離主站相對較遠;
節點到主站間的網路比較好,而且穩定;
節點容量比較大,不會佔用其他 CDN 太多的資源。

最後,還有一點也很重要,那就是:節點不要太多。基於上面幾個因素,選擇 CDN 的二級 Cache 比較合適,因為二級 Cache 數量偏少,容量也更大,讓使用者的請求先回源的 CDN 的二級 Cache 中,如果沒命中再回源站獲取資料
部署方式如下圖所示:
file

使用 CDN 的二級 Cache 作為快取,可以達到和當前服務端靜態化 Cache 類似的命中率,因為節點數不多,Cache 不是很分散,訪問量也比較集中,這樣也就解決了命中率問題,同時能夠給使用者最好的訪問體驗,是當前比較理想的一種 CDN 化方案。
除此之外,CDN 化部署方案還有以下幾個特點:

  • 把整個頁面快取在使用者瀏覽器中;
  • 如果強制重新整理整個頁面,也會請求 CDN;
  • 實際有效請求,只是使用者對“重新整理搶寶”按鈕的點選。

這樣就把 90% 的靜態資料快取在了使用者端或者 CDN 上,當真正秒殺時,使用者只需要點選特殊的“重新整理搶寶”按鈕,而不需要重新整理整個頁面。這樣一來,系統只是向服務端請求很少的有效資料,而不需要重複請求大量的靜態資料。秒殺的動態資料和普通詳情頁面的動態資料相比更少,效能也提升了 3 倍以上。

所以“搶寶”這種設計思路,讓我們不用重新整理頁面就能夠很好地請求到服務端最新的動態資料。

03 | 二八原則:有針對性地處理好系統的“熱點資料”

什麼是“熱點”

熱點分為熱點操作和熱點資料。所謂“熱點操作”,例如大量的重新整理頁面、大量的新增購物車、雙十一零點大量的下單等都屬於此類操作。對系統來說,這些操作可以抽象為“讀請求”和“寫請求”,這兩種熱點請求的處理方式大相徑庭,讀請求的優化空間要大一些,而寫請求的瓶頸一般都在儲存層,優化的思路就是根據 CAP 理論做平衡,這個內容我在“減庫存”一文再詳細介紹。

而“熱點資料”比較好理解,那就是使用者的熱點請求對應的資料。而熱點資料又分為“靜態熱點資料”和“動態熱點資料”

所謂“靜態熱點資料”,就是能夠提前預測的熱點資料。例如,我們可以通過賣家報名的方式提前篩選出來,通過報名系統對這些熱點商品進行打標。另外,我們還可以通過大資料分析來提前發現熱點商品,比如我們分析歷史成交記錄、使用者的購物車記錄,來發現哪些商品可能更熱門、更好賣,這些都是可以提前分析出來的熱點。

所謂“動態熱點資料”,就是不能被提前預測到的,系統在執行過程中臨時產生的熱點。例如,賣家在抖音上做了廣告,然後商品一下就火了,導致它在短時間內被大量購買。
由於熱點操作是使用者的行為,我們不好改變,但能做一些限制和保護,所以本文我主要針對熱點資料來介紹如何進行優化。

發現熱點資料

前面,我介紹瞭如何對單個秒殺商品的頁面資料進行動靜分離,以便針對性地對靜態資料做優化處理,那麼另外一個關鍵的問題來了:如何發現這些秒殺商品,或者更準確地說,如何發現熱點商品呢?

你可能會說“參加秒殺的商品就是秒殺商品啊”,沒錯,關鍵是系統怎麼知道哪些商品參加了秒殺活動呢?所以,你要有一個機制提前來區分普通商品和秒殺商品。

我們從發現靜態熱點和發現動態熱點兩個方面來看一下。

發現靜態熱點資料

如前面講的,靜態熱點資料可以通過商業手段,例如強制讓賣家通過報名參加的方式提前把熱點商品篩選出來,實現方式是通過一個運營系統,把參加活動的商品資料進行打標,然後通過一個後臺系統對這些熱點商品進行預處理,如提前進行快取。但是這種通過報名提前篩選的方式也會帶來新的問題,即增加賣家的使用成本,而且實時性較差,也不太靈活。不過,除了提前報名篩選這種方式,你還可以通過技術手段提前預測,例如對買家每天訪問的商品進行大資料計算,然後統計出 TOP N 的商品,我們可以認為這些 TOP N 的商品就是熱點商品。

發現動態熱點資料

我們可以通過賣家報名或者大資料預測這些手段來提前預測靜態熱點資料,但這其中有一個痛點,就是實時性較差,如果我們的系統能在秒級內自動發現熱點商品那就完美了。能夠動態地實時發現熱點不僅對秒殺商品,對其他熱賣商品也同樣有價值,所以我們需要想辦法實現熱點的動態發現功能。

這裡我給出一個動態熱點發現系統的具體實現。

1、構建一個非同步的系統,它可以收集交易鏈路上各個環節中的中介軟體產品的熱點 Key,如 Nginx、快取、RPC 服務框架等這些中介軟體(一些中介軟體產品本身已經有熱點統計模組)。
2、建立一個熱點上報和可以按照需求訂閱的熱點服務的下發規範,主要目的是通過交易鏈路上各個系統(包括詳情、購物車、交易、優惠、庫存、物流等)訪問的時間差,把上游已經發現的熱點透傳給下游系統,提前做好保護。比如,對於大促高峰期,詳情繫統是最早知道的,在統一接入層上 Nginx 模組統計的熱點 URL。
3、將上游系統收集的熱點資料傳送到熱點服務檯,然後下游系統(如交易系統)就會知道哪些商品會被頻繁呼叫,然後做熱點保護。

這裡我給出了一個圖,其中使用者訪問商品時經過的路徑有很多,我們主要是依賴前面的導購頁面(包括首頁、搜尋頁面、商品詳情、購物車等)提前識別哪些商品的訪問量高,通過這些系統中的中介軟體來收集熱點資料,並記錄到日誌中。

file

我們通過部署在每臺機器上的 Agent 把日誌彙總到聚合和分析叢集中,然後把符合一定規則的熱點資料,通過訂閱分發系統再推送到相應的系統中。你可以是把熱點資料填充到 Cache 中,或者直接推送到應用伺服器的記憶體中,還可以對這些資料進行攔截,總之下游系統可以訂閱這些資料,然後根據自己的需求決定如何處理這些資料。

打造熱點發現系統時,我根據以往經驗總結了幾點注意事項

1、這個熱點服務後臺抓取熱點資料日誌最好採用非同步方式,因為“非同步”一方面便於保證通用性,另一方面又不影響業務系統和中介軟體產品的主流程。
2、熱點服務發現和中介軟體自身的熱點保護模組並存,每個中介軟體和應用還需要保護自己。熱點服務檯提供熱點資料的收集和訂閱服務,便於把各個系統的熱點資料透明出來。
3、熱點發現要做到接近實時(3s 內完成熱點資料的發現),因為只有做到接近實時,動態發現才有意義,才能實時地對下游系統提供保護。

處理熱點資料

處理熱點資料通常有幾種思路:一是優化,二是限制,三是隔離。
先來說說優化。優化熱點資料最有效的辦法就是快取熱點資料,如果熱點資料做了動靜分離,那麼可以長期快取靜態資料。但是,快取熱點資料更多的是“臨時”快取,即不管是靜態資料還是動態資料,都用一個佇列短暫地快取數秒鐘,由於佇列長度有限,可以採用 LRU 淘汰演算法替換。

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

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

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

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

04 | 流量削峰這事應該怎麼做?

為什麼要削峰?

我們知道伺服器的處理資源是恆定的,你用或者不用它的處理能力都是一樣的,所以出現峰值的話,很容易導致忙到處理不過來,閒的時候卻又沒有什麼要處理。但是由於要保證服務質量,我們的很多處理資源只能按照忙的時候來預估,而這會導致資源的一個浪費。

這就好比因為存在早高峰和晚高峰的問題,所以有了錯峰限行的解決方案。削峰的存在,一是可以讓服務端處理變得更加平穩,二是可以節省伺服器的資源成本。針對秒殺這一場景,削峰從本質上來說就是更多地延緩使用者請求的發出,以便減少和過濾掉一些無效請求,它遵從“請求數要儘量少”的原則。

今天,我就來介紹一下流量削峰的一些操作思路:排隊、答題、分層過濾。這幾種方式都是無損(即不會損失使用者的發出請求)的實現方案,當然還有些有損的實現方案,包括我們後面要介紹的關於穩定性的一些辦法,比如限流和機器負載保護等一些強制措施也能達到削峰保護的目的,當然這都是不得已的一些措施,因此就不歸類到這裡了

排隊

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

file

但是,如果流量峰值持續一段時間達到了訊息佇列的處理上限,例如本機的訊息積壓達到了儲存空間的上限,訊息佇列同樣也會被壓垮,這樣雖然保護了下游的系統,但是和直接把請求丟棄也沒多大的區別。就像遇到洪水爆發時,即使是有水庫恐怕也無濟於事

除了訊息佇列,類似的排隊方式還有很多,例如:
1、利用執行緒池加鎖等待也是一種常用的排隊方式;
2、先進先出、先進後出等常用的記憶體排隊演算法的實現方式;
3、把請求序列化到檔案中,然後再順序地讀檔案(例如基於 MySQL binlog 的同步機制)來恢復請求等方式。

答題

你是否還記得,最早期的秒殺只是純粹地重新整理頁面和點選購買按鈕,它是後來才增加了答題功能的。那麼,為什麼要增加答題功能呢?

這主要是為了增加購買的複雜度,從而達到兩個目的。

第一個目的是防止部分買家使用秒殺器在參加秒殺時作弊。2011 年秒殺非常火的時候,秒殺器也比較猖獗,因而沒有達到全民參與和營銷的目的,所以系統增加了答題來限制秒殺器。增加答題後,下單的時間基本控制在 2s 後,秒殺器的下單比例也大大下降。答題頁面如下圖所示。

file

第二個目的其實就是延緩請求,起到對請求流量進行削峰的作用,從而讓系統能夠更好地支援瞬時的流量高峰。這個重要的功能就是把峰值的下單請求拉長,從以前的 1s 之內延長到 2s~10s。這樣一來,請求峰值基於時間分片了。這個時間的分片對服務端處理併發非常重要,會大大減輕壓力。而且,由於請求具有先後順序,靠後的請求到來時自然也就沒有庫存了,因此根本到不了最後的下單步驟,所以真正的併發寫就非常有限了。這種設計思路目前用得非常普遍,如當年支付寶的“咻一咻”、微信的“搖一搖”都是類似的方式。

這裡,我重點說一下秒殺答題的設計思路。

file

如上圖所示,整個秒殺答題的邏輯主要分為 3 部分。

題庫生成模組,這個部分主要就是生成一個個問題和答案,其實題目和答案本身並不需要很複雜,重要的是能夠防止由機器來算出結果,即防止秒殺器來答題。
題庫的推送模組,用於在秒殺答題前,把題目提前推送給詳情繫統和交易系統。題庫的推送主要是為了保證每次使用者請求的題目是唯一的,目的也是防止答題作弊。
題目的圖片生成模組,用於把題目生成為圖片格式,並且在圖片裡增加一些干擾因素。這也同樣是為防止機器直接來答題,它要求只有人才能理解題目本身的含義。這裡還要注意一點,由於答題時網路比較擁擠,我們應該把題目的圖片提前推送到 CDN 上並且要進行預熱,不然的話當使用者真正請求題目時,圖片可能載入比較慢,從而影響答題的體驗。

其實真正答題的邏輯比較簡單,很好理解:當使用者提交的答案和題目對應的答案做比較,如果通過了就繼續進行下一步的下單邏輯,否則就失敗。我們可以把問題和答案用下面這樣的 key 來進行 MD5 加密:

問題 key:userId+itemId+question_Id+time+PK
答案 key:userId+itemId+answer+PK

驗證的邏輯如下圖所示:

file

注意,這裡面的驗證邏輯,除了驗證問題的答案以外,還包括使用者本身身份的驗證,例如是否已經登入、使用者的 Cookie 是否完整、使用者是否重複頻繁提交等。
除了做正確性驗證,我們還可以對提交答案的時間做些限制,例如從開始答題到接受答案要超過 1s,因為小於 1s 是人為操作的可能性很小,這樣也能防止機器答題的情況。

分層過濾

前面介紹的排隊和答題要麼是少發請求,要麼對發出來的請求進行緩衝,而針對秒殺場景還有一種方法,就是對請求進行分層過濾,從而過濾掉一些無效的請求。分層過濾其實就是採用“漏斗”式設計來處理請求的,如下圖所示。

file

假如請求分別經過 CDN、前臺讀系統(如商品詳情繫統)、後臺系統(如交易系統)和資料庫這幾層,那麼:

大部分資料和流量在使用者瀏覽器或者 CDN 上獲取,這一層可以攔截大部分資料的讀取;
經過第二層(即前臺系統)時資料(包括強一致性的資料)儘量得走 Cache,過濾一些無效的請求;
再到第三層後臺系統,主要做資料的二次檢驗,對系統做好保護和限流,這樣資料量和請求就進一步減少;
最後在資料層完成資料的強一致性校驗。

這樣就像漏斗一樣,儘量把資料量和請求量一層一層地過濾和減少了。

分層過濾的核心思想是:在不同的層次儘可能地過濾掉無效請求,讓“漏斗”最末端的才是有效請求。而要達到這種效果,我們就必須對資料做分層的校驗。

**分層校驗的基本原則是: **

將動態請求的讀資料快取(Cache)在 Web 端,過濾掉無效的資料讀;
對讀資料不做強一致性校驗,減少因為一致性校驗產生瓶頸的問題;
對寫資料進行基於時間的合理分片,過濾掉過期的失效請求;
對寫請求做限流保護,將超出系統承載能力的請求過濾掉;
對寫資料進行強一致性校驗,只保留最後有效的資料。

05 | 影響效能的因素有哪些?又該如何提高系統的效能?

影響效能的因素

那麼,哪些因素對效能有影響呢?在回答這個問題之前,我們先定義一下“效能”,服務裝置不同對效能的定義也是不一樣的,例如 CPU 主要看主頻、磁碟主要看 IOPS(Input/Output Operations Per Second,即每秒進行讀寫操作的次數)。

而今天我們討論的主要是系統服務端效能,一般用 QPS(Query Per Second,每秒請求數)來衡量,還有一個影響和 QPS 也息息相關,那就是響應時間(Response Time,RT),它可以理解為伺服器處理響應的耗時。

正常情況下響應時間(RT)越短,一秒鐘處理的請求數(QPS)自然也就會越多,這在單執行緒處理的情況下看起來是線性的關係,即我們只要把每個請求的響應時間降到最低,那麼效能就會最高。

但是你可能想到響應時間總有一個極限,不可能無限下降,所以又出現了另外一個維度,即通過多執行緒,來處理請求。這樣理論上就變成了“總 QPS =(1000ms / 響應時間)× 執行緒數量”,這樣效能就和兩個因素相關了,一個是一次響應的服務端耗時,一個是處理請求的執行緒數。

首先,我們先來看看響應時間和 QPS 有啥關係。

對於大部分的 Web 系統而言,響應時間一般都是由 CPU 執行時間和執行緒等待時間(比如 RPC、IO 等待、Sleep、Wait 等)組成,即伺服器在處理一個請求時,一部分是 CPU 本身在做運算,還有一部分是在各種等待。

如果代理伺服器本身沒有 CPU 消耗,我們在每次給代理伺服器代理的請求加個延時,即增加響應時間,但是這對代理伺服器本身的吞吐量並沒有多大的影響,因為代理伺服器本身的資源並沒有被消耗,可以通過增加代理伺服器的處理執行緒數,來彌補響應時間對代理伺服器的 QPS 的影響。

其實,真正對效能有影響的是 CPU 的執行時間。這也很好理解,因為 CPU 的執行真正消耗了伺服器的資源。經過實際的測試,如果減少 CPU 一半的執行時間,就可以增加一倍的 QPS。

也就是說,我們應該致力於減少 CPU 的執行時間。

其次,我們再來看看執行緒數對 QPS 的影響。

單看“總 QPS”的計算公式,你會覺得執行緒數越多 QPS 也就會越高,但這會一直正確嗎?顯然不是,執行緒數不是越多越好,因為執行緒本身也消耗資源,也受到其他因素的制約。例如,執行緒越多系統的執行緒切換成本就會越高,而且每個執行緒也都會耗費一定記憶體。

那麼,設定什麼樣的執行緒數最合理呢?其實很多多執行緒的場景都有一個預設配置,即“執行緒數 = 2 * CPU 核數 + 1”。除去這個配置,還有一個根據最佳實踐得出來的公式:

執行緒數 = [(執行緒等待時間 + 執行緒 CPU 時間) / 執行緒 CPU 時間] × CPU 數量

當然,最好的辦法是通過效能測試來發現最佳的執行緒數。
換句話說,要提升效能我們就要減少 CPU 的執行時間,另外就是要設定一個合理的併發執行緒數,通過這兩方面來顯著提升伺服器的效能。
現在,你知道了如何來快速提升效能,那接下來你估計會問,我應該怎麼發現系統哪裡最消耗 CPU 資源呢?

如何發現瓶頸

就伺服器而言,會出現瓶頸的地方有很多,例如 CPU、記憶體、磁碟以及網路等都可能會導致瓶頸。此外,不同的系統對瓶頸的關注度也不一樣,例如對快取系統而言,制約它的是記憶體,而對儲存型系統來說 I/O 更容易是瓶頸。

我們定位的場景是秒殺,它的瓶頸更多地發生在 CPU 上。

怎樣簡單地判斷 CPU 是不是瓶頸呢?一個辦法就是看當 QPS 達到極限時,你的伺服器的 CPU 使用率是不是超過了 95%,如果沒有超過,那麼表示 CPU 還有提升的空間,要麼是有鎖限制,要麼是有過多的本地 I/O 等待發生。

如何優化系統

對 Java 系統來說,可以優化的地方很多,這裡我重點說一下比較有效的幾種手段,供你參考,它們是:減少編碼、減少序列化、Java 極致優化、併發讀優化。接下來,我們分別來看一下。

1. 減少編碼

Java 的編碼執行比較慢,這是 Java 的一大硬傷。在很多場景下,只要涉及字串的操作(如輸入輸出操作、I/O 操作)都比較耗 CPU 資源,不管它是磁碟 I/O 還是網路 I/O,因為都需要將字元轉換成位元組,而這個轉換必須編碼。每個字元的編碼都需要查表,而這種查表的操作非常耗資源,所以減少字元到位元組或者相反的轉換、減少字元編碼會非常有成效。減少編碼就可以大大提升效能

2. 減少序列化

序列化也是 Java 效能的一大天敵,減少 Java 中的序列化操作也能大大提升效能。又因為序列化往往是和編碼同時發生的,所以減少序列化也就減少了編碼。

序列化大部分是在 RPC 中發生的,因此避免或者減少 RPC 就可以減少序列化,當然當前的序列化協議也已經做了很多優化來提升效能。有一種新的方案,就是可以將多個關聯性比較強的應用進行“合併部署”,而減少不同應用之間的 RPC 也可以減少序列化的消耗。

所謂“合併部署”,就是把兩個原本在不同機器上的不同應用合併部署到一臺機器上,當然不僅僅是部署在一臺機器上,還要在同一個 Tomcat 容器中,且不能走本機的 Socket,這樣才能避免序列化的產生。

另外針對秒殺場景,我們還可以做得更極致一些,接下來我們來看第 3 點:Java 極致優化。

3. Java 極致優化

Java 和通用的 Web 伺服器(如 Nginx 或 Apache 伺服器)相比,在處理大併發的 HTTP 請求時要弱一點,所以一般我們都會對大流量的 Web 系統做靜態化改造,讓大部分請求和資料直接在 Nginx 伺服器或者 Web 代理伺服器(如 Varnish、Squid 等)上直接返回(這樣可以減少資料的序列化與反序列化),而 Java 層只需處理少量資料的動態請求。針對這些請求,我們可以使用以下手段進行優化:

1、直接使用 Servlet 處理請求。避免使用傳統的 MVC 框架,這樣可以繞過一大堆複雜且用處不大的處理邏輯,節省 1ms 時間(具體取決於你對 MVC 框架的依賴程度)
2、直接輸出流資料。使用 resp.getOutputStream() 而不是 resp.getWriter() 函式,可以省掉一些不變字元資料的編碼,從而提升效能;資料輸出時推薦使用 JSON 而不是模板引擎(一般都是解釋執行)來輸出頁面。

4. 併發讀優化

也許有讀者會覺得這個問題很容易解決,無非就是放到 Tair 快取裡面。集中式快取為了保證命中率一般都會採用一致性 Hash,所以同一個 key 會落到同一臺機器上。雖然單臺快取機器也能支撐 30w/s 的請求,但還是遠不足以應對像“大秒”這種級別的熱點商品。那麼,該如何徹底解決單點的瓶頸呢?

答案是採用應用層的 LocalCache,即在秒殺系統的單機上快取商品相關的資料。

那麼,又如何快取(Cache)資料呢?你需要劃分成動態資料和靜態資料分別進行處理:

1、像商品中的“標題”和“描述”這些本身不變的資料,會在秒殺開始之前全量推送到秒殺機器上,並一直快取到秒殺結束;

2、像庫存這類動態資料,會採用“被動失效”的方式快取一定時間(一般是數秒),失效後再去快取拉取最新的資料。

06 | 秒殺系統“減庫存”設計的核心邏輯

千萬不要超賣,這是大前提。

減庫存有哪幾種方式

在正常的電商平臺購物場景中,使用者的實際購買過程一般分為兩步:下單和付款。你想買一臺 iPhone 手機,在商品頁面點了“立即購買”按鈕,核對資訊之後點選“提交訂單”,這一步稱為下單操作。下單之後,你只有真正完成付款操作才能算真正購買,也就是俗話說的“落袋為安”。

那如果你是架構師,你會在哪個環節完成減庫存的操作呢?總結來說,減庫存操作一般有如下幾個方式:

下單減庫存,即當買家下單後,在商品的總庫存中減去買家購買數量。下單減庫存是最簡單的減庫存方式,也是控制最精確的一種,下單時直接通過資料庫的事務機制控制商品庫存,這樣一定不會出現超賣的情況。但是你要知道,有些人下完單可能並不會付款。
付款減庫存,即買家下單後,並不立即減庫存,而是等到有使用者付款後才真正減庫存,否則庫存一直保留給其他買家。但因為付款時才減庫存,如果併發比較高,有可能出現買家下單後付不了款的情況,因為可能商品已經被其他人買走了
預扣庫存,這種方式相對複雜一些,買家下單後,庫存為其保留一定的時間(如 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),可以在資料庫層上對單行記錄做到併發排隊。

你可能有疑問了,排隊和鎖競爭不都是要等待嗎,有啥區別?

如果熟悉 MySQL 的話,你會知道 InnoDB 內部的死鎖檢測,以及 MySQL Server 和 InnoDB 的切換會比較消耗效能,淘寶的 MySQL 核心團隊還做了很多其他方面的優化,如 COMMIT_ON_SUCCESS 和 ROLLBACK_ON_FAIL 的補丁程式,配合在 SQL 裡面加提示(hint),在事務裡不需要等待應用層提交(COMMIT),而在資料執行完最後一條 SQL 後,直接根據 TARGET_AFFECT_ROW 的結果進行提交或回滾,可以減少網路等待時間(平均約 0.7ms)。據我所知,目前阿里 MySQL 團隊已經將包含這些補丁程式的 MySQL 開源。

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

本文內容取自許令波老師《如何設計一個秒殺系統》這一課程的部分內容。

相關文章