祖傳程式碼如何優化效能?

捉蟲大師發表於2022-03-22

hello大家好呀,我是小樓~

今天又帶來一次效能優化的分享,這是我剛進公司時接手的祖傳(壞笑)專案,這個專案在我的文章中屢次被提及,我在它上面做了很多的效能優化,比如《記一次提升18倍的效能優化》這篇文章,比較偏向某個細節的優化,本文更偏向巨集觀上的效能優化,可以說是個老演員了。

image

背景

為了新朋友能快速進入場景,再描述一遍這個專案的背景,這個專案是一個自研的Dubbo註冊中心,上一張架構圖

image

  • Consumer 和 Provider 的服務發現請求(註冊、登出、訂閱)都發給 Agent,由它全權代理
  • Registry 和 Agent 保持 Grpc 長連結,長連結的目的主要是 Provider 方有變更時,能及時推送給相應的 Consumer。為了保證資料的正確性,做了推拉結合的機制,Agent 會每隔一段時間去 Registry 拉取訂閱的服務列表
  • Agent 和業務服務部署在同一臺機器上,類似 Service Mesh 的思路,儘量減少對業務的入侵,這樣就能快速的迭代了

這裡的Registry就是今天的主角,熟悉Dubbo的朋友可以把它當做是一個zookeeper,不熟悉的朋友可以就把它當做是一個Web應用,提供了註冊、登出、訂閱介面,雖然它是用Go寫的,但本文和Go本身關係不大,也用用一些虛擬碼來示意,所以也可以放心大膽地看下去。

一定要做效能優化嗎

在做效能優化之前,我們得回答幾個問題,效能優化帶來的收益是什麼?為什麼一定要做優化效能?不優化行不行?

效能優化無非有兩個目的:

  • 減少資源消耗,降低成本
  • 提高系統穩定性

如果只是為了降低成本,最好做之前估算一下大概能降低多少成本,如果吭哧吭哧幹了大半個月,結果只省下了一丁點的資源,那是得不償失的。

回到這個註冊中心,為什麼要做效能優化呢?

Dubbo應用啟動時,會向註冊中心發起註冊,如果註冊失敗,則會阻塞應用的啟動。

起初這個專案問題並不大,因為接入的應用並不多,而當我接手專案時,接入的應用越來越多。

話分兩頭,另一邊集團也在逐漸使用容器替代虛擬機器和物理機,在高峰期會用擴容的方式來抗住流量高峰,快速擴容就要求服務能在短時間內大量啟動,無疑對註冊中心是一個大的考驗。

而導致這次優化的直接導火索是集團內的一次演練,他們發現一個配置中心的啟動依賴,效能達不到標準而導致擴容失敗,於是覆盤下來,所有的啟動依賴必須達到一定的效能要求,而這個標準被定為1000qps。

於是就有了本文。

指標度量

如果不能度量,就沒法優化。

首先是把幾個核心介面加上metric,主要是請求量、耗時(p99 / p95 / p90)、錯誤請求量,無論是哪個專案,這點算是基本的了,如果沒加,得好好反思了。

其次對專案進行一次壓測,不知道現在的效能,後面的優化也無法證明其效果了。

以註冊介面為例,當時註冊的效能大概是40qps,記住這個值,看我們是如何一步一步達到1000qps的。

壓測成功的請求標準是:p99耗時在1秒以內,且無報錯。

瓶頸在哪裡

效能優化的最關鍵之處在於找到瓶頸在哪,否則就是無頭蒼蠅,到處瞎碰。

註冊介面到底幹了什麼呢?我這裡畫個簡圖

image

  • 整個流程加鎖,防止併發操作
  • Create App和Create Cluster是建立應用和叢集,只會在應用第一次建立,如果建立過就直接跳過
  • Insert Endpoint是插入註冊資料,即ip和port
  • 系統的底層儲存是基於MySQL,Lock和UnLock也是基於MySQL實現的悲觀鎖

從這個流程圖就能看出來,瓶頸大概率在鎖上,這是個悲觀鎖,而且粒度是App,把整個流程鎖住,同一時刻相同應用的請只允許一個通過,可想而知效能有多差。

至於MySQL如何實現一個悲觀鎖,我相信你會的,所以我就不展開。

為了證明猜想,我用了一個非常笨但很有效的方法,在每一個關鍵節點執行之後,記錄下耗時,最後列印到日誌裡,這樣就能一眼看出到底哪裡慢,果然最慢的就是加鎖。

鎖優化

在優化鎖之前,我們先搞清楚為什麼要加鎖,在我反覆測試,讀程式碼,看文件之後,發現事情其實很簡單,這個鎖是為了防止App、Cluster、Endpoint重複寫入。

為什麼防止重複寫入要這麼折騰呢?一個資料庫的唯一索引不就搞定了?這無法考證,但現狀就是這樣,如何破解呢?

  • 首先是看這些表能否加唯一索引,有則儘量加上
  • 其次資料庫悲觀鎖能否換成Redis的樂觀鎖?

這個其實是可以的,原因在於客戶端具有重試機制,如果併發衝突了,則發起重試,我們堵這個概率很小。

