使用LoadRunner进行游戏服务器压力测试

LoadRunner是HP公司出品的一款性能测试软件,可用来模拟大量用户的高并发操作,以达到考量系统性能的目的。在LoadRunner中主要分为三个模块:虚拟用户生成(VuGen)、负载测试控制(Controller)、测试结果分析(Analysis)。本文记录使用LoadRunner完成游戏服务器性能测试的过程,涉及到的代码可以到这里下载。

应用服务器压力测试

测试内容为游戏服务器压力测试,通过模拟大量用户在线玩游戏的场景,来实现对游戏服务器的压力测试。游戏的客户端在安卓和iOS平台,因此无法使用LoadRunner的脚本录制功能(实际上在windows或Unix上直接录制脚本会节省非常多的工作量)。在本项目中,虚拟用户脚本主要分为两个步骤,首先需要模拟用户连接web站点,根据openid请求用户账号id、密码、服务器,然后再用此信息连接游戏服务器(Socket),登录并开始游戏。因此,根据客户端和服务器的通讯协议,在LoadRunner中,需要同时使用http协议POST方法,以及TCP形式的Socket连接。

用户脚本框架

在LoadRunner中,可以使用录制功能自动生成虚拟用户脚本,也可以自己手工编写脚本。如果自己手工编写脚本,可以基于以下的框架(可以通过录制得到)。

使用web(http)协议

在LoadRunner中,使用web协议制作虚拟用户脚本主要会涉及到以下四个文件:
vuser_init.cAction.cvuser_end.cglobals.h,其中vuser_init.cvuser_end.c对应的是用户连接开始和结束的工作内容,处理连接的核心代码在Action.c中。主要框架如下:

vuser_init.c

1
2
3
4
vuser_init()
{
return 0;
}

Action.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Action()
{
web_submit_data("post1",
"Action=http://192.168.1.200:8060/login",
"Method=POST",
"RecContentType=text/html",
"Mode=HTML",
ITEMDATA,
"Name=openid","Value=open_01",ENDITEM,
"Name=channel","Value=22000",ENDITEM,
"Name=subPackage","Value=1",ENDITEM,
"Name=deviceType","Value=1",ENDITEM,
"Name=bundle","Value=1.0",ENDITEM,
LAST);
return 0;

}

vuser_end.c

1
2
3
4
vuser_end()
{
return 0;
}

globals.h

1
2
3
4
5
6
7
8
9
10
11
12
13
#ifndef _GLOBALS_H
#define _GLOBALS_H

//--------------------------------------------------------------------
// Include Files
#include "lrun.h"
#include "web_api.h"
#include "lrw_custom_body.h"

//--------------------------------------------------------------------
// Global Variables

#endif // _GLOBALS_H

实际操作的脚本可以在以上代码的基础上修改得到。

涉及到的函数

介绍几个上边代码中用到的和可能涉及到的函数,更多的用法和用例可以参考官网的API

web_submit_data

1
int web_submit_data( const char *StepName, const char *Action, <List of Attributes>, ITEMDATA, <List of data>, [ EXTRARES, <List of Resource Attributes>,] LAST );

可以用来模拟POST和GET请求,不需要表单上下文。

web_custom_request

1
int web_custom_request( const char *RequestName, <List of Attributes>, [EXTRARES, <List of Resource Attributes>,] LAST);

使用任何http支持的方式创建自定义的http请求,官网示例如下:

1
2
3
4
5
6
7
8
web_custom_request("post_query.exe", "Method=POST",
"URL=http://lazarus/cgi–bin/post_query.exe",
"Body=–––––––––––––––––––––––––––––292742461228954\r\nContent–Disp"
"osition: form–data; name=\"entry\"\r\n\r\nText\r\n––––––––––"
"–––––––––––––––––––292742461228954\r\nContent–Disposition: f"
"–––––––––––292742461228954––\r\n",
"TargetFrame=",
LAST );

使用Winsocket协议

在LoadRunner中,使用 制作虚拟用户脚本主要会涉及到以下四个文件:
vuser_init.cAction.cvuser_end.cdata.ws,其中vuser_init.cvuser_end.c对应的是用户连接开始和结束的工作内容,处理连接的核心代码在Action.c中,data.ws用来保存可能用到的数据。主要框架如下:

vuser_init.c

1
2
3
4
5
6
7
8
9
#include "lrs.h"


vuser_init()
{
lrs_startup(257);

return 0;
}

Action.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include "lrs.h"

