天天看點

go語言項目優化(經驗之談)1 Go的應用場景2 Go的業務優化3 GO的踩坑經驗4 相關文獻

1 Go的應用場景

在鬥魚我們将GO的應用場景分為以下三類,緩存類型資料,實時類型資料,CPU密集型任務。這三類應用場景都有着各自的特點。

● 緩存類型資料在鬥魚的案例就是我們的首頁,清單頁,這些頁面和接口的特點是不同使用者在同一段時間得到的資料都是一樣的,通常這些緩存類型資料的包都比較大,并且這些資料沒有使用者态,具有一定價值,很容易被爬蟲爬取。

● 實時類型資料在鬥魚的案例就是視訊流,關注資料,這些資料的特點是每次請求擷取的資料都不一樣。并且容易因為某些業務場景導流,例如主播開播提醒,或者某個大型賽事開賽,會在短時間内同時湧入大量使用者,導緻伺服器流量陡增。

● CPU密集型任務在鬥魚的案例就是我們的清單排序引擎。鬥魚的清單排序資料源較多,算法模型複雜。如何在短時間算完這些資料,提高清單的導流能力對于我們也是一個比較大的挑戰。

針對這三種業務場景如何做優化,我們也是走了不少彎路。而且跟一些程式員一樣,容易陷入到特定的技術和思維當中去。舉個簡單的例子。早期我們在優化GO的排序引擎的時候,上來就想着各種算法優化,引入了跳躍表,歸并排序,看似優化了不少性能,benchmark資料也比較好看。但實際上排序的算法時間和排序資料源擷取的時間數量級差别很大。優化如果找不對方向,業務中的優化隻能是事倍功半。是以在往後的工作中,我們基本上是按照如下圖所示的時間區域,找到業務優化的主要耗時區域。

go語言項目優化(經驗之談)1 Go的應用場景2 Go的業務優化3 GO的踩坑經驗4 相關文獻

從圖中,我們主要列舉了幾個時間分布,讓大家對這幾個數值有所了解。從用戶端到CDN回源到機房的時間大概是50ms到300ms。機房内部服務端之間通信大概是5ms到50ms。我們通路的記憶體資料庫redis傳回資料大概是500us到1ms。GO内部擷取記憶體資料耗時ns級别。了解業務的主要耗時區域,我們就可以知道應該着重優化哪個部分。

2 Go的業務優化

2.1 緩存資料優化

對于使用者通路一個url,我們假定這個url為/hello。這個url每個使用者傳回的資料結構都是一樣的。我們通常有可能會向下面示例這樣做。對于開發而言,代碼是最直覺最可控的。但這種方式通常隻是實作功能,但并不能夠提升使用者體驗。因為對于緩存資料我們沒有必要每次讓CDN回源到源站機房,增加使用者通路的鍊路時間。

// Echo instance
e := echo.New()
e.Use(mw.Cache) // Routers
e.GET("/hello", handler(HomeHandler))
           

2.1.1 添加CDN緩存

是以接下來,對于緩存資料,我們不會用go進行緩存,而是在前端cdn進行緩存優化。CDN鍊路如下所示

go語言項目優化(經驗之談)1 Go的應用場景2 Go的業務優化3 GO的踩坑經驗4 相關文獻

為了讓大家更好的了解CDN,我先問大家一個問題。從北京到深圳用光速行駛,大概要多久(7ms)。是以如圖所示,當一個使用者通路一個緩存資料,我們要盡量的讓資料緩存在離使用者近的CDN節點,這種優化方式稱為CDN緩存優化。通過該技術,CDN節點會把附件使用者的請求,聚合到一起統一回源到源站機房。這樣可以不僅節省機房流量帶寬,并且從實體層面上減少了一次鍊路。使得使用者可以更快的擷取到緩存資料。

為了更好的模拟CDN的緩存,我們拿nginx+go來描述這個流程。nginx就相當于圖中的基站,go服務就相當于北京的源站機房。