上面兩條優化下來只解決了部分問題,還有的表實在無法新增唯一索引,比如這裡App、Cluster由於一些特殊原因無法新增唯一索引,他們發生衝突的概率很高,同一個叢集釋出時,很可能是100臺機器同時拉起,只有一臺成功,剩餘99臺在建立App或者Cluster時被鎖擋住了,發起重試,重試又可能衝突,大家都陷入了無限重試,最終超時,我們的服務也可能被重試流量打垮。

這該怎麼辦?這時我想起了剛學Java時練習寫單例模式中,有個叫「雙重校驗鎖」的東西,我們看程式碼

public class Singleton {
    private static volatile Singleton instance = null;
    private Singleton() {
    }
    private static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        
        return instance;
    }
}

再結合我們的場景,App和Cluster只在建立時需要保證唯一性,後續都是先查詢,如果存在就不需要再執行插入,我們寫出虛擬碼

app = DB.get("app_name")
if app == null {
    redis.lock()
    app = DB.get("app_name")
    if app == null {
        app = DB.instert("app_name")
    }
    redis.unlock()
}

是不是和雙重校驗鎖一模一樣?為什麼這樣會效能更高呢?因為App和Cluster的特性是隻在第一次時插入,真正需要鎖住的概率很小,就拿擴容的場景來說,必然不會走到鎖的邏輯,只有應用初次建立時才會真正被Lock。

效能優化有一點是很重要的,就是我們要去優化執行頻率非常高的場景,這樣收益才高,如果執行的頻率很低,那麼我們是可以選擇性放棄的。

經過這輪優化,註冊的效能從40qps提升到了430qps,10倍的提升。

讀走快取

經過上一輪的優化,我們還有個結論能得出來,一個應用或叢集的基本資訊基本不會變化,於是我在想,是否可以讀取這些資訊時直接走Redis快取呢?

於是將資訊基本不變的物件加上了快取,再測試,發現qps從430提升到了440,提升不是很多,但蒼蠅再小,好歹是塊肉。

CPU優化

上一輪的優化效果不理想,但在壓測時注意到了一個問題,我發現Registry的CPU降低的很厲害,感覺瓶頸從鎖轉移到了CPU。說到CPU,這好辦啊,上火焰圖,Go自帶的pprof就能幹。

image

可以清楚地看到是ParseUrl佔用了太多的CPU,這裡簡單科普下,Dubbo傳參很多是靠URL傳參的,註冊中心拿到Dubbo的URL,需要去解析其中的引數,比如ip、port等資訊就存在於URL之中。

一開始拿到這個CPU profile的結果是有點難受的,因為ParseUrl是封裝的標準包裡的URL解析方法,想要寫一個比它還高效的,基本可以勸退。

但還是順騰摸瓜,看看哪裡呼叫了這個方法。不看不知道,一看嚇一跳,原來一個請求裡的URL,會執行過程中多次解析URL,為啥程式碼會這麼寫?可能是其中邏輯太複雜,一層一層的巢狀,但各個方法之間的傳參又不統一,所以帶來了這麼糟糕的寫法,

這種情況怎麼辦呢?

  • 重構,把URL的解析統一放在一個地方,後續傳參就傳解析後的結果,不需要重複解析
  • 對URL解析的方法,以每次請求的會話為粒度加一層快取,保證只解析一次

我選擇了第二種方式,因為這樣對程式碼的改動小,畢竟我剛接手這麼龐大、混亂的程式碼,最好能不動就不動,能少動就少動。

而且這種方式我很熟悉,在Dubbo的原始碼中就有這樣的處理,Dubbo在反序列化時,如果是重複的物件,則直接走快取而不是再去構造一遍,程式碼位於org.apache.dubbo.common.utils.PojoUtils#generalize

擷取一點感受下

private static Object generalize(Object pojo, Map<Object, Object> history) {
    ...
    Object o = history.get(pojo);
    if (o != null) {
        return o;
    }
    history.put(pojo, pojo);
    ...
}

根據這個思路,把ParseUrl改成帶cache的模式

func parseUrl(url, cache) {
    if cache.get(url) != null {
        return cache.get(url)
    }
    u = parseUrl0(url)
    cache.put(url, u)
    return u
}

因為是會話級別的快取,所以每個會話會new一個cache,這樣能保證一個會話中對相同的url只解析一次。

可以看下這次優化的成果,qps直接到1100,達到目標~

image

最後說兩句

可能有人看完就要噴了,這哪是效能優化?這分明是填坑!對,你說的沒錯,只不過這坑是別人挖的。

本文就以一種最小的代價來搞定對祖傳程式碼的效能優化,當然並不是鼓勵大家都去取巧,這專案我也正在重構,只是每個階段都有不同的解法,比如老闆要求你2周內接手一個新專案,並完成效能優化上線,重構是不可能的。

希望通過本文你能學到一些效能優化的基本知識,從為什麼要做的拷問出發,建立度量體系,找出瓶頸,一步一步進行優化,根據資料反饋及時調整優化方向。

今天到此為止,我們下期再見。


搜尋關注微信公眾號"捉蟲大師",後端技術分享,架構設計、效能優化、原始碼閱讀、問題排查、踩坑實踐。

image

歷史好文推薦

相關文章