性能测试工具 如何提高 Locust 的压测性能

bugVanisher · 2020年07月21日 · 最后由 taojy123 回复于 2020年08月19日 · 1987 次阅读

上一篇文章深入浅出 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 的测试报告,敬请期待~

共收到 7 条回复 时间 点赞
bugVanisher 深入浅出 Locust 实现 中提及了此贴 07月22日 10:46

666,之前尝试了一波,就发现 locust 无法增加并发提高压力,只能增加试压机。感觉好弱。使用 locust 压测,无非就是针对非 http 协议的服务端,这个 golang 的 boomer 可以尝试一波了😀

重来看雨 回复

用得顺手都是好工具😂

问下 grpc 加池的意义?我测了一下,不加池原生的 grpc 连接,产生的 tps 会更高,加池限制了池大小,就算调好参数,也没有直接创建新连接来的那么快,因为 pool.put 在持续并发里面有点浪费时间。所以同等数量的压力线程,不加池发出的 tps 远超加池。

少年 回复

不好意思,最近没怎么上来。
对于你的问题,连接池在纯产生压力的场景确实是没什么必要的,因为压力机就是为了在最短的时间产生最多的请求,这个在多协程共用单连接的时候达到顶峰。连接池的使用场景是在低频的请求序列时保持连接,减少反复连接 - 断开的开销。是的,文章里我应该说明一下,真实的场景是内部应用之间维护连接池,而不会只创建一个连接,使用连接池是因为我想保持跟 grpc 框架一致。这里我有一个问题,你是怎么理解并发用户数和连接数之间的关系的?在 http 和 grpc 的场景下,应该是不一样的

bugVanisher 回复

并发用户数只是个压力线程,具体 Tps 根据公式来算。

连接数就是系统吞吐量(或者性能?)的一个衡量方式。

具体 pool 和 nonpool grpc 与 http 压测分析在: https://testerhome.com/topics/24967

locust 网页上查看统计数据和图表曲线的体验还是很好的,美中不足的是他的导出功能太弱了。
我为 locust 写了一个 “导出报告” 的功能,可一键下载整份图文报告。
https://github.com/locustio/locust/pull/1516
目前该功能已被官方收录,下一个版本的 locust 将可直接导出报告

需要 登录 后方可回复, 如果你还没有账号请点击这里 注册