boomer 基於 gRPC 壓測併發方案及效能測評

少年發表於2020-07-31

前言

boomer 可使用 go 指令碼定製開發 slave 的特點,使得 boomer 不再侷限於做 http/https 的壓測,對於 tcp/udp/gRPC 等協議的壓測,也有著很大的擴充套件性。本次主要是對 gRPC 壓測方案設計及探索測評。

什麼是 gRPC

優點

簡單來說,gRPC 使用 HTTP2.0 通訊,並使用 protobuf 來定義介面和資料型別。

gRPC 主要應用場景:

  1. 需要對介面進行嚴格約束的情況,比如我們提供了一個公共的服務,很多人,甚至公司外部的人也可以訪問這個服務,這時對於介面我們希望有更加嚴格的約束,我們不希望客戶端給我們傳遞任意的資料,尤其是考慮到安全性的因素,我們通常需要對介面進行更加嚴格的約束。這時 gRPC 就可以通過 protobuf 來提供嚴格的介面約束。

  2. 對於效能有更高的要求時。有時我們的服務需要傳遞大量的資料,而又希望不影響我們的效能,這個時候也可以考慮 gRPC 服務,因為通過 protobuf 我們可以將資料壓縮編碼轉化為二進位制格式,通常傳遞的資料量要小得多,而且通過 http2 我們可以實現非同步的請求,從而大大提高了通訊效率。

缺點

暫不支援分散式,負載均衡等等。

一個 gRPC 服務,就算開多個 pod,流量也只會集中在一個 pod 裡面。

因為 http2 具備更低的延遲的特點,其實是通過利用單個長期存在的 TCP 連線並在其上多路複用請求 / 響應。這會給第 4 層(L4)負載均衡器帶來問題,因為它們的級別太低,無法根據接收到的流量型別做出路由決策。這樣,嘗試對 http2 流量進行負載平衡的 L4 負載平衡器將開啟一個 TCP 連線,並將所有連續的流量路由到該相同的長期連線,從而實際上取消了負載平衡。而 k8s 的 kube-proxy 本質上是一個 L4 負載平衡器,因此我們不能依靠它來平衡微服務之間的 gRPC 呼叫。

不過不要慌,問題不大。因為 service mesh(istio)天生就支援 gRPC 負載均衡。這個暫不在本話題討論範圍內,後續大家感興趣我可以寫一下。

boomer 基於 gRPC 壓測設計

問題: gRPC client 有沒有必要用連線池?

gRPC 的 http2 元件是自己實現的,沒有采用 golang 標準庫裡的 net/http。那要不要給 gRPC 加連線池呢?

不著急盲目加池,先測一下。

先寫個非池連線的 boomer slave。

package main

import (
"flag"
pb "google.golang.org/grpc/examples/helloworld/helloworld"
"log"
"time"

"github.com/myzhan/boomer"
"golang.org/x/net/context"
"google.golang.org/grpc"
)

var verbose bool
var targetUrl string
var conn *grpc.ClientConn

const defaultName = "world"

func NewConn() *grpc.ClientConn {
conn, err := grpc.Dial(targetUrl, grpc.WithInsecure())
if err != nil {
log.Fatalf("did not connect: %v", err)
}
return conn
}

func worker() {
startTime := time.Now()

c := pb.NewGreeterClient(conn)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()

name := defaultName

r, err := c.SayHello(ctx, &pb.HelloRequest{Name: name})

elapsed := time.Since(startTime)

if err != nil {
if verbose {
log.Fatalf("could not greet: %v", err)
}
boomer.RecordFailure("grpc", "helloworld.proto", 0.0, err.Error())
} else {
log.Printf("Greeting: %s", r.GetMessage())
boomer.RecordSuccess("grpc", "helloworld.proto",
elapsed.Nanoseconds()/int64(time.Millisecond), 10)
}
}

func main() {

defer func() {
_ = conn.Close()
}()

flag.StringVar(&targetUrl, "url", "", "URL")
flag.Parse()

if targetUrl == "" {
log.Println("Boomer target url is null")
return
}

log.Println("Boomer target url is", string(targetUrl))

conn = NewConn()

task := &boomer.Task{
Name: "worker",
Weight: 10,
Fn: worker,
}

boomer.Run(task)
}

打包好以後,正常容器排程測試,配置 500 壓力執行緒數併發。

rps 能發出 3700+/s 的效果。

現在試一下加池設定,使用 pool 作為連線池。

