☰
分布式压测实战:用Locust协程架构实现十万级并发
2026/10/7 4:11:30 网站建设 项目流程

欢迎来到一期聊到极致并发的话题。老读者都知道,我折腾压测工具已经有几年时间,平时最常用的是 JMeter,但只要是涉及十万级用户并发这类需求,我会毫不犹豫把 Locust 从工具链里拉出来。原因很简单:Locust 基于 Python 的协程机制,天然适合模拟大量轻量级用户,而且分布式架构做得足够干净,Master 负责控制与汇总,Worker 负责跑压力脚本。今天这篇就把从目标拆解到分布式部署、脚本编写、梯度加压、问题排查的完整路径从头到尾捋一遍,希望能帮你少踩几个我已经替你踩过的坑。

先解释一个容易混淆的问题:十万级并发到底指什么。在性能测试里,并发用户数并不等于同时发出的请求数,它通常指“虚拟用户意味着的活跃会话数量”。如果十万个虚拟用户都设置了随机等待时间,实际到达后端的 RPS(每秒请求数)可能只有一两万;如果所有用户都无脑循环调用接口,没有任何 think time,那 RPS 可能冲到几十万,这时候被压垮的往往不是业务系统,而是压测机自身的网络栈、文件描述符和 CPU。所以要实现十万级并发,必须先把业务模型想清楚:是十万在线用户、十万长时间连接(IM、WebSocket 场景),还是十万持续请求?业务模型不同,分布式架构的规模、脚本写法、监控重点全都不一样。

1. 目标拆解:十万级并发背后的真实压力和选型逻辑

性能测试最容易犯的错误是直接套命令,不管底层模型是否匹配。十分钟级并发这个目标听起来很性感,但如果不把业务场景拆细,后面做分布式部署时会发现要么资源浪费,要么压测机本身变成瓶颈。这一节想先把 Locust 的核心模型和分布式架构的逻辑说清楚,因为后面所有操作都建立在“为什么这样做”的基础上。

1.1 十万级并发是什么概念:从业务场景反推测试要求

先说一个我常用的估算方式:如果目标是十万在线用户,每个用户平均每 30 秒执行一次请求,那么系统需要承受的 QPS 大约是 100000 / 30 ≈ 3333;如果每个用户每 3 秒执行一次,QPS 就是 33333。这两个数量级的运维压力完全不同。所以接到“十万并发”需求时,我第一件事不是找机器,而是问需求方:你们说的并发是“在线用户数”还是“活跃连接数”?后台有没有采集业务埋点?平均请求间隔是多少?

只有把这些问题落到数字,才能规划出合理的压测脚本。比如我遇到过一个 IM 类项目,号称要支持十万在线长连接,这种场景用户大部分时间处于 idle 状态,真正每秒推送消息的高峰期,活跃连接也许只有两三千。这种情况下压测的关键就不是把 Locust 的虚拟用户数堆到十万,而是模拟大量 idle 连接,同时保证少量用户高频收发消息时系统能稳定。因此我用 Locust 脚本时会给不同任务分配权重:绝大多数用户执行长连接心跳任务,少部分用户执行消息发送任务,再配合wait_time控制请求间隔,这样得到的十万并发才是贴近真实的模拟。

1.2 Locust凭什么能做分布式:协程模型和主从架构的底层逻辑

Locust 的底层核心是 gevent 协程。每个模拟用户本质上是一个轻量级协程,同一个 Worker 进程里可以轻松创建成千上万个并发协程,而没有线程那种昂贵的上下文切换开销。这也是 Locust 比某些基于线程的压测工具更适合高并发的原因。但协程不是银弹,Python 进程内有 GIL 这一层限制,纯粹靠一个 Worker 进程很难把 CPU 压力拉满,所以 Locust 把复杂度转移到了进程和机器层面。

分布式架构上,Locust 采用经典的主从模型。Master(主节点)负责管理 Web UI、接受测试参数、下发启动指令、汇集 Worker 上报的统计数据;真正产生压力的是 Worker(工作节点),每个 Worker 是一个独立的 Python 进程。Master 默认监听 5557 端口做指令通信,5558 端口接收结果数据。启动时通过--master和--worker参数区分角色,--master-host、--master-port让 Worker 找到 Master。旧的文档和网上很多文章还在用--slave,那是老版本写法,新版本已经全部换成--worker了,照着老教程配置会直接报参数错误。

