開發與維運

go語言項目優化(經驗之談)

1 Go的應用場景

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

● 緩存類型數據在鬥魚的案例就是我們的首頁,列表頁,這些頁面和接口的特點是不同用戶在同一段時間得到的數據都是一樣的,通常這些緩存類型數據的包都比較大,並且這些數據沒有用戶態,具有一定價值,很容易被爬蟲爬取。
● 實時類型數據在鬥魚的案例就是視頻流,關注數據,這些數據的特點是每次請求獲取的數據都不一樣。並且容易因為某些業務場景導流,例如主播開播提醒,或者某個大型賽事開賽,會在短時間內同時湧入大量用戶,導致服務器流量陡增。
● CPU密集型任務在鬥魚的案例就是我們的列表排序引擎。鬥魚的列表排序數據源較多,算法模型複雜。如何在短時間算完這些數據,提高列表的導流能力對於我們也是一個比較大的挑戰。
針對這三種業務場景如何做優化,我們也是走了不少彎路。而且跟一些程序員一樣,容易陷入到特定的技術和思維當中去。舉個簡單的例子。早期我們在優化GO的排序引擎的時候,上來就想著各種算法優化,引入了跳躍表,歸併排序,看似優化了不少性能,benchmark數據也比較好看。但實際上排序的算法時間和排序數據源獲取的時間數量級差別很大。優化如果找不對方向,業務中的優化只能是事倍功半。所以在往後的工作中,我們基本上是按照如下圖所示的時間區域,找到業務優化的主要耗時區域。

image.png

從圖中,我們主要列舉了幾個時間分佈,讓大家對這幾個數值有所瞭解。從客戶端到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鏈路如下所示

image.png

為了讓大家更好的瞭解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

image.png

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

image.png

● 順帶提下nginx這個配置,還有額外的好處,如果後端go服務掛掉,這個緩存urlhello任然是可以返回數據的。nginx返回如下所
image.png

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。

image.png

2.2 實時數據優化

2.2.1 減少io操作

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

image.png

我們對於量大的實時數據,做了三層緩存。第一層是白名單,這類數據主要是通過人工干預,預設一些內存數據。第二層是通過算法,將我們的一些比較重要的房間信息放入到服務內存裡,第三層是通過請求量動態調整。通過這三層緩存設計。像大型賽事,大主播開播的時候,我們的請求是不會穿透到數據源,直接服務器的內存裡已經將數據返回。這樣的好處不僅減少了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
}

image.png

從圖中,我們可以看出採用併發拉去加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語言社區”,瞭解相關信息可以關注“Golang語言社區”。

Leave a Reply

Your email address will not be published. Required fields are marked *