//建立一個連線池: 初始化5,最大空閒連線是20,最大併發連線30
poolConfig := &pool.Config{
InitialCap: 100,//資源池初始連線數
MaxIdle: 400,//最大空閒連線數
MaxCap: 500,//最大併發連線數
Factory: factory,
Close: close,
//Ping: ping,
//連線最大空閒時間,超過該時間的連線 將會關閉,可避免空閒時連線EOF,自動失效的問題
IdleTimeout: 15 * time.Second,
}
p, err := pool.NewChannelPool(poolConfig)
if err != nil {
fmt.Println("err=", err)
}

//從連線池中取得一個連線
v, err := p.Get()

//將連線放回連線池中
p.Put(v)

//釋放連線池中的所有連線
p.Release()

池有了,再改下 invoke 的方式,採用 bugVanisher/grequester 發起請求。

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

// Call 發起請求
func (r *Requester) Call(req interface{}, resp interface{}) error {
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(r.timeoutMs)*time.Millisecond)
defer cancel()
cc, err := r.pool.Get()

if err != nil {
log.Printf("get rpc ClientConn error")
return err
}

// 必須要把連線放回連線池否則一直建立新連線
defer r.pool.Put(cc)

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

打包好以後,正常容器排程測試,配置 500 壓力執行緒數併發。

rps 能發出 2900+ 的效果。

說實話問題有點大,理想當中加池應該會有更高的效能,為什麼發出的 rps 反而減低了呢?

先來對比一下火焰圖。

nonpool:

pool:

可以看到,加了 pool,其實就是把原有的 helloworld.(*greeterClient).SayHello 拆開成 grpc.(*ClientConn).Invoke,pool.(*channelPool).Get,以及 pool.(*channelPool).Put。

加 pool 的初衷是為了減少 grpc.invoke 裡面的 grpc.newClientStream 的耗時,因為建立一個新的連線,理論上是比複用分配池裡的一個連結要久的。因為 grpc.newClientStream 裡面有個 grpc.(*clientStream).withRetry,看下程式碼。

func (cs *clientStream) withRetry(op func(a *csAttempt) error, onSuccess func()) error {
cs.mu.Lock()
for {
if cs.committed {
cs.mu.Unlock()
return op(cs.attempt)
}
a := cs.attempt
cs.mu.Unlock()
err := op(a)
cs.mu.Lock()
if a != cs.attempt {
// We started another attempt already.
continue
}
if err == io.EOF {
<-a.s.Done()
}
if err == nil || (err == io.EOF && a.s.Status().Code() == codes.OK) {
onSuccess()
cs.mu.Unlock()
return err
}
if err := cs.retryLocked(err); err != nil {
cs.mu.Unlock()
return err
}
}
}

可以看到這裡加了鎖,而且粒度還不小。所以加 pool 主要是為了節省 grpc.newClientStream 的時間。

再檢視一下時間。

nonpool:

pool:

這裡看確實優化到了,但,pool 裡面還有 pool.Get 和 pool.Put 操作。

由此可見,加池主要是提高一個服務系統的吞吐量,讓它在系統可容納連線的範圍內,使用池減少產生連線的時間,更快給出響應。但是對於一個併發工具來說,能不能更快響應不重要,請求能發出去就行,要的就是產生壓力,所以它也不需要回池的操作,它只需要一股腦建立大量 goroutine 來發起連線請求產生壓力即可,所以 pool.Put 在一系列高併發操作裡面,有點浪費時間,產生的壓力,也比沒有池的要低。

所以這樣一看,還是不能盲目加池。除非 pool.Get 和 pool.Put 加起來的收益比 grpc.newClientStream 高的多,比如一些複雜業務複雜資料之類的,這裡就是簡單的 helloworld,體現不出來效果。

方案

grpc-demo:
image: shaonian/grpc-demo:latest

locust-master:
image: shaonian/locust-master:latest
ports:
- "8089:8089"

locust-slave1:
image: shaonian/locust-slave-rpc:nonpool
command:
- ./main
- --master-host=locust-master
- --master-port=5557
- --url=grpc-demo:50051
- --cpu-profile=cpu.pprof
- --cpu-profile-duration=60s
links:
- locust-master
- grpc-demo

這裡內嵌了 pprof ,剛剛上面的資料也是從這裡產生的,然後直接從容器複製出來就可以檢視分析了,不用專門改程式碼內建來看具體耗時了。

net/http 與 gRPC 效能測評

都說 gRPC 好,快,那比普通的 http 要快多少呢?

同樣都是 hello world。

net/http:

grpc:

可以看到,http 大部分時間都用來系統排程了,都浪費在 tcp 握手裡面了。

總結

至此,boomer 基於 gRPC 的壓測改造已實現,以上即為 demo 以及各種框架效能測評。

專案在這裡: shaonian/boomer_locust

快來 star 吧~

相關話題: docker && k8s 分散式壓測 locust_boomer 方案

相關文章