“米粉節”背后的故事——小米網搶購系統開發實踐

作者: 韓祝鵬  來源: CSDN  發布時間: 2014-11-11 10:50  閱讀: 8144 次  推薦: 29   原文鏈接   [收藏]  

  2014年的米粉節

  2014年4月9日凌晨,我和同事們對小米網的搶購系統做了最后的檢查與演練。幾個小時后,小米網今年開年來最重要的一次大型活動“米粉節”就要開始了。

  這次米粉節活動,是小米電商的成人禮,是一次重要的考試。小米網從網站前端、后臺系統、倉儲物流、售后等各個環節,都將接受一次全面的壓力測試。

  10點整,一波流量高峰即將到來,幾百萬用戶將準點擠入小米網的服務器。而首先迎接壓力沖擊的,就是擋在最前面的搶購系統。

  而這個搶購系統是重新開發、剛剛上線不久的,這是它第一次接受這樣嚴峻的考驗。

  系統能不能頂住壓力?能不能順暢正確地執行業務邏輯?這些問題不到搶購高峰那一刻,誰都不能百分百確定。

  9點50分,流量已經爬升得很高了;10點整,搶購系統自動開啟,購物車中已經順利加入了搶購商品。

  一兩分鐘后,熱門的搶購商品已經售罄自動停止搶購。搶購系統抗住了壓力。

  我長舒一口氣,之前積累的壓力都消散了。我坐到角落的沙發里,默默回想搶購系統所經歷的那些驚心動魄的故事。這可真是一場很少人有機會經歷的探險呢。

  搶購系統是怎樣誕生的

  時間回到2011年底。小米公司在這一年8月16日首次發布了手機,立刻引起了市場轟動。隨后,在一天多的時間內預約了30萬臺。之后的幾個月,這30萬臺小米手機通過排號的方式依次發貨,到當年年底全部發完。

  然后便是開放購買。最初的開放購買直接在小米的商城系統上進行,但我們那時候完全低估了“搶購”的威力。瞬間爆發的平常幾十倍流量迅速淹沒了小米網商城服務器,數據庫死鎖、網頁刷新超時,用戶購買體驗非常差。

  市場需求不等人,一周后又要進行下一輪開放搶購。一場風暴就等在前方,而我們只有一周的時間了,整個開發部都承擔著巨大的壓力。

  小米網可以采用的常規優化手段并不太多,增加帶寬、服務器、尋找代碼中的瓶頸點優化代碼。但是,小米公司只是一家剛剛成立一年多的小公司,沒有那么多的服務器和帶寬。而且,如果代碼中有瓶頸點,即使能增加一兩倍的服務器和帶寬,也一樣會被瞬間爆發的幾十倍負載所沖垮。而要優化商城的代碼,時間上已沒有可能。電商網站很復雜,說不定某個不起眼的次要功能,在高負載情況下就會成為瓶頸點拖垮整個網站。

  這時開發組面臨一個選擇,是繼續在現有商城上優化,還是單獨搞一套搶購系統?我們決定冒險一試,我和幾個同事一起突擊開發一套獨立的搶購系統,希望能夠絕境逢生。

  擺在我們面前的是一道似乎無解的難題,它要達到的目標如下:

  • 只有一周時間,一周內完成設計、開發、測試、上線;
  • 失敗的代價無法承受,系統必須順暢運行;
  • 搶購結果必須可靠;
  • 面對海量用戶的并發搶購,商品不能賣超;
  •  一個用戶只能搶一臺手機;
  • 用戶體驗盡量好些。

  設計方案就是多個限制條件下求得的解。時間、可靠性、成本,這是我們面臨的限制條件。要在那么短的時間內解決難題,必須選擇最簡單可靠的技術,必須是經過足夠驗證的技術,解決方案必須是最簡單的。

  在高并發情況下,影響系統性能的一個關鍵因素是:數據的一致性要求。在前面所列的目標中,有兩項是關于數據一致性的:商品剩余數量、用戶是否已經搶購成功。如果要保證嚴格的數據一致性,那么在集群中需要一個中心服務器來存儲和操作這個值。這會造成性能的單點瓶頸。

  在分布式系統設計中,有一個CAP原理。“一致性、可用性、分區容忍性”三個要素最多只能同時實現兩點,不可能三者兼顧。我們要面對極端的爆發流量負載,分區容忍性和可用性會非常重要,因此決定犧牲數據的強一致性要求。

  做出這個重要的決定后,剩下的設計決定就自然而然地產生了:

  1. 技術上要選擇最可靠的,因為團隊用PHP的居多,所以系統使用PHP開發;
  2. 搶資格過程要最簡化,用戶只需點一個搶購按鈕,返回結果表示搶購成功或者已經售罄;
  3. 對搶購請求的處理盡量簡化,將I/O操作控制到最少,減少每個請求的時間;
  4. 盡量去除性能單點,將壓力分散,整體性能可以線性擴展;
  5. 放棄數據強一致性要求,通過異步的方式處理數據。

  最后的系統原理見后面的第一版搶購系統原理圖(圖1)。

