
< class="btn-play" rel="noopener" style="box-sizing: border-box;background: url("https://mmbiz.qpic.cn/mmbiz_png/eebrusskoydqh2sirwq1cuavuqbnranstfg7qnpu9j71cjudkobhib2s5yg1iaeviqwkungr2szkwtsofmitvwbg/640?wx_fmt=png") 0px 0px / contain no-repeat transparent;color: rgb(26, 188, 156);overflow-wrap: break-word;-webkit-tap-highlight-color: rgba(0, 0, 0, 0);border-width: initial;border-style: none;border-color: initial;flex-shrink: 0;width: 2.6875rem;height: 2.6875rem;" target="_blank">
朗讀人:秭明 13′28′′ | 6.17m
不知不覺,我們已經講到第五篇了,不知道聽到這裡,你對于秒殺系統的建構有沒有形成一些架構性的認識,這裡我再帶你簡單回憶下前面的主線。
前面的四篇文章裡,我介紹的内容多少都和優化有關:第一篇介紹了一些指導原則;第二篇和第三篇從動靜分離和熱點資料兩個次元,介紹了如何有針對性地對資料進行區分和優化處理;第四篇介紹了在保證實作基本業務功能的前提下,盡量減少和過濾一些無效請求的思路。
這幾篇文章既是在講根據指導原則實作的具體案例,也是在講如何實作能夠讓整個系統更“快”。我想說的是,優化本身有很多手段,也是一個複雜的系統工程。今天,我就來結合秒殺這一場景,重點給你介紹下服務端的一些優化技巧。
你想要提升性能,首先肯定要知道哪些因素對于系統性能的影響最大,然後再針對這些具體的因素想辦法做優化,是不是這個邏輯?
那麼,哪些因素對性能有影響呢?在回答這個問題之前,我們先定義一下“性能”,服務裝置不同對性能的定義也是不一樣的,例如 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 本身在做運算,還有一部分是在各種等待。
了解了伺服器處理請求的邏輯,估計你會說為什麼我們不去減少這種等待時間。很遺憾,根據我們實際的測試發現,減少線程等待時間對提升性能的影響沒有我們想象得那麼大,它并不是線性的提升關系,這點在很多代理伺服器(proxy)上可以做驗證。
如果代理伺服器本身沒有 cpu 消耗,我們在每次給代理伺服器代理的請求加個延時,即增加響應時間,但是這對代理伺服器本身的吞吐量并沒有多大的影響,因為代理伺服器本身的資源并沒有被消耗,可以通過增加代理伺服器的處理線程數,來彌補響應時間對代理伺服器的 qps 的影響。
其實,真正對性能有影響的是 cpu 的執行時間。這也很好了解,因為 cpu 的執行真正消耗了伺服器的資源。經過實際的測試,如果減少 cpu 一半的執行時間,就可以增加一倍的 qps。
也就是說,我們應該緻力于減少 cpu 的執行時間。
其次,我們再來看看線程數對 qps 的影響。
單看“總 qps”的計算公式,你會覺得線程數越多 qps 也就會越高,但這會一直正确嗎?顯然不是,線程數不是越多越好,因為線程本身也消耗資源,也受到其他因素的制約。例如,線程越多系統的線程切換成本就會越高,而且每個線程也都會耗費一定記憶體。
那麼,設定什麼樣的線程數最合理呢?其實很多多線程的場景都有一個預設配置,即“線程數 = 2 * cpu 核數 + 1”。除去這個配置,還有一個根據最佳實踐得出來的公式:
線程數 = [(線程等待時間 + 線程 cpu 時間) / 線程 cpu 時間] × cpu 數量
當然,最好的辦法是通過性能測試來發現最佳的線程數。
換句話說,要提升性能我們就要減少 cpu 的執行時間,另外就是要設定一個合理的并發線程數,通過這兩方面來顯著提升伺服器的性能。
現在,你知道了如何來快速提升性能,那接下來你估計會問,我應該怎麼發現系統哪裡最消耗 cpu 資源呢?
就伺服器而言,會出現瓶頸的地方有很多,例如 cpu、記憶體、磁盤以及網絡等都可能會導緻瓶頸。此外,不同的系統對瓶頸的關注度也不一樣,例如對緩存系統而言,制約它的是記憶體,而對存儲型系統來說 i/o 更容易是瓶頸。
這個專欄中,我們定位的場景是秒殺,它的瓶頸更多地發生在 cpu 上。
那麼,如何發現 cpu 的瓶頸呢?其實有很多 cpu 診斷工具可以發現 cpu 的消耗,最常用的就是 jprofiler 和 yourkit 這兩個工具,它們可以列出整個請求中每個函數的 cpu 執行時間,可以發現哪個函數消耗的 cpu 時間最多,以便你有針對性地做優化。
當然還有一些辦法也可以近似地統計 cpu 的耗時,例如通過 jstack 定時地列印調用棧,如果某些函數調用頻繁或者耗時較多,那麼那些函數就會多次出現在系統調用棧裡,這樣相當于采樣的方式也能夠發現耗時較多的函數。
雖說秒殺系統的瓶頸大部分在 cpu,但這并不表示其他方面就一定不出現瓶頸。例如,如果海量請求湧過來,你的頁面又比較大,那麼網絡就有可能出現瓶頸。
怎樣簡單地判斷 cpu 是不是瓶頸呢?一個辦法就是看當 qps 達到極限時,你的伺服器的 cpu 使用率是不是超過了 95%,如果沒有超過,那麼表示 cpu 還有提升的空間,要麼是有鎖限制,要麼是有過多的本地 i/o 等待發生。
現在你知道了優化哪些因素,又發現了瓶頸,那麼接下來就要關注如何優化了。
對 java 系統來說,可以優化的地方很多,這裡我重點說一下比較有效的幾種手段,供你參考,它們是:減少編碼、減少序列化、java 極緻優化、并發讀優化。接下來,我們分别來看一下。
1. 減少編碼
java 的編碼運作比較慢,這是 java 的一大硬傷。在很多場景下,隻要涉及字元串的操作(如輸入輸出操作、i/o 操作)都比較耗 cpu 資源,不管它是磁盤 i/o 還是網絡 i/o,因為都需要将字元轉換成位元組,而這個轉換必須編碼。
每個字元的編碼都需要查表,而這種查表的操作非常耗資源,是以減少字元到位元組或者相反的轉換、減少字元編碼會非常有成效。減少編碼就可以大大提升性能。
那麼如何才能減少編碼呢?例如,網頁輸出是可以直接進行流輸出的,即用 resp.getoutputstream() 函數寫資料,把一些靜态的資料提前轉化成位元組,等到真正往外寫的時候再直接用 outputstream() 函數寫,就可以減少靜态資料的編碼轉換。
我在《深入分析 java web 技術内幕》一書中介紹的“velocity 優化實踐”一章的内容,就是基于把靜态的字元串提前編碼成位元組并緩存,然後直接輸出位元組内容到頁面,進而大大減少編碼的性能消耗的,網頁輸出的性能比沒有提前進行字元到位元組轉換時提升了 30% 左右。
2. 減少序列化
序列化也是 java 性能的一大天敵,減少 java 中的序列化操作也能大大提升性能。又因為序列化往往是和編碼同時發生的,是以減少序列化也就減少了編碼。
序列化大部分是在 rpc 中發生的,是以避免或者減少 rpc 就可以減少序列化,當然目前的序列化協定也已經做了很多優化來提升性能。有一種新的方案,就是可以将多個關聯性比較強的應用進行“合并部署”,而減少不同應用之間的 rpc 也可以減少序列化的消耗。
所謂“合并部署”,就是把兩個原本在不同機器上的不同應用合并部署到一台機器上,當然不僅僅是部署在一台機器上,還要在同一個 tomcat 容器中,且不能走本機的 socket,這樣才能避免序列化的産生。
另外針對秒殺場景,我們還可以做得更極緻一些,接下來我們來看第 3 點:java 極緻優化。
3. java 極緻優化
java 和通用的 web 伺服器(如 nginx 或 apache 伺服器)相比,在處理大并發的 http 請求時要弱一點,是以一般我們都會對大流量的 web 系統做靜态化改造,讓大部分請求和資料直接在 nginx 伺服器或者 web 代理伺服器(如 varnish、squid 等)上直接傳回(這樣可以減少資料的序列化與反序列化),而 java 層隻需處理少量資料的動态請求。針對這些請求,我們可以使用以下手段進行優化:
直接使用 servlet 處理請求。避免使用傳統的 mvc 架構,這樣可以繞過一大堆複雜且用處不大的處理邏輯,節省 1ms 時間(具體取決于你對 mvc 架構的依賴程度)。
直接輸出流資料。使用 resp.getoutputstream() 而不是 resp.getwriter() 函數,可以省掉一些不變字元資料的編碼,進而提升性能;資料輸出時推薦使用 json 而不是模闆引擎(一般都是解釋執行)來輸出頁面。
4. 并發讀優化
也許有讀者會覺得這個問題很容易解決,無非就是放到 tair 緩存裡面。集中式緩存為了保證命中率一般都會采用一緻性 hash,是以同一個 key 會落到同一台機器上。雖然單台緩存機器也能支撐 30w/s 的請求,但還是遠不足以應對像“大秒”這種級别的熱點商品。那麼,該如何徹底解決單點的瓶頸呢?
答案是采用應用層的 localcache,即在秒殺系統的單機上緩存商品相關的資料。
那麼,又如何緩存(cache)資料呢?你需要劃分成動态資料和靜态資料分别進行處理:
像商品中的“标題”和“描述”這些本身不變的資料,會在秒殺開始之前全量推送到秒殺機器上,并一直緩存到秒殺結束;
像庫存這類動态資料,會采用“被動失效”的方式緩存一定時間(一般是數秒),失效後再去緩存拉取最新的資料。
你可能還會有疑問:像庫存這種頻繁更新的資料,一旦資料不一緻,會不會導緻超賣?
這就要用到前面介紹的讀資料的分層校驗原則了,讀的場景可以允許一定的髒資料,因為這裡的誤判隻會導緻少量原本無庫存的下單請求被誤認為有庫存,可以等到真正寫資料時再保證最終的一緻性,通過在資料的高可用性和一緻性之間的平衡,來解決高并發的資料讀取問題。
性能優化的過程首先要從發現短闆開始,除了我今天介紹的一些優化措施外,你還可以在減少資料、資料分級(動靜分離),以及減少中間環節、增加預處理等這些環節上做優化。
首先是“發現短闆”,比如考慮以下因素的一些限制:光速(光速:c = 30 萬千米 / 秒;光纖:v = c/1.5=20 萬千米 / 秒,即資料傳輸是有實體距離的限制的)、網速(2017 年 11 月知名測速網站 ookla 釋出報告,全國平均上網帶寬達到 61.24 mbps,千兆帶寬下 10kb 資料的極限 qps 為 1.25 萬 qps=1000mbps/8/10kb)、網絡結構(交換機 / 網卡的限制)、tcp/ip、虛拟機(記憶體 /cpu/io 等資源的限制)和應用本身的一些瓶頸等。
其次是減少資料。事實上,有兩個地方特别影響性能,一是服務端在處理資料時不可避免地存在字元到位元組的互相轉化,二是 http 請求時要做 gzip 壓縮,還有網絡傳輸的耗時,這些都和資料大小密切相關。
再次,就是資料分級,也就是要保證首屏為先、重要資訊為先,次要資訊則異步加載,以這種方式提升使用者擷取資料的體驗。
最後就是要減少中間環節,減少字元到位元組的轉換,增加預處理(提前做字元到位元組的轉換)去掉不需要的操作。
此外,要做好優化,你還需要做好應用基線,比如性能基線(何時性能突然下降)、成本基線(去年雙 11 用了多少台機器)、鍊路基線(我們的系統發生了哪些變化),你可以通過這些基線持續關注系統的性能,做到在代碼上提升編碼品質,在業務上改掉不合理的調用,在架構和調用鍊路上不斷的改進。