Action()
{
lrs_create_socket("socket0", "TCP", "LocalHost=0", "RemoteHost=192.168.1.200:400", LrsLastArg);

lrs_send("socket0", "buf0", LrsLastArg);

lrs_receive("socket0", "buf1", LrsLastArg);

lrs_close_socket("socket0");

return 0;
}

vuser_end.c

1
2
3
4
5
6
7
8
#include "lrs.h"

vuser_end()
{
lrs_cleanup();

return 0;
}

data.ws

1
2
3
4
5
6
7
8
;WSRData 2 1

send buf0 3
"123"
recv buf1 3
"456"

-1

涉及到的函数

介绍几个上边代码中用到的和可能涉及到的函数,更多的用法和用例可以参考官网的API

lrs_create_socket

1
int lrs_create_socket( char *s_desc, char *type, [ char* LocalHost,] [char* peer,] [char *backlog,] LrsLastArg );

创建并初始化一个socket。

lrs_send

1
int lrs_send( char *s_desc, char *buf_desc, [char *target], [char *flags,] LrsLastArg );

使用socket发送一个基于数据报或流的数据。

lrs_receive

1
int lrs_receive( char *s_desc, char *bufindex, [char *flags], LrsLastArg );

通过数据报或流接收数据。

lrs_receive_ex

1
int lrs_receive_ex( char *s_desc, char *bufindex, [char *flags,] [char *size,]     [char *terminator,] [char *mismatch,] [char *RecordingSize,]    LrsLastArg );

通过数据报或流接收指定长度的数据。

lrs_get_last_received_buffer

1
int lrs_get_last_received_buffer( char *s_desc, char **data, int *size );

获取最近一次收到的buffer的内容与长度。

lrs_set_recv_timeout

1
void lrs_set_recv_timeout( long sec, long u_sec );

设置接收消息超时判断。

lrs_set_recv_timeout2

1
void lrs_set_recv_timeout2( long sec, long u_sec );

接收到消息之后,如果接收到的消息长度与录制或指定长度不一致,则等待继续接收。此函数用来设置这一时间的长度。

完善虚拟用户脚本的功能

项目实际使用中,可以基于以上的代码框架来逐渐加入更多的内容,以达到需要的功能。脚本中使用C语言,并且LoadRunner提供了很多功能模块供使用:

参数化

为了更方便高效地运行脚本,可以使用LoadRunner中参数化的方法,将数据存储在参数中,在脚本运行时读取。

此处用一例子说明,定义一个参数openid,将对应的参数值存入参数列表,并在发送http请求时调用。具体主要包括以下几个步骤:

选择打开参数列表界面(快捷键Ctrl+L):

fig

新建一个变量openid并改变其设置,如下图:

fig

其中openid参数文件如下,可以直接使用记事本编辑(点击Edit with Notepad)。

文件openid.dat:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
openid
open_000001
open_000002
open_000003
open_000004
open_000005
open_000006
open_000007
open_000008
open_000009
open_000010
open_000011
open_000012
open_000013
open_000014
open_000015

注意在下方的参数设置项中,选择下一行的方式(Select next row)指定为唯一(Unique),更新参数值(Update value on)设置为每次出现(Each occurrence),以保证每个虚拟用户脚本读到此变量时取到不同的值。关于参数取值的设置具体会在后边讨论。

设置参数完毕之后,即可在之前的http请求中使用参数,直接在字符串中将之替换为{openid}即可。

1
2
3
4
5
6
7
8
9
10
11
12
web_submit_data("post1",
"Action=http://192.168.1.200:8060/login",
"Method=POST",
"RecContentType=text/html",
"Mode=HTML",
ITEMDATA,
"Name=openid","Value={openid}",ENDITEM,
"Name=channel","Value=22001",ENDITEM,
"Name=subPackage","Value=1",ENDITEM,
"Name=deviceType","Value=1",ENDITEM,
"Name=bundle","Value=1.0",ENDITEM,
LAST);

在虚拟用户脚本中,可以使用{para_name}或\在代码中使用参数,当使用http时协议默认为花括号,使用socket默认为尖括号,可以在Tools -> General Options中设置,如下:

fig

在使用socket时,发送的数据也可以写在参数中,会用到函数lr_eval_string,参考代码:

1
2
3
4
5
6
7
8
//socket send with para

char sendStr[512];

strcpy(sendStr,lr_eval_string("{str_t1}"));

lrs_set_send_buffer("socket0", sendStr , strlen(sendStr) );
lrs_send("socket0", "buf0", LrsLastArg);