nginx 配置如下所示:

server { listen 8088; location ~ /hello { access_log /home/www/logs/hello_access.log; proxy_pass http://127.0.0.1:9090; proxy_cache vipcache; proxy_cache_valid 200 302 20s; proxy_cache_use_stale error timeout invalid_header updating http_500 http_502 http_503 http_504 http_403 http_404; add_header Cache-Status "$upstream_cache_status";
}
}           

go 代碼如下所示

package main
import ( "fmt" "io" "net/http")
func main() {
http.Handle("/hello", &ServeMux{})
err := http.ListenAndServe(":9090", nil) if err != nil {
fmt.Println("err", err.Error())
} }
type ServeMux struct {
}
func (p *ServeMux) ServeHTTP(w http.ResponseWriter, r *http.Request) {
fmt.Println("get one request")
fmt.Println(r.RequestURI)
io.WriteString(w, "hello world")
}           

啟動代碼後,我們可以發現。

● 第一次通路hello,nginx和go都會收到請求,nginx的響應頭裡cache-status中會有個miss内容,說明了nginx請求穿透到go

go語言項目優化(經驗之談)1 Go的應用場景2 Go的業務優化3 GO的踩坑經驗4 相關文獻

● 第二次再通路hello,nginx會收到請求,go這個時候就不會收到請求。nginx裡響應頭裡cache-status會與個hit内容,說明了nginx請求沒有回源到go

go語言項目優化(經驗之談)1 Go的應用場景2 Go的業務優化3 GO的踩坑經驗4 相關文獻

● 順帶提下nginx這個配置,還有額外的好處,如果後端go服務挂掉,這個緩存urlhello任然是可以傳回資料的。nginx傳回如下所

go語言項目優化(經驗之談)1 Go的應用場景2 Go的業務優化3 GO的踩坑經驗4 相關文獻

2.1.2 CDN去問号緩存

正常使用者在通路hellourl的時候,是通過界面引導,然後擷取hello資料。但是對于爬蟲使用者而言,他們為了擷取更加及時的爬蟲資料,會在url後面加各種随機數hello?123456,這種行為會導緻cdn緩存失效,讓很多請求回源到源站機房。造成更大的壓力。是以一般這種情況下,我們可以在CDN做去問号緩存。通過nginx可以模拟這種行為。nginx配置如下:

server { listen 8088; if ( $request_uri ~* "^/hello") { rewrite /hello? /hello? break;
} location ~ /hello { access_log /home/www/logs/hello_access.log; proxy_pass http://127.0.0.1:9090; proxy_cache vipcache; proxy_cache_valid 200 302 20s; proxy_cache_use_stale error timeout invalid_header updating http_500 http_502 http_503 http_504 http_403 http_404; add_header Cache-Status "$upstream_cache_status";
}
}           

2.1.3 大流量上鎖

之前我們有講過如果突然之間有大型賽事開播,會出現大量使用者來通路。這個時候可能會出現一個場景,緩存資料還沒有建立,大量使用者請求仍然可能回源到源站機房。導緻服務負載過高。這個時候我們可以加入proxy_cache_lock和proxy_cache_lock_timeout參數

server { listen 8088; if ( $request_uri ~* "^/hello") { rewrite /hello? /hello? break;
} location ~ /hello { access_log /home/www/logs/hello_access.log; proxy_pass http://127.0.0.1:9090; proxy_cache vipcache; proxy_cache_valid 200 302 20s; proxy_cache_use_stale error timeout invalid_header updating http_500 http_502 http_503 http_504 http_403 http_404; proxy_cache_lock on; procy_cache_lock_timeout 1; add_header Cache-Status "$upstream_cache_status";
}
}           

2.1.4 資料優化

