性能测试工具 深入浅出开源性能测试工具 Locust (使用篇 1)

debugtalk · 2017年02月22日 · 最后由 zhaohexiang-zhx 回复于 2021年07月21日 · 6270 次阅读

《【LocustPlus 序】漫谈服务端性能测试》中,我对服务端性能测试的基础概念和性能测试工具的基本原理进行了介绍,并且重点推荐了Locust这一款开源性能测试工具。然而,当前在网络上针对Locust的教程极少,不管是中文还是英文,基本都是介绍安装方法和简单的测试案例演示,但对于较复杂测试场景的案例演示却基本没有,因此很多测试人员都感觉难以将Locust应用到实际的性能测试工作当中。

经过一段时间的摸索,包括通读Locust官方文档和项目源码,并且在多个性能测试项目中对Locust进行应用实践,事实证明,Locust完全能满足日常的性能测试需求,LoadRunner能实现的功能Locust也基本都能实现。

本文将从Locust的功能特性出发,结合实例对Locust的使用方法进行介绍。考虑到大众普遍对LoadRunner比较熟悉,在讲解Locust时也会采用LoadRunner的一些概念进行类比。

概述

先从Locust的名字说起。Locust的原意是蝗虫,原作者之所以选择这个名字,估计也是听过这么一句俗语,“蝗虫过境,寸草不生”。我在网上找了张图片,大家可以感受下。

Locust工具生成的并发请求就跟一大群蝗虫一般,对我们的被测系统发起攻击,以此检测系统在高并发压力下是否能正常运转。

《【LocustPlus 序】漫谈服务端性能测试》中说过,服务端性能测试工具最核心的部分是压力发生器,而压力发生器的核心要点有两个,一是真实模拟用户操作,二是模拟有效并发。

Locust测试框架中,测试场景是采用纯 Python 脚本进行描述的。对于最常见的HTTP(S)协议的系统,Locust采用 Python 的requests库作为客户端,使得脚本编写大大简化,富有表现力的同时且极具美感。而对于其它协议类型的系统,Locust也提供了接口,只要我们能采用 Python 编写对应的请求客户端,就能方便地采用Locust实现压力测试。从这个角度来说,Locust可以用于压测任意类型的系统。

在模拟有效并发方面,Locust的优势在于其摒弃了进程和线程,完全基于事件驱动,使用gevent提供的非阻塞IOcoroutine来实现网络层的并发请求,因此即使是单台压力机也能产生数千并发请求数;再加上对分布式运行的支持,理论上来说,Locust能在使用较少压力机的前提下支持极高并发数的测试。

脚本编写

编写Locust脚本,是使用Locust的第一步,也是最为重要的一步。

简单示例

先来看一个最简单的示例。

from locust import HttpLocust, TaskSet, task

class WebsiteTasks(TaskSet):
    def on_start(self):
        self.client.post("/login", {
            "username": "test",
            "password": "123456"
        })

    @task(2)
    def index(self):
        self.client.get("/")

    @task(1)
    def about(self):
        self.client.get("/about/")

class WebsiteUser(HttpLocust):
    task_set = WebsiteTasks
    host = "http://debugtalk.com"
    min_wait = 1000
    max_wait = 5000

在这个示例中,定义了针对http://debugtalk.com网站的测试场景:先模拟用户登录系统,然后随机地访问首页(/)和关于页面(/about/),请求比例为2:1;并且,在测试过程中,两次请求的间隔时间为1~5秒间的随机值。

那么,如上 Python 脚本是如何表达出以上测试场景的呢?

从脚本中可以看出,脚本主要包含两个类,一个是WebsiteUser(继承自HttpLocust,而HttpLocust继承自Locust),另一个是WebsiteTasks(继承自TaskSet)。事实上,在Locust的测试脚本中,所有业务测试场景都是在LocustTaskSet两个类的继承子类中进行描述的。

那如何理解LocustTaskSet这两个类呢?

简单地说,Locust类就好比是一群蝗虫,而每一只蝗虫就是一个类的实例。相应的,TaskSet类就好比是蝗虫的大脑,控制着蝗虫的具体行为,即实际业务场景测试对应的任务集。

这个比喻可能不是很准确,接下来,我将分别对LocustTaskSet两个类进行详细介绍。

class HttpLocust(Locust)

Locust类中,具有一个client属性,它对应着虚拟用户作为客户端所具备的请求能力,也就是我们常说的请求方法。通常情况下,我们不会直接使用Locust类,因为其client属性没有绑定任何方法。因此在使用Locust时,需要先继承Locust类,然后在继承子类中的client属性中绑定客户端的实现类。