1.3 单机压测撞墙的四个原因

在把目标定为十万级后,如果还指望一台 32 核机器跑完,通常会在四五个地方撞墙。首先是操作系统文件描述符限制,默认进程可以打开的 FD(文件描述符)数量只有 1024 或 65535 左右,而一个用户如果使用独立连接,十万个用户就需要十万个连接,第一道墙就在这里。其次是端口资源,客户端发起大量短连接时会快速消耗本机临时端口,如果端口范围不够大,会出现Cannot assign requested address或大量TIME_WAIT堆积,连接根本建不起来。第三是 Python 进程本身的 GIL,即使协程再多,10万个协程之间也会有 Python 字节码切换和统计信息上报的成本,单个 Worker 进程抗两三万并发还行,再往上 CPU 就顶满了。最后是带宽和网络栈,压测机到被测系统之间如果只有千兆网,打满也就是一秒钟 100MB 级别,一旦请求响应体较大,很容易把网卡打满,造成响应时间虚高。

因此单机跑十万级并发基本不现实,分布式部署是唯一靠谱的路径。

2. 分布式压测环境搭建:Master-Worker架构从入门到能跑

聊清楚了底层选型,就可以动手搭环境了。很多新手第一次搭 Locust 分布式集群,容易把 Master 也当作压力节点,或者在 Master 上又跑 Web 又跑脚本,结果整个控制台卡死,数据看板掉线。这一节说说怎么搭出一套干净可靠的分布式压测环境。

2.1 最精简的分布式部署:一台Master加N台Worker

先把最核心的部署架构写出来,最小可用集群是一台 Master 加 N 台 Worker。Master 机器配置可以一般,2 核 4G 内存都够用,但最好单独跑,不要在上面启动压测任务,因为 Web UI 和所有 Worker 的结果汇聚都会吃 CPU 和内存。Worker 机器的配置才是重点。

假设我们准备了 6 台 Worker,每台 32 核 64G 内存,操作系统是 CentOS 7 或 Ubuntu 20.04。先安装 Locust:

pip3 install locust

然后在压测脚本目录下分别启动:

# Master 节点 locust -f loadtest.py --master --expect-workers=6 --web-port=8089 --host=https://api.example.com # Worker 节点(6台机器都执行) locust -f loadtest.py --worker --master-host=192.168.1.10 --master-port=5557 --processes=4

这里有几个关键点。--expect-workers=6告诉 Master 预计有多少个 Worker 接入,当 Worker 数量达不到这个值时,Web UI 会显示等待状态,方便在启动前检查集群是否全部就绪。如果省略这个参数,Worker 陆续连上来也能跑,但测试启动时可能因为部分 Worker 掉线造成并发分配不均。--processes=4表示在单台 Worker 机器上额外创建 4 个独立进程,结合机器的 32 核,每个 Worker 进程大约占用 8 核,算是比较合理的配置。

启动顺序上,我习惯先启动 Master,再启动所有 Worker,然后在 Web UI 或通过命令行确认所有 Worker 处于“Ready”状态后再点击“Start”。如果用命令行启动压测,可以:

locust -f loadtest.py --master --expect-workers=6 --run-time 30m -u 100000 -r 2000 --headless

这里-u 100000是虚拟用户总数,-r 2000是每秒启动用户数,--headless表示不启动 Web UI,纯命令行模式适合自动化 CI。

2.2 按CPU核数和业务模型规划Worker数量

Worker 数量怎么定,不能拍脑袋。先要压测脚本平均每个虚拟用户占用的 CPU 开销,我的经验是 FastHttpUser 场景下,一个 32 核 Worker 进程(不额外开--processes)跑 1.5 万到 2 万虚拟用户,CPU 会到 70% 左右。如果加--processes 4,单台机器可以尝试跑到 5 万虚拟用户,但这时候要给 CPU 预留 20% 余量,防止因为调度波动导致响应时间失真。