在上面我們還提到鬥魚緩存類型的首頁,清單頁。這些頁面接口資料通常會傳回大量資料。在這裡我們拿Go模拟了一次請求中擷取120個資料的情況。将slice分為三種情況,未預設slice的長度,預設了slice長度,預設了slice長度并且使用了sync.map。代碼如下所示。這裡面每個goroutine相當于一次http請求。我們拿benchmark跑一次資料

package slice_testimport ( "strconv" "sync" "testing")// go test -bench="."type Something struct {
roomId int
roomName string}func BenchmarkDefaultSlice(b *testing.B) {
b.ReportAllocs() var wg sync.WaitGroup for i := 0; i < b.N; i++ {
wg.Add(1) go func(wg *sync.WaitGroup) { for i := 0; i < 120; i++ {
output := make([]Something, 0)
output = append(output, Something{
roomId: i,
wg.Done()
roomName: strconv.Itoa(i), }) }
}func BenchmarkPreAllocSlice(b *testing.B) {
}(&wg) } wg.Wait()
b.ReportAllocs() var wg sync.WaitGroup for i := 0; i < b.N; i++ {
wg.Add(1) go func(wg *sync.WaitGroup) { for i := 0; i < 120; i++ {
output := make([]Something, 0, 120)
output = append(output, Something{
roomId: i,
wg.Done()
roomName: strconv.Itoa(i), }) }
}func BenchmarkSyncPoolSlice(b *testing.B) {
}(&wg) } wg.Wait()
b.ReportAllocs() var wg sync.WaitGroup var SomethingPool = sync.Pool{
New: func() interface{} {
b := make([]Something, 120) return &b
},
} for i := 0; i < b.N; i++ {
wg.Add(1) go func(wg *sync.WaitGroup) {
obj := SomethingPool.Get().(*[]Something) for i := 0; i < 120; i++ {
some := *obj
some[i].roomId = i
some[i].roomName = strconv.Itoa(i)
} SomethingPool.Put(obj)
}
wg.Done() }(&wg) }
wg.Wait()           

得到以下結果。可以從最慢的12us降低到1us。

go語言項目優化(經驗之談)1 Go的應用場景2 Go的業務優化3 GO的踩坑經驗4 相關文獻

2.2 實時資料優化

2.2.1 減少io操作

上面我們提到了在業務突然導流的情況下,我們服務有可能在短時間内湧入大量流量,如果不對這些流量進行處理,有可能會将後端資料源擊垮。還有一種情況在突發流量下像視訊流這種請求如果耗時較長,使用者在長時間得不到的資料,有可能進一步重新整理頁面重新請求接口,造成二次攻擊。是以我們針對這種實時接口,進行了合理優化。

go語言項目優化(經驗之談)1 Go的應用場景2 Go的業務優化3 GO的踩坑經驗4 相關文獻

我們對于量大的實時資料,做了三層緩存。第一層是白名單,這類資料主要是通過人工幹預,預設一些記憶體資料。第二層是通過算法,将我們的一些比較重要的房間資訊放入到服務記憶體裡,第三層是通過請求量動态調整。通過這三層緩存設計。像大型賽事,大主播開播的時候,我們的請求是不會穿透到資料源,直接伺服器的記憶體裡已經将資料傳回。這樣的好處不僅減少了IO操作,而且還對流量起到了鎮流的作用,使流量平穩的到達資料源。

其他量級小的非實時資料,我們都是通過etcd進行推送

2.2.2 對redis參數調優

要充分了解redis的參數。隻有這樣我們才能根據業務合理調整redis的參數。達到最佳性能。maxIdle設定高點,可以保證突發流量情況下,能夠有足夠的連接配接去擷取redis,不用在高流量情況下建立連接配接。maxActive,readTimeout,writeTimeout的設定,對redis是一種保護,相當于go服務對redis這塊做的一種簡單限流,降頻操作。

redigo 參數調優

maxIdle = 30
maxActive = 500
dialTimeout = "1s"
readTimeout = "500ms"
writeTimeout = "500ms"
idleTimeout = "60s"           

2.2.3 服務和redis調優

