記一次提升18倍的效能優化

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

背景

最近負責的一個自研的 Dubbo 註冊中心經常收到 CPU 使用率的告警,於是進行了一波優化,效果還不錯,於是打算分享下思考、優化過程,希望對大家有一些幫助。

自研 Dubbo 註冊中心是個什麼東西,我畫個簡圖大家稍微感受一下就好,看不懂也沒關係,不影響後續的理解。

image

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

回到今天的重點,這個註冊中心最近 CPU 使用率長期處於中高水位,偶爾有應用釋出,推送量大時,CPU 甚至會被打滿。

以前沒感覺到,是因為接入的應用不多,最近幾個月應用越接越多,慢慢就達到了告警閾值。

尋找優化點

由於這專案是 Go 寫的(不懂 Go 的朋友也沒關係,本文重點在演算法的優化,不在工具的使用上), 找到哪裡耗 CPU 還是挺簡單的:開啟 pprof 即可,去線上採集一段時間即可。

具體怎麼操作可以參考我之前的這篇文章,今天文章中用到的知識和工具,這篇文章都能找到。

image

CPU profile 截了部分圖,其他的不太重要,可以看到消耗 CPU 多的是 AssembleCategoryProviders方法,與其直接關聯的是

  • 2個 redis 相關的方法
  • 1個叫assembleUrlWeight的方法

稍微解釋下,AssembleCategoryProviders 方法是構造返回 Dubbo provider 的 url,由於會在返回 url 時對其做一些處理(比如調整權重等),會涉及到對這個 Dubbo url 的解析。又由於推拉結合的模式,線上服務使用方越多,這個處理的 QPS 就越大,所以它佔用了大部分 CPU 一點也不奇怪。

這兩個 redis 操作可能是序列化佔用了 CPU,更大頭在 assembleUrlWeight,有點琢磨不透。

接下來我們就分析下 assembleUrlWeight 如何優化,因為他佔用 CPU 最多,優化效果肯定最好。

下面是 assembleUrlWeight 的虛擬碼:

func AssembleUrlWeight(rawurl string, lidcWeight int) string {
	u, err := url.Parse(rawurl)
	if err != nil {
		return rawurl
	}

	values, err := url.ParseQuery(u.RawQuery)
	if err != nil {
		return rawurl
	}

	if values.Get("lidc_weight") != "" {
		return rawurl
	}

	endpointWeight := 100
	if values.Get("weight") != "" {
		endpointWeight, err = strconv.Atoi(values.Get("weight"))
		if err != nil {
			endpointWeight = 100
		}
	}

	values.Set("weight", strconv.Itoa(lidcWeight*endpointWeight))

	u.RawQuery = values.Encode()
	return u.String()
}

傳參 rawurl 是 Dubbo provider 的url,lidcWeight 是機房權重。根據配置的機房權重,將 url 中的 weight 進行重新計算,實現多機房流量按權重的分配。

這個過程涉及到 url 引數的解析,再進行 weight 的計算,最後再還原為一個 url

Dubbo 的 url 結構和普通 url 結構一致,其特點是引數可能比較多,沒有 #後面的片段部分。

image

CPU 主要就消耗在這兩次解析和最後的還原中,我們看這兩次解析的目的就是為了拿到 url 中的 lidc_weightweight 引數。

url.Parse 和 url.ParseQuery 都是 Go 官方提供的庫,各個語言也都有實現,其核心是解析 url 為一個物件,方便地獲取 url 的各個部分。

如果瞭解資訊熵這個概念,其實你就大概知道這裡面一定是可以優化的。Shannon(夏農) 借鑑了熱力學的概念,把資訊中排除了冗餘後的平均資訊量稱為資訊熵

image

url.Parse 和 url.ParseQuery 在這個場景下解析肯定存在冗餘,冗餘意味著 CPU 在做多餘的事情。

因為一個 Dubbo url 引數通常是很多的,我們只需要拿這兩個引數,而 url.Parse 解析了所有的引數。

舉個例子,給定一個陣列,求其中的最大值,如果先對陣列進行排序,再取最大值顯然是存在冗餘操作的。

排序後的陣列不僅能取最大值,還能取第二大值、第三大值...最小值,資訊存在冗餘了,所以先排序肯定不是求最大值的最優解。

優化

優化獲取 url 引數效能