圖1  第一版搶購系統原理圖

  系統基本原理:在PHP服務器上,通過一個文件來表示商品是否售罄。如果文件存在即表示已經售罄。PHP程序接收用戶搶購請求后,查看用戶是否預約以及是否搶購過,然后檢查售罄標志文件是否存在。對預約用戶,如果未售罄并且用戶未搶購成功過,即返回搶購成功的結果,并記錄一條日志。日志通過異步的方式傳輸到中心控制節點,完成記數等操作。

  最后,搶購成功用戶的列表異步導入商場系統,搶購成功的用戶在接下來的幾個小時內下單即可。這樣,流量高峰完全被搶購系統擋住,商城系統不需要面對高流量。

  在這個分布式系統的設計中,對持久化數據的處理是影響性能的重要因素。我們沒有選擇傳統關系型數據庫,而是選用了Redis服務器。選用Redis基于下面幾個理由。

  1. 首先需要保存的數據是典型的Key/Value對形式,每個UID對應一個字符串數據。傳統數據庫的復雜功能用不上,用KV庫正合適。
  2. Redis的數據是in-memory的,可以極大提高查詢效率。
  3. Redis具有足夠用的主從復制機制,以及靈活設定的持久化操作配置。這兩點正好是我們需要的。

  在整個系統中,最頻繁的I/O操作,就是PHP對Redis的讀寫操作。如果處理不好,Redis服務器將成為系統的性能瓶頸。

  系統中對Redis的操作包含三種類型的操作:查詢是否有預約、是否搶購成功、寫入搶購成功狀態。為了提升整體的處理能力,可采用讀寫分離方式。

  所有的讀操作通過從庫完成,所有的寫操作只通過控制端一個進程寫入主庫。

  在PHP對Redis服務器的讀操作中,需要注意的是連接數的影響。如果PHP是通過短連接訪問Redis服務器的,則在高峰時有可能堵塞Redis服務器,造成雪崩效應。這一問題可以通過增加Redis從庫的數量來解決。

  而對于Redis的寫操作,在我們的系統中并沒有壓力。因為系統是通過異步方式,收集PHP產生的日志,由一個管理端的進程來順序寫入Redis主庫。

  另一個需要注意的點是Redis的持久化配置。用戶的預約信息全部存儲在Redis的進程內存中,它向磁盤保存一次,就會造成一次等待。嚴重的話會導致搶購高峰時系統前端無法響應。因此要盡量避免持久化操作。我們的做法是,所有用于讀取的從庫完全關閉持久化,一個用于備份的從庫打開持久化配置。同時使用日志作為應急恢復的保險措施。

  整個系統使用了大約30臺服務器,其中包括20臺PHP服務器,以及10臺Redis服務器。在接下來的搶購中,它順利地抗住了壓力。回想起當時的場景,真是非常的驚心動魄。

  第二版搶購系統

  經過了兩年多的發展,小米網已經越來越成熟。公司準備在2014年4月舉辦一次盛大的“米粉節”活動。這次持續一整天的購物狂歡節是小米網電商的一次成人禮。商城前端、庫存、物流、售后等環節都將經歷一次考驗。

  對于搶購系統來說,最大的不同就是一天要經歷多輪搶購沖擊,而且有多種不同商品參與搶購。我們之前的搶購系統,是按照一周一次搶購來設計及優化的,根本無法支撐米粉節復雜的活動。而且經過一年多的修修補補,第一版搶購系統積累了很多的問題,正好趁此機會對它進行徹底重構。

  第二版系統主要關注系統的靈活性與可運營性(圖2)。對于高并發的負載能力,穩定性、準確性這些要求,已經是基礎性的最低要求了。我希望將這個系統做得可靈活配置,支持各種商品各種條件組合,并且為將來的擴展打下良好的基礎。

