上一篇文章深入浅出 Locust 实现最后埋了两个伏笔,那么今天我们继续探讨其一——如何提高 Locust 的压测性能?
熟悉 Python 的人应该都知道,基于 cpython 编译器的 python 天生就受限于它的全局解释锁 GIL(global interpreter lock),尽管可以使用 jython、pypy 摆脱 GIL 但是很多经典的库在它们上面还没有经过严格的测试。好在 Locust 使用基于 gevent 的协程来实现并发,实际上,使用了 libev 或者 libuv 作为 eventloop 的 gevent 可以极大地提高 Python 的并发能力,拥有不比 JAVA 多线程并发模型差的能力。然而,还是由于 GIL,gevent 也只是释放了单核 CPU 的能力,导致 Locust 的并发能力必须通过起与 CPU 核数相同的 slave 才能发挥出来。
Locust 默认使用 requests 作为 http 请求库,了解 requests 库的人,无不惊讶于它设计得如此精妙好用的 API。然而,在性能上却与它的易用性相差甚远,如果需要提高施压能力,可以使用fasthttp,预估能提高 5 倍左右的性能,但是正如 Locust 作者所说的,fasthttp 目前并不能完全替代 requests。
相信有些人吐槽过,在并发比较大的情况下,Locust 的响应时间 rt 波动比较大,甚至变得不可信。rt 是通过 slave 去统计的,因此并发大导致 slave 不稳定也是 Locust 被人诟病的问题之一,下面我们看一张压测对比图:
简单说明上图中显示的是在 100 并发下,各个压测工具对相同系统压测(未到瓶颈),响应时间的中位数。可以看到接口正常 rt 应该在 2ms 以内,而 Locust 统计到的却是 30ms。(可以看到 jmeter 也好不到哪里去,真是难兄难弟啊~)
以上便是我认为 Locust 目前所面临的性能问题,只有解决了这三个问题,才能让 Locust 成为真正能够投入『生产使用』的工具。随着整体测试人员能力的提升,与 Jmeter 的 GUI 界面点点点的方式相比,Locust 的可编程性、灵活性和可玩性会更强一些,颇有些 hack for fun 的感觉。
原理上,支持分布式压测的系统都可以通过不断地增加施压机来提高并发能力,但是这会增加机器成本和维护成本,Locust 不仅支持单机、也支持分布式压测,但是,不断增加 slave 显然不是一个很好的方案。
Python 多线程受 GIL 的影响较大,只有在 IO 密集型的场景下才能体现并发的优势,如果线程与并发用户是一一对应的关系,那么就又回到 GIL 的问题了,无法获得令人满意的并发性能,如果是线程与并发用户是一对多,那不如使用协程。而多进程,采用单 slave 多进程的方式似乎可以摆脱 GIL 的影响,单机可以不用起那么多 slave,但是这与单机多 slave 相比性能并没有得到本质上的提升,此外单 slave 多进程的方式无疑会造成多进程间的 IPC 消耗,更不用说实现上的复杂程度了。
有没有可能换一种语言?重新实现一套施压端 slave 端的逻辑?这种语言需要天生拥有强大的并发能力,支持与 master 沟通的语言 Zeromq。
还真的有这样的语言: Golang
Golang 下的 goroutine
那么有没有一个开源项目是用 go 实现了 Locust 的 slave 端呢?
答案是有的,它就是: boomer
目前 boomer 除了比较完整实现了 Locust 的 slave 端逻辑,还内置支持指定 TPS,理论上支持任意协议的压测。
然而,boomer 对 Locust 那套 Event 机制支持的还不足,也无法把自定义数据上报给 master,但不妨碍它成为一个优秀的压力生成器。
在 boomer 项目中有非常多的 examples,同时也提供了简洁明了的说明文档,无论你是否熟悉 go,相信也能很快上手,我一般在两种场景下使用 Locust + boomer,一是压测 http 服务但又需要较大的并发能力,二是需要压测一些非 http 协议的系统。下面,我就以压测 grpc 协议的系统为例(讲 http 已经显得有点无趣了,,,),讲解一下我是如何通过 boomer 提高 Locust 的压测性能的。
gRPC是一个高性能、通用的开源 RPC 框架,其由 Google 主要面向移动应用开发并基于 HTTP/2 协议标准而设计,基于 ProtoBuf(Protocol Buffers) 序列化协议开发,且支持众多开发语言。
gRPC 具有以下重要特征:
官网上有非常多语言的快速入门,为了演示跨语言调用,且 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
grpc 使用 PB 结构传输消息,.proto 文件定义了 PB 数据,使用 protoc 工具可以生成直接给不同语言使用的数据结构和接口定义文件,如下
-> % protoc helloworld.proto --go_out=./
执行成功后生成 helloworld.pb.go 文件,供 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
使用 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 的测试报告,敬请期待~