如何提高 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 的測試報告,敬請期待~

相關文章