对于常见的HTTP(S)协议,Locust已经实现了HttpLocust类,其client属性绑定了HttpSession类,而HttpSession又继承自requests.Session。因此在测试HTTP(S)Locust脚本中,我们可以通过client属性来使用Python requests库的所有方法,包括GET/POST/HEAD/PUT/DELETE/PATCH等,调用方式也与requests完全一致。另外,由于requests.Session的使用,因此client的方法调用之间就自动具有了状态记忆的功能。常见的场景就是,在登录系统后可以维持登录状态的Session,从而后续 HTTP 请求操作都能带上登录态。

而对于HTTP(S)以外的协议,我们同样可以使用Locust进行测试,只是需要我们自行实现客户端。在客户端的具体实现上,可通过注册事件的方式,在请求成功时触发events.request_success,在请求失败时触发events.request_failure即可。然后创建一个继承自Locust类的类,对其设置一个client属性并与我们实现的客户端进行绑定。后续,我们就可以像使用HttpLocust类一样,测试其它协议类型的系统。

原理就是这样简单!

Locust类中,除了client属性,还有几个属性需要关注下:

  • task_set: 指向一个TaskSet类,TaskSet类定义了用户的任务信息,该属性为必填;
  • max_wait/min_wait: 每个用户执行两个任务间隔时间的上下限(毫秒),具体数值在上下限中随机取值,若不指定则默认间隔时间固定为 1 秒;
  • host:被测系统的 host,当在终端中启动locust时没有指定--host参数时才会用到;
  • weight:同时运行多个Locust类时会用到,用于控制不同类型任务的执行权重。

测试开始后,每个虚拟用户(Locust实例)的运行逻辑都会遵循如下规律:

  1. 先执行WebsiteTasks中的on_start(只执行一次),作为初始化;
  2. WebsiteTasks中随机挑选(如果定义了任务间的权重关系,那么就是按照权重关系随机挑选)一个任务执行;
  3. 根据Locust类min_waitmax_wait定义的间隔时间范围(如果TaskSet类中也定义了min_wait或者max_wait,以TaskSet中的优先),在时间范围中随机取一个值,休眠等待;
  4. 重复2~3步骤,直至测试任务终止。

class TaskSet

再说下TaskSet类

性能测试工具要模拟用户的业务操作,就需要通过脚本模拟用户的行为。在前面的比喻中说到,TaskSet类好比蝗虫的大脑,控制着蝗虫的具体行为。

具体地,TaskSet类实现了虚拟用户所执行任务的调度算法,包括规划任务执行顺序(schedule_task)、挑选下一个任务(execute_next_task)、执行任务(execute_task)、休眠等待(wait)、中断控制(interrupt)等等。在此基础上,我们就可以在TaskSet子类中采用非常简洁的方式来描述虚拟用户的业务测试场景,对虚拟用户的所有行为(任务)进行组织和描述,并可以对不同任务的权重进行配置。

TaskSet子类中定义任务信息时,可以采取两种方式,@task装饰器tasks属性

采用@task装饰器定义任务信息时,描述形式如下:

from locust import TaskSet, task

class UserBehavior(TaskSet):
    @task(1)
    def test_job1(self):
        self.client.get('/job1')

    @task(2)
    def test_job2(self):
        self.client.get('/job2')

采用tasks属性定义任务信息时,描述形式如下:

from locust import TaskSet

def test_job1(obj):
    obj.client.get('/job1')

def test_job2(obj):
    obj.client.get('/job2')

class UserBehavior(TaskSet):
    tasks = {test_job1:1, test_job2:2}
    # tasks = [(test_job1,1), (test_job1,2)] # 两种方式等价

在如上两种定义任务信息的方式中,均设置了权重属性,即执行test_job2的频率是test_job1的两倍。

若不指定执行任务的权重,则相当于比例为1:1

from locust import TaskSet, task

class UserBehavior(TaskSet):
    @task
    def test_job1(self):
        self.client.get('/job1')

    @task
    def test_job2(self):
        self.client.get('/job2')
from locust import TaskSet

def test_job1(obj):
    obj.client.get('/job1')

def test_job2(obj):
    obj.client.get('/job2')

class UserBehavior(TaskSet):
    tasks = [test_job1, test_job2]
    # tasks = {test_job1:1, test_job2:1} # 两种方式等价

TaskSet子类中除了定义任务信息,还有一个是经常用到的,那就是on_start函数。这个和LoadRunner中的vuser_init功能相同,在正式执行测试前执行一次,主要用于完成一些初始化的工作。例如,当测试某个搜索功能,而该搜索功能又要求必须为登录态的时候,就可以先在on_start中进行登录操作;前面也提到,HttpLocust使用到了requests.Session,因此后续所有任务执行过程中就都具有登录态了。

脚本增强

掌握了HttpLocustTaskSet,我们就基本具备了编写测试脚本的能力。此时再回过头来看前面的案例,相信大家都能很好的理解了。

