這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
背景:
讀取一個500w行的大檔案,將每一行的資料讀取出來做資料整合歸併之後,再按照一定的邏輯和演算法進行處理後存入redis。
檔案格式:
url地址 使用者32位標識 點擊次數
http://jingpin.pgc.panda.tv/hd/xiaopianpian.html aaaaaaaaaaaaaaaaaaaa 5
具體情境:
本節先看一下大檔案處理最簡單的情況,即在讀檔案的過程中針對檔案每一行都開啟一個協程來做資料合併,看看這種情況下的定位以及最佳化的思路。
問題現象:
如果將整個檔案串列執行來做資料整合的話,只需要4 or 5s就可以完成,但是每行並發處理卻需要幾十秒到幾十分鐘不等。
代碼如下:
simple.go
package mainimport ("bufio""fmt""io""io/ioutil""log""net/http"_ "net/http/pprof""os""singleflight""strconv""strings""sync""time""utils")var (wg sync.WaitGroupmu sync.RWMutex //全域鎖single = &singleflight.Task{})func main() {defer func() {if err := recover(); err != nil {fmt.Println(err)}}()go func() {log.Println(http.ListenAndServe("localhost:8080", nil))}()file := "/data/origin_data/part-r-00000"if fp, err := os.Open(file); err != nil {panic(err)} else {start := time.Now()defer fp.Close()defer func() { //時間消耗fmt.Println("time cost:", time.Now().Sub(start))}()//統計下每個url的點擊使用者數hostNums := hostsStat()buf := bufio.NewReader(fp)hostToFans := make(map[string]utils.MidList) //[url][]使用者idfor {line, err := buf.ReadString('\n')if err != nil {if err == io.EOF { //遭遇行尾fmt.Println("meet the end")break //跳出死迴圈}panic(err)}//每一行單獨處理wg.Add(1)go handleLine(line, hostToFans, hostNums)}wg.Wait() fmt.Println("*************************handle file data complete************************")}}//處理每一行的資料func handleLine(line string, hostToFans map[string]utils.MidList, hostNums map[string]int) {defer wg.Done()line = strings.TrimSpace(line)components := strings.Split(line, "\t")//先判斷是否是合法網站的urlschemes := strings.Split(components[0], "/")if utils.In_array(utils.ValidPlatforms, schemes[2]) == false {fmt.Println("invalid url: ", components[0])return}mu.RLock()if _, ok := hostToFans[components[0]]; ok {mu.RUnlock()click_times, _ := strconv.Atoi(components[2])mu.Lock()hostToFans[components[0]] = hostToFans[components[0]].Append(components[1], click_times)mu.Unlock()} else { //下一個urlmu.RUnlock()startElement := false //用以標識是否是某個url統計的初始元素 //singleflight代碼 防止有多個相同url同時訪問時,該url對應的[]string還沒有初始化,導致多次make代碼的執行single.Do(components[0], func() (interface{}, error) {mu.RLock()if _, ok := hostToFans[components[0]]; ok { //再判斷一遍, 防止高並發的情形下,多個相同url的寫map操作,都會進入重新分配空間的步驟mu.RUnlock()return nil, nil}mu.RUnlock()mu.Lock()click_times, _ := strconv.Atoi(components[2])hostToFans[components[0]] = utils.NewMidList(hostNums[components[0]])hostToFans[components[0]] = hostToFans[components[0]].Append(components[1], click_times)mu.Unlock()startElement = truereturn nil, nil})if !startElement {mu.Lock()click_times, _ := strconv.Atoi(components[2])hostToFans[components[0]] = hostToFans[components[0]].Append(components[1], click_times)mu.Unlock()}}}//針對url:使用者的統計檔案 該檔案列出每個url對應的獨立使用者個數func hostsStat() map[string]int {hostStats := "./scripts/data/stat.txt"bytes, _ := ioutil.ReadFile(hostStats) //....some code....return hostNums}
執行一下這個代碼之後,發現程式執行不久,記憶體佔用就噌噌噌的漲到90%+,過了一會cpu佔用下降到極低,但是load一直保持再過載的水平,看。所以大膽猜測因為gc導致進程夯住了。之後用pprof和gctrace也印證了這個想法,如果對pprof和gctrace不太清楚的同學可以看筆者之前的文章 golang 如何排查和定位GC問題。
其實在筆者最開始的代碼中,已經有一些地方在注意降低記憶體的消耗了,比如說在初始化每個url對應的使用者id集合時借鑒groupcache的singleflight,確保不會多次重複申請空間;比如url對應的使用者id切片,先算好具體大小再make。雖然代碼很簡單, 但是上文中的代碼顯然還是有一些問題。
通過pprof,我發現程式執行的過程中,大部分時間都消耗在gc上,如。劃紅線的都是和gc有關的函數。所以問題就變成排查為什麼gc會這麼長時間。
大部分情況下gc被導致的原因是分配的記憶體達到某個閾值,很顯然,本例屬於這種情況,前文提到記憶體佔用穩定在90+。那麼為什麼這個進程會佔用這麼多的記憶體呢?筆者一直試圖用pprof的heap和profile來分析出這個問題,但是一直無果。直到有一次通過pprof查看goroutine的狀態時,發現當前正在工作的協程高達幾十萬,甚至有時能到達接近150w的量級, 如。這樣就能夠解釋一部分問題了,單個協程如果是3K大小,那麼當協程數量到達百萬時,就算協程裡什麼都沒有也會佔用4G的記憶體。而筆者在做實驗的機器只有8g的記憶體,所以肯定會出現記憶體被吃滿頻繁gc導致進程夯住。
所以第一步,肯定是要控制一下當前的協程數,不能無限的增長。在讀取常值內容的loop裡,加上對行數的計算,這樣每到一個閾值時,就可以休息一下,暫緩下協程增長的速率。加上限制之後,進程不會再卡死,整個的執行時間穩定在20~30s之間。
iterator := 0for { line, err := buf.ReadString('\n') if err != nil { if err == io.EOF { //遭遇行尾 fmt.Println("meet the end") break //跳出死迴圈 } panic(err) } //每一行單獨處理 這裡需要加邏輯防止並發過大導致大量佔用cpu和記憶體,使得整個進程因為gc夯住, //可以每讀10w行左右就休息一會降低一下程式同時線上的協程 iterator++ if iterator <= 120000 { wg.Add(1) go handleLine(line, hostToFans, hostNums) } else { iterator = 1 <-time.After(130 * time.Millisecond) //暫停需要5s左右 wg.Add(1) go handleLine(line, hostToFans, hostNums) }}wg.Wait()
對比一下串列執行的結果,可以發現雖然現在並發執行已經穩定,但是就算刨去休眠時間,和串列執行相比還是慢很多,所以肯定還有最佳化的空間。這個時候pprof的profile以及heap分析就有了施展拳腳的地方了。看下面兩張圖,分別是記憶體消耗和cpu消耗圖:
cpu耗時分析
記憶體佔用分析
上面只截取了一部分的圖,但是從中我們已經能夠找到需要最佳化的地方了。可以看到strings.Split函數耗時和耗記憶體都很嚴重,主要是它會產生slice。分析一下前文的代碼可以發現至少判斷url是否是合法網站這一塊的strings.Split是可以不要的。這裡不光會有額外的已耗用時間還會產生slice佔用記憶體導致gc。所以對這塊功能進行改造:
//先判斷是否是合法平台的主播if is_valid_platform(utils.ValidPlatforms, components[0]) == false { fmt.Println("invalid url: ", components[0]) return nil, errors.New("invalid url")}func is_valid_platform(platforms []string, hostUrl string) bool { for _, platform := range platforms { if strings.Index(hostUrl, platform) != -1 { return true } } return false}
這樣的就可以減少不必要的slice引起的分配空間。改完之後再執行,整個任務穩定在15s左右,減去休眠時間的話就是10s。到這裡其實已經算是最佳化的差不多了,但是其實還有一個地方可以看一下。
上面的heap分析圖可以看到其實singleflight.(*Task).Do函數佔用更多的記憶體,並且也佔用了很多的cpu時間,如下:
除了一些原生函數之外,就屬它最高了,而且該函數也只會在每次新的url出現的時候才會執行。可以看下singleflight的主要結構體,會發現使用了指標變數,而指標變數在gc的時候會導致二次遍曆,使得整個gc變慢。雖然筆者此處用的singleflight肯定是不能修改, 但是如果有可能的話,盡量還是要少用指標。
這一節只能算是簡單講了下最佳化的思路和過程,希望下一節能把完整版的最佳化方案寫出來。