這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
這個API系統的本身目的是解決大量並發關聯式資料庫查詢的問題,主體設計思路是對PG中的表資料通過LRU等緩衝演算法緩衝其中的資料,並進行請求合并也就是請求削峰服務,從而大量減少直連資料庫操作,減輕資料庫的壓力,查詢效率也大大提高。由於這個項目第一版各種指標要求不是很高,接著就面臨一系列嚴重的問題:包括無法承載百萬終端層級的資料;請求壓力過大請求失敗;記憶體和CPU在請求高峰時打滿;出現goruntine崩潰和map讀寫衝突問題;效能和預期差的很遠。獨自接下最佳化這個項目後不得不一點dian迴歸重構,期間也就遇到了很多坑。。。
此次最佳化更多的是演算法和Go提示和特性的最佳化,包括簡化遞迴演算法的設計加速啟動資料載入,資料結構的精簡,緩衝以及資料的複用共用減少記憶體壓力,記憶體和CPU暴增的排查,定位和解決,GC與記憶體配置的最佳化,channel的使用坑,計時器的使用誤區等等。對其中重要的我將通過幾個真實的情境問題來介紹。
1.啟動時資料庫快取資料載入最佳化
情境:API系統開啟後需要快速的將100W終端的詳細資料以及它們組成的大約1-10W不等分組資訊從資料庫中載入出來,但是速度特別慢100W資料需要大概1分鐘以上,10W分組需要二十多分鐘才能完全load下來。
100W的資料想要一條SQL查詢下來並緩衝肯定會出現不可預期的問題,資料量太大,這樣搞最後肯定會卡死。所以採用切分策略,每次只取2000條左右,大概要取50次,ok,這沒問題。問題出在如何切分以及下次從哪裡開始查詢,原先代碼如下:
var minId int ShovelSize = 2000 if err := Pg.Get(&minId, "SELECT MIN(id) FROM client"); err != nil { return err } // 得到最小的id lastSyncedID := minId for { var thisQueryResultCount = 0 // 然後查詢id>lastSyncedID的資料 rows, err := Pg.Queryx(fmt.Sprintf(`SELECT id,mid,gid FROM client WHERE id >= %d ORDER BY id ASC LIMIT %d; `, lastSyncedID, ShovelSize)) /.../ // 每次lastSyncedID+2000 lastSyncedID += ShovelSize for rows.Next() { thisQueryResultCount++ ... err = rows.StructScan(&c) api.clientMgr.ReplaceInto(&c) // lastSyncedID = c.Id +1 } if thisQueryResultCount == 0 { break } }
從上面的代碼可以看出問題所在,當這100W資料id是連續的,每次取2000條然後每次lastSyncedID加2000沒問題,一旦出現斷了的id,就會出現重複的查詢,而插入緩衝的代碼還要判斷是否目前存在了該資訊,存在了還要覆蓋或者刪除再添加,只要出現一個id中斷要重複50次左右,出現100個中斷將重複50000次的刪除寫入操作。所以需要修改兩個地方,一個是lastSyncedID = c.Id +1,即隨著每次查詢更新最後面的id,不再盲目的直接增加2000,這樣我們保證了每次查出來的資訊都是與之前沒有重複的,另一個是將Replace直接用Add替換,因為可以放心的直接插入不用再像之前那樣判斷很多項是否存在,然後再插入。這樣100W的終端資料緩衝速度在幾秒鐘就完成啦。
2.精簡遞迴演算法提高查詢效率
情境:影響初始化速度的另一個因素是每次加入一條分組資訊要迴歸所有的子分組和父分組的children資訊,更新資料,10W條分組要遞迴10W次,導致10W資料要20+分鐘才能載入完。通過gid擷取該分組的所有子孫分組以及從頂層到該層的分組路徑,當查詢gid=1時需要60s+,如何最佳化?並且一旦有一個分組的資訊改變可能需要遞迴所有相關的分組緩衝資訊,如何做到?
遞迴演算法是一種直接或者間接調用自身函數或者方法的演算法。實質是把問題分解成規模縮小的同類問題的子問題,然後遞迴調用方法來表示問題的解。遞迴是一門偉大的藝術,使得程式的正確性更容易確認,而不需要犧牲效能,但這需要我們能將複雜的問題抽象出最簡單的資料模型來實現,使用不當不僅效率大大下降,有時候還會出現意外的崩潰。之前刷ACM題的時候就和同學經常說大部分的演算法題都可以用二叉樹和遞迴解決,真實情境確實也如此。
10W分組的資料,每個分組帶有分組的id以及父分組的id,還有分組資訊的更新時間等一些資訊,程式原先的方法是緩衝下來所有的分組資訊,並一一計算它所有的children的id,一旦有一個分組出現修改要復原計算所有的children分組和父分組,計算量還是非常大的,試想分組id如果是1,將重新遞迴計算10W分組資訊。但我分析了下資料特徵發現這些複雜資料的遞迴本質上是不必要的。
原始程式碼片段:
groupTbl map[int]*RawGroupinfo type GroupCalcResult struct { Children []int Path []int OutputJson []byte } func getAllchildrenbyid(id int, children []int, recursiveChildrenNum int, maxRecursive int) ([]int, int) { isEnd := 0 recursiveChildrenNum++ if recursiveChildrenNum <= (maxRecursive + 1) { allFirstInfo := gm.groupTbl for childrenkey, childrenvalue := range allFirstInfo { if childrenvalue.Pid == id { children = append(children, childrenkey) children, isEnd = getAllchildrenbyid(childrenkey, children, recursiveChildrenNum, maxRecursive) } } } else { isEnd = 1 } return children, isEnd}
可以看到這個遞迴使用上沒有什麼問題,在資料量不大的情況下問題同樣可以解決,但是我們可以看出一點,就是關注的資訊太多,傳回值太多,而這些值對於我們問題本身並沒有什麼必要用處,我們關注的就是id之間的父子關係,並且遞迴過程還出現了map讀寫衝突的bug,而這些讀寫是不必要的,也不是我們問題的關鍵。最初我以為是遞迴演算法本身的問題,我照著上面的結構擼了個多叉樹期望在演算法效率上解決這個問題,經過測試我發現關注的資訊太多並沒有明顯的優勢。經過仔細分析,其實我們關注的無非就是int型的id以及id之間的上下級關係嘛。我們抽象下這個資料結構,10W分組的資料,結構如下:
1:[2,3,4,5,6,7,8,9]2:[11,12,13,14,15,16,...]11:[110,111,112...]....
首先,我們不能時時刻刻維護這一個數組來儲存著子子孫孫,只需要關注自己的兒子,不然怎麼管的過來,還有就是對於沒用的每個分組的附加資訊在我們的關係結構中根本不需要,試想我們知道一個人和自己的關係,還需要帶上他穿多少衣服,今天吃了什麼嗎?兩個資料結構就可以維護我們的分組資訊,10W的分組詳細資料:map[gid]GidInfo和分組的父子資訊map[id][]int。最佳化後
// 某個組id的children查詢func childrenSearch(groupFamily map[int][]int, data []int) []int { res := make([]int, 0) for _, v := range data { res = append(res, v) res = append(res, childrenSearch(groupFamily, groupFamily[v])...) } return res}
經過測試後, 10W分組的id查詢時間從之前的60s+變成了0.1s左右。
3.golang程式的記憶體佔用不是你看到的記憶體實際佔用
情境:通過gid擷取全部的終端資訊,請求查詢gid=1時返回資料大概32Mb,上百並發時16G記憶體的伺服器一下子佔用了其中的12G+,如何降低?
一個請求的返回資料32M,當同時有上百個這樣的請求發起時記憶體暴增12G,當時的我是崩潰的,一遍遍的過代碼,看pprof的記憶體資料,始終沒有發現記憶體流失的問題,後來才發現,這個問題其實不是我看到的問題表面原因或者說問題是為何記憶體長時間佔用不釋放,並發請求過來記憶體暴增其實是正常的,因為畢竟一個請求就32M,好幾百個請求同時過來不暴增才怪。
這裡其實是Golang記憶體配置認識的一個坑:為了保證程式內記憶體的連續,Golang會申請一大塊記憶體(甚至唯寫一個hello, world可能都會佔用100M+記憶體)。當使用者的程式申請的記憶體大於之前預申請的記憶體時,runtime會進行一次GC,並且將GC的閾值翻倍。也就是說,之前是超過4M時進行GC,那麼下一次GC就是超過8M才進行。我們記憶體暴增的原因,就是訪問量過大導致記憶體申請,並且GC閾值也一下子變大,回收頻率變低,同時GC還預測以後可能還會需要這塊大記憶體為了最佳化記憶體配置就一直不釋放給系統啦。而且Golang採用了一種拖延症策略,即使是被釋放的記憶體,runtime也不會立刻把記憶體還給系統,而是在自己不需要並且系統需要的情況下才回還給系統。這就導致了記憶體降不下來,一種記憶體流失的假象。所以難怪我一直找不到記憶體流失原因。
找到原因之後,解決方案是定時將不需要的記憶體還給作業系統,debug.FreeOSMemory(),不過這個請慎用,最好不用,最好的解決方案是:
1.盡量減少對象建立
2.盡量做到變數複用,很多採用共用buffer來減少記憶體配置
3.如果局部變數過多,可以把這些變數放到一個大結構體裡面,這樣掃描的時候可以只掃描一個變數,回收掉它包含的很多記憶體
4.CPU隨著時間持續增長
情境:在系統剛調試完成,請求正確處理的那一刻內心是激動的,然後當服務開啟之後,習慣性的查看了下功耗,記憶體使用量正常,CPU卻在眼下不斷增高,2%-5%-10%-20%-50%-80%-100%,在短短几分鐘就飆到了100%。。。內心是崩潰的,此時剛從一堆代碼中縷順流程。如何快速的定位到問題?我應該從哪些方面下手,或者說我應該查看哪些資料來推測所有的可能性?究竟是哪個package,哪個函數甚至是哪一行代碼影響了它的效能?這裡主要說明排查定位過程。
1.遇到這種情況最先想到的是死迴圈的情況,回到代碼,檢查有沒有死迴圈之類的bug,仔細看了下所有可能存在迴圈的地方,並沒有發現,而且從癥狀來看不像是死迴圈的情況(如果是死迴圈的情況,CPU的增長不會那麼慢,肯定是要立即上去的)
2.開啟GC看看是不是由於GC次數過多佔用CPU,GC明顯是2分鐘一次屬於預設的情況,所以也不是GC頻繁導致的
3.此時只有pprof了,查看程式的運行時資料
開啟pprof介面查看運行時cpu資料和heap資料。
從上面的可以看出flat%最高的是rumtime.mach_semaphore_signal方法,在這裡只有28%左右其實更高,達到60+%(此圖截錯了,罷了,罷了,在此說明問題就行)。Ok,知道了到底是什麼導致的CPU高,下面我們追溯下代碼路徑,在此我使用火焰圖來分析,更加直觀。
從火焰圖我們可以很方便的分析各個方法佔用的CPU的時間。 它是SVG格式的,滑鼠放上去還能看細節。Y軸是棧的深度,X軸是所有的採樣點的集合,每個方框代表一個棧幀(stack frame)。 顏色沒有意義,只是隨機的選取的。左右順序也不重要。看的時候可以從最寬的幀看起,從底往上看,幀上的分叉代表不同的代碼路徑。快速識別和量化的CPU使用率
從火焰圖的資訊可以看到,在整個cpu佔用資訊採集過程中幾乎全部都是runtime的資訊,並且是runtime.timerproc。通過分析go語言的源碼我們可以知道這是一個定時器調度goruntine。(代碼是go1.7.3源碼,我經過了裁剪,保留大意)
調度流程分析如下:
1.判斷堆中是否有Timer? 如果沒有就將Timers的rescheduling設定為true的狀態,true就代表timerproc goroutine被掛起,需要重新調度。這個重新調度的時刻就是在添加一個Timer進來的時候,會ready這個goroutine。這裡掛起goroutine使用的是runtime·park()函數。
2.如果堆中有Timer存在,就取出堆頂的一個Timer,判斷是否逾時。逾時後,就刪除Timer,執行Timer中掛載的方法。這一步是迴圈檢查堆,直到堆中沒有Timer或者沒有逾時的Timer為止。
3.在堆中的Timer還沒逾時之前,這個goroutine將處於sleep狀態,也就是設定Timers的sleeping為true狀態。這個地方是通過runtime·notesleep()函數來完成的,其實現是依賴futex鎖。這裡,goroutine將sleep多久呢?它將sleep到最近一個Timer逾時的時候,就開始執行。 從火焰圖往上或者我們從pprof的cpu走向來分析(如)看就可以看到我們上面提到的runtime.mach_semaphore_timewait。所以在此我們可以推斷,根源就在定時器的使用,那麼全程式碼搜尋所有使用time包方法的代碼。發現是和time.Tick()和time.After()兩個函數相關。那麼問題就出在這裡啦。
上面這兩個例子,單獨執行,不會發現任何問題,但是當你將這樣的代碼放到一個7 * 24小時的Service中,並且timeout間隔為更短時間,比如0.1s時,問題就出現了。
最初我懷疑是不是代碼中有死迴圈,但仔細巡查一遍代碼後,沒有發現死迴圈的痕迹,演算法邏輯也沒問題。於是重啟了一下這個service,發現cpu佔用降了下來。繼續用top觀察,不好,這個service佔用了1%的CPU一直上升,觀察一段時間後,發現這個service對cpu的佔用率隨著時間的推移而增加。
從火焰圖上可以看出幾乎都是timeproc goroutine的調度。回到代碼,發現可能存在問題的只有這裡的tick和after。
回到我們的代碼,timerproc指標維護的是一個goroutine,這個goroutine的主要功能就是檢查小頂堆中的Timer是否逾時。當然,逾時就是刪除Timer,並且執行Timer對應的動作。我的Timer間隔是1s。這樣每1s都會建立一個runtime timer,而通過runtime的源碼來看,這些timer都扔給了runtime調度(一個heap)。時間長了,就會有超多的timer需要 runtime調度,不耗CPU才怪。
找到原因之後,繼續分析了造成timer過多的原因,一個是配置項中的時間間隔太小(0.1s)一個是上面看到的代碼邏輯bug,兩者一起造成了CPU飆升.修複代碼中存在的bug,調節參數間隔大小.CPU立馬降了下來。
那麼問題來了我們應該如何正確使用定時器呢?
正常情況下,Tick代表是永久的鬧鐘,只要你定時了以後就定時叫醒你,After更像是一次性鬧鐘,到了時間叫醒你,如果你沒有接收到,那麼它也會停止失效。所以Tick可不要放在迴圈裡面,不然會一直增長下去。但是像左側第二個你回傳現它的位置並不是最裡面的迴圈,所以並沒有原則上的不允許,要在具體情況下選擇正確的方法。
5.channel長度的陷阱
情境:這是客戶那裡發現的一個很深的bug,說它深是,這個bug會在正常啟動程式後,接收到請求後中斷資料庫才可以觸發。
之前寫C/C++時經常堅守的一個規則是在使用數組時,如果要遍曆要首先擷取到數組的長度,然後再去遍曆它,而不是直接在for迴圈中求長度,因為一旦數組有變化會導致不可預期的bug,但通常直接寫在迴圈中是可以的,所以很多人會習慣這個寫法。一般的程式還好,但是在golang中最好不要這樣,因為在並發中這樣寫非常容易出錯,例如:
if subChain, ok := psgrRegister[strings.Join(psgr, `-`)]; ok { // 此處應該注意不能直接在迴圈裡面使用len(),因為一旦close一個chan這個數字就變了,會導致嚴重bug BROADCASTLOOP: for i := 0; i < len(subChain); i++ { select { case waiter := <-subChain: waiter <- msg close(waiter) // 一旦close chan的長度會減少1 default: break BROADCASTLOOP } } close(subChain)}
可以看到每當close一個chan原先的chan數組元素個數就減少了1,所以在並發請求過來後發現最後的幾個請求結果是null,就是因為在這裡提前結束了。
6.資料庫觸發器和goruntine的配合使用
情境:在給某銀行60W終端壓力測試過程中,出現記憶體佔用暴增的情況,原因是API緩衝系統一個重要功能就是監控資料庫狀態並及時同步資料變更,我們採用的觸發器的形式通知API系統,壓力測試時,頻繁的改動和表變更導致開啟了大量goruntine處理資料,高峰時堆積達到幾百萬個
這個原因是兩個方面,一個是我們的資料處理很慢,大概可以達到每秒幾十個的樣子,後來經過我最佳化提升了十倍左右能每秒處理上百個但沒有解決根本問題,另一個重要問題是設計上的問題,也就是我們兩個業務共用了一個觸發器,但其中一個只對錶中的四五個欄位感興趣,其他修改不修改無所謂,但我們沒有分開,導致大量變更觸發,而我們這部分邏輯的處理效能達不到,導致了這個問題。這個問題在剛接手這個項目時考慮到了這個潛在問題,但當時一直沒有問題,索性沒有理會它,成功的驗證了墨菲定律。
7.其他
其他就是一些零碎的知識點了,例如map在並發讀寫時一定加鎖,slice大小初始化有根據,channel不要忘記close等等。
總結
從系統最佳化的這些問題可以看出:
1.在一個系統初始化的時候要盡量以最快的速度完成初始化,例如緩衝和資料準備,不要將初始化和商務邏輯混在一起
2.系統架構設計最初要以高規格要求或者說高於生產環境要求,例如資料量和並發請求量的標準,否則後期會非常麻煩,會讓你有種重寫的衝動。
3.真實的業務情境需要的演算法沒有我們想象的那麼複雜,大多都可以用最基本的遞迴,排序,搜尋演算法解決,關鍵是在於資料結構的設計和資料模型的抽象
4.任何一門語言瞭解語言的底層很重要,不僅能讓你出高效能的代碼,在遇到問題時也能快速想到原因所在
5.面對一個項目首先要瞭解需要實現的業務或者功能,在寫代碼的時候要關注的是資料和演算法邏輯,不能一味的寫業務代碼不可自拔
6.堅持最基本的代碼規範會讓自己避開很多坑