所以我常用的估算是:十万虚拟用户,一台 32 核 Worker 承担 3 万左右,至少需要 4 台 Worker;如果业务场景包含大量解析 JSON、加密签名等 Python 逻辑,这个容量要再减半,可能要 6 到 8 台。对于 IO 密集但 CPU 逻辑少的纯转发场景,一台 Worker 可以再多扛一些。计算方式如下:单个 Worker 进程能稳定支撑的虚拟用户数 = 单进程可用的 CPU 核心算力 / 每个用户任务的 CPU 消耗系数,这个系数只能靠小规模预压测得出,没有万能公式。

如果你手头机器有限,还可以在一台机器上开多个--processes,相当于把多核 CPU 利用起来。但要注意,进程数不是越多越好,每个 Worker 进程都会建立独立的连接池,进程数过多会造成连接堆积和端口耗尽,反而触发网络栈问题。32 核机器建议--processes 6到--processes 8以内,具体可以压测过程中观察 CPU 使用率来调整。

2.3 网络拓扑和云上部署的几个注意点

分布式压测比单机压测多了一个压测机与压测机之间、压测机与业务系统之间的网络规划。如果 6 台 Worker 在同一个内网,带宽资源可以共享,但要注意别把压测流量打成 P2P 把内网带宽吃满。我一般要求被测系统部署在独立网段,压测集群和被测系统之间至少保留千兆以上带宽;如果压测机在云上,建议使用同一区域、同一 VPC 内固定带宽,避免跨公网导致数据包延迟和丢包,最后把性能数据污染了。

另一个容易忽略的是时间同步。Master 和 Worker 的系统时间要一致,否则统计响应时间、汇总 RPS 数据时会出现错误的数据对齐。简单粗暴的做法是在每台压测机上执行:

sudo ntpdate -u ntp.aliyun.com

或者在云上直接用云平台提供的 NTP 服务。时间同步虽然看起来不影响压测本身,但实验做完回看数据的时候,你不想看到一台 Worker 的统计延迟了 10 秒吧。

3. 写一个能扛住十万级并发的Locust压测脚本

环境搭好之后,核心就是压测脚本。一个写不好的脚本,会在十万虚拟用户场景下把压测机资源直接耗尽,或者在业务侧造成大量重复无效请求。所以脚本必须围绕并发模型和业务场景精细设计。

3.1 HttpUser还是FastHttpUser:并发场景下的选择

如果你是第一次接触 Locust,很可能直接用传统HttpUser。它基于requests库实现,API 友好,能比较方便地处理登录、Cookie、重定向等常见需求。但requests在协程环境下每次请求都要做很多 Python 层面的封装,并发上去后,CPU 开销非常明显,特别是在十万量级下,Worker 的 CPU 会被requests库大量消耗。

因此,只要不是必须用到复杂的请求跟踪、自定义代理或特定重定向逻辑,我都推荐直接用FastHttpUser。它底层使用geventhttpclient,连接复用率更高,请求开销更小,在高并发时能把更多 CPU 留给实际的数据处理任务。下面是一个最小示例:

from locust import FastHttpUser, task, between class ApiUser(FastHttpUser): wait_time = between(0.01, 0.05) @task def get_order(self): with self.client.get("/api/order/1", headers={"Authorization": "Bearer xxx"}, catch_response=True) as resp: if resp.status_code != 200: resp.failure("status code error")

注意FastHttpUser的self.client返回对象与requests的响应对象有些差别。比如判断响应内容时需要调用resp.text或resp.content,大文件下载场景要使用stream=True方式处理。如果在脚本里出现resp.json()后又被忽略返回值,也会白白增加 CPU 开销。

3.2 账号池、数据分片和用户身份隔离

十万虚拟用户如果都用一个账号去压测,业务系统大概率会因为单账号 Token 刷新或 Session 覆盖而返回大量 401。所以压测脚本里必须处理用户身份,并且要在分布式 Worker 之间做数据隔离。

我的做法是准备一个数据文件,比如 CSV 格式的账号列表,里面包含用户名、密码、用户ID,然后启动每个 Worker 时通过环境变量传入两个参数:LOCUST_WORKER_INDEX和LOCUST_WORKER_COUNT。脚本启动时读取这些变量,再把自己负责的账号数据从总数据集中切片取出来。这样每个 Worker 只使用自己分到的账号,既能避免多个协程同时修改同一个账号状态,又可以最大化模拟真实用户。