其中str_t1是变量名称,其值储存在变量值列表中。

内置函数

LoadRunner内部有将字符串形式的数据保存到参数及从参数中读取字符串的函数:

lr_save_string
1
int lr_save_string( const char *param_value, const char *param_name);

将字符串保存至参数,字符串包含'\0'终止符。

lr_eval_string
1
char *lr_eval_string( char *instring  );

将传入的字符串中内置参数读取为其值,并返回完整的字符串。参考官网的一个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Action()
{
lr_save_int(0, "searchCount");
if (atoi(lr_eval_string("{searchCount}") ) == 0) {

lr_output_message("Is zero, %s", lr_eval_string("{searchCount}"));
}

lr_save_int(47, "searchCount");
if (atoi(lr_eval_string("{searchCount}") ) != 0) {
lr_output_message("Not zero, %s", lr_eval_string("{searchCount}"));
}

return 0;
}

参数读取控制

当参数列表中增加参数后,需要设置参数的读取方式,主要有以下三个设置变量,即“如何选择下一个值”、“何时选择下一个值”、“超出值的数量时”,先考虑前两个:

如何选择下一个值(Select next row)

  • 顺序(Sequential):按照参数值列表中值的顺序,一个一个地取
  • 随机(Random):每次随机地从参数值列表中取数据
  • 唯一(Unique):为每个虚拟用户分配一条唯一的数据

何时选择下一个值(Update value on)

  • 每次迭代(Each iteration) :每次迭代时取新的值,假如50个用户都取第一条数据,称为一次迭代;完了50个用户都取第二条数据,后面以此类推。

  • 每次出现(Each occurrence):每次参数时取新的值,这里强调前后两次取值不能相同。

  • 只取一次(once) :参数化中的数据,一条数据只能被抽取一次。(如果数据轮次完,脚本还在运行将会报错)

以上两种设置搭配可以实现多种取值方法,基本能够满足需求。当取值超出值的数量时(When out of values),有三种选项:

  • 放弃虚拟用户(Abort Vuser):即取消此虚拟用户

  • 使用循环方法(Continue in a cyclic manner):从值列表的首行重新开始取值

  • 使用最后一个值(Continue with last value):持续使用值列表中的最后一个值

可以将http请求获取的返回数据中获取的内容保存至参数,供后边的代码使用:

1
2
3
4
5
6
web_reg_save_param("uid","LB=\"uid\":\"","RB=\",",LAST);
web_submit_data("post1",
//...略去无关
LAST);
//...略去无关
sprintf(_buff, "a,%s",lr_eval_string("{uid}"));

数据格式

使用socket进行发送和接收数据时,需要处理字节流。发送的数据可以实现写成十六进制形式存储在参数或文件中,在执行脚本时取值或读取,然后发送。这里首先介绍两个LoadRunner内置的函数,然后编写了两个自定义的函数:

内置函数

lrs_decimal_to_hex_string
1
char*  lrs_decimal_to_hex_string( char* s_desc, char* buf, long length );

将整数转化为十六进制字符串。参考示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//In the following example, the **lrs_decimal_to_hex_string** function converts an integer into a hexadecimal string. The script displays the value of the HEX string if it is not NULL.

lrs_send("socket0", "buf3", LrsLastArg);

lrs_receive("socket0", "buf4", LrsLastArg);

tmp = lrs_get_received_buffer("socket0", 29, 1, NULL);

    /* translate 4 to \x04 */

tmp2 = lrs_decimal_to_hex_string("socket0",tmp,1);

if (NULL != tmp2 && lrs_get_user_buffer_size("socket0") == 4 )

{
    lr_output_message("tmp2=%s",tmp2);
}
lrs_send("socket0", "buf5", LrsLastArg);
lrs_receive("socket0", "buf6", LrsLastArg);

lrs_hex_string_to_int
1
int lrs_hex_string_to_int( char* buff, long length, int* mpiOutput );

将十六进制字符串转化为十进制整数。参考示例:

1
2
3
4
5
6
7
8
9
10
11
char* tmp;
char* tmp2;
int i = 0;
lrs_create_socket("socket0", "TCP", "LocalHost=0", "RemoteHost=server2.mercury.co.il:23", LrsLastArg);
lrs_receive("socket0", "buf0", LrsLastArg);
tmp = lrs_get_received_buffer("socket0", 5, 2, NULL);
if ( ! lrs_hex_string_to_int(tmp,2,&i))
{
lr_output_message("i=%d",i);
/* compare i with the same selection in the data.ws and displayed value (F7) */
}

