6. 秒殺系統-影響性能的因素和提高系統性能的方法

影響性能的因素

       “性能”,服務設備不同對性能的定義也是不一樣的,例如 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 的執行時間。因爲 CPU 的執行真正消耗了服務器的資源。經過實際的測試,如果減少 CPU 一半的執行時間,就可以增加一倍的 QPS。也就是說,我們應該致力於減少 CPU 的執行時間。

線程數對 QPS 的影響

        單看“總 QPS”的計算公式,你會覺得線程數越多 QPS 也就會越高,但這會一直正確嗎?顯然不是,線程數不是越多越好,因爲線程本身也消耗資源,也受到其他因素的制約。例如,線程越多系統的線程切換成本就會越高,而且每個線程也都會耗費一定內存。 很多多線程的場景都有一個默認配置,即“線程數 = 2 * CPU 核數 + 1”。除去這個配置,還有一個根據最佳實踐得出來的公式:

線程數 = [(線程等待時間 + 線程 CPU 時間) / 線程 CPU 時間] × CPU 數量

要提升性能我們就要減少 CPU 的執行時間,另外就是要設置一個合理的併發線程數,通過這兩方面來顯著提升服務器的性能。

如何發現瓶頸

       在秒殺系統中,它的瓶頸更多地發生在 CPU 上。有很多 CPU 診斷工具可以發現 CPU 的消耗,最常用的就是 JProfilerYourkit 這兩個工具,它們可以列出整個請求中每個函數的 CPU 執行時間,可以發現哪個函數消耗的 CPU 時間最多,以便你有針對性地做優化。

       雖說秒殺系統的瓶頸大部分在 CPU,但這並不表示其他方面就一定不出現瓶頸。例如,如果海量請求湧過來,你的頁面又比較大,那麼網絡就有可能出現瓶頸。

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

如何優化系統

       對 Java 系統來說,可以優化的地方很多,其中比較有效的爲以下幾種手段:減少編碼、減少序列化、Java 極致優化、併發讀優化。

1. 減少編碼

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

       那麼如何才能減少編碼呢?例如,網頁輸出是可以直接進行流輸出的,即用 resp.getOutputStream() 函數寫數據,把一些靜態的數據提前轉化成字節,等到真正往外寫的時候再直接用 OutputStream() 函數寫,就可以減少靜態數據的編碼轉換。

2. 減少序列化

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

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

3.  Java 極致優化

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

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

4. 併發讀優化

       集中式緩存爲了保證命中率一般都會採用一致性 Hash,所以同一個 key 會落到同一臺機器上。雖然單臺緩存機器也能支撐 30w/s 的請求,但還是遠不足以應對像“大秒”這種級別的熱點商品。要解決此問題應該採用應用層的 LocalCache,即在秒殺系統的單機上緩存商品相關的數據。你需要劃分成動態數據和靜態數據分別進行處理:

  • 像商品中的“標題”和“描述”這些本身不變的數據,會在秒殺開始之前全量推送到秒殺機器上,並一直緩存到秒殺結束;
  • 像庫存這類動態數據,會採用“被動失效”的方式緩存一定時間(一般是數秒),失效後再去緩存拉取最新的數據。

       但像庫存這種頻繁更新的數據,一旦數據不一致,會不會導致超賣?這就要用到前面介紹的讀數據的分層校驗原則了,讀的場景可以允許一定的髒數據,因爲這裏的誤判只會導致少量原本無庫存的下單請求被誤認爲有庫存,可以等到真正寫數據時再保證最終的一致性,通過在數據的高可用性和一致性之間的平衡,來解決高併發的數據讀取問題。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章