作者: "Jack47, ZhiYan" 轉載請保留作者和 "原文出處" 性能優化,優化的東西一定得在主路徑上,結合測量的結果去優化。不然即使性能再好,邏輯相對而言執行不了幾次,其實對提示性能的影響微乎其微。記得抖哥以前說多隆在幫忙查廣告搜索引擎的問題,看到了一處代碼,激動的說這裡用他的辦法,性能可 ...
轉載請保留作者和原文出處
性能優化,優化的東西一定得在主路徑上,結合測量的結果去優化。不然即使性能再好,邏輯相對而言執行不了幾次,其實對提示性能的影響微乎其微。記得抖哥以前說多隆在幫忙查廣告搜索引擎的問題,看到了一處代碼,激動的說這裡用他的辦法,性能可以提升至少10倍。但實際上,這裡的邏輯基本走不到 face_palm。
性能優化的幾個跟語言無關的大方向:
減少演算法的時間複雜度
例子1
我們實現了一個CallBack的機制,一段執行流程里,會有多個plugin,每個plugin可以添加callback,每個callback有唯一的名字;添加callback時,需要註意覆蓋的問題,如果覆蓋了,需要返回老的callback。一開始我們的實現機制是使用數組,這樣添加時,需要挨個遍歷,查看是否時覆蓋的情況。Update操作的時間複雜度為O(n);後來我們添加了一個輔助的Map,用來存儲 <name, callbackIdx>的映射關係。Update的平均時間複雜度降低為O(1)
例子2
在我們的pipeline場景里,類似net/http里的context,我們有個task的概念的。每個階段(plugin)都可以向裡面塞數據,一開始為了支持cancel某個階段,重新執行這個階段的功能,我們是使用嵌套,類似遞歸的方式。這樣就可以很方便的撤銷某個階段放入的數據。但是這種設計,如果要從裡面取數據,需要層層遍歷,類似遞歸一樣,時間複雜度為O(n);因為每個plugin都會與task打交道,所以這裡 task里數據的存取是高頻操作,而且我們後來經過權衡,覺得支持取消掉某個階段對task的操作,不是必須的,不支持也沒關係,所以後來簡化了task的設計,直接用一個map來做,這樣時間複雜度又降下來了。
根據業務邏輯,設計優化的數據結構
我們有個場景,是要對URL執行類似歸一化的操作,把裡面重覆的\
字元刪掉,比如 \\
-> \
。這個邏輯對於網關,是高頻邏輯,因為每個請求來了,都需要判斷,但是真正要刪掉重覆的\
的操作,其實比較少,大部分場景是檢查完,發現正常,不需要做修改。
一開始我們的實現是把url字元挨個檢查,沒問題的放入 bytes.Buffer 中,最終返回 buffer.String();後來我們優化了一下,採用了標準庫中 path/path.go 中的 Lazybuf 的方式,LazyBuf中發現要寫入的字元和基準的字元不一樣時,才分配記憶體來存儲修改後的字元串,不然最終還是基準的字元,直接返回就行,避免了無謂的記憶體拷貝操作。
這裡其實體現了一個小技巧,儘量想想自己需要的操作,是否標準庫里有,同時也要多看看標準庫的實現,吸取經驗。
儘量減少磁碟IO次數
IO操作儘量批量進行。比如我們的網關會記錄訪問日誌,類似Nginx的access.log。在生產環境/壓測環境下,會生成大量的日誌,雖然操作系統寫入文件是有緩衝的,但是這個緩衝機制我們應用程式沒法直接控制,而且寫入文件時調用系統API,也比較耗時。我們可以在應用層面,給日誌留緩衝區(buffer),定時或達到一定量(4k,跟虛擬文件系統的塊大小保持一致)時調用操作系統IO操作來寫入日誌。
總結一下,就是寫入日誌是非同步的,同時是攢夠一批之後,再調用操作系統的寫入
具體實現:進來的數據,先放到一個2048位元組大小的channel里,由一個固定的go routine負責不斷的從channel里讀取數據,寫入到buffered io里。這裡2048位元組的channel,類似隊列一樣,是有削峰作用的。當有大批日誌寫入時,channel可以暫時緩衝一下,降低 buffer.io 真正flush的頻率。;寫入文件時,套上一個 bufio.Writer(size=512)
,即內部是有512位元組大小的緩衝區,滿了才使用整塊數據調用Write();
儘量復用資源
資源的申請和釋放,跟記憶體(也是一種資源)的申請和釋放其實是一樣的,儘量復用,避免重覆/頻繁申請;
比如下麵的這個time.Tick,適用於使用者不需要關閉它,即非頻繁調用的情況。使用它很方便,但是要註意,它沒法關閉,所以垃圾回收器也沒法回收它。來看一下下麵的這段代碼修改記錄:
+ ticker := time.NewTicker(time.Second)
+ defer ticker.Stop()
+
for {
select {
- case <-time.Tick(time.Second):
+ case <-ticker.C:
修改前,for迴圈里會頻繁創建time.Ticker,但都沒有回收機制。改動後,for迴圈里復用同一個time.Ticker,而且會在當前函數執行結束時釋放time.Ticker。
sync.Map的使用
其實看清楚map.go里的註釋,註意使用場景。
sync.Map適合兩種用途:
- 指定的key,value只會被寫入一次,但是會被讀取很多次
- 多個goroutine讀取、寫入、覆蓋的數據都是沒有交集的
只有上述情況下,sync.Map才能相比Go map搭配單獨的Mutex或RWMutex而言,顯著降低鎖的競爭,均攤複雜度是常數(amortized constant time)
大部分情況下,應該用 map ,然後用單獨的鎖或者同步機制,這樣類型安全,而且可以有其他的邏輯
鎖相關
Mutexes
鎖在滿足以下條件的情況下,是很快的:
- 沒有其他人競爭 (想象為擠公交車,此時沒人跟你搶,你直接上車)
- 鎖覆蓋的代碼,執行時間非常快 (想象為擠公交車,大家速度都很快,嗖嗖就上去了,下一個人等待上一個人擠上去的時間很短)
當競爭越激烈,鎖的性能下降的越厲害。
Reference:
locks aren't slow, lock contention is
鎖的粒度儘量小
比如我們的pipeline生命周期的管理,一開始是通過一把大鎖來控制併發的,後續優化時,發現裡面可以細分成兩塊,各自可以用一把鎖來控制,這樣鎖的粒度變小,併發程度會提高。
這裡比較好的例子是BigCache的實現。它使用分片(sharding)的方式,
跟Java 7里的concurrent hash map的實現類似,對數據進行分片,分片之間是獨立的,可以併發的進行寫操作。對細分後的分片進行併發控制,這樣能有效減小鎖的粒度,讓併發度儘可能高。
Reference: Writing Fast Cache Service in Go
RWMutexes
- 是否有多讀少寫的場景,如果是,儘量用讀寫鎖;這樣儘量把寫鎖的粒度縮小,能用讀鎖解決的,就不需要用寫鎖,真正需要修改結果時,才使用寫鎖。
比如:
func (b *DataBucket) QueryDataWithBindDefault(key interface{}, defaultValueFunc DefaultValueFunc) (interface{}, error) {
先上讀鎖,看key是否存在,如果存在,就返回 // 大部分情況下是這樣,所以這個優化肯定很有意義
否則,上寫鎖,把預設值加上 // 這種情況只會發生一次
}
儘量使用無鎖的方式:
是否真的需要加鎖?是否能用CAS的操作來代替Mutex?
例如:
利用 atmoic int stopped = 0/1 來代表是否停止,需要停止時,設置為1。
golang里Atomic操作有:Atomic.CompareAndSet, LoadInt(), StoreInt()
如果利用某個變數代表現在是否在幹活,close時需要等別人幹完活,那麼在close時,需要通過spin的方式等待幹活的人結束:
for atomic.LoadInt(&doing) > 0 {
sleep(1ms)
}
記憶體相關
減少記憶體分配的次數
生成字元串時,儘量寫入 bytes.Buffer, 而不是用 fmt.Sprintf()
+ var repeatingRune rune
- result := string(s[0])
+ result := bytes.NewBuffer(nil)
for _, r := range s[:1] {
- result = fmt.Sprintf("%s%s", result, string(r))
+ result.WriteRune(r)
+ }
數據結構初始化時,儘量指定合適的容量
比如Java或者Go裡面,如果數組,Map的大小已知,可以在聲明時指定大小,這樣避免後續追加數據時需要擴展內部容量,造成多次記憶體分配
- eventStream := make(chan cluster.Event)
+ eventStream := make(chan cluster.Event, 1024)
語言(Go)相關
語言相關的其實還有很多,但是隨著語言的發展,基本上都會被解決掉,所以這裡只提一下下麵的這個,對Go語言感興趣的同學,可以看So You Wanna Go Fast
避免記憶體拷貝
如下的代碼,兩者有什麼區別?
- for _, bucket := range s.buckets {
- bucket.Update(v)
+ for i := 0; i < len(s.buckets); i++ {
buckets[i].Update(v)
修改前的這種方式,bucket是通過拷貝生成的臨時變數;而且這種方式下,由於操作的是臨時變數,所以 s.buckets並不會被更新!
Go routine雖好,也有代價
我們的網關,一開始的時候,由於大家也都是剛接觸Go語言,用Go routine用的也順手,所以很喜歡用Go routine;比如我們的主流程里,需要記錄本次請求的一些指標,為了不影響主流程的執行,這些記錄指標的邏輯都是啟動一個新的go routine去執行的。後來發現我們在一臺機器上,一個程式里,某一時刻啟動了十萬計的go routine,而這些go routine生命周期很短,會不斷的銷毀和創建。我也簡單的用Go Benchmark測試模擬了一個場景,測試了之後發現go routine數量上去後,性能下降很大,說明此時的調度開銷也比較大了。後來我們修改了設計,讓大家把需要更新的數據放到channel里,啟動固定的go routine去做更新的事情,這樣可以避免頻繁創建go routine的情況。
使用多個http.Client來發送請求
一開始我們是通過一個http.Client來發送同一個API的請求,後來擔心這裡可能存在併發的瓶頸,嘗試了創建多個http.Client,發送時隨機使用某一個發送的機制,發現性能提升了。其實性能有多少提升,取決於使用場景的,還是得實際測量,用數值說話,我們的方法不一定對你們有用!
Go語言在benchmark方面,提供了很多強有力的工具,可以參加下麵的文章:
An Introduction to go tool trace
Writing and Optimizing Go code
好了,以上就是所有內容了,歡迎留下你的性能優化的思路和方法!
如果您看了本篇博客,有所收穫,請點擊右下角的“推薦”,讓更多人看到! 打賞也是對自己的肯定 微信打賞