将字符串转化为十六进制数据

src中存储的字符串转化为LoadRunner中使用的十六进制格式,存入des

1
2
3
4
5
6
7
8
9
10
11
12
int strToHex(char* src, char* des)
{
int i = 0 ;
for(i = 0; i < strlen(src) ; i++ )
{
char hex[5];
sprintf(hex,"\\x%02X",(unsigned char)src[i]);
strcat(des + i , hex);
}

return 0;
}

将字节流转化为十六进制数据

src中存储的长度为length的字节流转化为LoadRunner中使用的十六进制格式,存入des

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int dataToHex(char* src, char* des, int length )
{
int i = 0 ;
for(i = 0; i < length ; i++ )
{
char hex[5];
sprintf(hex,"\\x%02X",(unsigned char)src[i]);
memcpy(des + i*4 , hex,4);
}
memcpy(des + length * 4 , "\0",4);

return 0;

}

文件读写

在某些情况下会涉及到文件的读写操作,具体读写文件的方式与C语言中一致,不在赘述。需要注意的是,如果使用字符文件来存储十六进制的数据,会比参数列表格式存储时少一个转义的反斜杠,如:

使用参数的值列表形式存储的待发送数据:“\\x0A\\x1B”,如果使用字符文件来存储,则需要写成:“\x0A\x1B”。

接收消息的长度处理

在脚本中,默认的消息接收函数是配合录制进行的,即会按照录制时接收的消息长度(或在data.ws中指定的消息长度) 来接收数据,如果实际运行脚本时接收消息的长度与指定的不一致,则会发出警告。接下来使用一种变长度的接收消息方法,可以对接收到的内容进行判断。首先接收消息的前四个字节,前四个字节存储的是消息内容的长度,然后根据读取到的消息长度来读取消息的内容。官方提供了一个自定义的函数,本应用中对其进行了微小的改动,具体如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
//自定义函数 接收可变长度的消息 具体可以根据协议调整
//1、接收消息的前四个字节 并将接收的字节转化为十进制数
//2、根据前四个字节读取到的长度信息 接收消息剩余部分
int custom_lrs_receive(char *sock_desc, char *buf_desc, void *dummy)
{
int rc;

int buf_len = 4;

char szBytesLength[30], *buf = NULL, *pszError, *pszLastChar;

/* Get first 4 bytes */
rc = lrs_receive_ex(sock_desc, buf_desc, "NumberOfBytesToRecv=4", LrsLastArg);

if (rc != 0) //正常情况下函数返回为0,非0表示函数有错误
{
//lr_error_message("Receive 4 bytes failed. The error code = %d", rc);

return -1;
} /* Receive failed */

//判断前4个字节是否接受成功
lrs_get_last_received_buffer(sock_desc, &buf, &buf_len);

if (buf == NULL || buf_len != 4)
{
lr_error_message("receive of %s failed", buf_desc);

return -1;
}

/* Compute buffer length */
//与官方的demo不同 前四个字节的消息长度即后边消息的长度 不需要减去前4个字节
sprintf (szBytesLength, "NumberOfBytesToRecv=%d", fiFromHexBinToInt(buf));
//调用另一个自定义函数:计算总长度的函数

lr_debug_message(LR_MSG_CLASS_FULL_TRACE, "!!!! Bytes length = %s", szBytesLength);

/* 接受剩下的字节流 */
rc = lrs_receive_ex(sock_desc, buf_desc, szBytesLength, LrsLastArg);

if (rc != 0) /* Receive failed */
return -1;

return 0;
}


//将接收到的4个字节转化为十进制整数并返回
int fiFromHexBinToInt (char *szBuffer)
{

int i, j, iIntValue = 0, iExp = 1;

for( i = 3; i >= 0; i--)
{
iExp = 1;

for (j = 6; j > i*2; j--)
iExp *= 16;

iIntValue += (szBuffer[i] & 0x0000000f) * iExp + ((szBuffer[i] & 0x000000f0) >> 4) * iExp * 16;

}

return iIntValue;

}

接收消息的数量处理

