1. 程式人生 > >web server效能優化淺談

web server效能優化淺談

Update:

2018.8.8 在無鎖小節增加了一些內容

效能優化,優化的東西一定得在主路徑上,結合測量的結果去優化。不然即使效能再好,邏輯相對而言執行不了幾次,其實對提示效能的影響微乎其微。記得抖哥以前說多隆在幫忙查廣告搜尋引擎的問題,看到了一處程式碼,激動的說這裡用他的辦法,效能可以提升至少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適合兩種用途:

  1. 指定的key,value只會被寫入一次,但是會被讀取很多次
  2. 多個goroutine讀取、寫入、覆蓋的資料都是沒有交集的

只有上述情況下,sync.Map才能相比Go map搭配單獨的Mutex或RWMutex而言,顯著降低鎖的競爭,均攤複雜度是常數(amortized constant time)

大部分情況下,應該用 map ,然後用單獨的鎖或者同步機制,這樣型別安全,而且可以有其他的邏輯

鎖相關

Mutexes

鎖在滿足以下條件的情況下,是很快的:

  • 沒有其他人競爭 (想象為擠公交車,此時沒人跟你搶,你直接上車)
  • 鎖覆蓋的程式碼,執行時間非常快 (想象為擠公交車,大家速度都很快,嗖嗖就上去了,下一個人等待上一個人擠上去的時間很短)

當競爭越激烈,鎖的效能下降的越厲害。

Reference:

鎖的粒度儘量小

比如我們的pipeline生命週期的管理,一開始是通過一把大鎖來控制併發的,後續優化時,發現裡面可以細分成兩塊,各自可以用一把鎖來控制,這樣鎖的粒度變小,併發程度會提高。

這裡比較好的例子是BigCache的實現。它使用分片(sharding)的方式, 跟Java 7裡的concurrent hash map的實現類似,對資料進行分片,分片之間是獨立的,可以併發的進行寫操作。對細分後的分片進行併發控制,這樣能有效減小鎖的粒度,讓併發度儘可能高。

RWMutexes

  1. 是否有多讀少寫的場景,如果是,儘量用讀寫鎖;這樣儘量把寫鎖的粒度縮小,能用讀鎖解決的,就不需要用寫鎖,真正需要修改結果時,才使用寫鎖。

比如:

func (b *DataBucket) QueryDataWithBindDefault(key interface{}, defaultValueFunc DefaultValueFunc) (interface{}, error) {
    先上讀鎖,看key是否存在,如果存在,就返回 // 大部分情況下是這樣,所以這個優化肯定很有意義
    否則,上寫鎖,把預設值加上 // 這種情況只會發生一次
 }

儘量使用無鎖的方式:

是否真的需要使用加鎖的方式來保證整個程式碼塊是互斥的?是否能用原子操作(CAS)來代替鎖? 原子操作和鎖的主要區別在於鎖的粒度,使用鎖,可以讓鎖保護的整個程式碼塊是互斥的,使用原子操作,只能讓操作的這個變數是互斥的。所以原子操作適合修改某個值的情況。

例如:

利用 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方面,提供了很多強有力的工具,可以參加下面的文章:

好了,以上就是所有內容了,歡迎留下你的效能優化的思路和方法!