做后端开发或运维的同学都绕不开一个问题:上线的HTTP服务到底能扛住多少请求?每秒能处理多少请求(RPS)?高并发下会不会报错?传统的单线程压测工具往往自己先"卡壳",测不出服务的真实性能------今天我们就聊聊一款能充分利用服务器资源、精准测试高负载HTTP服务的压测工具,拆解它的设计思路和实现原理。
一、为什么需要"够快"的HTTP压测工具?
先说说痛点:比如Apache Bench(ab)这类常用压测工具是单线程的------不管你的服务器有8核、16核CPU,这个工具最多只能占满一个核心。如果被测服务是Nginx、Redis这类基于事件驱动的高并发服务,单线程压测工具自己就会成为瓶颈:工具的CPU跑满了,服务还没到极限,测出来的RPS数值根本不是服务的真实能力,相当于"用小水管去测大水库的出水速度"。
所以我们需要一款"自身性能足够强"的压测工具:它能充分利用服务器的多核CPU,高效管理上千个并发连接,让被测服务真正跑满,才能测出真实的性能上限。
二、先搞懂:HTTP压测到底测什么?
大白话讲,HTTP压测就是模拟大量用户同时给服务发HTTP请求,核心就看三个指标:
- 总请求数:一共发了多少个请求;
- 成功/失败请求数:成功是服务返回1xx/2xx/3xx状态码,失败是4xx(客户端错误)/5xx(服务端错误);
- 每秒请求数(RPS):最核心的指标,反映服务的处理能力,计算方式=总请求数÷总耗时。
压测的最终目标,就是找到服务在"不报错、响应时间可接受"前提下的最大RPS。
三、高性能压测工具的核心设计思路
要做出"不拖后腿"的压测工具,核心就解决两个问题:把CPU核心用满 、高效管理大量并发连接,对应的设计思路就两点:
1. 多线程:榨干多核CPU的性能
既然单线程只能用一个核心,那就创建和CPU核心数一致的线程(比如8核CPU开8个线程)。每个线程独立处理一部分并发连接,让所有CPU核心都跑起来,工具自身不会因为CPU不够用而成为瓶颈。
2. IO多路复用(epoll):高效管上千个连接
如果每个连接都配一个线程(线程池模式),并发数到几千、几万时,线程切换的开销会把程序拖垮。这时候就需要IO多路复用------用一个线程通过epoll管理成百上千个连接,只处理"有事件发生"的连接(比如连接能发数据、有响应可读),不用挨个轮询,效率直接拉满。
3. 非阻塞IO:避免单个连接卡住整体
所有网络连接都设置为非阻塞模式:比如发起连接时不用等连接建立完成,发数据时能发多少发多少,不会因为一个连接的网络延迟,导致整个程序卡住。
四、核心实现原理(从代码逻辑拆解)
我们以这款工具的核心代码为例,用大白话讲清楚它是怎么跑起来的,还附了流程示意图,一看就懂。
1. 整体执行流程
整个工具的运行过程就6步,流程图如下:
解析命令行参数
解析目标URL:拆分主机/端口/请求路径
初始化目标服务器地址(IP+端口)
创建指定数量的工作线程
每个线程:初始化并发连接+epoll事件循环
事件循环:处理连接写/读事件→统计请求结果→循环压测
接收到终止信号(Ctrl+C)或达到总请求数→停止压测
计算耗时、RPS、成功率→输出测试结果
cpp
...
int main(int argc, char* argv[])
{
...
if (argc == 1)
print_usage();
do {
next_option = getopt_long(argc, argv, short_options, long_options, NULL);
switch (next_option) {
case 'n':
max_requests = strtoull(optarg, 0, 10);
break;
case 'c':
concurrency = atoi(optarg);
break;
case 't':
num_threads = atoi(optarg);
break;
case 'd':
debug = 0x03;
break;
case '%':
print_usage();
case -1:
break;
default:
printf("Unexpected argument: '%c'\n", next_option);
return 1;
}
} while (next_option != -1);
if (optind >= argc) {
printf("Missing URL\n");
return 1;
}
/* parse URL */
s = argv[optind];
if (!strncmp(s, HTTP_REQUEST_PREFIX, sizeof(HTTP_REQUEST_PREFIX) - 1))
s += (sizeof(HTTP_REQUEST_PREFIX) - 1);
host = s;
rq = strpbrk(s, ":/");
if (rq == NULL)
rq = "/";
else if (*rq == '/') {
host = malloc(rq - s);
memcpy(host, rq, rq - s);
} else if (*rq == ':') {
*rq++ = 0;
port = atoi(rq);
rq = strchr(rq, '/');
if (rq == NULL)
rq = "/";
}
h = gethostbyname(host);
if (!h || !h->h_length) {
printf("gethostbyname failed\n");
return 1;
}
ssin.sin_addr.s_addr = *(u_int32_t*)h->h_addr;
ssin.sin_family = PF_INET;
ssin.sin_port = htons(port);
outbuf = malloc(strlen(rq) + sizeof(HTTP_REQUEST_FMT) + strlen(host));
outbufsize = sprintf(outbuf, HTTP_REQUEST_FMT, rq, host);
ticks = max_requests / 10;
signal(SIGINT, &sigint_handler);
if (!max_requests) {
ticks = 1000;
printf("[Press Ctrl-C to finish]\n");
}
start_time();
for(n = 0; n < num_threads - 1; ++n)
pthread_create(&useless_thread, 0, &worker, 0);
worker(0);
delta = tve.tv_sec - tv.tv_sec + ((double)(tve.tv_usec - tv.tv_usec)) / 1e6;
printf("\n"
"requests: %"PRIu64"\n"
"good requests: %"PRIu64" [%d%%]\n"
"bad requests: %"PRIu64" [%d%%]\n"
"seconds: %.3f\n"
"requests/sec: %.3f\n"
"\n",
num_requests,
good_requests, (int)(num_requests ? good_requests * 100 / num_requests : 0),
bad_requests, (int)(num_requests ? bad_requests * 100 / num_requests: 0),
delta,
delta > 0
? max_requests / delta
: 0
);
return 0;
}
If you need the complete source code, please add the WeChat number (c17865354792)
2. 关键步骤拆解
(1)参数解析:确定压测规则
运行工具时我们会传几个关键参数(和ab类似):
-n:总请求数(不传就是无限压测,按Ctrl+C停止);-c:并发连接数(同时发多少个请求);-t:线程数(建议等于CPU核心数)。
代码会先解析这些参数,比如确定"要发10000个请求,同时保持100个并发,用8个线程处理"。
bash
# 核心测试命令:10000个总请求、100个并发、8个线程,测试本地8080端口
./http_bench -n 10000 -c 100 -t 8 localhost:8080
# 无限发请求,100个并发、8个线程,按Ctrl+C停止并输出结果
./http_bench -c 100 -t 8 localhost:8080
(2)URL解析:找到要压测的服务
比如输入localhost:8087/test,代码会拆分出:
- 主机:localhost(通过域名解析转成IP地址);
- 端口:8087(默认80);
- 请求路径:/test。
然后拼接成标准的HTTP GET请求(比如GET /test HTTP/1.0\r\nHost: localhost\r\n\r\n),存在内存里备用------不用每次发请求都拼接,减少开销。
(3)工作线程:核心的"压测工人"
每个工作线程是真正干活的,核心流程就三件事:
① 创建epoll实例:相当于给这个线程配一个"连接管家",专门管所有并发连接的事件;
② 初始化并发连接:按指定的并发数(比如100)创建非阻塞的网络连接,发起连接请求后,把这些连接注册到epoll里,关注"可写事件(EPOLLOUT)";
③ 进入epoll事件循环(最核心):
- 当连接触发"可写事件":说明连接建好了,把提前准备的HTTP请求发出去;发完后,把这个连接的关注事件改成"可读事件(EPOLLIN)",等着收响应;
- 当连接触发"可读事件":说明服务返回响应了,读取响应数据后,重点检查状态码(比如响应里第10个字符是4/5,就是4xx/5xx错误);统计成功/失败后,关闭这个连接,立刻新建一个连接------保证并发数不变,持续压测。
(4)数据统计:保证准确不混乱
多线程会同时修改"总请求数""成功请求数"这些数据,代码里用了原子操作(比如__sync_fetch_and_add),避免多个线程"抢着改数"导致统计错误。比如线程A和B同时给"总请求数"加1,原子操作能保证最终加的是2,而不是1。
(5)结果计算:输出直观的压测报告
当达到总请求数,或按Ctrl+C触发终止信号,工具会停止压测,计算从开始到结束的耗时,算出RPS(总请求数÷耗时)、成功/失败请求的百分比,最后输出像这样的报告:
requests: 10000
good requests: 10000 [100%]
bad requests: 0 [0%]
seconds: 0.742
requests/sec: 13469.301
五、背后的核心知识点:搞懂这些,你也能写这类工具
这款工具的设计,用到了高性能网络编程的核心知识点,也是做高并发程序的通用思路:
1. IO模型的选择:epoll是高并发的"最优解"
常见的IO模型有3种,对比一下就知道epoll的优势:
- 阻塞IO:一个连接卡壳,整个程序都等,并发数超不过几百;
- select/poll:能管多连接,但要轮询所有连接,连接多了(上千)效率暴跌;
- epoll:只关注"有事件的连接",连接数越多优势越明显,轻松管上万并发。
这也是Nginx、Redis这类高并发服务都用epoll的原因------压测工具要测这类服务,自己也得用epoll,不然根本跟不上。
2. 多线程的核心:避免"抢数据"
多线程共享数据时,必须用原子操作或锁,不然会出现"数据不一致"。比如两个线程同时给"总请求数"加1,不用原子操作的话,可能最后只加了1,统计结果就错了。
3. 非阻塞IO:不让单个连接拖慢整体
所有网络连接设为非阻塞(fcntl设置O_NONBLOCK),比如发起连接时不用等结果,epoll会在连接建好后通知;发/收数据时能处理多少就处理多少,不会因为一个连接的网络慢,导致整个事件循环卡住。
4. HTTP协议极简应用
压测用的是最简单的HTTP 1.0请求(没有长连接、没有复杂请求头),只保证"能发请求、能判响应对错"------核心是测性能,不是测HTTP协议的兼容性,减少工具自身的开销。
六、设计思路的核心价值:测准服务的真实性能
这款工具的设计本质,就是"让压测工具自身的性能适配硬件和业务场景":
- 多线程利用多核CPU:避免工具自身CPU瓶颈,能"喂饱"被测服务;
- epoll+非阻塞IO:高效管理大量并发连接,模拟真实的高并发场景;
- 极简的请求处理:减少工具自身的开销,把资源都用在"发请求、收响应"上。
举个实际例子:用单线程工具测8核的Nginx服务,工具占满1个核,测出来RPS只有8000;用8线程的工具测,能占满8个核,测出来RPS能到13000------这才是Nginx的真实性能。
总结
高性能HTTP压测工具的设计,核心就三个关键点:
- 多线程充分利用多核CPU,避免工具自身成为性能瓶颈;
- epoll+非阻塞IO高效管理高并发连接,适配事件驱动型服务的特性;
- 原子操作保证多线程数据统计的准确性,极简HTTP处理减少工具开销。
Welcome to follow WeChat official account【程序猿编码】