示例脚本片段:

import os import pandas as pd worker_index = int(os.environ.get("LOCUST_WORKER_INDEX", "1")) worker_count = int(os.environ.get("LOCUST_WORKER_COUNT", "1")) accounts = pd.read_csv("accounts.csv")["account"].tolist() my_accounts = accounts[worker_index-1::worker_count] class RealUser(FastHttpUser): def on_start(self): account = my_accounts.pop() if my_accounts else "fallback_user" # 使用该账号进行登录,刷新 token 到 self.token

注意on_start会在每个虚拟用户启动时被调用一次,如果十万个用户全部重新登录,会对认证系统造成巨大的瞬时分流压力。所以我一般会预生成一批登录 Token,或者在on_start里直接建立会话并缓存。压测脚本的登录逻辑越重,压测系统自身的误差就越大。

3.3 关键配置项和启动命令参数对照

我平时不太记全部参数,但有几个关键配置会写在小本本上,这里整理成一个对照表:

参数/配置作用我的常见值
--master以主控模式启动Master 节点
--worker以负载生成模式启动Worker 节点
--expect-workers等待指定数量 Worker 接入30 或 40,与 Worker 数一致
--processes单机额外创建进程数按核数决定,一般 4 到 8
--run-time设置压测持续时间30m 到 2h
--spawn-rate每秒启动用户数1000 到 5000,视系统承受能力
--headless无 Web UI 模式CI 或自动化脚本常用
--skip-log-setup关闭默认日志,减少 IO压测高峰时开启
--csv定时导出指标 CSV便于后续分析
--web-host绑定 Web UI 地址0.0.0.0方便调试

启动压测时,--spawn-rate尤其重要。十万用户如果一开始就设成-r 100000,所有协程会在几秒内瞬间创建,压测机内存飙升,Master 和 Worker 之间的消息也会出现积压,数据统计直接失真。我实测下来的稳妥做法是先用-r 2000或-r 5000,让系统慢慢爬坡,观察稳定后再通过 Web UI 增加用户数。如果一定要一次到位,可以先调低预期,等集群稳定后再二次调整。

3.4 脚本本身的性能陷阱

脚本不是写完就能跑,我有几个踩过很多次的坑,在这里一次性说透。

第一,不用FastHttpUser时,requests.Session默认会做重定向和历史记录,每次请求都在 Python 层维护 Cookies。压测逻辑中如果不需要重定向,尽量显式设置allow_redirects=False,减少额外请求。

第二,wait_time不要设置成固定值,比如wait_time = 5,这会让所有用户节奏完全一致,产生“波浪效应”,请求会集中在一瞬间打过来。推荐使用between(1, 5)这类范围,或者用自定义函数模拟业务真实思考时间。

第三,catch_response=False是默认行为,响应内容会被丢弃,实际上会好一些;但如果需要用with client.get(...) as resp去判断响应体,就相当于开启响应读取,响应体越大,内存和 CPU 开销越高。压测接口如果响应体动辄几百 KB,脚本的解析开销会反过来限制并发能力。这种情况下,建议在业务测试方案里约定只压测核心逻辑,或者把断言简化成status_code判断,不要用正则表达式全量扫描响应体。

4. 从5000并发到100000并发的执行路径与指标观测

环境搭好了,脚本写好了,接下来就是真正压上十万并发。这不是一次start按钮就完事的事情,而是一个爬坡、观察、调整、再爬坡的过程。

4.1 梯度加压:如何安全地把并发拉上去

我负责的大多数压测项目,都会遵循一个爬坡节奏。假设最终目标是 100000 虚拟用户,那么我会先在 Web UI 或命令行设置 5000,运行 3 到 5 分钟观察系统表现;确认稳定后,再一次性调整到 20000,观察 RPS、错误率、内存变化;再到 50000 稳定后,如果一切正常,最后加到 100000。

为什么不能直接从 0 拉到十倍?一方面是业务系统可能因为缓存预热不够,前期错误率特别高,容易掩盖真正问题;另一方面是 Locust 的 Worker 需要创建大量协程和连接池,瞬间创建十万个协程对 Master 的广播和 Worker 的内存回收都是压力。我曾遇到一次直接设 10 万用户,结果 Master 的 Web UI 直接超时,十几秒低响应,最后只能重启集群。后来我习惯在脚本里用LoadTestShape自定义阶段爬坡策略,比如:

