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

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

- 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秒以內,且無報錯,
瓶頸在哪里
性能優化的最關鍵之處在于找到瓶頸在哪,否則就是無頭蒼蠅,到處瞎碰,
注冊介面到底干了什么呢?我這里畫個簡圖

- 整個流程加鎖,防止并發操作
- 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就能干,

可以清楚地看到是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,達到目標~

最后說兩句
可能有人看完就要噴了,這哪是性能優化?這分明是填坑!對,你說的沒錯,只不過這坑是別人挖的,
本文就以一種最小的代價來搞定對祖傳代碼的性能優化,當然并不是鼓勵大家都去取巧,這專案我也正在重構,只是每個階段都有不同的解法,比如老板要求你2周內接手一個新專案,并完成性能優化上線,重構是不可能的,
希望通過本文你能學到一些性能優化的基本知識,從為什么要做的拷問出發,建立度量體系,找出瓶頸,一步一步進行優化,根據資料反饋及時調整優化方向,
今天到此為止,我們下期再見,
搜索關注微信公眾號"捉蟲大師",后端技術分享,架構設計、性能優化、原始碼閱讀、問題排查、踩坑實踐,

歷史好文推薦
- 《慘,給Go提的代碼被批麻了》
- 《大廠偏愛的Agent技術究竟是個啥》
- 《剛出爐的《Java開發手冊黃山版》,我幫你們圈出了改動點!》
- 《這個Dubbo注冊中心擴展,有點意思!》
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/446999.html
標籤:Java
上一篇:Java基礎——日期類Date