因為redis是記憶體資料庫,響應速度比較塊。服務裡可能會大量使用redis,很多時候我們服務的壓測,瓶頸不在代碼編寫上,而是在redis的吞吐性能上。因為redis是單線程模型,是以為了提高速度,我們通常做的方式是采用pipeline指令,增加redis從庫,這樣go就可以根據redis數量,并發拉取資料,達到性能最佳。以下我們模拟了這種場景。

package redis_testimport ( "sync" "testing" "time" "fmt")// go testfunc Test_OneRedisData(t *testing.T) {
t1 := time.Now() for i := 0; i < 120; i++ {
getRemoteOneRedisData(i)
}
fmt.Println("Test_OneRedisData cost: ",time.Since(t1))
}func Test_PipelineRedisData(t *testing.T) {
t1 := time.Now()
ids := make([]int,0, 120) for i := 0; i < 120; i++ {
ids = append(ids, i)
}
getRemotePipelineRedisData(ids)
fmt.Println("Test_PipelineRedisData cost: ",time.Since(t1))
}func Test_GoroutinePipelineRedisData(t *testing.T) {
t1 := time.Now()
ids := make([]int,0, 120) for i := 0; i < 120; i++ {
ids = append(ids, i)
}
getGoroutinePipelineRedisData(ids)
fmt.Println("Test_GoroutinePipelineRedisData cost: ",time.Since(t1))
}func getRemoteOneRedisData(i int) int { // 模拟單個redis請求,定義為600us
time.Sleep(600 * time.Microsecond) return i
}func getRemotePipelineRedisData(i []int) []int {
length := len(i) // 使用pipeline的情況下,單個redis資料,為500us
time.Sleep(time.Duration(length)*500*time.Microsecond) return i
}func getGoroutinePipelineRedisData(ids []int) []int {
idsNew := make(map[int][]int, 0)
idsNew[0] = ids[0:30]
idsNew[1] = ids[30:60]
idsNew[2] = ids[60:90]
idsNew[3] = ids[90:120]
resp := make([]int,0,120) var wg sync.WaitGroup for j := 0; j < 4; j++ {
wg.Add(1) go func(wg *sync.WaitGroup, j int) {
resp = append(resp,getRemotePipelineRedisData(idsNew[j])...)
wg.Done() }(&wg, j) }
wg.Wait() return resp
}           
go語言項目優化(經驗之談)1 Go的應用場景2 Go的業務優化3 GO的踩坑經驗4 相關文獻

從圖中,我們可以看出采用并發拉去加pipeline方式,性能可以提高5倍。 redis的優化方式還有很多。例如

1.增加redis從庫2.對批量資料,根據redis從庫數量,并發goroutine拉取資料3.對批量資料大量使用pipeline指令4.精簡key字段5.redis的value解碼改為msgpack

3 GO的踩坑經驗

踩坑代碼位址:

https://github.com/askuy/gopherlearn

3.1 指針類型串号

3.2 多重map上鎖問題

3.3 channel使用問題

4 相關文獻

坑踩得多,說明書看的少。

https://stackoverflow.com/questions/18435498/why-are-receivers-pass-by-value-in-go/18435638

以上問題都可以在相關文獻中找到原因,具體原因請閱讀文檔。

When are function parameters passed by value?

As in all languages in the C family, everything in Go is passed by value. That is, a function always gets a copy of the thing being passed, as if there were an assignment statement assigning the value to the parameter. For instance, passing an int value to a function makes a copy of the int, and passing a pointer value makes a copy of the pointer, but not the data it points to. (See a later section for a discussion of how this affects method receivers.)

Map and slice values behave like pointers: they are descriptors that contain pointers to the underlying map or slice data. Copying a map or slice value doesn’t copy the data it points to. Copying

原文釋出時間為:2018-11-26

本文作者:askuy

本文來自雲栖社群合作夥伴“

Golang語言社群

”,了解相關資訊可以關注“

”。