然而,当面对较复杂的测试场景,可能有的同学还是会感觉无从下手;例如,很多时候脚本需要做关联或参数化处理,这些在LoadRunner中集成的功能,换到Locust中就不知道怎么实现了。可能也是这方面的原因,造成很多测试人员都感觉难以将 Locust 应用到实际的性能测试工作当中。

其实这也跟Locust的目标定位有关,Locust的定位就是small and very hackable。但是小巧并不意味着功能弱,我们完全可以通过 Python 脚本本身来实现各种各样的功能,如果大家有疑问,我们不妨逐项分解来看。

LoadRunner这款功能全面应用广泛的商业性能测试工具中,脚本增强无非就涉及到四个方面:

  • 关联
  • 参数化
  • 检查点
  • 集合点

先说关联这一项。在某些请求中,需要携带之前从 Server 端返回的参数,因此在构造请求时需要先从之前请求的 Response 中提取出所需的参数,常见场景就是session_id。针对这种情况,LoadRunner虽然可能通过录制脚本进行自动关联,但是效果并不理想,在实际测试过程中也基本都是靠测试人员手动的来进行关联处理。

LoadRunner中手动进行关联处理时,主要是通过使用注册型函数,例如web_reg_save_param,对前一个请求的响应结果进行解析,根据左右边界或其它特征定位到参数值并将其保存到参数变量,然后在后续请求中使用该参数。采用同样的思想,我们在Locust脚本中也完全可以实现同样的功能,毕竟只是 Python 脚本,通过官方库函数re.search就能实现所有需求。甚至针对 html 页面,我们也可以采用lxml库,通过etree.HTML(html).xpath来更优雅地实现元素定位。

然后再来看参数化这一项。这一项极其普遍,主要是用在测试数据方面。但通过归纳,发现其实也可以概括为三种类型。

  • 循环取数据,数据可重复使用:e.g. 模拟 3 用户并发请求网页,总共有 100 个 URL 地址,每个虚拟用户都会依次循环加载这 100 个 URL 地址;
  • 保证并发测试数据唯一性,不循环取数据:e.g. 模拟 3 用户并发注册账号,总共有 90 个账号,要求注册账号不重复,注册完毕后结束测试;
  • 保证并发测试数据唯一性,循环取数据:模拟 3 用户并发登录账号,总共有 90 个账号,要求并发登录账号不相同,但数据可循环使用。

通过以上归纳,可以确信地说,以上三种类型基本上可以覆盖我们日常性能测试工作中的所有参数化场景。

LoadRunner中是有一个集成的参数化模块,可以直接配置参数化策略。那在Locust要怎样实现该需求呢?

答案依旧很简单,使用 Python 的listqueue数据结构即可!具体做法是,在WebsiteUser定义一个数据集,然后所有虚拟用户在WebsiteTasks中就可以共享该数据集了。如果不要求数据唯一性,数据集选择list数据结构,从头到尾循环遍历即可;如果要求数据唯一性,数据集选择queue数据结构,取数据时进行queue.get()操作即可,并且这也不会循环取数据;至于涉及到需要循环取数据的情况,那也简单,每次取完数据后再将数据插入到队尾即可,queue.put_nowait(data)

最后再说下检查点。该功能在LoadRunner中通常是使用web_reg_find这类注册函数进行检查的。在Locust脚本中,处理就更方便了,只需要对响应的内容关键字进行assert xxx in response操作即可。

针对如上各种脚本增强的场景,我也通过代码示例分别进行了演示。但考虑到文章中插入太多代码会影响到阅读,因此将代码示例部分剥离了出来,如有需要请点击查看《深入浅出开源性能测试工具 Locust(脚本增强)》

GitHub 项目地址

https://github.com/debugtalk/Stormer

硬广

欢迎关注我的个人博客和微信公众号。

如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
共收到 35 条回复 时间 点赞
yyy 回复

按照完整的请求响应来统计,那就是相同的值啊

具体地,TaskSet 类实现了虚拟用户所执行任务的调度算法,包括规划任务执行顺序(schedule_task)、挑选下一个任务(execute_next_task)、执行任务(execute_task)、休眠等待(wait)、中断控制(interrupt)等等

想了解一下 schedule_task、execute_next_task、和 execute_task 的用法,emmm, 没有搜索到哪里有用到它们的 demo~

比如 execute_next_task,挑选下一个任务,接口却是没有任何参数,那么我如何知道哪一个接口是作为下一个执行的任务。还是说,我对这个函数的理解都是有误的~
求翻牌~

