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周內接手一個新項目,並完成性能優化上線,重構是不可能的。
希望通過本文你能學到一些性能優化的基本知識,從為什么要做的拷問出發,建立度量體系,找出瓶頸,一步一步進行優化,根據數據反饋及時調整優化方向。
今天到此為止,我們下期再見。
搜索關注微信公眾號"捉蟲大師",后端技術分享,架構設計、性能優化、源碼閱讀、問題排查、踩坑實踐。
歷史好文推薦