Locust 压测脚本入门¶
Locust 的价值在于"压测脚本就是普通 Python 代码":能写分支、能读数据库、能调 SDK。代价是并发模型容易写错——它是 gevent 协程,不是线程,一次阻塞调用就能把并发打回单线程。下面按一个能跑的真实脚本展开,再讲分布式和踩过的坑。
一、环境与版本¶
python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install "locust>=2.20" requests
locust -V
Locust 2.x 与 1.x 的差异要留意:--no-web 改成 --headless,--csv 输出文件名不变,但移除了一些旧参数。网上大量 1.x 教程会让人照着敲报错。
二、先定压力模型,再写脚本¶
写脚本前必须能回答三个问题,否则压出来的数字没有意义:
- 业务比例:浏览:加购:下单 = 6:3:1,不是平均分配。
- 思考时间:真实用户两次点击间隔 1~3 秒,压测机不模拟这个,RPS 会虚高。
- 目标指标:是压 TPS 上限,还是验证 500 并发下 P95 < 300ms。两者场景设计完全不同。
三、一个可用的基础脚本¶
import random
from locust import HttpUser, task, between, events
class OrderUser(HttpUser):
# 思考时间 1~3 秒,均匀分布
wait_time = between(1, 3)
def on_start(self):
# 登录:失败要显式标记,否则会算成一次成功的业务请求
with self.client.post(
"/api/login",
json={"username": self.username(), "password": "Passw0rd!"},
name="登录",
catch_response=True,
) as resp:
if resp.status_code != 200:
resp.failure(f"login http {resp.status_code}")
self.environment.runner.quit() # 登录都失败,直接停止,别产生脏数据
return
body = resp.json()
if body.get("code") != 0:
resp.failure(f"login biz {body.get('code')}")
self.environment.runner.quit()
return
self.token = body["data"]["token"]
self.client.headers.update({"Authorization": f"Bearer {self.token}"})
def username(self):
# 从账号池里取,避免几千人共用一个账号导致缓存命中虚高
return f"qa_user_{random.randint(1, 200):03d}"
@task(6)
def browse(self):
page = random.randint(1, 20)
self.client.get(f"/api/products?page={page}", name="商品列表")
@task(3)
def add_cart(self):
with self.client.post(
"/api/cart", json={"skuId": random.randint(1000, 1100), "count": 1},
name="加购", catch_response=True,
) as resp:
if resp.status_code == 409:
resp.success() # 库存不足是业务正常分支,不算失败
elif resp.status_code != 200:
resp.failure(f"add_cart {resp.status_code}")
@task(1)
def create_order(self):
with self.client.post(
"/api/orders", json={"skuId": 1001, "count": 1},
name="下单", catch_response=True,
) as resp:
data = resp.json() if resp.status_code == 200 else {}
if data.get("code") != 0:
resp.failure(f"order failed: {resp.text[:120]}")
三个容易写错的点:
name=必须加。否则/api/products?page=1到page=20会被统计成 20 个独立接口,P95 全被拆散。catch_response=True之后必须调用resp.success()或resp.failure(),否则请求会被记为成功但没有任何断言。on_start里不要做重活(比如拉全量商品、建复杂数据)。启动阶段所有用户同时执行,会把 RT 曲线打出一个假尖刺。
四、定速:constant_throughput 与 constant_pacing¶
要"恒定 RPS"而不是"恒定用户数",用内置等待策略:
from locust import HttpUser, task, constant_throughput, constant_pacing
class FixedRpsUser(HttpUser):
# 每个用户每秒最多发 1 个请求;配合 -u 50 就是约 50 RPS
wait_time = constant_throughput(1)
class FixedIntervalUser(HttpUser):
# 每个用户每 2 秒发一次(固定节奏,不受响应时间影响)
wait_time = constant_pacing(2)
区别:
| 策略 | 语义 | 适用 |
|---|---|---|
between(a, b) |
随机等待 | 模拟真实用户 |
constant_throughput(n) |
每用户每秒 n 次,会自动扣掉响应时间 | 恒定 RPS |
constant_pacing(t) |
每用户固定 t 秒一轮 | 恒定节奏、定时任务 |
constant_throughput 的实际 RPS = 用户数 × n,但响应变慢时会自动缩短等待,所以它是"最少 n 次/秒",不是精确值。要精确控制 RPS,用 LoadTestShape 反推用户数。
五、自定义指标:事件钩子¶
Locust 默认只统计请求级指标。要做"登录成功率""下单转化率",用事件钩子自己记:
from locust import events
order_success = 0
order_total = 0
@events.request.add_listener
def on_request(request_type, name, response_time, response_length,
response, context, exception, **kwargs):
global order_success, order_total
if name == "下单":
order_total += 1
if exception is None and response is not None and response.status_code == 200:
order_success += 1
@events.quitting.add_listener
def on_quit(environment, **kwargs):
if order_total:
rate = order_success / order_total * 100
print(f"[自定义指标] 下单成功率 {rate:.2f}% ({order_success}/{order_total})")
# 成功率低于阈值时让压测失败,方便 CI 卡口
if rate < 99:
environment.process_exit_code = 1
注意:events.request 的回调是全局串行执行的,里面不要做 IO(写文件、发 HTTP)。要落盘就用内存计数,退出时统一写。多 worker 时计数器是各进程独立的,需要汇总就用 master 的 --csv 结果。
六、分布式与多进程¶
# 单机多核:用 --processes 打满 CPU(-1 表示等于核数)
locust -f locustfile.py --headless -u 2000 -r 100 -t 10m \
--processes -1 --host https://api.example.com --csv=run1
# 多机分布式:master 只做汇总,不产生压力
locust -f locustfile.py --master --master-bind-port 5557 \
--expect-workers 3 --host https://api.example.com
# 每台 worker
locust -f locustfile.py --worker --master-host 192.168.1.10 --master-port 5557
要点:
--expect-workers 3会让 master 等到 3 个 worker 连上才开始,否则前 10 秒的 RPS 曲线是残缺的。- 脚本、数据文件、Python 依赖版本必须在所有 worker 上一致。版本不一致会出现"某些 worker 报
KeyError"这种难查的问题。 - 分布式模式下 master 不产生压力,但 CSV 只在 master 上生成,worker 不需要挂盘。
- 容器里跑 worker 记得
--master-host用服务名而不是127.0.0.1。
七、负载形状:阶梯加压与自动停止¶
from locust import LoadTestShape
class StepShape(LoadTestShape):
stages = [
{"duration": 300, "users": 100, "spawn_rate": 10},
{"duration": 600, "users": 300, "spawn_rate": 20},
{"duration": 1200, "users": 500, "spawn_rate": 20},
{"duration": 1800, "users": 800, "spawn_rate": 40},
]
def tick(self):
run_time = self.get_run_time()
for stage in self.stages:
if run_time < stage["duration"]:
return stage["users"], stage["spawn_rate"]
return None # 返回 None 自动结束压测
配合阈值退出:
@events.quitting.add_listener
def abort_on_error(environment, **kwargs):
stats = environment.stats.total
if stats.fail_ratio > 0.01: # 失败率 > 1% 直接判失败
print(f"失败率 {stats.fail_ratio:.2%} 超阈值")
environment.process_exit_code = 1
这才是"压测能进 CI"的关键:有明确的通过/失败判定,而不是让人看图表。
八、真实踩过的坑¶
8.1 端口耗尽:Cannot assign requested address¶
现象:并发上到 800 左右,开始大量 ConnectionError,服务端却毫无压力。
根因:客户端 TIME_WAIT 占满本地端口。默认 ip_local_port_range 只有约 2.8 万个端口,短连接 + 高并发很快耗尽。
# 查看
ss -s | grep timewait
cat /proc/sys/net/ipv4/ip_local_port_range
# 调整(压测机侧,临时生效)
sysctl -w net.ipv4.ip_local_port_range="1024 65535"
sysctl -w net.ipv4.tcp_tw_reuse=1
sysctl -w net.ipv4.tcp_fin_timeout=15
sysctl -w net.core.somaxconn=65535
ulimit -n 65535
更好的办法是复用连接:Locust 的 HttpUser.client 本身就是 requests.Session,会复用连接。但如果你在 task 里自己 requests.get(...),每个请求都是新连接,端口必炸。
8.2 gevent 阻塞:并发上不去,CPU 只跑满一个核¶
现象:-u 1000 但 RPS 只有 50,CPU 单核 100%。
根因:在协程里调用了同步阻塞库(pymysql、boto3、time.sleep 之外的 requests 之外的 C 扩展)。
# 错误:同步 MySQL 驱动会阻塞整个 event loop
import pymysql
conn = pymysql.connect(...)
# 正确:monkey patch 必须在所有 import 之前
import gevent.monkey
gevent.monkey.patch_all()
import pymysql # patch 之后再导入
排查方法:压测时 py-spy dump --pid <locust_pid>,看协程卡在哪个栈。
8.3 统计项被打散¶
self.client.get(f"/api/item/{id}") 不加 name,1000 个 ID 就是 1000 行统计,P95 全是单样本。所有带路径参数的请求都必须加 name。
8.4 on_start 失败被当成正常¶
默认情况下 on_start 里的请求失败不会让用户退出,也不会让压测失败。大量用户"登录失败但继续跑",结果看起来 RPS 很高、错误率很低,实际全是未登录请求打 401。必须在 on_start 里显式 failure()。
8.5 CSV 看不懂¶
--csv=run1 生成三个文件:
| 文件 | 内容 |
|---|---|
run1_stats.csv |
各接口的请求数、失败数、中位数、P95、P99、RPS |
run1_failures.csv |
失败原因聚合 |
run1_stats_history.csv |
按时间点的用户数、RPS、响应时间(画曲线用) |
stats_history.csv 才是判断"是否稳定"的依据:只看最终汇总会掩盖中途的尖刺。
8.6 压测机先到瓶颈¶
判断压测机是否成为瓶颈的三个信号:
- 压测机 CPU 用户态 > 80%,且服务端 CPU 不高
- RPS 不再随用户数增长,RT 线性上升
- 压测机
ss -s中timewait持续增长
处理:加 worker、复用连接、关闭详细日志(--loglevel WARNING)、别在压测机上跑监控采集。
九、Locust vs JMeter¶
| 维度 | Locust | JMeter |
|---|---|---|
| 脚本形态 | Python 代码 | GUI + XML |
| 复杂逻辑 | 直接写代码、可调库 | 需 JSR223/插件 |
| 并发模型 | gevent 协程(注意阻塞) | 线程(吃内存) |
| 分布式 | 原生 master/worker | 需配 RMI,坑多 |
| 报告 | Web UI + CSV | 聚合报告 + HTML |
| 精确 RPS | constant_throughput / Shape |
Constant Throughput Timer |
| 适合 | 开发型团队、链路复杂 | 传统团队、需要 GUI |
选型建议:接口链路有签名、加密、动态依赖时用 Locust;只需要压登录/查询这类简单接口时 JMeter 更快。
十、验收清单¶
- 每个 task 都有
name,统计项数量与业务接口数一致 -
on_start失败会让压测停止或标记失败 - 业务失败(code != 0)通过
catch_response显式标记 - 思考时间符合真实用户,不是 0
- 账号按用户分散,不是全员一个账号
- 压测机端口、文件句柄、CPU 已确认不是瓶颈
- 有明确的通过/失败阈值,并能在 CI 中返回非 0
- 保存了
stats_history.csv,能画出随时间变化的曲线