对 locust 的 user 的概念 还是有点疑惑,感觉更像是并发数的意思,而不是真正的 user 的意思。
譬如 我想模拟 call 一个 api, url 为 http://xxx.com/id 然后 id 是变化的,我有 300k 不同的 id
这种时候 假设 我设置 user 为 50 其实 是模拟 50 个并发 然后我需要把这个 300k 的数据分成 50 份,让每个 locust 实例去分别 call 每组里面不同的 url。

为什么没有人关心 locust 如何实现 tcp 的压力测试?

如果是有多台服务器,,怎么做到负载均衡呢?
比如 loadrunner 可以把服务器 IP 做参数化,locust 怎么做呢?

大神,请教个问题啊,无论 loadrnner,jmeter,locust,在分布式运行的时候,如何去解决所有用户参数不重复。比如以注册为例,手机号如何解决?

大神,如果一个页面需要同时请求多个接口,怎么做并发呢

#2 楼 @wuxixuxiaodong 还真没用过stop_timeout,学习了

6666

讲的详细了,可以模拟 jmeter 进行 csv 做配置文件,还有 locust 的一个痛点就是 stop_timeout 固定运行时间一般文档都不提及。

基于 gevent 实现,确实给力

—— 来自 TesterHome 官方 安卓客户端

全局搜索就能找到啊。

关注

很好 受教了

debugtalk 回复

找到了,谢谢!

最近大疆互联网事业部在招聘测试,有兴趣的可跟我联系。欢迎推荐和自荐哈!

用这个很久了,大概 4G 内存可以模拟近 10w 个连接的请求吧。

csxie 回复

on_start 的确是都执行了一次,只是 Locust 在统计的数据里面,on_start 并完全统计。
如果你有怀疑,可以在 on_start 里面增加一个全局的计数器并打印出来。

楼主咨询下,我起了 500 个 user,发现 on_start 并不是执行了一次能帮忙解释下为什么吗

34楼 已删除

楼主把第二篇也发上来呀

33楼 已删除
debugtalk 回复

前辈你好,最近正在看您的 blog 的脚本增强篇,关于里面的提到的关联部分
使用了增强后,实际在压测的时候是不是会同时请求一个接口获取参数和待压测接口,这样的话压测接口是不是误差就比较大了
还是我对关联的理解有问题?

雪怪 回复

不大理解你的意思。
关联的核心,在于从前一个请求的响应结果中解析出某些字段,然后用到后面的请求中作为参数。
这个对于被测系统来说是没有影响的。对于压测机本身,可能会因为解析数据而耗费一些 CPU,影响就是对生成的总并发数可能会有一些影响。不过这部分可以通过监控压测机本身的 CPU 状况来保障。

debugtalk 回复

恩~就是我们压测目标的接口的时候,实际上在压测的过程中,被测系统实际上是同时收到 2 个接口的请求
那么对于最后我们得出的,被测系统所能承受的待测接口的最大并发数,数据上是不是会有偏差呢

debugtalk 回复

例如我们现在想要知道当前服务器下能承受的接口 A 的最大并发量,
但是接口 A 请求的参数需要带上一个标示不同用户的叫做 sid 的参数,而 sid 是每个用户调用登录接口 B 后服务器返回的一个唯一的一个字符串
我们想要模拟最真实的压测情景的话,是希望待压测的接口 A,在压测过程中每个请求带的参数 sid 都不同
现在就是纠结这样的需求怎么处理好呢

雪怪 回复

同时收到两个接口的请求?这个跟关联没有没有关系吧,我还是无法理解你的问题。

雪怪 回复

理解了。对于这种情况,的确是没法只调用 A 的,采用的做法也是先调用 B,获取 sid,然后作为 A 接口的请求参数。这个并没有什么问题,因为实际的业务场景中,A 和 B 也不是独立的,用户在请求 A 接口之前也会请求 B 接口,所以设计测试场景的时候,也不应该将其独立开来。

debugtalk 回复

个人理解,如果是一般场景下这种 A 和 B 这 2 个接口确实不应独立来测。
只是这个 sid,特殊在于它有效期一般都比较长,客户端登录后都会存储在本地,遇到待测场景的时候再取出 sid 拼接到参数里面,这种情况的话压测时还是要独立吧
————————————————————————————————
想了下,还是先请求一遍登录接口,把 sid 存在本地,然后压测时再去读 sid

雪怪 回复

这是可以的,而且这个 sid 也可以从数据库中批量导出大量的。


大神,可以请教一个问题么?我使用你那个开源的 Stormer 框架,python3 main.py locust -f examples/demo_task.py,怎么报那个 freeze_support() 错误


一旦运行就报错

@debugtalk 大神,请问一下 locust 生成的报表,requestes per second这里是指服务器的吞吐率还是运行 locust 的压力机的吞吐率啊?

回复

Locust 框架是基于 Gevent,而 Gevent 在 Windows 上的支持情况貌似不是很好啊

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