实际应用的TCP连接中,发出的消息与服务器返回的消息未必会一一对应,为了处理发出一条消息而服务器返回多条消息的情况,使用以下的自定义函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 接收消息直到没有可接收的内容
int custom_lrs_receive_all(char *sock_desc, char *buf_desc, void *dummy)
{
int i = 0;
i = custom_lrs_receive(sock_desc, buf_desc, dummy);

//判断首条是否接收成功
if(i == -1)
{
lr_error_message("接收消息失败");
return -1;
}

//继续接收 直到没有
//while(buf_desc + lrs_get_last_received_buffer_size(sock_desc)+1 != NULL )
while(i != -1)
{
i = custom_lrs_receive(sock_desc, buf_desc, dummy);
}
}

事务(Transaction)

为了更好地对操作进行封装,以及更好地评估系统性能,可以在LoadRunner中使用事务功能,事务用于模拟用户的一个相对完整的、有意义的业务操作过程,如登录、新手教学、征兵、建造等。使用事务时,需要标记事务的开始点和结束点,可直接在菜单按钮上点击,如下图:

fig

也可以使用代码实现该功能:

1
2
3
4
5
//标记事务开始
int lr_start_transaction( char *transaction_name );

//标记事务结束
int lr_end_transaction( char *transaction_name, int status) ;

当事务结束时,需要制定事务结束的状态,如成功(LR_PASS)、失败(LR_FAIL)等。

带心跳的等待时间

LoadRunner中有内置的函数来模拟等待时间:

1
void lr_think_time( double thinkTime  );

在应用中,为防止过长的等待时间使服务器断掉socket连接,可以在等待的过程中增加心跳包,心跳包的内容根据协议转为十六进制的字符串即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int waitWithHeartBeat(double waitTime)
{
// 心跳包数据
char* send = "\\x00\\x06\\x04\\x13\\x00\\x00\\x00\\x00";

while(waitTime > 1)
{

waitTime-= 1;
lr_think_time(1);

lrs_set_send_buffer("socket0", send , strlen(send) );
lrs_send("socket0", "buf0", LrsLastArg);

}
lr_think_time(waitTime);
return 0;
}

使用随机数

在脚本运行过程中,常常会使用随机数,为更方便地在脚本中使用随机数,可以增加一个参数以及一个自定义函数来模拟。首先在参数列表增加一个参数,种类为随机数,取值范围 0 - 9999:

fig

除此之外构建一个自定义函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
float getRandom(float min , float max)
{

float ret = max;


if(max < min)
{
return 0 ;
}

if(max > min)
{
//随机数是 0 - 9999
float rand = (float)(atoi(lr_eval_string("{rand}")));

ret = min + rand/9999.0 * (max - min) ;
}

return ret;

}

使用此函数可以返回指定范围内的一个随机浮点数。注意:该随机数是将minmax区间等分为10000份,并从中取值,可以取到minmax本身。

取得时间戳

部分功能会用到时间戳,可以使用以下代码直接获取:

1
2
3
int timeStamp;
timeStamp = time(NULL);
lr_output_message("timeStamp = %d" ,timeStamp);

集合点(Rendezvous)

为更好地控制虚拟用户的行为,模拟高并发操作,可以在代码中标记集合点。

1
lr_rendezvous("phase_1");

具体使用时需要在测试控制过程中来设置具体的要求,有如下选项:

fig

可以配置为“当百分之多少的用户到达集合点时脚本继续”、“当百分之多少的运行中用户到达集合点时脚本继续”或“当多少个用户到达集合点时脚本继续”。

负载测试控制

接下来记录一些在负载测试控制中涉及到的内容:

运行时设置

新建情景(Scenario)并加载虚拟用户脚本后,可以对脚本的运行时属性进行重新配置,主要包括下图左侧的各条目。可对负载测试过程中对应虚拟用户脚本的Log和Think Time等进行调整。

fig

fig

流程控制

在情景调度器的左下角,可以看到关于虚拟用户脚本运行动作的设置,主要包括初始化、开始、持续、结束等,可以根据需要进行配置。
fig

初次接触LoadRunner,仅记录学习和使用过程,如有错误还望批评和指正。

REFERENCE

http://lrhelp.saas.hp.com/en/latest/help/function_reference/FuncRef.htm
http://blog.csdn.net/fengshuiyue/article/details/42401223
http://www.cnblogs.com/fnng/archive/2012/06/22/2558900.html
http://www.cnblogs.com/samfish/archive/2010/06/10/1755600.html
http://www.ltesting.net/ceshi/ceshijishu/rjcsgj/mercury/loadrunner/2010/0505/171198.html
http://blog.csdn.net/richnaly/article/details/7967364
http://www.cnblogs.com/expect88/articles/1932831.html