from locust import LoadTestShape class StepShape(LoadTestShape): """ 每 10 分钟加 20000 用户,直到 100000 """ def tick(self): run_time = self.get_run_time() step = int(run_time // 600) user_count = (step + 1) * 20000 if user_count > 100000: return None return user_count, 1000

自定义 Shape 的好处是压测过程完全自动化,不需要人工盯着 Web UI 反复点击增加用户,尤其在过夜或者远程执行时特别有用。

4.2 看哪些指标才能判断压测有效

并发人数只是表象,真正能说明系统瓶颈的指标是 RPS、响应时间分位数、错误率和资源利用率。我在压测过程中最关注的三个数字是:

  • RPS:每秒请求数,代表系统当前实际处理能力;
  • 95 分位响应时间(p95):比平均值更能反映用户真实体验;
  • 错误率:如果错误率超过 0.5%,即使 RPS 很高,测试结果也不算成功。

Locust 的 Web UI 自带这些统计,但如果你的压测机上没有浏览器访问,可以通过--csv参数定期导出 CSV 文件。示例:

locust -f loadtest.py --master --expect-workers=10 --headless -u 100000 -r 5000 --run-time 60m --csv=result --csv-full-history

执行后每 2 秒会生成一个以result为前缀的 CSV 文件,包含当前用户数、请求数、失败数、中位数响应时间、95 分位响应时间等,非常方便结束后做趋势分析。我一直觉得--csv-full-history这个参数很实用,它保留全部历史数据,不会因为滚动窗口丢失早期信息。

4.3 用Grafana把压测数据可视化

如果压测时间长、节点多,光看 CSV 和 Web UI 不过瘾。我通常会用 Locust 插件把统计指标推到 InfluxDB,再用 Grafana 拉出一张实时大盘。插件安装方式很简单:

pip3 install locust-plugins

然后在脚本里注册事件监听器,把每个阶段的用户数、RPS、响应时间、异常数写入 InfluxDB。Grafana 中配置对应的数据源后,可以自定义面板,比如按 Worker 分组查看每个节点的 CPU 和网络指标。这个方案的好处是,压测时不用频繁切换页面,任何人只要打开仪表盘就能实时看到瓶颈出现在哪一层。

需要注意的是,如果压测目标是十万并发放级别,Grafana 和 InfluxDB 本身的采样频率不要设得太高,否则监控系统也会成为资源消耗者。一般 5 秒采样一次就足够了,重点看趋势而不是瞬时的毛刺。

5. 高并发实测中的常见问题与排查实录

再完美的规划也会在实践中翻车。这一节我把自己真实遇到的排查经历整理成速查表,希望能让你少走弯路。

5.1 file descriptor耗尽导致ConnectionError

第一次跑高并发时,Worker 进程突然报大量[Errno 24] Too many open files,随后请求失败率直线上升。排查过程很直接:ulimit -n显示只有 65535,而每个虚拟用户如果建立独立连接,那十万用户就需要十万个文件描述符,单机六个 Worker 进程加起来已经超过系统限制。

解决办法是提高进程级文件描述符上限。启动 Worker 前执行:

ulimit -n 1048576

同时在/etc/sysctl.conf中调高全局限制:

fs.file-max = 2097152 net.ipv4.ip_local_port_range = 1024 65535 net.ipv4.tcp_tw_reuse = 1 net.core.somaxconn = 65535

改完执行sysctl -p生效。这里要特别提醒:net.ipv4.ip_local_port_range的作用是让客户端可以同时发起更多短连接;如果压测使用的是大量短连接,默认的 32768 到 61000 范围很可能不够。但这个参数也不能无限调大,端口范围过大反而会造成系统维护大量TIME_WAIT状态占用内存。

5.2 Worker之间并发分配不均的真实原因

分布式压测还有一个迷惑现象:Master 显示总用户数已经到了 100000,但每个 Worker 的实际用户数差异巨大。有的 Worker 跑了 18000,有的只有 8000。翻看日志发现是因为--expect-workers设置有误,一个 Worker 反复重连后没有完成握手,Master 仍然把用户数平均分配给已连接的 Worker,导致已连接节点压力上涨。

另一种常见原因是 Worker 机器资源不同,但 Locust 默认是不感知机器处理能力的,Master 只会按 Worker 数量平均分配用户。如果一台机器配置较弱,它很快 CPU 打满,请求响应变慢,反而拉低了整轮测试的有效性。我建议在压测前对 Worker 机器做一次规格摸底,如果你混合使用不同规格的机器,可以通过分组或手动指定不同启动参数来达到均衡负载。比如弱机器减少--processes数,强机器增加--processes数,不要让它们裸奔在默认进程数量下。

5.3 RPS和响应时间虚高,先查压测机自己

有次压测系统表现特别好,RPS 高得吓人,响应时间也低得离谱。仔细一查,原来响应体里大部分是静态缓存,业务层根本没走到数据库和核心链路。这类问题属于测试设计目标不明确。但还有一类更隐蔽的问题:压测机自身上限导致被测系统并没有收到全部请求。

当压测机 CPU 打满或者网络带宽打满时,Worker 的发送队列会堆积,Locust 统计的响应时间就包含了在客户端排队等待的时间,这会让 p95 虚高。反过来,如果请求在客户端还没发出去,系统侧可能观察到的 RPS 并没有那么高。所以每次压测结束后,我都会看一眼压测机的 CPU、内存、网卡流量曲线,确认客户端自身没有成为瓶颈。如果 Worker node 的 CPU 持续在 90% 以上,我会增加 Worker 机器或者降低用户数,而不是一味修改被测系统。

5.4 被测系统顶不住时:从Nginx到数据库并发锁

当并发真正拉到十万级别,压测机往往还没出问题,被测系统先崩了。最常见的是 Nginx 配置限制。Nginx 的最大并发连接数约等于worker_processes * worker_connections,默认worker_connections只有 1024,只要并发的 TCP 连接超过这个值,客户端就会收到502或Connection reset by peer。分布式压测前,一定要先和运维确认被测系统 Nginx、网关服务的连接数参数已经被放大,比如worker_connections 65535,否则压力根本打不到后面的应用层。

另外数据库并发锁是更靠后的坎。比如用户表执行UPDATE user SET last_login=? WHERE id=?,十万个用户同时更新,行锁竞争会让数据库的 QPS 卡在一个很低的水平。这时候不要急着骂 Locust,而要先定位锁等待时间。通过SHOW ENGINE INNODB STATUS或数据库慢查询日志,一般能看到大量锁等待。解决思路是减少热点行更新,或者把同一条 SQL 分散到多个分片;如果只是压测场景,可以考虑在测试数据中把用户按分片表分布,减少对同一行记录的竞争。这是被测系统侧的优化,但对于性能测试从业者来说,你必须能一眼分辨出瓶颈是在压测端还是被压端。

5.5 压测结束后的内核参数恢复与结果归档

压测完成后,有一个经常被省略的步骤:恢复压测机的内核参数。如果你在 Worker 上临时调高了fs.file-max和端口范围,压测结束后最好把配置改回默认值,避免把高并发的临时配置带到生产环境或影响后续其他任务。我一般会先把当前配置快照保存下来,压测结束后用快照回滚。

结果归档同样重要。压测报告里除了放 RPS 和响应时间表格,还要附上集群拓扑、Worker 机器规格、脚本版本、被测系统版本。有次我们为了定位一个诡异的内存泄漏,回看几个月前的报告,发现当时用的脚本和现在完全不一样,浪费了大半天。如果你也遇到类似问题,就会明白“把压测环境和脚本版本一起归档”这件事有多重要。

最后再分享一个小技巧:压测十万级并发不是一次性的任务,更建议把整套启动命令、脚本、数据文件、内核配置做成一个 shell 脚本,每次执行时自动检查 Worker 机器数量、文件描述符上限、端口范围、带宽情况。我在实际项目里就用这个方式,把原本需要半小时的手动准备压缩到一分钟,而且出错概率低很多。对你来说,最终的目标不只是一次成功的十万并发压测,而是能把这次经验沉淀成一个可复用的测试资产,以后任何时候需要压测高并发系统,都能一键拉起一套稳定的分布式压测环境。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询