小米網搶購系統開發實踐和我的個人觀察

小雷FansUnion發表於2016-04-21

本文個人觀察部分,為自己的一點看法。

正文內容,轉載於

《程式設計師》2014年11月刊:電商峰值系統架構設計

http://www.csdn.net/article/2014-11-04/2822459


個人觀察

1.小米搶購系統,是在小米電商比較成功之後,才開發掛在電商平臺上的。
  因此,搶購系統剛剛上線,就有很大的流量。
  而普通的網站,剛剛上線,流量是逐步增加的。

2.一個周就重新實現了搶購系統,也太牛了吧,似乎有誇張的成份。
3.在現有網站裡改造,確實比較難,不能有任何的失誤。
  升級搶購系統,算是一次比較大的重構。
  為了新功能,把已有功能搞出問題了,可是大事。
4.把同步換成非同步,是“第一版搶購系統”的核心思想。
    類似於電商購物流程中的,把“下單”和“支付”分成2個階段。
5.空間換時間。
  Redis,資料都儲存在記憶體中,少數Redis有持久化配置,以防萬一。
6.搶購系統的業務似乎並不複雜。
   有幾個系統,記錄點日誌,非同步讀取日誌並處理,一週就能幹完的。
7.使用者只需要點1個按鈕,按鈕背後卻是有很多的故事。
   作為使用者也真不錯,不用想那麼多煩心的事。
   生活中,還有很多這樣的事。
8.看了系統架構圖和思路,也就那樣。
   大公司,知名公司,技術解決方案也都是在業界成熟的基礎技術之上實現的,更多還是側重公司的業務、技術思想、工程實踐、模擬測試。
   視野開闊了,很多技術問題,根本沒有那麼難。
   除了學習能力,資訊不對稱,也是個關鍵的問題。
   現在網際網路很普及了,資訊不對稱這個問題,比早些年好多了,我們真是趕上了一個好年頭。
9.強一致性。
   看了不少高併發大流量的網站設計,基本都是在“一致性”方面有所讓步。只滿足最終的一致性,中間步驟出現不一致性,不影響大局。
10.node.js非同步程式設計。
   2014年用過一段時間的node.js,效能很不錯,非同步程式設計很不習慣。完全人工的去寫非同步程式碼,太痛苦了。
好在node.js有一些框架,可以手寫“同步過程程式碼”,內部轉換成非同步的。
   聽說淘寶內部,也很早就開始實踐node.js了。
   個人覺得,業務教簡單,效能要求高的系統,可以考慮用node.js實現。
   如果只是想做個web網站,真心不建議用node.js。
11.用檔案實現分散式鎖。
   在PHP伺服器上,通過一個檔案來表示商品是否售罄。
    用檔案實現分散式鎖,是可以實現的。不過有個問題。
   PHP伺服器,肯定不止一臺吧。那麼這個檔案存放在哪裡呢?不太可能每個伺服器上都存一份吧。如果這樣,
檔案至少會有同步的過程。
    如果檔案是放在,單獨的伺服器上,1臺或者2臺,這樣如何呢?想了想,和前一種情況並沒有本質的區別。
    只用1臺,比較簡單,但萬一掛了 ,分散式鎖服務就沒了。

  “通過一個檔案來表示商品是否售罄”又想了想,感覺自己理解錯了。這個場景並不是“用檔案實現分散式鎖”。
   商品售罄的時候,在每一個PHP伺服器上,放一個檔案嗎?
   在放檔案的過程中,又來了搶購這個商品的情況,怎麼辦呢?

12.分析業務,做出架構,編碼實現,會比較高效。
   看這篇總結,思路還是挺清晰的。
   不過,我倒是覺得,他們在開發過程中,肯定發現了一些新問題的。
   我們現在看到的只是最終的版本。

   另外,層次劃分也很重要,HTTP層,業務層,不同的層,幹不同的事情。
13.這個搶購系統的研發過程,提現了“技術人員”“程式設計師”這個職業,可以是“智力密集型”的。
    每天碼重複程式碼,也真是夠坑的。
    生產力上不去,未來怎麼進一步發展呢。
14.畫圖很重要。
    看架構圖、流程圖,非常重要。
   梳理自己的,檢驗自己的設計,方便與人交流。
15.PHP、node.js、go。
    這麼多語言,要學習好多內容額。
    在這種專案,有人帶,跟著學習和實踐,成長會比較快。
   我在想一個問題,那些寫PHP系統的哥們,可以看到nodejs程式碼嗎?
16.小米搶購系統,只有少部分程式設計師可以接觸到。
    大部分的公司,都是創業小公司、業務量不大的公司。
    很多程式設計師沒有機會去實踐,自己去寫和模擬,動力不足,不容易堅持下去。
    幫公司做專案,自己增加經驗,還有錢拿,多好。

17.網際網路系統架構的資料,越來越多。
    想要提升,更多還是在於實踐

搶購系統是怎樣誕生的

時間回到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前端伺服器最大穩定連線數可以超過一百萬。

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

總結

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

作者韓祝鵬,小米公司程式設計師。早期負責MIUI系統釋出與運營,後帶領小米網系統組設計與開發小米網搶購系統。

相關文章