圖2  第二版系統總體結構圖

  在這一版中,搶購系統與商城系統依然隔離,兩個系統之間通過約定的數據結構交互,信息傳遞精簡。通過搶購系統確定一個用戶搶得購買資格后,用戶自動在商城系統中將商品加入購物車。

  在之前第一版搶購系統中,我們后來使用Go語言開發了部分模塊,積累了一定的經驗。因此第二版系統的核心部分,我們決定使用Go語言進行開發。

  我們可以讓Go程序常駐內存運行,各種配置以及狀態信息都可以保存在內存中,減少I/O操作開銷。對于商品數量信息,可以在進程內進行操作。不同商品可以分別保存到不同的服務器的Go進程中,以此來分散壓力,提升處理速度。

  系統服務端主要分為兩層架構,即HTTP服務層和業務處理層。HTTP服務層用于維持用戶的訪問請求,業務處理層則用于進行具體的邏輯判斷。兩層之間的數據交互通過消息隊列來實現。

  HTTP服務層主要功能如下:

  1. 進行基本的URL正確性校驗;
  2. 對惡意訪問的用戶進行過濾,攔截黃牛;
  3. 提供用戶驗證碼;
  4. 將正常訪問用戶數據放入相應商品隊列中;
  5. 等待業務處理層返回的處理結果。

  業務處理層主要功能如下:

  1. 接收商品隊列中的數據;
  2. 對用戶請求進行處理;
  3. 將請求結果放入相應的返回隊列中。

  用戶的搶購請求通過消息隊列,依次進入業務處理層的Go進程里,然后順序地處理請求,將搶購結果返回給前面的HTTP服務層。

  商品剩余數量等信息,根據商品編號分別保存在業務層特定的服務器進程中。我們選擇保證商品數據的一致性,放棄了數據的分區容忍性。

  這兩個模塊用于搶購過程中的請求處理,系統中還有相應的策略控制模塊,以及防刷和系統管理模塊等(圖3)。

圖3  第二版系統詳細結構圖

  在第二版搶購系統的開發過程中,我們遇到了HTTP層Go程序內存消耗過多的問題。

  由于HTTP層主要用于維持住用戶的訪問請求,每個請求中的數據都會占用一定的內存空間,當大量的用戶進行訪問時就會導致內存使用量不斷上漲。當內存占用量達到一定程度(50%)時,Go中的GC機制會越來越慢,但仍然會有大量的用戶進行訪問,導致出現“雪崩”效應,內存不斷上漲,最終機器內存的使用率會達到90%以上甚至99%,導致服務不可用。

  在Go語言原生的HTTP包中會為每個請求分配8KB的內存,用于讀緩存和寫緩存。而在我們的服務場景中只有GET請求,服務需要的信息都包含在HTTP Header中,并沒有Body,實際上不需要如此大的內存進行存儲。

  為了避免讀寫緩存的頻繁申請和銷毀,HTTP包建立了一個緩存池,但其長度只有4,因此在大量連接創建時,會大量申請內存,創建新對象。而當大量連接釋放時,又會導致很多對象內存無法回收到緩存池,增加了GC的壓力。

  HTTP協議是構建在TCP協議之上的,Go的原生HTTP模塊中是沒有提供直接的接口關閉底層TCP連接的,而HTTP 1.1中對連接狀態默認使用keep-alive方式。這樣,在客戶端多次請求服務端時,可以復用一個TCP連接,避免頻繁建立和斷開連接,導致服務端一直等待讀取下一個請求而不釋放連接。但同樣在我們的服務場景中不存在TCP連接復用的需求。當一個用戶完成一個請求后,希望能夠盡快關閉連接。keep-alive方式導致已完成處理的用戶連接不能盡快關閉,連接無法釋放,導致連接數不斷增加,對服務端的內存和帶寬都有影響。

  通過上面的分析,我們的解決辦法如下。

  1. 在無法優化Go語言中GC機制時,要避免“雪崩效應”就要盡量避免服務占用的內存超過限制(50%),在處于這個限制內時,GC可以有效進行。可通過增加服務器的方式來分散內存壓力,并盡力優化服務占用的內存大小。同時Go 1.3也對其GC做了一定優化。
  2. 我們為搶購這個特定服務場景定制了新的HTTP包,將TCP連接讀緩存大小改為1KB。
  3. 在定制的HTTP包中,將緩存池的大小改為100萬,避免讀寫緩存的頻繁申請和銷毀。
  4. 當每個請求處理完成后,通過設置Response的Header中Connection為close來主動關閉連接。

  通過這樣的改進,我們的HTTP前端服務器最大穩定連接數可以超過一百萬。

  第二版搶購系統順利完成了米粉節的考驗。

  總結

  技術方案需要依托具體的問題而存在。脫離了應用場景,無論多么酷炫的技術都失去了價值。搶購系統面臨的現實問題復雜多變,我們也依然在不斷地摸索改進。

29
2
 
 
 

文章列表

arrow
arrow
    全站熱搜
    創作者介紹
    創作者 大師兄 的頭像
    大師兄

    IT工程師數位筆記本

    大師兄 發表在 痞客邦 留言(0) 人氣()