在软件开发的生命周期中,性能压测往往是上线前的最后一道关卡。许多测试人员在进行压测时,往往陷入"脚本一把梭"的误区——把所有逻辑塞进一个文件,换个环境就得改二十处,测试结果不可复用,场景切换全靠复制粘贴。本文记录一套可直接复用的 Locust 压测脚手架,让你从压测游击队升级为压测正规军。
我们将以 Python 生态中知名的压测框架 Locust 为核心,手把手带你从零搭建一套企业级压测项目。
一、为什么选择 Locust
市面上的压测工具不少,JMeter 功能强大但界面笨重,Loadrunner 性能不错但授权费对个人开发者来说还是比较贵的,Locust 则是一个介于两者之间的选择——免费、基于 Python、协程高并发、分布式支持好,且二次开发门槛低。
| 工具 | 费用 | 语言 | 并发模型 | 分布式 | 学习成本 |
|---|---|---|---|---|---|
| Locust | 免费 | Python | 协程 | 原生支持 | 低 |
| JMeter | 免费 | Java | 线程 | 支持 | 中 |
| Loadrunner | 商业付费 | C | 进程 | 原生支持 | 高 |
Locust 基于协程实现,这意味着单台机器能模拟的并发用户数远超基于线程的方案。对于需要超过 500 RPS 的单接口测试,Locust 的 FastHttpUser 模式性能通常是普通 HttpUser 的 5-10 倍。
二、压测时,你是否被这些问题困扰过
接手一个压测需求,代码散落在各处——认证逻辑在 A 文件,测试数据在 B 文件,主流程又在 C 文件。换个环境,地址、端口、账号密码要改十几处。压到一半发现数据不够用了,临时手动补充。跑场景测试时,多个接口的调用比例靠注释写着"70%读30%写"来控制。
这些问题背后是同一个根因:没有一套规范化的压测项目结构。
既然痛点这么多,我们干脆整理了一套适用于大多数业务场景的 Locust 压测项目。目录结构清晰、配置集中管理、数据与代码分离,换项目时只需要改配置,不用动核心代码。
三、项目初始化
3.1 标准目录结构
一个规范的 Locust 压测项目应该有清晰的目录分层:
{project_name}/
├── config/ # 配置层:所有环境相关配置
│ ├── __init__.py
│ ├── settings.py # 环境地址、API路径、压测参数
│ ├── db_config.py # 数据源连接配置
│ └── test_data.py # 测试数据生成器 + 用户会话
├── common/ # 公共基础层:所有用户类的父类
│ ├── __init__.py
│ ├── base_user.py # BaseApiUser 基础用户类
│ └── utils.py # 工具函数
├── api_tests/ # 用户类层:单接口压测
│ ├── __init__.py
│ └── {module}_api.py
├── scenario_tests/ # 用户类层:场景化压测
│ ├── __init__.py
│ └── {scenario}_scenario.py
├── locustfile.py # 主入口文件
├── requirements.txt
└── README.md
这个目录结构的分层逻辑很清晰:配置层提供运行时参数,公共基础层封装通用能力,用户类层实现具体业务,压测时只需要写用户类,不需要重复造轮子。
3.2 项目架构图
┌─────────────────────────────────────────────┐
│ locustfile.py │ ← 主入口层
│ (定义 User 类、分发任务、注册钩子) │
└────────────────────┬────────────────────────┘
│ 继承
┌────────────────────▼────────────────────────┐
│ api_tests / scenario_tests │ ← 用户类层
│ (业务-specific 用户类,写压测逻辑) │
└────────────────────┬────────────────────────┘
│ 继承
┌────────────────────▼────────────────────────┐
│ common/base_user.py │ ← 公共基础层
│ (BaseApiUser: 认证、请求、校验通用封装) │
└────────────────────┬────────────────────────┘
│ 调用
┌────────────────────▼────────────────────────┐
│ config/settings.py | db_config.py │ ← 配置层
│ config/test_data.py | .env │
└─────────────────────────────────────────────┘
3.3 依赖安装
pip install locust faker python-dotenv pymysql redis
# requirements.txt
locust>=2.15.0
faker>=18.0.0
python-dotenv>=1.0.0
pymysql>=1.1.0
redis>=4.5.0
四、配置层设计
4.1 settings.py:环境与压测参数
# config/settings.py
import os
from dotenv import load_dotenv
load_dotenv() # 加载 .env 文件
# ===== 环境配置 =====
ENV = os.getenv("ENV", "dev")
BASE_URLS = {
"dev": "http://localhost:8080",
"test": "http://test-api.example.com",
"prod": "http://api.example.com",
}
BASE_URL = BASE_URLS.get(ENV, BASE_URLS["dev"])
# ===== API 路径配置 =====
API_PATHS = {
"login": "/api/v1/auth/login",
"get_user_info": "/api/v1/user/info",
"create_order": "/api/v1/order/create",
"pay_order": "/api/v1/order/pay",
}
# ===== 压测参数 =====
STRESS_CONFIG = {
"single_api": {"users": 100, "spawn_rate": 10, "duration": 60},
"scenario": {"users": 200, "spawn_rate": 20, "duration": 300},
"peak": {"users": 500, "spawn_rate": 50, "duration": 600},
}
# ===== 性能阈值 =====
PERFORMANCE_THRESHOLDS = {
"avg_response_ms": 200,
"p95_response_ms": 500,
"error_rate": 0.01,
}
REQUEST_TIMEOUT = 10 # 请求超时时间(秒)
4.2 多环境配置切换
开发环境、测试环境、生产环境的地址、账号、密码不可能写死在代码里。通过 .env 文件管理这些敏感配置:
# .env 文件(禁止提交到 Git)
ENV=dev
DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PASSWORD=your_password_here
REDIS_HOST=localhost
REDIS_PORT=6379
# 在 settings.py 顶部添加
from dotenv import load_dotenv
load_dotenv() # 自动加载 .env 文件中的环境变量
安全规范:
.env文件严禁提交到 Git 仓库。团队共享配置时使用.env.example文件(不含真实密码)。
4.3 日志配置
压测脚本应使用 logging 模块替代 print,避免高并发下的 IO 性能损耗:
import logging
logging.basicConfig(
level=logging.WARNING, # 压测时设为 WARNING,调试时设为 INFO
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
| 场景 | 日志级别 | 说明 |
|---|---|---|
| 正式压测 | WARNING | 只记录异常和错误,最小 IO 开销 |
| 调试阶段 | INFO | 记录关键流程信息 |
| 问题排查 | DEBUG | 记录详细请求/响应信息 |
五、公共基础层设计
5.1 BaseApiUser 核心类
BaseApiUser 是所有业务用户类的父类,封装了认证、请求发送、响应校验等通用逻辑。用户类只需要继承它,然后实现具体的压测任务。
# common/base_user.py
import time
import logging
import random
from locust import HttpUser, FastHttpUser, between, constant_throughput, constant_pacing
from config.settings import API_PATHS, REQUEST_TIMEOUT
from config.test_data import TestDataGenerator, UserSession
logger = logging.getLogger(__name__)
# ===== 性能模式切换 =====
# 中低并发(< 500 RPS)使用 HttpUser
# 高并发(> 500 RPS)切换为 FastHttpUser,性能提升 5-10 倍
_BASE_CLASS = FastHttpUser # 默认高性能模式
BaseApiUser 的核心方法有两个:on_start 处理用户启动时的初始化(获取身份、登录认证),on_stop 处理资源回收。
class BaseApiUser(_BASE_CLASS):
"""API 压测基础用户类"""
abstract = True
# ===== 吞吐量模型 =====
# Closed Model(模拟真实用户行为):
wait_time = between(0.5, 2)
# Open Model(测试服务端极限):
# wait_time = constant_throughput(1) # 每用户每秒 1 请求
def on_start(self):
"""用户启动时初始化:获取身份 + 认证"""
user_info = TestDataGenerator.get_random_user()
self.session = UserSession(user_info)
self.login()
def on_stop(self):
"""用户结束时资源回收"""
if hasattr(self, 'session'):
self.session.cleanup()
def login(self):
"""调用认证接口获取 token"""
login_path = "/api/v1/auth/login"
login_params = {
"user_id": self.session.user_id,
"device_id": self.session.device_id,
}
with self.client.get(
login_path, params=login_params, name="[Auth] Login", catch_response=True
) as response:
if response.status_code == 200:
result = response.json()
if result.get("code") == 0:
self.session.auth_token = result.get("result", {}).get("token", "")
response.success()
else:
response.failure(f"Login failed: {result.get('message')}")
统一请求方法 api_request() 是 BaseApiUser 的核心,它处理请求发送、响应校验、SLA 断言、网络异常重试等逻辑:
def api_request(self, method: str, api_key: str, params: dict = None,
data: dict = None, name: str = None,
sla_ms: int = 0, retries: int = 0) -> dict:
"""
统一 API 请求方法
Args:
method: 请求方法 (GET/POST)
api_key: API_PATHS 中的 key
params: URL 查询参数
data: POST 表单数据
name: 请求名称(用于统计分组)
sla_ms: 响应时间 SLA 阈值(毫秒),超过则标记为失败
retries: 网络异常重试次数
"""
path = API_PATHS.get(api_key)
if not path:
raise ValueError(f"Unknown API key: {api_key}")
request_name = name or api_key
for attempt in range(1 + retries):
with self.client.request(
method=method, url=path, params=params, data=data,
headers=self.get_headers(), name=request_name,
catch_response=True, timeout=REQUEST_TIMEOUT,
) as response:
try:
if response.status_code == 200:
# SLA 断言
if sla_ms and response.elapsed.total_seconds() * 1000 > sla_ms:
response.failure(f"SLA breach: {response.elapsed.total_seconds()*1000:.0f}ms > {sla_ms}ms")
result = response.json()
if result.get("code") == 0 or result.get("success"):
response.success()
return result
else:
response.failure(f"Business error: {result.get('msg')}")
return result
elif response.status_code in (502, 504) and attempt < retries:
# 网关临时错误 — 可重试
response.failure(f"HTTP {response.status_code} (retry)")
time.sleep(0.5 * (attempt + 1))
continue
else:
response.failure(f"HTTP {response.status_code}")
return {}
except (ConnectionError, TimeoutError) as e:
if attempt < retries:
response.failure(f"Network error (retry): {e}")
time.sleep(0.5)
continue
response.failure(f"Network error: {e}")
return {}
return {}
def get_headers(self) -> dict:
"""构建请求头(认证 token + 追踪 ID)"""
return {
"Content-Type": "application/x-www-form-urlencoded",
"X-Request-Id": f"{self.session.user_id}_{int(time.time() * 1000)}",
"Authorization": f"Bearer {self.session.auth_token}",
}
def get_api(self, api_key: str, params: dict = None, name: str = None) -> dict:
"""GET 请求"""
return self.api_request("GET", api_key, params=params, name=name)
def post_api(self, api_key: str, data: dict = None, name: str = None) -> dict:
"""POST 请求"""
return self.api_request("POST", api_key, data=data, name=name)
5.2 工具函数
common/utils.py 中放置一些通用的辅助函数:
# common/utils.py
import time
import random
def random_sleep(min_sec: float = 0.1, max_sec: float = 0.5):
"""随机等待,模拟用户思考时间"""
time.sleep(random.uniform(min_sec, max_sec))
class Pacing:
"""
节奏控制器,确保每个用户在固定时间窗口内执行固定次数的场景
用法:
pacing = Pacing(interval=6.0) # 每 6 秒执行一次
# ... 执行 Task ...
pacing.wait() # 自动补足剩余等待时间
"""
def __init__(self, interval: float):
self.interval = interval
self._start_time = time.time()
def reset(self):
self._start_time = time.time()
def wait(self):
elapsed = time.time() - self._start_time
remaining = self.interval - elapsed
if remaining > 0:
time.sleep(remaining)
self.reset()
Pacing vs wait_time:
wait_time控制请求间隔,Pacing控制整轮场景的循环频率。当业务要求"每用户每分钟执行 10 次场景"时,应使用Pacing。
六、用户类层设计
具体业务的用户类继承 BaseApiUser,只需要实现压测任务逻辑。以订单场景为例:
# api_tests/order_api.py
from locust import task, between
from common.base_user import BaseApiUser
class OrderApiUser(BaseApiUser):
"""订单接口压测用户"""
wait_time = between(1, 3)
@task(3)
def get_order_list(self):
"""获取订单列表(权重 3)"""
self.get_api("get_order_list", name="[Order] Get List")
@task(1)
def create_order(self):
"""创建订单(权重 1)"""
data = {"product_id": 1001, "quantity": 1}
self.post_api("create_order", data=data, name="[Order] Create")
七、测试数据管理
7.1 TestDataGenerator 数据生成器
TestDataGenerator 提供数据池懒加载、随机获取、模拟数据降级等能力:
# config/test_data.py
import random
import threading
from collections import deque
from faker import Faker
fake = Faker()
class TestDataGenerator:
"""测试数据生成器"""
# 类级数据池(懒加载,所有用户共享)
USER_POOL = []
ENTITY_POOL = []
RUNTIME_POOL = []
# 一次性数据队列(线程安全)
UNIQUE_DATA_QUEUE = deque()
_queue_lock = threading.Lock()
# 分布式分片支持
_worker_index = 0
_worker_count = 1
@classmethod
def load_from_db(cls, limit=10000):
"""从数据库加载测试数据,失败时降级为模拟数据"""
if cls.USER_POOL:
return
try:
import pymysql
from config.db_config import MYSQL_CONFIG
conn = pymysql.connect(**MYSQL_CONFIG)
cursor = conn.cursor()
cursor.execute("SELECT id, username FROM users LIMIT %s", (limit,))
for row in cursor.fetchall():
cls.USER_POOL.append({
"user_id": str(row[0]),
"account_id": str(row[0]),
})
cursor.close()
conn.close()
except Exception as e:
cls._generate_mock_data()
@classmethod
def _generate_mock_data(cls):
"""降级:生成模拟测试数据"""
for i in range(100):
cls.USER_POOL.append({
"user_id": f"mock_user_{i:04d}",
"device_id": f"device_{i:04d}",
"account_id": f"account_{i:04d}",
})
for i in range(1000):
cls.ENTITY_POOL.append({"entity_id": i + 1, "owner_id": f"mock_user_{i % 100:04d}"})
@classmethod
def get_random_user(cls) -> dict:
"""获取随机用户信息"""
if not cls.USER_POOL:
cls.load_from_db()
return random.choice(cls.USER_POOL)
@classmethod
def setup_worker_shard(cls, worker_index: int, worker_count: int):
"""分布式模式下按 Worker 编号切片数据,避免多 Worker 冲突"""
cls._worker_index = worker_index
cls._worker_count = worker_count
if cls.USER_POOL and worker_count > 1:
cls.USER_POOL = [u for i, u in enumerate(cls.USER_POOL) if i % worker_count == worker_index]
cls.ENTITY_POOL = [e for i, e in enumerate(cls.ENTITY_POOL) if i % worker_count == worker_index]
7.2 UserSession 用户会话
UserSession 跟踪单个虚拟用户在压测过程中的运行时状态:
class UserSession:
"""用户会话管理"""
__slots__ = ('user_id', 'device_id', 'account_id', 'auth_token',
'created_entities', 'related_users', 'collected_items')
MAX_LIST_SIZE = 100 # 列表最大长度,防止长时间压测 OOM
def __init__(self, user_info: dict):
self.user_id = user_info.get("user_id")
self.device_id = user_info.get("device_id")
self.account_id = user_info.get("account_id")
self.auth_token = user_info.get("auth_token", "")
self.created_entities = []
self.related_users = []
self.collected_items = []
def _trim_list(self, lst: list) -> list:
"""列表超长时截断,防止浸泡测试 OOM"""
if len(lst) > self.MAX_LIST_SIZE:
return lst[-self.MAX_LIST_SIZE // 2:]
return lst
def add_entity(self, entity_id):
self.created_entities.append(entity_id)
self.created_entities = self._trim_list(self.created_entities)
def cleanup(self):
self.created_entities.clear()
self.related_users.clear()
self.collected_items.clear()
八、实战演示
8.1 最小可运行示例
# locustfile.py
from locust import HttpUser, task, between
class QuickStartUser(HttpUser):
"""最小压测示例:5 秒快速验证接口连通性"""
wait_time = between(1, 2)
@task
def hello(self):
self.client.get("/api/health")
启动命令:
locust -f locustfile.py --host=http://localhost:8080
浏览器打开 http://localhost:8089 查看 Web UI,开始压测。
8.2 完整项目启动
# 单接口压测
locust -f api_tests/order_api.py \
--headless \
--users 100 \
--spawn-rate 10 \
--run-time 60s \
--host=http://test-api.example.com
# 输出 CSV 报告
locust -f api_tests/order_api.py \
--headless \
--users 200 \
--spawn-rate 20 \
--run-time 300s \
--host=http://test-api.example.com \
--csv=reports/single_api/order_test
8.3 分布式压测
# Master 节点
locust -f locustfile.py \
--master \
--host=http://test-api.example.com
# Worker 节点(在同一台或不同机器上)
locust -f locustfile.py \
--worker \
--master-host=<master_ip>
九、常用命令行参数
| 参数 | 说明 | 示例 |
|---|---|---|
-f | 指定压测文件 | -f api_tests/order_api.py |
-u | 虚拟用户数 | -u 500 |
-r | 用户 spawn 速率(每秒启动用户数) | -r 50 |
--run-time | 压测持续时间 | --run-time 300s |
--headless | 无 GUI 模式(CI/CD 集成必备) | --headless |
--host | 目标主机地址 | --host=http://api.example.com |
--csv | 输出 CSV 格式报告 | --csv=reports/test |
--master | 分布式 Master 模式 | --master |
--worker | 分布式 Worker 模式 | --worker --master-host=127.0.0.1 |
十、避坑指南
1. 数据库连接池耗尽
- 现象:压测一段时间后接口开始大量超时
- 原因:每个虚拟用户都保持一个数据库连接,万级用户时连接数爆表
- 解决:使用连接池(SQLAlchemy Pool)或将数据预加载到内存
2. 认证 token 过期
- 现象:压测后期成功率下降
- 原因:token 有时效性,长时间压测后 token 失效
- 解决:在 BaseApiUser 中定期刷新 token,或缩短单次压测时长
3. 数据不够用
- 现象:某些接口返回"数据不存在"错误
- 原因:测试数据量小于虚拟用户数或请求量
- 解决:增加
load_from_db的 limit,或开启UNIQUE_DATA_QUEUE队列模式
总结
本文从痛点出发,介绍了如何从零搭建一套企业级 Locust 压测项目。核心要点:
| 层次 | 核心文件 | 职责 |
|---|---|---|
| 配置层 | settings.py、db_config.py、test_data.py | 环境配置、数据源、压测参数 |
| 公共基础层 | common/base_user.py | 认证、请求、校验通用封装 |
| 用户类层 | api_tests/、scenario_tests/ | 具体业务压测逻辑 |
| 主入口 | locustfile.py | 用户类定义、任务分发 |
相关技术栈: Python / Locust / FastHttpUser / Faker / python-dotenv