如何提高 Locust 的壓測效能

bugVanisher發表於2020-07-21

上一篇文章深入淺出 Locust 實現最後埋了兩個伏筆,那麼今天我們繼續探討其一——如何提高Locust的壓測效能?

Locust的效能缺陷

一、GIL

熟悉Python的人應該都知道,基於cpython編譯器的python天生就受限於它的全域性解釋鎖GIL(global interpreter lock),儘管可以使用jython、pypy擺脫GIL但是很多經典的庫在它們上面還沒有經過嚴格的測試。好在Locust使用基於gevent的協程來實現併發,實際上,使用了 libev 或者 libuv 作為eventloop的gevent可以極大地提高Python的併發能力,擁有不比JAVA多執行緒併發模型差的能力。然而,還是由於GIL,gevent也只是釋放了單核CPU的能力,導致Locust的併發能力必須通過起與CPU核數相同的slave才能發揮出來。

二、效能不佳的requests庫

Locust預設使用requests作為http請求庫,瞭解requests庫的人,無不驚訝於它設計得如此精妙好用的API。然而,在效能上卻與它的易用性相差甚遠,如果需要提高施壓能力,可以使用fasthttp,預估能提高5倍左右的效能,但是正如Locust作者所說的,fasthttp目前並不能完全替代requests。

三、rt評估不準

相信有些人吐槽過,在併發比較大的情況下,Locust的響應時間rt波動比較大,甚至變得不可信。rt是通過slave去統計的,因此併發大導致slave不穩定也是Locust被人詬病的問題之一,下面我們看一張壓測對比圖:

簡單說明上圖中顯示的是在100併發下,各個壓測工具對相同系統壓測(未到瓶頸),響應時間的中位數。可以看到介面正常rt應該在2ms以內,而Locust統計到的卻是30ms。(可以看到jmeter也好不到哪裡去,真是難兄難弟啊~)

以上便是我認為Locust目前所面臨的效能問題,只有解決了這三個問題,才能讓Locust成為真正能夠投入『生產使用』的工具。隨著整體測試人員能力的提升,與Jmeter的GUI介面點點點的方式相比,Locust的可程式設計性、靈活性和可玩性會更強一些,頗有些hack for fun的感覺。

如何提高Locust的施壓效能

一、增加slave?

原理上,支援分散式壓測的系統都可以通過不斷地增加施壓機來提高併發能力,但是這會增加機器成本和維護成本,Locust不僅支援單機、也支援分散式壓測,但是,不斷增加slave顯然不是一個很好的方案。

二、多執行緒or多程式?

Python多執行緒受GIL的影響較大,只有在IO密集型的場景下才能體現併發的優勢,如果執行緒與併發使用者是一一對應的關係,那麼就又回到GIL的問題了,無法獲得令人滿意的併發效能,如果是執行緒與併發使用者是一對多,那不如使用協程。而多程式,採用單slave多程式的方式似乎可以擺脫GIL的影響,單機可以不用起那麼多slave,但是這與單機多slave相比效能並沒有得到本質上的提升,此外單slave多程式的方式無疑會造成多程式間的IPC消耗,更不用說實現上的複雜程度了。

三、換一種語言?

有沒有可能換一種語言?重新實現一套施壓端slave端的邏輯?這種語言需要天生擁有強大的併發能力,支援與master溝通的語言Zeromq。

還真的有這樣的語言: Golang

Golang下的goroutine

  • 可以理解是使用者態執行緒,goroutine的切換沒有核心開銷
  • 記憶體佔用小,執行緒棧空間通常是 2M,goroutine 棧空間最小 2K
  • G-M-P排程模型

那麼有沒有一個開源專案是用go實現了Locust的slave端呢?

答案是有的,它就是: boomer

目前boomer除了比較完整實現了Locust的slave端邏輯,還內建支援指定TPS,理論上支援任意協議的壓測。

然而,boomer對Locust那套Event機制支援的還不足,也無法把自定義資料上報給master,但不妨礙它成為一個優秀的壓力生成器。

Boomer example

在boomer專案中有非常多的examples,同時也提供了簡潔明瞭的說明文件,無論你是否熟悉go,相信也能很快上手,我一般在兩種場景下使用Locust + boomer,一是壓測http服務但又需要較大的併發能力,二是需要壓測一些非http協議的系統。下面,我就以壓測grpc協議的系統為例(講http已經顯得有點無趣了,,,),講解一下我是如何通過boomer提高Locust的壓測效能的。

一、grpc服務

gRPC是一個高效能、通用的開源RPC框架,其由Google主要面向移動應用開發並基於HTTP/2協議標準而設計,基於ProtoBuf(Protocol Buffers)序列化協議開發,且支援眾多開發語言。

gRPC具有以下重要特徵:

  • 強大的IDL特性 RPC使用ProtoBuf來定義服務,ProtoBuf是由Google開發的一種資料序列化協議,效能出眾,得到了廣泛的應用。
  • 支援多種語言 支援C++、Java、Go、Python、Ruby、C#、Node.js、Android Java、Objective-C、PHP等程式語言。
  • 基於HTTP/2標準設計

官網上有非常多語言的快速入門,為了演示跨語言呼叫,且boomer是基於go語言的,所以我演示的案例是go->python。首先根據官方文件的指引,起一個helloworld的grpc服務。

根據python 的快速入門,起一個基於Python的grpc服務。

-> % python greeter_server.py                                                                                                                                          

為了驗證服務是否正常啟動了,先直接使用greeter_client.py驗證一下:

