CAT 性能优化的实践和思考
作者簡介
錦華,攜程高級技術(shù)專家,超過 10 年互聯(lián)網(wǎng)研發(fā)經(jīng)驗,2011 年至今一直從事框架和中間件相關(guān)產(chǎn)品研發(fā),對高并發(fā)、分布式中間件以及應(yīng)用性能優(yōu)化等有濃厚興趣。
*本文來自錦華在Qcon的分享,首發(fā)于Qcon公眾號*
作為業(yè)界知名的應(yīng)用監(jiān)控產(chǎn)品,CAT 已經(jīng)成功地為多家公司提供了完整的監(jiān)控領(lǐng)域解決方案。2015 年 CAT 在攜程落地,目前已經(jīng)成為公司內(nèi)部非常重要的監(jiān)控基礎(chǔ)設(shè)施,很好地支撐了來自 70000+ 客戶端的 8000 億條消息 / 天、900TB/ 天的實時監(jiān)控流量。本文將分享攜程在 CAT 性能優(yōu)化上的實踐,并通過這些實踐總結(jié)出一些普適性的性能優(yōu)化思路與方法。
一、CAT 在攜程的落地及發(fā)展情況
CAT 是大眾點評開源的一個基于 Trace 的應(yīng)用監(jiān)控系統(tǒng)。2014 年底,攜程開始引入 CAT 并落地。在這幾年里,公司的數(shù)據(jù)量一直呈現(xiàn)著爆發(fā)式的增長,我們也不斷地對 CAT 做了很多大大小小的優(yōu)化,使得機器數(shù)目并沒有像數(shù)據(jù)量那樣呈現(xiàn)出指數(shù)級的增加。到目前為止,有超過 7 萬的客戶端,每天處理的消息樹超過8000 億,每天處理的日志量超過 4 萬億行,峰值流量達到 1.5 億行 / 秒。
二、CAT 性能優(yōu)化案例
在介紹案例之前,我們簡單介紹一下 CAT 的計算模型。
CAT 客戶端的監(jiān)控數(shù)據(jù)會組裝成一種樹狀結(jié)構(gòu),也就是 CAT 里面的 MessageTree,然后發(fā)送到 CAT 服務(wù)端。CAT 服務(wù)端會把這份數(shù)據(jù)同時分發(fā)給多個不同用途的報表分析器進行實時計算,計算出來的結(jié)果會被存到服務(wù)端的內(nèi)存報表里面。
我們的報表是什么樣子的?這里是一個 CAT 最基本的 Transaction 報表截圖,可以把它簡單理解成一段時間內(nèi)的一些監(jiān)控指標的聚合。這個例子里面展示的是 RPCService 這個 Transaction 在這一小時內(nèi)每一分鐘發(fā)生的次數(shù)、平均耗時、每分鐘的失敗以及這個小時的平均耗時,99 線、95 線。
下面看一下,CAT 服務(wù)端經(jīng)常遇到什么樣的問題。首先,從上面的計算模型可以看到,報表分析其實是一個 CPU 密集型的任務(wù),所以 CPU 跑滿是一個經(jīng)常遇到的問題。另外,還是根據(jù)上面的計算模型,所有實時的報表數(shù)據(jù)都會放在內(nèi)存里面,所以 GC 頻繁也是經(jīng)常遇到的一個問題。
2.1 案例一:線程模型優(yōu)化
1)CAT 線程模型
CAT 客戶端的監(jiān)控數(shù)據(jù)發(fā)送到服務(wù)端后,服務(wù)端會同時將這份數(shù)據(jù)分發(fā)給多個不同用途的報表分析器,報表分析器內(nèi)部會根據(jù)這個監(jiān)控數(shù)據(jù)的客戶端信息(APP ID 和 IP)計算一個哈希值,然后再分發(fā)到報表分析器內(nèi)部的一個隊列里面。這個隊列后面會綁定一個線程進行實時分析,計算出來的結(jié)果會放到這個線程綁定的內(nèi)存報表中。這種模型有一個好處,就是它把數(shù)據(jù)跟線程綁定在一起,使得對同一個客戶端過來的監(jiān)控數(shù)據(jù)的實時分析、計算和內(nèi)存報表的更新都是無鎖的。
2)遇到的問題
隨著流量不斷增加,我們發(fā)現(xiàn),其實不同的應(yīng)用和客戶端發(fā)送過來的數(shù)據(jù)是非常不均的。數(shù)據(jù)的不均就會導(dǎo)致隊列堆積,最終的結(jié)果就是某些堆積的隊列對應(yīng)的客戶端監(jiān)控數(shù)據(jù)丟失。對于數(shù)據(jù)不均的問題,天然的我們就會想到通過增加更多的隊列,讓哈希算法更加均勻就可以解決。
但是,在這種模型里面,增加隊列同時意味著也要增加對應(yīng)的處理線程。一開始我們一直是用這個方法去解決數(shù)據(jù)不均的問題,直到有一次發(fā)現(xiàn)當前的隊列以及處理線程已經(jīng)太多了,再加下去甚至還會出現(xiàn)處理能力的下降。
3)分析問題
于是就開始去分析,為什么處理能力反而下降了。查看監(jiān)控指標之后,發(fā)現(xiàn)操作系統(tǒng)的上下文切換已經(jīng)達到了每秒鐘幾百萬次之多。這時候趕緊去看一下服務(wù)端的線程,發(fā)現(xiàn)經(jīng)過前面的一頓操作,最后所有的報表加在一起已經(jīng)有了幾千個線程,非常的多。回過頭來審視我們的模型,當數(shù)據(jù)不均的時候,核心需求是要通過增加隊列來將數(shù)據(jù)進一步的打散,為什么處理線程也要隨之增加呢?
所以,第一個想法就是把這模型里面的隊列和線程做個解耦。
在談及隊列和線程解耦的模型時,第一想法就是 IO 模型。可以看看現(xiàn)在的線程模型,它跟 IO 里面最老的 BIO 模型是不是非常相似?它們都是每個隊列或者 channel 后面有一個線程在阻塞等待數(shù)據(jù)的到來,然后去計算,所以會有非常多的線程。每個 channel 即使沒有數(shù)據(jù)到達也需要有個線程在阻塞等待,非常浪費。
BIO 模型往下的一個演進就是 NIO 模型。NIO 通過引入一個 Selector 模塊, 很輕量地就可以監(jiān)聽非常多的隊列數(shù)據(jù)。于是我們就想,是不是也類似這樣,通過引入一個 Selector 去監(jiān)聽多個隊列來達到隊列跟線程解耦的目的呢?
4)新線程模型
來思考下,我們所要引入的這個 Selector 在 CAT 的服務(wù)端需要實現(xiàn)什么樣的功能?首先,毫無疑問的是需要一個監(jiān)聽隊列的功能。另外,還需要在上面實現(xiàn)一套調(diào)度策略,一方面可以讓它充分利用后面的線程池,另一方面要保持我們的模型跟原來的模型一樣,在同一個客戶端,同一隊列的更新是不需要加鎖的。
這里有個小小的細節(jié),實現(xiàn) Selector 的時候并不是說直接開一個線程不斷地空輪詢所有的隊列,這樣會非常低效,你永遠不知道哪個隊列什么時候會有數(shù)據(jù),只能一直空輪詢。我們的實現(xiàn)方式是在數(shù)據(jù)進入隊列的時候,反過來去 notify ?Selector,讓 Selector 決定在這個時候是觸發(fā)調(diào)度還是應(yīng)該先 hold 一下,后面再調(diào)度。
把報表分析器的模型改造好后,把它放到整個 CAT 服務(wù)端去看。一個服務(wù)端會有很多報表分析器,會看到改造后在 CAT 服務(wù)端的每個報表分析器都有自己的 Selector 與線程池。但真的需要這么多線程池嗎?從這個模型就可以知道,其實我們的報表分析任務(wù)全都是計算密集型的,按理說整個系統(tǒng)里面應(yīng)該只需要開 CPU 核數(shù)個的線程就可以充分利用所有的 CPU 資源,于是自然而然地就把我們的所有計算線程合并到同一個線程池中。
再看 Selector 模塊。既然設(shè)計初衷就是讓一個 Selector 可以非常輕量就可以監(jiān)聽非常多的隊列,那么其實用一個跟用多個都是能達到同樣的效果。另外,如果只用一個 Selector 的話,可以比較容易地實現(xiàn)一套優(yōu)先級調(diào)度的策略。
為什么需要一套優(yōu)先級調(diào)度的策略呢?因為監(jiān)控數(shù)據(jù)其實也是有優(yōu)先級的,我們會希望在系統(tǒng)負載比較高的時候,一些高優(yōu)先級的報表和監(jiān)控數(shù)據(jù)可以得到更多的資源,優(yōu)先去計算。于是把 Selector 合并在一起,在它上面實現(xiàn)一套優(yōu)先級的調(diào)度。這就是我們當前所使用的 CAT 服務(wù)端的線程模型。
5)小結(jié)
稍微總結(jié)一下,我們通過線程模型優(yōu)化,從原來的像 BIO 一樣的線程模型改造成了現(xiàn)在這種像 NIO 一樣的線程模型。
首先它滿足了初衷,讓隊列和線程做一個解耦。那么解耦之后,就可以設(shè)置非常多的隊列,一方面很好解決數(shù)據(jù)均勻的問題,另外一方面,因為增加了隊列,同時也減少了單個隊列的鎖競爭,那么只要開 CPU 核數(shù)個線程就可以了,不會像原來那樣每個報表分析器都要開自己的線程,線程數(shù)減少了,相應(yīng)地也能減少很多沒有用的上下文切換。
另外,我們在這個新模型里還提供了一套比原來更加靈活的調(diào)度策略,可以實現(xiàn)優(yōu)先級調(diào)度。這個模型上線后,我們從原來每臺機器跑到超過 90% 的 CPU,最后還出現(xiàn) 5% 的丟失,優(yōu)化到數(shù)據(jù)不丟 CPU 還下降到 70% 左右。
2.2 案例二:客戶端計算
1)遇到的問題
經(jīng)過之前的優(yōu)化,CPU 已經(jīng)利用得比較充分了。但隨著流量進一步增加,我們最終還是把 CPU 全部用滿,數(shù)據(jù)又開始丟了。
2)分析問題
現(xiàn)在的資源是不是真的不夠了,是不是要進行機器的擴容?出于成本的考量,我們決定先進行優(yōu)化,看能不能再節(jié)省一些 CPU 出來,從而能夠在成本不變的情況下扛更大的流量。
經(jīng)過分析,借鑒了當下的一些思路,決定將服務(wù)端的部分計算下放到客戶端去。
那么到底什么樣的計算比較適合放到客戶端?首先,一定要是一個不變的,或者是變的很少的邏輯。否則每次更新計算邏輯都會需要更新客戶端,這個過程會變得非常的漫長。其次,肯定要是一些在服務(wù)端占用 CPU 資源比較多的計算,否則花了很多力氣去改造,最后卻只能得到一個不是很劃算的效果。
3)Transaction/Event Report 的 CPU 使用
說到不變的邏輯,自然就會想到 CAT 中的兩份基本報表:Transaction 和 Event Report。這兩份報表的分析計算非常簡單,就是針對 Transaction 和 Event 這兩個基本模型進行數(shù)據(jù)的聚合統(tǒng)計。這兩個報表到底占用了多少服務(wù)端的資源呢?通過調(diào)整上述 Selector 模型提及的調(diào)度策略,我們將這兩份報表的計算放到獨立的線程池中進行,得到了如下的資源使用率:
從以上數(shù)據(jù)可以看到,服務(wù)端中, Transaction 報表的計算用了 7 個線程,分別都會占到 0.8、0.9 個核,所以這 7 個線程總共在服務(wù)端占了 5.3 個核。Event 報表有 4 個線程,每個線程也跑到百分之四五十、六十左右,加在一起也有 2.2 個核,這兩張報表的分析在服務(wù)端就使用了一臺機器中的 7.5 個核。如果機器是 32 核的話,相當于它占了 23% 的計算資源。如果能把這個計算挪到客戶端,那會省下來很多的資源。
4)Transaction/Event Report 計算
來看這兩份數(shù)據(jù)是怎么計算的。
首先,看一下服務(wù)端的計算方式。客戶端會把一個個監(jiān)控數(shù)據(jù)組織成 MessageTree ,以一種樹狀的結(jié)構(gòu)發(fā)送到服務(wù)端。服務(wù)端會遍歷所有發(fā)送來的 MessageTree,找到樹里面嵌套的 Transaction 和 Event 結(jié)點,然后做一個數(shù)值的統(tǒng)計。這個計算量是非常大的,它不僅會跟客戶端發(fā)送過來的 MessageTree 的個數(shù)有關(guān)系,還會跟每個 MessageTree 里面到底有多少個 Event 和 Transaction 也有關(guān)系。
那怎么把它挪到客戶端去呢?我們可以把客戶端里多個 MessageTree 內(nèi)的統(tǒng)計數(shù)據(jù)合并成一份,一次性把這個統(tǒng)計數(shù)據(jù)發(fā)送過來。這樣的話服務(wù)端的計算量將會極大的減少。
這種計算方式下,計算量只會和客戶端數(shù)量以及客戶端發(fā)送這份統(tǒng)計數(shù)據(jù)的間隔時間有關(guān)系。看看最后改造出來的架構(gòu),客戶端增加了兩個用于進行統(tǒng)計的 Aggregator 分別來計算 Transaction 和 Event 的指標,然后再將這些數(shù)據(jù)定時發(fā)送到服務(wù)端去。
此外,我們做了一個小小的改動,把原來 CAT 的一個發(fā)送隊列改造成了兩個發(fā)送隊列,一個隊列用來發(fā)送原來的 MessageTree,另外一個則是用來發(fā)送客戶端預(yù)聚合過的統(tǒng)計數(shù)據(jù)。統(tǒng)計量隊列的發(fā)送優(yōu)先級會稍微高一些,這樣的話就可以保證客戶端即使在整體負載偏高,數(shù)據(jù)來不及發(fā)送的情況下,也能優(yōu)先把統(tǒng)計量發(fā)送到后端去,從而保證了監(jiān)控系統(tǒng)統(tǒng)計量的準確性。
在這個架構(gòu)上線后,可以看到 Transaction 報表從原來的 7 個線程占了五點幾個核,下降到現(xiàn)在 7 個線程,每個線程只要 0.02 個核。
Event 報表也從原來的 4 個線程,每個線程占 0.4、0.5 個核,變到現(xiàn)在 4 個線程,每個線程只能吃掉 0.01 個核。效果還是非常明顯的,省了很多資源出來。
這樣的一個計算,在客戶端到底有多大的影響?在客戶端的視角,內(nèi)存的占用其實非常少。因為我們僅僅只是在客戶端增加了幾秒鐘的統(tǒng)計數(shù)據(jù)的聚合,這個數(shù)據(jù)量非常少,而且過幾秒鐘后這份數(shù)據(jù)就會被送走了,所以不會對客戶端的內(nèi)存使用造成很大的影響。
根據(jù)我們的統(tǒng)計,整體的內(nèi)存消耗只在 10M 以下,CPU 的占用則基本上可以忽略。服務(wù)端的計算需要對一棵棵監(jiān)控樹進行遍歷然后去分析統(tǒng)計,但在客戶端這個流程其實是反的。客戶端是先有統(tǒng)計數(shù)據(jù)再組織成樹,所以其實只要在產(chǎn)生監(jiān)控數(shù)據(jù)的時候,在它埋點結(jié)束之后多加一個統(tǒng)計量,而省去了遍歷一棵樹的消耗,所以它對客戶端 CPU 的影響幾乎為 0。
5)小結(jié)
總結(jié)一下這個案例。我們之前很多時候會想讓客戶端做的比較薄,盡量讓邏輯落到服務(wù)端來獲得一些靈活性,但其實也可以考慮把一些相對簡單且變更較少的計算邏輯挪到客戶端去。
客戶端相比服務(wù)端來說,有足夠多的上下文信息,因而客戶端的分析計算可能會比服務(wù)端計算簡單得多。從這個例子里可以看到客戶端計算的時候,它只要在你兩個基本模型 Transaction、Event 埋點結(jié)束的時候多做兩個統(tǒng)計的累加就好了。但在服務(wù)端就很麻煩,服務(wù)端需要把那棵樹重新反序列化出來再去遍歷它。
另外我們做了一個類似于批量處理發(fā)送的優(yōu)化,從而讓服務(wù)端的計算量變得只會跟客戶端的數(shù)量和間隔時間有關(guān)系。這樣做有一個非常大的好處就是服務(wù)端的計算會比較平滑,不會因為客戶端這邊突然間有個流量峰值過來,突然間發(fā)了很多監(jiān)控數(shù)據(jù),從而導(dǎo)致后端產(chǎn)生抖動影響。
2.3 案例三:Report 雙緩沖
1)遇到的問題
隨著流量又進一步繼續(xù)增加,我們發(fā)現(xiàn)一個比較奇怪的現(xiàn)象——每個小時切換的前幾分鐘都會發(fā)生一定的監(jiān)控數(shù)據(jù)丟失。
看了一下網(wǎng)卡監(jiān)控,發(fā)現(xiàn)網(wǎng)卡流量是平穩(wěn)的,并不會出現(xiàn)每小時前幾分鐘會抖動的現(xiàn)象,因此排除了由于入口流量導(dǎo)致異常的推斷。又看了服務(wù)端的應(yīng)用監(jiān)控指標,發(fā)現(xiàn) Young GC 也是有著類似的趨勢,每小時的前幾分鐘有個抖動,甚至在某一分鐘里面出現(xiàn)了 60 秒鐘里面有 10 秒鐘在做 Young GC。于是就要去看 CAT 服務(wù)端里面到底是什么東西在用我們的內(nèi)存。
2)CAT 服務(wù)端內(nèi)存使用
從計算模型可以看到, CAT 服務(wù)端會接收大量的客戶端發(fā)送過來的實時監(jiān)控數(shù)據(jù)。這部分數(shù)據(jù)到了服務(wù)端,接下來就要被分發(fā)去做實時的報表統(tǒng)計分析,所以這部分數(shù)據(jù)會占用服務(wù)端很多內(nèi)存。另外,根據(jù) CAT 的計算模型也可以看到 ,CAT 服務(wù)端會將大量的當前小時的實時報表數(shù)據(jù)放在內(nèi)存中,所以當前小時的報表也是內(nèi)存占用的一大來源。
3)CAT Report 的生命周期
要看報表的問題,需要先看一下 CAT 報表的生命周期是怎么樣的。首先, CAT 會把當前小時的報表存在內(nèi)存里面,跨小時的時候重新創(chuàng)建一份新的空報表供下個小時用。小時切換后,上個小時的內(nèi)存報表在被持久化到存儲之后基本就沒有用了,等待被下一次的 GC 釋放。
報表在內(nèi)存里面的樹結(jié)構(gòu)是怎么樣的?CAT 報表基本上結(jié)構(gòu)比較相似,我這邊舉兩個基本的 Transaction 和 Event 報表。
我們可以把這兩份報表簡單理解成以字符串為 key,Map 作為 value 的一個 Map 套 Map 的結(jié)構(gòu)。比如這個例子里面,它第一層 Map 就是通過 AppId 作為 key, 去找到這個 AppId 對應(yīng)的所有 IP 的數(shù)據(jù)構(gòu)成的一個子 Map。然后通過 IP 列表的 Map 再往下逐層尋找,直到最后找到對應(yīng)條件在某個時間點的指標數(shù)據(jù)。
從這個生命周期里面可以看到,這樣的使用方式第一個問題,就是每小時它都會創(chuàng)建一份新的報表,而這份報表在剛創(chuàng)建出來的時候,由于駐留索引的缺失以及新的數(shù)據(jù)的不斷投遞,它會被不斷地填充,這個報表會不斷地創(chuàng)建對應(yīng)的下層的 Map。在不斷地填充過程中,你會發(fā)現(xiàn)這個 Map 還會不斷地需要去 resize,扔掉一些之前已經(jīng)使用的內(nèi)存,非常浪費。
我們看到 CAT 的當前小時的報表是常駐在內(nèi)存里面的,到下個小時持久化完它就可以釋放掉了。從這個地方我們可以看到報表生命周期的第二個問題,一個報表一開始創(chuàng)建時在 Young 區(qū),隨著監(jiān)控指標不斷過來,報表會不斷擴大。這個報表會不斷地被 Young GC 掃描到,但是由于這個報表是常駐內(nèi)存一小時的,所以其實這些 Young GC 都是沒有用的。當 GC 次數(shù)達到閾值后, Young GC 還會把它搬到 Old 區(qū)。那么這份 Old 區(qū)里面的報表到了下個小時完成持久化之后,就會變成一份沒有用的報表,導(dǎo)致 Old 區(qū)里面就無緣無故多了一份沒有用的數(shù)據(jù)。
4)分析問題
我們可以從這個生命周期里面看到好幾個問題。
針對 Map 會不斷地被 Resize 的問題,很自然會想到,一個公司的監(jiān)控系統(tǒng)里面的監(jiān)控指標應(yīng)該是基本穩(wěn)定的,所以是不是可以在跨小時的時候直接 clone 上個小時的報表,按照上個小時的報表的索引以及 size 去創(chuàng)建。但我們發(fā)現(xiàn)其實 clone 并沒有解決第二個問題,在 clone 的過程中還是會一直在創(chuàng)建下層的 map,而且依然還是在 Young 區(qū)產(chǎn)生一份報表,然后慢慢搬到 Old 區(qū),最后還是要丟棄掉,內(nèi)存的結(jié)構(gòu)并沒有發(fā)生變動,所以這個 clone 的方法肯定是不行的。
另外一個方法,能不能直接創(chuàng)建兩份報表,一直輪換著使用。這兩份報表因為一開始就創(chuàng)建好了,它很快就會到 Old 區(qū),然后在輪換使用時,這份報表其實就一直在 Old 區(qū)根本就沒有任何的浪費,它也不需要被創(chuàng)建。
那么我們把報表的生命周期改造成了現(xiàn)在的樣子:一開始直接創(chuàng)建兩份報表,它們一直就在 Old 區(qū)里面輪換著使用。為了保證這個 Map 不會在經(jīng)過長時間運行之后慢慢充斥著很多無用的監(jiān)控指標項目,我們加一個定時清理的策略,把一些內(nèi)存中里面,比如說兩三個小時已經(jīng)沒有再接收到的監(jiān)控數(shù)據(jù)清除掉。
5)效果
可以看到首先 Young GC 已經(jīng)達到最開始的期望,達到了穩(wěn)定的狀態(tài),而且因為它根本不用搬動這份大的數(shù)據(jù)到 Old 區(qū),也不用掃這份大的內(nèi)存,GC 速度也快了很多。此外,我們的 Full GC 也從每天的 20 次降到了每天的 3 次。
6)小結(jié)
對于 GC 問題可能會想到要去調(diào)參數(shù)讓效果好一點,但實際上根本問題是需要考慮盡量少分配內(nèi)存,因為不分配內(nèi)存才是根本訴求。如果一定要把內(nèi)存創(chuàng)建出來,就得考慮是否可以復(fù)用這份內(nèi)存。
2.4 案例四:字符串
第四個案例,我這邊起名成字符串,是因為我們在 CAT 里面做了一系列字符串相關(guān)的優(yōu)化。先看一下為什么要做字符串優(yōu)化。
這是從服務(wù)端抓出來的一份 Flight Recorder 的數(shù)據(jù)。可以看到,我們這里一分鐘內(nèi)創(chuàng)建的 String,Char[] 以及 UTF_8Decoder 對象總共有 60 多 G,相當于每秒鐘有 1 個 G 的對象產(chǎn)生。如果能把這部分的內(nèi)存節(jié)省下來,系統(tǒng)能夠運行得更好。
1)MessageTree 的傳輸
這個問題得先看一下 CAT 里面 MessageTree 的傳輸過程。首先它會從客戶端把 MessageTree 序列化成一個字節(jié)流,通過網(wǎng)絡(luò)傳輸?shù)椒?wù)端,然后服務(wù)端會把這個字節(jié)流完全的反序列化復(fù)原成原來的 MessageTree 再分發(fā)給報表分析器去計算。
MessageTree 其實就是由 CAT 兩大模型,Transaction 和 Event 組成的樹狀嵌套結(jié)構(gòu)。
可以看到這 Transaction 和 Event 里面有四個字段都是字符串的: type,name,status,data。字節(jié)流反序列化成 MessageTree 的過程中會把這個 Tree 里面的每個 Transaction 和 Event 里的這幾個字段做一次反解的操作。
這個操作到底有多大的損失?翻看一下 JDK 代碼就可以發(fā)現(xiàn),就這么一個簡單的字符串構(gòu)造,看起來很簡單的一個構(gòu)造函數(shù),實際上它里面做了兩次 Char 數(shù)組的分配,還做了一次字符集的解碼,將字節(jié)流變成 UTF_8 編碼的 Char。所以僅僅只是這么一個簡單的構(gòu)造函數(shù),既消耗內(nèi)存和也消耗 CPU。
2)byte[] —> String
那么就要看,剛剛分析出來這幾個字符串字段是不是都需要做這樣的一個反序列化操作。首先看 data,data 我們可以把它簡單理解成 Transaction 和 Event 的一個附加信息。大部分時候我們的報表分析其實根本不關(guān)心這個附加信息,附加信息一般都是用來給用戶查問題的時候,他點開這個 MessageTree 去看,但實時分析大部分情況不會用到的。所以這個 data 我們是完全可以按需的解開就好了。
然后再來看第二個字段,status。它是用來描述 Transaction 和 Event 成功與否的狀態(tài),如果失敗的話,它會把失敗原因放進去。所以可以想象,如果它失敗的話,status 里面有可能是一個非常大的字符串。
大部分情況下我們的報表只關(guān)心狀態(tài)到底是成功還是失敗,具體的失敗原因一般也是用戶點開的時候才關(guān)心,它分析的時候是不關(guān)心的。所以對于這個字段可以簡單地特殊處理一下,在序列化的時候多引入一個字節(jié)去描述它是成功還是失敗,具體失敗原因可以放到后面去,也是一個字符串,但是后面那個字符串基本上也是按需解開即可。
3)type/name 需要 byte[] —> String?
我們來看 type/name 這兩個字段。這兩個字段會稍微復(fù)雜一些,我們需要單獨拿出來看。首先,我們來看一下為什么會用到 type/name 這樣的東西。一個 MessageTree 通過序列化變成字節(jié)流傳輸?shù)椒?wù)端之后,服務(wù)端要更新的值其實就是這個字節(jié)流里面的 Metrics 部分。報表的內(nèi)存結(jié)構(gòu)類似于 Map 套 Map 的結(jié)構(gòu),AppId,IP,Type 和 Name 其實就是一個個的索引 Key,最終的目的就是為了定位并更新他們對應(yīng)的指標值。
那么為了完成這個 Map 套 Map 結(jié)構(gòu)的報表的計算過程,需要把這些字段一個一個反序列成字符串,然后去跟報表里面的每一層 Map Key 進行比較,一直往下找,找到最下層的 Map 后再去更新 Metrics 的值。而這些字符串基本都是在完成了比較后就會被丟棄。
這里的每一個操作,每一次字符串轉(zhuǎn)化都會引入剛剛說的兩次損失。看著這個圖思考一下,其實每一次的字符串轉(zhuǎn)化都只是為了用于在 Map 中進行逐層的尋找和比較,這個過程是不是一定要用字符串呢?
既然監(jiān)控數(shù)據(jù)的完整字節(jié)流已經(jīng)過來了,能不能直接在字節(jié)流上面做比較,而不用創(chuàng)建出來的字符串去比較?基于這個想法,我們寫了一個 BytesWrapper,它其實就是引用了完整的字節(jié)流,通過成員變量標記了對應(yīng)的字段在這個字節(jié)流里面的一個起始位置和長度。我們在定位 Map Value 的時候就可以通過這個 BytesWrapper 來直接進行字節(jié)流的比較,從而省掉字符串創(chuàng)建的損失。
4)進一步思考
這樣是不是就已經(jīng)完美了?我們知道其實每個 Java 對象創(chuàng)建,既使是再小的對象,都會有對象頭的損失,有時候還會有字節(jié)對齊的損失。我們可以看到我們這個 BytesWrapper 在 64 位機器,而且打開壓縮指針的情況下,它首先會占掉前面 16 個字節(jié)的對象頭,中間有三個 4 字節(jié)的字段,最后因為要做 8 字節(jié)對齊,它還有 4 字節(jié)。整體算上來,它需要用到 32 個字節(jié)。
在這個比較里面,雖然我們能夠優(yōu)化掉字符串比較,但是實際上我們還是要創(chuàng)建一個 BytesWrapper,而且這個 BytesWrapper 依然是不能避免創(chuàng)建出來還是要被扔掉的命運。
其實我們就是為了想直接用字節(jié)流里面的數(shù)據(jù)來做比較,為什么要創(chuàng)建這樣一個對象呢?關(guān)鍵的問題就在這里。因為 HashMap 需要一個 Object,通過這個 Object 里面的 Equals 和 HashValue 來做比較。那如果我有這樣一個 Map,它的 get 方法直接接受一個字節(jié)流,以及對應(yīng)的 offset 和 length ,它可以直接幫你進行比較、尋值,是不是就可以避免這個創(chuàng)建對象的損失了?
因此,我們針對這個場景重寫了一個 HashMap ,它內(nèi)部通過字節(jié)流以及它的 offset 和 length 來計算它的 hashcode 和 equals。這個時候就可以把剛剛這個模型變成了報表里面只有一 BytesHashMap 作為它的結(jié)構(gòu),我們完全就可以在計算時直接在網(wǎng)絡(luò)傳輸而來的字節(jié)流上做比較,不會有多引入任何一個對象創(chuàng)建,當然也不會有 discard 操作。現(xiàn)在再回過來看 type/name 這兩個字段,它其實也是不需要進行字符串轉(zhuǎn)換的,只要把報表改成 BytesHashMap 這樣一個結(jié)構(gòu),就能夠直接利用原始的字節(jié)流來完成操作。
改動上線后可以看到, Young GC 減少了 40%。
5)小結(jié)
我們一定要去關(guān)注代碼中大量使用的對象,以及它的創(chuàng)建過程究竟會有多大損失。即使是這個例子中里面這么簡單的字符串構(gòu)造函數(shù),實際上它對我們的內(nèi)存和 CPU 也有一定的消耗。在這個例子里面,我們通過直接引用網(wǎng)絡(luò)傳輸而來字節(jié)流進行計算處理就可以避免這些損耗了。
三、總結(jié)和思考
最后一部分,性能優(yōu)化的一些思考。
對于 CPU 問題,第一個要想到的是減少額外的損失,額外損失是代碼直接引入的消耗以外的損失。比如說例子里面就是優(yōu)化線程模型。一方面是減少了上下文切換,另一方面還減少鎖競爭。在 Java 中,一般來說鎖的實現(xiàn)在前面幾次的比較中使用的是 spin lock 的方式,所以你會發(fā)現(xiàn)其實減少鎖競爭對的 CPU 也是有著一定的好處。
把額外損失減少之后,還得考慮優(yōu)化自己的代碼實現(xiàn),減少不必要的操作。看一下每行代碼,比如說例子中減少字符串的構(gòu)建就可以減少掉沒有必要的字符集的解碼。除此之外,還可以考慮一下把一些計算邏輯從服務(wù)端移到客戶端,幫我們分攤計算。
對于 GC 問題,根本上應(yīng)該是要減少不必要對象的創(chuàng)建。這幾個案例里面,第一個是減少了創(chuàng)建字符串,直接復(fù)用字節(jié)流。還有就是案例里面通過復(fù)用內(nèi)存減少了報表的重復(fù)創(chuàng)建、填充還有 resize。
總結(jié)
以上是生活随笔為你收集整理的CAT 性能优化的实践和思考的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 天天写业务代码?写业务代码中的成长机会!
- 下一篇: 不要再问了,数据库不建议上Docker