原文链接
性能测试脚本的编写和调试
摘要: 性能测试是一个入门简单,但是精通难,很依赖实践经验的技术活。如何编写压测脚本只是小术,而如何快速找到问题的原因,压出瓶颈却是大有学问。这次,云享团的专家从“术”入手,对一个自己临时写的的一个网站进行压测,希望能帮大家更好理解性能测试产品,特别是脚本编写的部分。
性能测试是一个入门简单,但是精通难,很依赖实践经验的技术活。如何编写压测脚本只是小术,而如何快速找到问题的原因,压出瓶颈却是大有学问。不过本文先从术入手,先对一个自己临时写的的一个网站进行压测,希望能帮大家更好理解性能测试产品,特别是脚本编写的部分。
开始压测第一件事情绝对不是直接动手就写压测脚本。一个规范的性能测试需要包括需求调研、测试准备、执行压测、生成压测结果并做汇总几个部分。这些步骤都有其存在的意义,保证我们压测不会跑偏,这里针对具体的case我们分析下(注:本文涉及的机器会在本文发布前释放,相关请求地址不再可用,大家就不要压文中的地址了)。
压测之前
需求调研
这一步我们需要先知道自己要压的系统的情况。需要根据实际的项目情况进行需求调研。
项目背景
这是一个很简单的测试系统,功能上涉及的主要是主页浏览、一个登录功能和一个登录后的一个简易下单操作。
项目目标
这次我主要是希望压出这个网站里的首页(静态页面)、登录、下单3个页面能承载的最大TPS,我会使用不同的并发去压,只为了寻找处理能力的上限。如果是实际的场景里,大家很可能是被问的是,xx个用户能不能顶的住。这时候可以通过这里来估算。算出并发数后,根据这些并发数压测后的响应时间、成功率等指标是否达到预期来判断软件是否满足要求。
项目范围
这个网站搭建在我刚购买的机器(公网:120.55.240.49/内网:10.47.121.62)上。上面搭建了个Tomcat,跑了个通过war包打出的简单java web应用。本次压测主要涉及主页(http://120.55.240.49:8080/demo/ )、登录页面(http://120.55.240.49:8080/demo/login.jsp )和购买页面(http://120.55.240.49:8080/demo/buy.jsp )。其中购买页面是需要登录成功后才能下单的,否则会302回登录界面。
软件架构
ECS上安装Tomcat,部署的一个简单Java应用。其中登录需要用账号密码去查询数据库的用户表,目前表里就初始化了一个admin/123作为登录账号。购买页面的下单操作也会往数据库里写一条记录。这里只用了一台ECS,没有使用负载均衡。总体而言,是一个简单的一台ECS+一个RDS的应用。
这次压测没有分生产系统和测试环境。不过在实际场景里,需要注明生产环境和测试的环境的区别,并在压测的过程中加以注意。
当前系统里只有少量几条测试数据,所以数据库查询的话,理论上不会有数据库慢查询(实际上这次也就压测涉及的数据库查询只有登录的时候会查用户表,而用户表目前只有一条记录)。而关于写入,目前没有在表上做索引。实际工作中,不仅需要考虑到系统的当前数据量,还需要顾及未来2-3年的数据量情况,以免以后数据量增加的时候负载跟不上。
硬件准备是否充分。这里可以先评估的是峰值的网络带宽。CPU、内存主要是需要根据压测的结果进行评估,但是带宽可以根据预先估算的TPS乘以每个请求涉及的文件的大小来估算。我这里是压瓶颈,回头看下瓶颈是不是在带宽上。
性能指标
主要涉及网站预期的性能指标,比如TPS、响应时间、成功率、压测过程中的涉及的ECS/RDS的负载。我这里就想看看它能“走多远”,先不设置TPS的指标。但是响应时间,我希望首页、登录、下单的响应时间能在2秒内,请求成功率在99.9%,在压测的过程中ECS的各项指标低于80%,数据库的资源利用率低于60%。如前面提到的,设置指标的时候最好能考虑到未来2-3年的情况,至少要考虑到近期的峰值(比如接下来是否会有大促)的性能要求。
业务描述
涉及主页、登录、下单3个页面。
主页包括1个html1个css1个图片。
登录页面通过post请求提交。如果账号密码错会302回到登录页面。如果是登录成功,会跳到成功页面提示处理成功。为联机操作。
下单页面也一样通过post请求提交当前购买的商品和数量。服务器会判断当前session里的用户信息,如果取不到判断为没登录状态,会302跳到登录界面。下单的逻辑很简单,没有对库存做校验,只是增加一条记录。也是联机操作。
本系统不涉及跑批作业,也不涉及其他外部系统。
业务描述
我也没编出来: ) 不过大家实际使用中,需要注意用户的行为方式,比如服务对象,他们的使用均值、高峰如何,一般都是如何使用系统的。这对于脚本编写逻辑和压测的目标的设置有非常重要的参考意义。
测试准备
测试准备主要包括测试环境的配置、测试内容的梳理和测试策略的设定。
测试环境
本例子没有分测试环境/线上环境,直接就开始压了。真实的压测例子里,需要记录生产环境和测试环境在系统架构图、部署图、硬件配置、软件环境,并分析其差异。这里我就例子里的被压系统做下记录:
系统架构图用的是serverlet+jdbc直接连mysql,没有连接池,或者诸如ssh、Ibatis等常用框架。因为太简单这里就不画图了。
部署情况为一台ECS上安装了tomcat 8,然后直接拷上war包完事。mysql的数据用的是之前调试代码里就创好的表,没有走平时的上线流程之类的。
硬件配置为:
ECS的配置为华东1区域的2核4G I/O优化实例,使用操作系统为CentOS 6.8 64位。公网带宽购买时设置为5Mbps(峰值)。
RDS的配置为1核2G通用型MySQL 5.6。能达到的最大IOPS为1000,最大连接数为600。
软件环境上为Java 8,Tomcat没有调JVM参数,没有调过其他参数。
测试内容
本例子先只涉及单交易负载测试,基准测试、混合场景下的测试先暂时不考虑。需要压测的页面为:
模块 | 涉及的请求 | 参数 | 前提条件 |
---|---|---|---|
首页 | http://120.55.240.49:8080/demo/ | 无 | 无 |
登录 | http://120.55.240.49:8080/demo/login.jsp | userName=admin&password=123 | 无 |
下单 | http://120.55.240.49:8080/demo/buy.jsp | goods=g02&count=1 | 登录 |
测试策略
我们会先调试通过后,先用1-5个并发保证压测能跑起来,然后逐渐调整并发用户数,每次调整后停留至少30秒观察服务器的负载和数据库的负载,以及诸如TPS、响应时间的性能指标。在观察到服务器的TPS达到瓶颈或者负载达到上限后停止压测,认为服务的处理能力已经达到。整个压力过程中的并发数是人为根据当时的情况动态调整的。这里不要起来就是几千一万的压力去压,否则一般情况下,除了把服务器压挂掉外别的什么都说明不了。
关于监控模型,我们配置ECS、RDS为监控对象。不过因为性能测试的监控数据有延迟,ECS为1分钟,RDS为5分钟,所以在压测的过程中,会登录到ECS上,使用TOP命令来观察更加实时的ECS负载,并登录到RDS的DMS上使用实时性能功能观察RDS的负载。
首页
首页是一个简单的静态页面,这里主要是展示一下如何使用性能测试产品提供的脚本录制工具的使用方法。
脚本分析
产生的脚本为(第一次建议先只看注释不看代码,就是#之后的)
#! /usr/bin/env python
# -*- coding: utf-8 -*-
# PTS Script record tool v0.2.6.4
# PTS脚本SDK:框架API、常用HTTP请求/响应处理API
from util import PTS
from HTTPClient import NVPair
from HTTPClient import Cookie
from HTTPClient import HTTPRequest
from HTTPClient import CookieModule
# 脚本初始化段,可以设置压测引擎的常用HTTP属性
#PTS.HttpUtilities.setKeepAlive(False)
#PTS.HttpUtilities.setUrlEncoding('GBK')
#PTS.HttpUtilities.setFollowRedirects(False)
#PTS.HttpUtilities.setUseCookieModule(False)
PTS.HttpUtilities.setUseContentEncoding(True)
PTS.HttpUtilities.setUseTransferEncoding(True)
## 如想通过ECS内网IP进行压测,必须在下方“innerIp”备注行中输入ECS内网IP,如有多个请以英文逗号分隔,例如:127.0.0.1,127.0.0.2
# innerIp:
## 脚本执行单元类,每个VU/压测线程会创建一个TestRunner实例对象
class TestRunner:
# TestRunner对象的初始化方法,每个线程在创建TestRunner后执行一次该方法
def __init__(self):
self.threadContext = PTS.Context.getThreadContext()
self.init_cookies = CookieModule.listAllCookies(self.threadContext)
# 主体压测方法,每个线程在测试生命周期内会循环调用该方法
def __call__(self):
PTS.Data.delayReports = 1
for c in self.init_cookies:
CookieModule.addCookie(c, self.threadContext)
# 在call里调用事物1的函数
statusCode = self.action1()
PTS.Framework.setExtraData(statusCode)
PTS.Data.report()
PTS.Data.delayReports = 0
# TestRunner销毁方法,每个线程循环执行完成后执行一次该方法
def __del__(self):
for c in self.init_cookies:
CookieModule.addCookie(c, self.threadContext)
# 定义请求函数
## action1
def action1(self):
statusCode = [0L, 0L, 0L, 0L]
headers = [ NVPair('Accept', '*/*'), NVPair('Upgrade-Insecure-Requests', '1'), NVPair('X-DevTools-Emulate-Network-Conditions-Client-Id', '4c145a4a-8df7-4d40-9906-592a0a1ea620'), NVPair('Accept-Encoding', 'gzip, deflate, sdch'), NVPair('Accept-Language', 'zh-CN,zh;q=0.8'), NVPair('User-Agent', 'PTS-HTTP-CLIENT'), ]
result = HTTPRequest().GET('http://120.55.240.49:8080/demo/', None, headers)
PTS.Framework.addHttpCode(result.getStatusCode(), statusCode)
headers = [ NVPair('Accept', '*/*'), NVPair('X-DevTools-Emulate-Network-Conditions-Client-Id', '4c145a4a-8df7-4d40-9906-592a0a1ea620'), NVPair('Referer', 'http://120.55.240.49:8080/demo/'), NVPair('Accept-Encoding', 'gzip, deflate, sdch'), NVPair('Accept-Language', 'zh-CN,zh;q=0.8'), NVPair('User-Agent', 'PTS-HTTP-CLIENT'), ]
result = HTTPRequest().GET('http://120.55.240.49:8080/demo/css/demo.css', None, headers)
PTS.Framework.addHttpCode(result.getStatusCode(), statusCode)
headers = [ NVPair('Accept', '*/*'), NVPair('X-DevTools-Emulate-Network-Conditions-Client-Id', '4c145a4a-8df7-4d40-9906-592a0a1ea620'), NVPair('Referer', 'http://120.55.240.49:8080/demo/'), NVPair('Accept-Encoding', 'gzip, deflate, sdch'), NVPair('Accept-Language', 'zh-CN,zh;q=0.8'), NVPair('User-Agent', 'PTS-HTTP-CLIENT'), ]
result = HTTPRequest().GET('http://120.55.240.49:8080/demo/hello-world.png', None, headers)
PTS.Framework.addHttpCode(result.getStatusCode(), statusCode)
## statusCode[0]代表http code < 300 个数, statusCode[1] 代表 300<=http code<400 个数
# statusCode[2]代表400<=http code<500个数, statusCode[3] 代表 http code >=500个数
# 如果http code 300 到 400 之间是正常的
# 那么判断事务失败,请将statusCode[1:3] 改为 statusCode[2:3] 即可
if(sum(statusCode[1:3]) > 0):
PTS.Data.forCurrentTest.success = False
PTS.Logger.error(u'事务请求中http 返回状态大于300,请检查请求是否正确!')
return statusCode
# 调用施压引擎施压。第一个参数是事务名,可以为中文;第二个参数是执行事务方法的方法名;第三个统一写TestRunner
PTS.Framework.instrumentMethod(u'action1', 'action1', TestRunner)
可以看到函数里需要注意的是def __init__(self)
做初始化,这里暂时不涉及,后面会提到。初始化后压测服务会多次调用def __call__(self)
。最后调用一次__del__(self)
收尾。
脚本调试
在保存按钮边上有个调试按钮,点击后可以看到调试的结果。
在脚本调试的过程中,每个请求的内容,响应内容一目了然。执行日志里还有提供压测的过程中的日志。如果中间有自己打印了一些日志,也可以在这里看到。关于日志打印的功能后面也会实践里提到。
压测过程
保存了脚本后,去创建一个压测场景:
把这个场景运行起来。看到并发很低,从性能测试产品上可以看到性能参数图:
同时对比一下ECS的负载指标:
看到ECS的CPU根本没用掉。通过TOP命令看到的CPU、内存的使用情况也是如此。同时我还用iftop -i eth1
看了下公网网卡的流量情况,和监控上看到的一样,公网带宽被打满了
结果总结
从压测结果可以看到,瓶颈在公网带宽上。因为ECS购买的公网带宽比较小,而首页的静态文件比较大(图片比较大),可以考虑在够用的情况下减少图片的分辨率减少图片的大小。另外可以做到动静分离,一些静态文件就放到对象存储OSS上面,再配合CDN就完美了。压测的时候也就不需要在压测这些已经放在OSS/CDN的文件。
登录功能
同样的登录功能也是脚本录制出来的。这里就不重复说明。因为后面的登录后下单的这个例子包括登录的所有功能点,这里登录就先跳过。
下单功能
下单是本次测试的最复杂的一个模块。首先,下单前需要登录,但是我们这次只是为了测试下单的工单,所有所有的请求,我们希望只登录一次(实际上如果是用了多台施压机,脚本里写一次登录,实际上是每台机器一次,一共登录会被执行多次)。根据前面讲的脚本的组成逻辑,我们需要把登录写在def __init__(self)
里。除了登录,我们还希望测试每次下单购买的是不同的商品和数量,这时候需要用到参数文件。另外因为我们这次是希望压测下单的过程中ECS和数据库的压力,对于之前的瓶颈公网带宽,我们假设已经通过动静分离解决了,所以在这里压测脚本里我们不涉及静态资源,走内网压测。还有我们希望在这个例子里对脚本代码做一次调试,所以需要做一些日志打印。