第一想法是,不要解析全部 url,只拿相應的引數,這就很像我們寫的演算法題,比如獲取 weight 引數,它只可能是這兩種情況(不存在 #,所以簡單很多):

  • dubbo://127.0.0.1:20880/org.newboo.basic.MyDemoService?weight=100&...
  • dubbo://127.0.0.1:20880/org.newboo.basic.MyDemoService?xx=yy&weight=100&...

要麼是 &weight=,要麼是 ?weight=,結束要麼是&,要麼直接到字串尾,程式碼就很好寫了,先手寫個解析引數的演算法:

func GetUrlQueryParam(u string, key string) (string, error) {
	sb := strings.Builder{}
	sb.WriteString(key)
	sb.WriteString("=")
	index := strings.Index(u, sb.String())
	if (index == -1) || (index+len(key)+1 > len(u)) {
		return "", UrlParamNotExist
	}

	var value = strings.Builder{}
	for i := index + len(key) + 1; i < len(u); i++ {
		if i+1 > len(u) {
			break
		}
		if u[i:i+1] == "&" {
			break
		}
		value.WriteString(u[i : i+1])
	}
	return value.String(), nil
}

原先獲取引數的方法可以摘出來:

func getParamByUrlParse(ur string, key string) string {
	u, err := url.Parse(ur)
	if err != nil {
		return ""
	}

	values, err := url.ParseQuery(u.RawQuery)
	if err != nil {
		return ""
	}

	return values.Get(key)
}

先對這兩個函式進行 benchmark:

func BenchmarkGetQueryParam(b *testing.B) {
	for i := 0; i < b.N; i++ {
		getParamByUrlParse(u, "anyhost")
		getParamByUrlParse(u, "version")
		getParamByUrlParse(u, "not_exist")
	}
}

func BenchmarkGetQueryParamNew(b *testing.B) {
	for i := 0; i < b.N; i++ {
		GetUrlQueryParam(u, "anyhost")
		GetUrlQueryParam(u, "version")
		GetUrlQueryParam(u, "not_exist")
	}
}

Benchmark 結果如下:

BenchmarkGetQueryParam-4          103412              9708 ns/op
BenchmarkGetQueryParam-4          111794              9685 ns/op
BenchmarkGetQueryParam-4          115699              9818 ns/op
BenchmarkGetQueryParamNew-4      2961254               409 ns/op
BenchmarkGetQueryParamNew-4      2944274               406 ns/op
BenchmarkGetQueryParamNew-4      2895690               405 ns/op

可以看到效能大概提升了20多倍

新寫的這個方法,有兩個小細節,第一是返回值中區分了引數是否存在,這個後面會用到;第二是字串的操作用到了 strings.Builder,這也是實際測試的結果,使用 +或者 fmt.Springf 效能都沒這個好,感興趣可以測試下看看。

優化 url 寫入引數效能

計算出 weight 後再把 weight 寫入 url 中,這裡直接給出優化後的程式碼:

func AssembleUrlWeightNew(rawurl string, lidcWeight int) string {
	if lidcWeight == 1 {
		return rawurl
	}

	lidcWeightStr, err1 := GetUrlQueryParam(rawurl, "lidc_weight")
	if err1 == nil && lidcWeightStr != "" {
		return rawurl
	}

	var err error
	endpointWeight := 100
	weightStr, err2 := GetUrlQueryParam(rawurl, "weight")
	if weightStr != "" {
		endpointWeight, err = strconv.Atoi(weightStr)
		if err != nil {
			endpointWeight = 100
		}
	}

	if err2 != nil { // url中不存在weight
		finUrl := strings.Builder{}
		finUrl.WriteString(rawurl)
		if strings.Contains(rawurl, "?") {
			finUrl.WriteString("&weight=")
			finUrl.WriteString(strconv.Itoa(lidcWeight * endpointWeight))
			return finUrl.String()
		} else {
			finUrl.WriteString("?weight=")
			finUrl.WriteString(strconv.Itoa(lidcWeight * endpointWeight))
			return finUrl.String()
		}
	} else { // url中存在weight
		oldWeightStr := strings.Builder{}
		oldWeightStr.WriteString("weight=")
		oldWeightStr.WriteString(weightStr)

		newWeightStr := strings.Builder{}
		newWeightStr.WriteString("weight=")
		newWeightStr.WriteString(strconv.Itoa(lidcWeight * endpointWeight))
		return strings.ReplaceAll(rawurl, oldWeightStr.String(), newWeightStr.String())
	}
}

主要就是分為 url 中是否存在 weight 兩種情況來討論:

  • url 本身不存在 weight 引數,則直接在 url 後拼接一個 weight 引數,當然要注意是否存在 ?
  • url 本身存在 weight 引數,則直接進行字串替換

細心的你肯定又發現了,當 lidcWeight = 1 時,直接返回,因為 lidcWeight = 1 時,後面的計算其實都不起作用(Dubbo 權重預設為100),索性別操作,省點 CPU。

全部優化完,總體做一下 benchmark:

func BenchmarkAssembleUrlWeight(b *testing.B) {
	for i := 0; i < b.N; i++ {
		for _, ut := range []string{u, u1, u2, u3} {
			AssembleUrlWeight(ut, 60)
		}
	}
}

func BenchmarkAssembleUrlWeightNew(b *testing.B) {
	for i := 0; i < b.N; i++ {
		for _, ut := range []string{u, u1, u2, u3} {
			AssembleUrlWeightNew(ut, 60)
		}
	}
}

結果如下:

BenchmarkAssembleUrlWeight-4               34275             33289 ns/op
BenchmarkAssembleUrlWeight-4               36646             32432 ns/op
BenchmarkAssembleUrlWeight-4               36702             32740 ns/op
BenchmarkAssembleUrlWeightNew-4           573684              1851 ns/op
BenchmarkAssembleUrlWeightNew-4           646952              1832 ns/op
BenchmarkAssembleUrlWeightNew-4           563392              1896 ns/op

大概提升 18 倍效能,而且這可能還是比較差的情況,如果傳入 lidcWeight = 1,效果更好。

效果

優化完,對改動方法寫了相應的單元測試,確認沒問題後,上線進行觀察,CPU Idle(空閒率) 提升了10%以上

image

最後

其實本文展示的是一個 Go 程式非常常規的效能優化,也是相對來說比較簡單,看完後,大家可能還有疑問:

  • 為什麼要在推送和拉取的時候去解析 url 呢?不能事先算好存起來嗎?
  • 為什麼只優化了這點,其他的點是否也可以優化呢?

針對第一個問題,其實這是個歷史問題,當你接手系統時他就是這樣,如果程式出問題,你去改整個機制,可能週期比較長,而且容易出問題

image

第二個問題,其實剛也順帶回答了,這樣優化,改動最小,收益最大,別的點沒這麼好改,短期來說,拿收益最重要。當然我們後續也打算對這個系統進行重構,但重構之前,這樣優化,足以解決問題。

相關文章