【Python】Locust 压测从0到1实战:企业级压测项目设计

0 阅读11分钟

在软件开发的生命周期中,性能压测往往是上线前的最后一道关卡。许多测试人员在进行压测时,往往陷入"脚本一把梭"的误区——把所有逻辑塞进一个文件,换个环境就得改二十处,测试结果不可复用,场景切换全靠复制粘贴。本文记录一套可直接复用的 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