-> % python greeter_client.py
Greeter client received: Hello, you!

二、序列化和反序列化

為了從boomer側發起請求,首先需要對請求和響應做序列化與反序列化,在閱讀grpc的原始碼後,定義一個結構體ProtoCodec:

// ProtoCodec ...
type ProtoCodec struct{}

// Marshal ...
func (s *ProtoCodec) Marshal(v interface{}) ([]byte, error) {
return proto.Marshal(v.(proto.Message))
}

// Unmarshal ...
func (s *ProtoCodec) Unmarshal(data []byte, v interface{}) error {
return proto.Unmarshal(data, v.(proto.Message))
}

// Name ...
func (s *ProtoCodec) Name() string {
return "ProtoCodec"
}

三、服務呼叫

我的想法是提供一套呼叫grpc服務的通用client,所以呼叫服務+方法時需要是動態的,正好grpc提供了Invoke方法可以滿足這一點,接下來定義一個Requester結構體。

// Requester ...
type Requester struct {
addr string
service string
method string
timeoutMs uint
pool pool.Pool
}

Requester中定義兩個方法,一個是獲取真實的呼叫方法getRealMethodName,一個是發起請求的方法Call,其中Call是暴露給外層呼叫的。

// getRealMethodName
func (r *Requester) getRealMethodName() string {
return fmt.Sprintf("/%s/%s", r.service, r.method)
}

Call方法核心程式碼

if err = cc.(*grpc.ClientConn).Invoke(ctx, r.getRealMethodName(), req, resp, grpc.ForceCodec(&ProtoCodec{})); err != nil {
fmt.Fprintf(os.Stderr, err.Error())
return err
}

四、連線池

如http1.1的Keep-Alive,在高併發下需要保持grpc連線以提高效能,所以需要實現一個grpc的連線池管理,這也是Requester結構體中pool的職責。

Requester例項化時初始化連線池:

// NewRequester ...
func NewRequester(addr string, service string, method string, timeoutMs uint, poolsize int) *Requester {
//factory 建立連線的方法
factory := func() (interface{}, error) { return grpc.Dial(addr, grpc.WithInsecure()) }

//close 關閉連線的方法
closef := func(v interface{}) error { return v.(*grpc.ClientConn).Close() }

//建立一個連線池: 初始化5,最大連線200,最大空閒10
poolConfig := &pool.Config{
InitialCap: 5,
MaxCap: poolsize,
MaxIdle: 10,
Factory: factory,
Close: closef,
//連線最大空閒時間,超過該時間的連線 將會關閉,可避免空閒時連線EOF,自動失效的問題
IdleTimeout: 15 * time.Second,
}
apool, _ := pool.NewChannelPool(poolConfig)
return &Requester{addr: addr, service: service, method: method, timeoutMs: timeoutMs, pool: apool}
}

這裡使用了開源庫 pool 來做grpc的連線管理。在Call方法中每次發起請求前在連線池中獲取一個連線,呼叫完成後放回連線池中。

五、指令碼編寫

接下來就是編寫boomer指令碼了,我們需要兩個檔案,一個定義pb結構的請求和響應,一個是執行邏輯main.go

a、基於.proto生成供go使用的pb.go檔案

grpc使用PB結構傳輸訊息,.proto檔案定義了PB資料,使用protoc工具可以生成直接給不同語言使用的資料結構和介面定義檔案,如下

-> % protoc helloworld.proto --go_out=./

執行成功後生成helloworld.pb.go檔案,供main.go引用。

b、編寫壓測指令碼main.go

在helloworld例子中存在兩個PB物件,分別是HelloRequest、HelloReply,python暴露的rpc服務和介面分別為helloworld.Greeter和SayHello,所以呼叫方式如下:


// 修改為要壓測的服務介面
var service = "helloworld.Greeter"
var method = "SayHello"
...

client = grequester.NewRequester(addr, service, method, timeout, poolsize)
...

startTime := time.Now()

// 構建請求物件
request := &HelloRequest{}
request.Name = req.Name

// 初始化響應物件
resp := new(HelloReply)
err := client.Call(request, resp)

elapsed := time.Since(startTime)

完整的檔案地址請看 main.go

c、除錯指令碼

使用boomer的 --run-tasks 除錯指令碼

-> % cd examples/rpc
-> % go run *.go -a localhost:50051 -r '{"name":"bugVanisher"}' --run-tasks rpcReq
2020/04/21 21:31:11 {"name":"bugVanisher"}
2020/04/21 21:31:11 Running rpcReq
2020/04/21 21:31:11 Resp Length: 29
2020/04/21 21:31:11 message:"Hello, bugVanisher!"

至此,基於boomer的grpc壓測指令碼已經完成了,剩下的就是結合Locust對被測系統進行壓測了,我這裡就不贅述了。這僅是一個演示,真實的業務一般會針對grpc框架做封裝,也許不同的語言有各自完整的一套開源框架了。需要注意的地方是,不同的框架下,我們Invoke時,真實的method可能有所不同,要根據實際情況做修改。

小結

本文簡單說明了Locust目前的一些效能缺陷,以及展示瞭如何壓測一個官方Demo的grpc服務介面,實踐發現,一臺使用boomer的4C8G壓力機,能夠很輕鬆輸出上萬的併發數,以及數萬的tps,這是Locust自帶的WorkerRunner無法企及的。

關於Locust,我還想分享一篇文章——重新定義Locust的測試報告,敬請期待~

相關文章