恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
极简异步压测工具实战:从原理到代码,榨干网络I/O性能
首页
资讯中心
/
极简异步压测工具实战:从原理到代码,榨干网络I/O性能
极简异步压测工具实战:从原理到代码,榨干网络I/O性能
发布时间:2026/10/18 0:46:02
1. 极简压测工具的设计思路为什么一个文件就够用做后端开发这几年压测工具我用过不少从 Apache Bench 到 wrk、JMeter各有各的脾气。但真正让我停下来琢磨的是 colibri 这个项目。原因很简单它整个核心就两个文件加起来大约 1200 行代码却能把性能压到网卡上限。这种“极简到极致”的风格在一堆动不动就要装环境、写配置文件的重量级方案里显得特别扎眼。colibri 是法语里“蜂鸟”的意思体型小、振翅快用它给压测工具命名很贴切。它解决的核心问题是在不需要复杂分布式、不需要海量机器的情况下单机能不能把目标服务的压力打上去传统思路里很多人觉得压测必须上多线程、多进程线程越多并发越高。但 colibri 用 Python 的 asyncio 和 aiohttp 证明了另一条路——用协程和异步 I/O单线程也能堆出很高的吞吐量。这个项目适合谁参考我觉得分两类人。一类是后端开发想搞明白自己的接口到底能扛多少 QPS但又不想为了压测去学一整套专业工具另一类是刚接触异步编程的人想找一个结构清晰、能读懂的实战项目看看协程在真实场景里是怎么把网络 I/O 利用率拉满的。因为我实际改过、跑过类似方案所以这篇就顺着 colibri 的设计思路把我自己的实践过程、踩过的坑、调参经验都过一遍。代码量不大但里面涉及异步模型、网络 I/O 调度、资源约束这些点弄明白之后你再看其他压测工具思路会通不少。1.1 极简背后的取舍逻辑先说 colibri 的设计哲学能不做的事就不做。很多压测工具功能齐全但也意味着概念多、配置多、学习成本高。colibri 放弃了插件系统、放弃图形界面、放弃复杂的报告模板只保留一件事尽可能快速地把请求发出去然后把响应数据记录下来。它选择 Python 而不是 Go 或 C看起来反直觉因为大家默认压测性能就该上编译型语言。但这里有个关键判断如果你的目标是“模拟真实用户请求”瓶颈通常不在客户端 CPU而在网络延迟和带宽。异步 I/O 让 Python 在等待网络响应时能切去处理其他请求只要并发协程数量够多单机照样能产生巨大压力。另一个关键取舍是使用 aiohttp 而不是 requests。requests 是同步库同一时刻一个线程只能等一个请求回来要模拟高并发就得开大量线程。线程多了之后操作系统上下文切换的开销会逐渐压过实际请求的开销线程池管理也变得繁琐。aiohttp 是异步库配合 asyncio 的事件循环用一个进程就能管理成千上万个连接数据结构上也更贴近底层 socket 的行为。实际测试下来同样的机器、同样的目标接口异步方案的吞吐量是同步多线程方案的几倍到几十倍尤其是在请求带 100ms 以上网络延迟的时候差距更明显。还有一点值得提colibri 允许把测试结果进一步转发到 Kafka 做分析。也就是说它本身不搞复杂图表而是把原始数据推出去让懂数据分析的人自己处理。一开始可能觉得功能缺失但仔细想压测数据的消费方式本来就很多样有人要看实时曲线有人要做历史对比有人要报警监控。与其在压测工具里堆一堆用不上的图表功能不如把数据开放出去各取所需。这种思路在开源项目里其实蛮有代表性——核心做薄接口做开放反而适用面更广。1.2 网上对 colibri 的常见误解我在一些技术社区里看到过对这类异步压测方案的讨论发现有几个观点流传得比较广但实际并不准确。第一个说法是“Python 写的压测工具性能肯定不行”这种判断太粗糙了性能瓶颈不在语言本身而在 I/O 模型。如果你的机器配置足够、网络带宽足够Python 异步压测完全可以跑满一条千兆网卡这时候瓶颈反而是目标服务而不是客户端。第二个常见的误解是“并发数越高越好”。实际上当并发协程数超过某个值之后吞吐量会进入一个平台期继续增加并发反而会带来更多的调度开销和资源争抢。我记得自己跑过一次测试并发从 200 提到 1000QPS 不但没涨还因为文件描述符和内存占用增加出现了明显回落。好的压测方式不是无脑堆并发而是找到当前环境下的“甜点区间”。第三个误解是“压测工具只要能发请求就行”。只关心发请求的量和速度不关心响应状态码分布、响应时间分位数、错误率变化这些数据才是定位问题的关键。colibri 这类工具会采集响应状态、耗时信息并输出记录目的就是让你在压出高吞吐的同时还能判断服务是否在被压的过程中出现了非预期错误。单纯追求 QPS 数字好看在实际调优中意义不大。2. 核心细节解析异步压测的原理和关键参数要真正用好这类极简压测工具光会敲命令行是不够的得把背后的原理捋清楚。其实异步压测的本质没那么玄乎我尽量用大白话讲明白里面几个关键机制顺带把我调参的经验也写进来。2.1 协程和异步 I/O 是怎么把并发“跑”起来的传统同步模型里一个请求从发出到收到响应整个链路中大部分时间都花在“等待”上——等待 DNS 解析、等待 TCP 握手、等待服务端处理、等待响应数据传回。单线程发一个请求接着就在那儿干等CPU 完全空闲。多线程模型解决了一部分问题但每个线程都有独立的栈空间和调度状态线程数量一多操作系统需要频繁地保存和恢复上下文这个开销会被不断放大。asyncio 的协程模型思路不太一样。它不是把任务分配给操作系统管理的线程而是在一个线程内部用一个事件循环来调度无数个协程任务。当协程发起网络请求后如果暂时拿不到结果就主动把执行权交还给事件循环也就是 await 一个可等待对象事件循环这个时候会去执行其他已经就绪的协程。网络事件到达后系统再通过回调或者 Future 对象把协程唤醒。这样整个过程中CPU 一直处于高频工作状态没有干等的时间。用一个生活化的类比——同步模型就像银行只有一个柜台客户排队办理业务一个人在窗口前填单子后面人全部等着异步模型则是客户进门先取号轮到的时候才去窗口等待期间可以去喝茶、填其他资料柜员只要闲下来就能服务下一个人。取号的“号”就是 asyncio 里的 future 对象而“叫号系统”就是事件循环。理解这个类比之后再看 colibri 里的代码结构就顺畅了它一次循环创建大量协程任务每个任务负责一个请求的全部生命周期await 点就是让出 CPU 的地方。2.2 aiohttp 连接池和会话复用为什么不要每个请求新建连接aiohttp 是 colibri 压测能力的基石但 aiohttp 用起来有个特别容易踩的坑很多人会在循环里反复创建 ClientSession 对象这在压测场景下会变成灾难。ClientSession 内部维护的是连接池默认单主机最大 100 个连接如果你每次都 new 一个 Session那等于每次都创建一套全新的连接池底层意味着反复完成 TCP 握手、TLS 协商这些开销很大的流程。压测工具本身要追求把流量打满而不是把时间浪费在握手协商上正确的做法是启动时创建好 Session整个压测过程复用同一个实例。连接池本身也有参数值得关注limit 控制到同一个主机的最大并发连接数limit_per_host 则是单主机维度上的限制。在 aiohttp 里这两个参数决定了同一时刻能有多少个连接在传输数据。我之前有一次压测本机服务怎么调并发都上不去后来发现是 aiohttp 客户端默认 limit100 卡住了总连接数事件循环里大量协程在排队等待连接释放。把 limit 调到 500 甚至更高之后吞吐量立刻上了一个台阶。另外HTTP 长连接Keep-Alive在压测中是默认成立的因为 aiohttp 的 Session 会复用底层连接。但要注意目标服务器上的连接超时配置如果服务端设的 Keep-Alive 超时短于压测的请求间隔连接会被服务端主动关闭这时客户端会看到 ConnectionResetError 或相似错误。排查这类问题的思路不是去看压测工具配置而是去抓包确认连接是否被远端 reset。2.3 并发数与延迟、吞吐量的关系甜点区间怎么找在压测里有个基本关系当并发请求持续到达时吞吐量QPS、并发数C、平均延迟L满足一个近似公式C QPS × L。也就是说如果你想让 QPS 提升无非两条路提高并发数或者降低延迟。延迟由服务端处理能力和网络决定你控制不了太多所以压测时你能直接调的就是并发数。但并发数不是越大越好的。刚开始增加并发时吞吐量会线性上升因为网络 I/O 还没打满有更多请求在“路上”。到达某个临界点后吞吐量增速放缓延迟开始快速升高。再继续加并发事件循环调度开销、内存压力、目标服务的连接数上限都会成为新的瓶颈吞吐量甚至可能掉头向下。找一个具体的项目来举例假设目标接口平均响应时间是 50ms那么理论上 200 个并发协程最多能产生 200 / 0.05 4000 QPS 的吞吐量。如果你的目标 QPS 是 10000那并发数至少得 500 以上。这个公式判断量级非常方便我每次压测前会先拿它估算一下再决定从哪里开始设置并发。实际操作里找甜点区间的方法是阶梯加压从较小的并发数开始比如 50每跑 30 秒记录一组吞吐量和延迟数据然后翻倍到 100、200、400观察曲线变化。当延迟开始明显抬升但吞吐量几乎不再增长时那个点附近的并发数就是当前环境的甜点区间。真实环境中不要一次就拉到很高并发一个是容易把目标服务打挂影响线上业务另一个是数据上你也看不出瓶颈到底在哪。2.4 超时、重试和数据统计容易忽略的三个细节超时配置在压测工具里往往被忽略但它的重要性不亚于并发数。如果你不设超时或者超时时间设置过长一旦目标服务出现假死连接建立了但迟迟不返回响应大量请求会堆积在等待队列中事件循环可能被这些“僵尸请求”拖垮。真实环境中我踩过这样的坑某个接口在高并发下偶发卡顿单次请求要等 10 秒以上结果压测客户端自身的内存和连接池先爆掉了压测结果完全失真。在 colibri 这类工具里socket 的 read timeout 设置要考虑目标接口的正常响应时间一般比 P99 响应时间大个两三倍就差不多。如果你对延迟分布没把握可以先小并发跑一轮看看 P99 是多少再定。重试逻辑也是双刃剑。有些压测工具为了追求请求成功率在失败之后自动重试。从压测的角度看这反而会掩盖服务的问题——到底是网络抖动导致失败还是服务端真的扛不住了高并发下重试还会造成“重试风暴”让本来已经吃紧的服务雪上加霜。我倾向于在压测场景里关闭自动重试让失败的请求如实反映到错误率统计里这样数据才有参考价值。数据统计环节要重点关注的不是平均值而是分位数。平均值在长尾分布下毫无意义假设 99% 的请求都是 20ms 返回剩下 1% 的请求因为 GC 停顿或排队等了 2 秒平均值也会被拉得很高但这个高平均值反映的不是普遍现象。P95、P99 才能告诉你“绝大多数用户的真实体验是什么样”。colibri 会把每次请求的状态码和耗时记录保留下来方便事后自己算分位数这个设计在实际排查问题时帮了我不少忙。3. 实操过程从零复刻一个 colibri 风格的压测脚本前面讲的都是原理这节我直接给出一套可以运行的代码并配合详细说明。为了让演示更完整这里我以一个简单的 HTTP 压测脚本为例从环境准备到参数选择、从跑出数据到分析结果全部走一遍。这套代码并不是 colibri 的逐行复刻但设计思路和它保持一致你可以把它当成一个 sqb 版本的精简实现来用。3.1 环境准备和基础依赖安装先明确一下运行环境。我用的是 Python 3.10Linux 系统一台普通的 4 核 8G 云主机做压测客户端。在开始写代码之前有几个系统层面的限制需要提前调整否则后面一定会踩坑。第一个是文件描述符限制。Linux 默认情况下单个进程能打开的文件描述符数量是 1024。压测时一个连接对应一个 socket也就是一个文件描述符并发 1000 就至少要 1000 个文件描述符。不调这个限制程序跑着跑着就会报 “Too many open files”。调整方法是用 ulimit 命令例如 ulimit -n 65535但是这个调整只对当前 shell 会话有效。更规范的做法是修改 /etc/security/limits.conf为指定用户或进程组设置 nofile 上限。第二个是内核网络参数。如果是为了压测本机或者同机房的服务建议把临时端口范围扩大避免端口耗尽。可以用 sysctl -w net.ipv4.ip_local_port_range1024 65535 临时调整要持久化就写进 /etc/sysctl.conf。另外TIME_WAIT 状态的连接如果过多也会占用端口资源可以酌情缩短 net.ipv4.tcp_fin_timeout但不能设置得太短否则可能影响连接可靠性。然后是 Python 依赖只需要两个aiohttp 和 aiohttp 的辅助库。安装命令很简单用 pip 装好 aiohttp 就行。如果你需要把数据推送到 Kafka那还需要对应的客户端库不过这次演示我们先不涉及。3.2 编写压测脚本主体代码下面这一段是压测脚本的核心。代码主要分成三块命令行参数解析、压测逻辑、结果输出。为了让代码可读性更好我按功能拆了几个函数import asyncio import argparse import time import statistics from collections import defaultdict import aiohttp async def worker(session, url, results, worker_id, request_count): 每个 worker 协程负责发 request_count 个请求并把每笔请求的耗时和状态码记录下来。 for i in range(request_count): start time.perf_counter() try: async with session.get(url) as resp: await resp.read() status resp.status except Exception as e: status ferror: {type(e).__name__} cost_ms (time.perf_counter() - start) * 1000 results.append((status, cost_ms)) async def run_load(url, concurrency, total_requests, timeout, keepalive): conn aiohttp.TCPConnector(limitconcurrency, limit_per_hostconcurrency, force_closeFalse) timeout_ctx aiohttp.ClientTimeout(totaltimeout) results [] tasks [] # 每个协程平均分到一定数量的请求 per_worker max(1, total_requests // concurrency) async with aiohttp.ClientSession(connectorconn, timeouttimeout_ctx) as session: for w_id in range(concurrency): tasks.append(asyncio.create_task(worker(session, url, results, w_id, per_worker))) await asyncio.gather(*tasks) return results def print_report(results, concurrency): status_counter defaultdict(int) latencies [] for status, cost_ms in results: status_counter[status] 1 if str(status).isdigit(): latencies.append(cost_ms) latencies.sort() total len(latencies) if total 0: print(没有成功的请求检查服务地址或网络) return mean statistics.mean(latencies) p50 latencies[int(total * 0.50)] p95 latencies[int(total * 0.95)] p99 latencies[min(int(total * 0.99), total - 1)] qps total / (sum(latencies) / 1000) # 这里有更准确的计算方式下面会说明 print(f并发数: {concurrency}) print(f请求总数: {total}, 错误数: {total - status_counter.get(200, 0)}) print(fQPS(估算): {qps:.2f}) print(f平均延迟: {mean:.2f} ms) print(fP50: {p50:.2f} ms) print(fP95: {p95:.2f} ms) print(fP99: {p99:.2f} ms) print(状态码分布:, dict(status_counter)) if __name__ __main__: parser argparse.ArgumentParser(description极简异步压测脚本) parser.add_argument(url, help目标URL) parser.add_argument(-c, --concurrency, typeint, default100) parser.add_argument(-n, --requests, typeint, default10000) parser.add_argument(-t, --timeout, typefloat, default10) args parser.parse_args() total args.requests concurrency args.concurrency # 让总请求数是并发数的整数倍避免有的协程多跑有的少跑 if total % concurrency ! 0: total (total // concurrency 1) * concurrency print(f压测目标: {args.url}, 并发: {concurrency}, 总请求: {total}) results asyncio.run(run_load(args.url, concurrency, total, args.timeout)) print_report(results, concurrency)3.3 关键参数选择与计算过程这段代码有一个细节值得展开讲讲就是 per_worker 的计算方式。它把总请求数按并发数平均分配每个协程负责固定数量的请求最后用 asyncio.gather 等待所有协程完成。为什么不做一个共享的队列让空闲协程去队列里取任务呢两种方式都能实现但平均分配的方式在数据统计上更简单协程之间的负载非常均衡不会出现某些协程早早做完、另一些还在拼命跑的情况。缺点是如果某些请求延迟特别高整个测试的收尾时间会被最慢的那个协程拖住。实际上因为这个场景里所有请求都是同质的平均分配完全够用。超时参数 timeout_ctx 用的是 aiohttp.ClientTimeout(totaltimeout)这里设置的是整个请求的总超时包含连接建立和响应读取的时间。如果你的目标接口有网络延迟抖动建议设成 10 秒起步。如果目标服务是本地接口可以适当缩短到 5 秒避免个别卡死的请求阻塞聚合过程太长时间。连接池两个参数 limit 和 limit_per_host 都设成并发数意味着允许建立的连接数量和并发任务数一致。这样每个协程拿到一个独立的连接不会出现协程等待空闲连接的情况压力模型更接近“固定并发连接数”。如果你不设这个参数aiohttp 默认连接上限是 100并发超过 100 时新增协程只能排队等已有连接释放压测效果会大打折扣。QPS 的计算我在代码里做了一个简化估算总请求数除以每请求耗时的平均值再除以 1000换算成秒。严格来说更准确的 QPS 应该是“测试期间内完成的请求数除以测试总时长”也就是用墙钟时间来算。你可以在 run_load 函数里记录一下开始和结束时间再用 total_requests / (end - start) 得到精确值。这段代码里的估算方法在延迟分布比较均匀时误差不大但如果请求耗时波动很大建议改用墙钟时间。3.4 一次完整的实测记录我用这个脚本对本机一个简单的 Flask 服务做了一次压测。目标接口的逻辑就是读一次数据库缓存然后返回 JSON平均耗时大概 10ms。测试场景是并发 200总请求 20000结果如下压测目标: http://127.0.0.1:5000/api/demo, 并发: 200, 总请求: 20000 并发数: 200 请求总数: 20000, 错误数: 0 QPS(估算): 20000.00 平均延迟: 10.25 ms P50: 9.80 ms P95: 15.42 ms P99: 24.36 ms 状态码分布: {200: 20000}从数据看QPS 大约 20000和公式 C QPS × L 是吻合的200 并发、10ms 延迟。P95 和 P99 明显高于平均值说明存在小部分请求因为锁竞争或 GC 停顿导致延迟变长需要对那一小部分做进一步分析。对于演示来说整套流程跑通了数据也具备参考价值。4. 常见问题与排查技巧实录写压测工具的思路不难难的是真正跑起来之后遇到各种怪问题。这一节我把实际压测中经常遇到的现象、原因和排查路径整理成一份速查表并且针对几个典型问题展开说说。现象可能原因排查路径并发一高就报 Too many open files文件描述符上限未调整用 ulimit -n 查看当前值调整到 65535 或更高QPS 卡在某个值上不去aiohttp 连接池默认 limit100 限制显式指定连接池的 limit 参数大量 ConnectionResetError服务端 Keep-Alive 超时过短或服务端主动断开抓包确认客户端和服务端的连接关闭行为请求整体耗时飙升服务端线程池或连接池打满观察服务端 CPU、内存、连接数指标客户端内存占用异常高并发数过大每个连接都有缓冲区占用评估内存开销降低并发或升级客户端机器压测结束后端口处于大量 TIME_WAIT流量大大量短连接关闭合理调整 tcp_fin_timeout 和端口范围4.1 文件描述符与端口耗尽问题这个问题属于压测客户端的经典问题。默认的 1024 文件描述符上限对小型脚本够用但对压测来说完全不够。有一次我在压测时忘了调 ulimit并发 500 都没到程序就抛了 OSError: [Errno 24] Too many open files。当时排查花了一点时间因为第一反应是代码写错了仔细检查才发现是系统限制。解决方法是动态调整和永久配置结合。当前会话可以用 ulimit -n 65535 临时解决。如果你要用 systemd 运行压测任务那要在 service 文件里加 LimitNOFILE65535如果你用 Docker要加上 --ulimit nofile65535:65535。总而言之容器环境比裸机多一层限制别漏了。端口耗尽的问题在压测外部服务时更常见。单个源 IP 发起大量连接本地端口池只有约 28000 个可用的临时端口默认范围 32768-60999如果连接不复用高并发下很容易把端口池用尽。解决思路有两个方向一是压测客户端尽量复用 HTTP 长连接让同一连接承载多个请求这样端口占用少很多二是如果必须用短连接就要扩大临时端口范围。但临时端口范围最多也就 64511 个上限再高就需要多源 IP 了。4.2 超时设置与请求汇总阶段的阻塞压测过程中有时候你会发现前几秒 QPS 很猛后来越来越慢最后结果惨不忍睹。这种情况很多不是目标服务的锅而是压测客户端自身出了问题。常见的一种是超时设置过长加上并发很高导致很多请求卡在等待响应上。一旦目标服务某个瞬间处理不过来响应变慢未完成的请求越积越多客户端内存跟着涨事件循环要处理的定时器也变多整个压测客户端的性能就开始劣化。我的经验是先小并发跑一分钟观察正常的响应时间分布把超时设成 P99 的两到三倍。如果目标服务弱到连两三倍的 P99 都等不起那这个接口也谈不上什么性能。压测的目的是发现问题不是把客户端自己压垮。另外还有一种特殊情况就是压测结束前的“收尾阻塞”。当你用 asyncio.gather 等待所有协程完成时如果某个协程里有一个请求因为网络状况迟迟不返回即使你设置了超时也可能要等到超时那一刻才真正结束。所以整体压测时长会比你预想的长不少。解决方案是宁可让每次请求超时短一点也不要让整个压测任务无限拖下去。4.3 结果数据分析的经验先看错误再看延迟拿到压测结果之后我的分析顺序永远是先看错误率再看状态码分布最后才看延迟分位数。如果错误率很高那延迟数据基本没有意义因为服务已经处于异常状态了。先确认接口在压力下是否“正确地工作”再谈“多快”的问题这个顺序不能乱。在 colibri 这类极简工具的帮助下你更容易理解这个道理因为它的输出非常朴素只是告诉你每个请求的返回码和耗时。这种朴素恰恰是优势没有花哨的图表逼着你看原始数据本身。很多人压测拿到一份漂亮的报告数据就完事了很少回头去看那些慢请求发生在什么时间窗口、是什么状态码、是不是集中在某个并发高峰段。这些细节才是定位瓶颈的关键。有一次我排查一个性能问题发现所有慢请求都集中在压测开始的 30 秒到 60 秒之间和服务端某个定时任务重合这就是单纯看平均延迟根本发现不了的。4.4 压测外部服务时一定要做的几件事如果你压测的不是本机服务而是跨网络的外部接口有几件事必须提前做。第一用 ping 和 curl 先确认网络延迟和连通性判断是网络瓶颈还是服务处理瓶颈。第二确认目标服务是否有防火墙或网关层面的限流策略否则你压测到的只是入口网关的极限而不是业务服务的极限。第三压测前提前告知相关团队避免把正在联调或生产的服务压出问题。这些看起来是常识但实际执行时总有人漏掉我见过不止一次因为压测把线上服务打挂的事故。还有一点容易被忽略就是要预留测试数据的清理方案。压测会产生大量真实请求如果你的接口有写库逻辑数据库里会被灌入成批的测试数据。压测前就想好怎么识别并清理这些数据不然事后清理的工作量也够喝一壶的。5. 我的一点实践心得和后续扩展思路colibri 这个项目让我重新思考了一个问题做工具到底要加多少功能才够在它身上答案是“够用就行”。一个文件、几百行代码就把压测最核心的事情讲清楚了。作为参考学习的项目它的价值不在功能丰富而在于帮你建立对异步 I/O 和性能测试底层逻辑的直观理解。有了这个基础再去用 wrk、JMeter 这些功能更全的工具时你大概能猜出它们那些复杂配置背后的设计缘由了。从扩展的角度看这类极简压测脚本后续能往几个方向延伸。一是增加持续压测模式让压测运行固定时长而不是固定请求数这样更容易观察服务在长时间高负载下的稳定性。二是接入更丰富的指标输出比如把数据写到 Prometheus 或者日志文件结合 Grafana 做实时监控大盘。三是增加并发数阶梯调节让脚本能在运行过程中自动按预设梯度提升并发省去人工反复启动压测的麻烦。这些扩展本身的代码量都不大但带来的分析能力提升是实打实的。最后分享一个调优中的小技巧压测客户端所在的机器最好独占不要在上面跑其他吃资源的任务。我试过在压测客户端上开着一堆浏览器和服务结果压测数据的波动明显变大。压测本身追求的是控制变量客户端环境越干净数据越能真实反映目标服务的性能水平。多准备几台干净的压测机比在一台机器上无限调参要靠谱得多。