一、问题引入:Locust参数化数据太多时的坑
做接口性能测试时,很多人会用Locust这个工具,它用Python写测试逻辑,上手快还能模拟高并发场景。但很多人会遇到一个头疼的问题:参数化数据量太大的时候,测试要么跑不起来,要么跑起来后性能掉得离谱。
比如我之前帮一个电商项目做活动页的性能测试,需要模拟10万个不同的用户请求,每个请求要带不同的用户ID、手机号、地址这些参数。一开始我把所有参数都存在一个大列表里,结果Locust启动的时候直接卡了十几分钟,跑起来后还经常出现请求超时、失败率飙升的情况。后来才发现,不是Locust本身不行,是我加载和分发参数的方式错了。
二、核心原理:参数化数据加载分发的本质
要解决这个问题,首先得搞明白Locust里参数化数据是怎么运作的。简单说,参数化就是给每个虚拟用户(Locust里叫User)分配一套专属的测试数据,不能重复,也不能错配。
当数据量小的时候,比如只有几千条,不管怎么加载都没问题,但数据量到10万、100万级的时候,问题就来了:首先是加载慢,Python读大文件、处理大列表本身就耗时;其次是分发难,怎么把这么多数据公平地分给所有虚拟用户,还不能重复;最后是内存占比高,大列表会占用大量内存,甚至挤掉Locust本身的运行资源。
三、优化方案1:高效加载大体积参数数据
3.1 别把所有数据一次性读进内存
很多人加载参数的习惯是把整个文件读成一个列表,比如读CSV文件时用pandas.read_csv()然后转成列表,或者用open()函数一次性读所有行。但如果是100万行的CSV,这种方式会把所有数据都存在内存里,内存占用可能达到几百MB甚至上GB,非常浪费。
正确的做法是用迭代器,迭代器不会一次性加载所有数据,而是每次只取一条,用完就释放,内存占用能降到几KB甚至更低。
技术栈:Python(CSV模块、迭代器)
举个完整的示例,比如我们有一个100万行的用户参数CSV,内容是每行一个用户的user_id,phone,address:
import csv
from typing import Iterator
# 定义迭代器类,用来逐行读取CSV,不一次性加载所有数据
class ParamIterator:
def __init__(self, file_path: str):
self.file_path = file_path
self.file = None # 保存打开的文件对象
self.reader = None # 保存CSV读取器
def __enter__(self) -> Iterator[dict]:
# 用上下文管理器打开文件,避免文件泄漏
self.file = open(self.file_path, 'r', encoding='utf-8')
# 用DictReader读取,每行转成字典,key是CSV的表头
self.reader = csv.DictReader(self.file)
# 返回迭代器本身,支持for循环遍历
return self
def __exit__(self, exc_type, exc_val, exc_tb):
# 遍历结束后自动关闭文件
if self.file:
self.file.close()
def __iter__(self) -> Iterator[dict]:
# 实现迭代协议,每次返回一行数据
for row in self.reader:
yield row
# 测试迭代器的使用
if __name__ == "__main__":
# 用上下文管理器打开CSV,自动管理文件打开关闭
with ParamIterator("large_user_params.csv") as params:
# 每次只取一条,不会加载所有数据
for idx, param in enumerate(params):
print(f"第{idx+1}条参数:{param}")
# 测试时只取前10条,避免输出太多
if idx >= 9:
break
这个示例的核心是实现了一个带上下文管理器的迭代器,它只会在需要的时候加载一行数据,内存占用非常小。不管CSV有多大,都能稳定加载。
3.2 选择合适的参数存储格式
除了加载方式,存储格式也很重要。很多人喜欢用JSON存参数,因为JSON转字典方便,但JSON不适合存大体积的结构化数据,解析速度慢,占用内存也大。
如果参数是结构化的(比如每行都是固定的字段),优先用CSV,CSV的解析速度比JSON快3-5倍,占用内存也小。如果参数有嵌套结构,不能用CSV,那可以用Parquet格式,Parquet是列式存储,解析速度快,压缩比高,适合大体积结构化数据。
技术栈:Python(PyArrow库)
比如用Parquet存储参数的示例:
import pyarrow.parquet as pq
from typing import Iterator
# 读取Parquet文件的迭代器,每次取一个批次(比如1000条)
def read_parquet_params(file_path: str, batch_size: int = 1000) -> Iterator[dict]:
# 打开Parquet文件
parquet_file = pq.ParquetFile(file_path)
# 按批次读取,每次取batch_size条
for batch in parquet_file.iter_batches(batch_size=batch_size):
# 把批次转成字典列表
batch_dicts = batch.to_pylist()
# 逐个返回每条参数
for param in batch_dicts:
yield param
# 测试使用
if __name__ == "__main__":
for idx, param in enumerate(read_parquet_params("large_user_params.parquet")):
print(f"第{idx+1}条参数:{param}")
if idx >= 9:
break
Parquet的优势是压缩比高,100万条结构化参数的Parquet文件可能只有几十MB,比CSV还小,解析速度也快,适合超大体积的参数存储。
四、优化方案2:合理分发参数给虚拟用户
加载完参数后,怎么分发是另一个关键。很多人会把参数列表存在全局变量里,然后每个虚拟用户启动的时候随机取一条,这种方式很容易出现重复分配的问题,甚至会导致两个虚拟用户用同一个用户ID请求,测试结果就不准了。
4.1 用队列实现公平不重复分发
队列是解决参数分发的好办法,队列的特点是先进先出,每个虚拟用户从队列里取一条参数,取完就拿走,不会重复。Python的queue.Queue是线程安全的,适合Locust的多虚拟用户场景。
技术栈:Python(queue模块、Locust库)
举个完整的Locust参数分发示例:
import queue
from locust import HttpUser, task, between
from typing import Iterator
# 先加载参数迭代器(复用之前的CSV迭代器)
class ParamIterator:
def __init__(self, file_path: str):
self.file_path = file_path
self.file = None
self.reader = None
def __enter__(self) -> Iterator[dict]:
self.file = open(self.file_path, 'r', encoding='utf-8')
self.reader = csv.DictReader(self.file)
return self
def __exit__(self, exc_type, exc_val, exc_tb):
if self.file:
self.file.close()
def __iter__(self) -> Iterator[dict]:
for row in self.reader:
yield row
# 初始化参数队列,线程安全
param_queue = queue.Queue(maxsize=0) # maxsize=0表示队列大小无限制
# 把参数迭代器里的所有参数放进队列
def init_param_queue(file_path: str):
with ParamIterator(file_path) as params:
for param in params:
param_queue.put(param)
# 定义Locust的用户类
class EcommerceUser(HttpUser):
wait_time = between(1, 3) # 每个任务之间的等待时间,1-3秒随机
# 重写on_start方法,每个虚拟用户启动时执行一次
def on_start(self):
# 从队列里取一条参数,每个虚拟用户专属
self.user_param = param_queue.get(block=True) # block=True表示如果队列空了就等待
@task # 标记这是一个测试任务
def get_user_info(self):
# 用专属的参数发送请求
user_id = self.user_param['user_id']
phone = self.user_param['phone']
# 发送GET请求,把参数放到URL里
self.client.get(f"/api/user/info?user_id={user_id}&phone={phone}")
# 测试初始化队列
if __name__ == "__main__":
# 先初始化参数队列,把CSV里的所有参数放进队列
init_param_queue("large_user_params.csv")
# 可以打印队列的大小,确认参数数量
print(f"参数队列初始化完成,总参数数量:{param_queue.qsize()}")
这个示例的核心是queue.Queue,它是线程安全的,多个虚拟用户同时取参数的时候不会出现竞争问题,也不会重复分配参数。每个虚拟用户启动的时候从队列里取一条,用完就归自己所有,非常适合需要不重复参数的场景。
4.2 虚拟用户数远小于参数量的场景优化
如果虚拟用户数远小于参数量,比如参数有100万条,虚拟用户只有1万个,那不需要把所有参数都放进队列,只需要加载一部分参数,或者用迭代器动态生成参数。
比如可以把参数按虚拟用户数分组,每组一个批次,虚拟用户取完一组后再加载下一组,这样能进一步减少内存占用。
技术栈:Python(Locust库、迭代器)
示例:
import csv
from locust import HttpUser, task, between
from typing import Iterator
# 定义按批次加载参数的迭代器
class BatchParamIterator:
def __init__(self, file_path: str, batch_size: int = 10000): # 批次大小等于虚拟用户数
self.file_path = file_path
self.batch_size = batch_size
self.file = None
self.reader = None
def __enter__(self) -> Iterator[dict]:
self.file = open(self.file_path, 'r', encoding='utf-8')
self.reader = csv.DictReader(self.file)
return self
def __exit__(self, exc_type, exc_val, exc_tb):
if self.file:
self.file.close()
def __iter__(self) -> Iterator[dict]:
# 每次返回一个批次的参数,每个批次大小等于虚拟用户数
batch = []
for row in self.reader:
batch.append(row)
if len(batch) == self.batch_size:
# 批次满了,返回这个批次
yield from batch
# 清空批次,准备下一个
batch = []
# 返回最后一个不满的批次
if batch:
yield from batch
# 初始化批次迭代器,批次大小等于虚拟用户数(比如10000)
batch_params = BatchParamIterator("large_user_params.csv", batch_size=10000)
class EcommerceUser(HttpUser):
wait_time = between(1, 3)
def on_start(self):
# 每个虚拟用户从迭代器取一条参数,自动按批次加载
self.user_param = next(batch_params)
@task
def get_user_info(self):
user_id = self.user_param['user_id']
self.client.get(f"/api/user/info?user_id={user_id}")
if __name__ == "__main__":
# 初始化迭代器,确认能正常取到参数
with batch_params:
first_param = next(batch_params)
print(f"第一个参数:{first_param}")
这个示例的优势是,只有当前批次的参数会被加载,内存占用只有一个批次的大小,适合虚拟用户数远小于参数量的场景。
五、应用场景、优缺点、注意事项总结
5.1 应用场景
这些优化方案适合所有需要大体积参数化的Locust测试场景,比如:
- 电商项目的用户登录、订单提交测试,需要大量不同的用户数据;
- 金融项目的交易测试,需要大量不同的账户、交易数据;
- 内容平台的内容查询测试,需要大量不同的内容ID、分类数据。
5.2 技术优缺点
| 方案 | 优点 | 缺点 |
|---|---|---|
| 迭代器加载CSV | 内存占用极低,加载速度快,实现简单 | 不适合有嵌套结构的参数 |
| Parquet存储参数 | 压缩比高,解析速度快,适合超大体积数据 | 依赖PyArrow库,学习成本稍高 |
| 队列分发参数 | 线程安全,不重复分配,公平分发 | 所有参数都要加载到队列,内存占用中等 |
| 批次迭代器分发参数 | 内存占用极低,适合虚拟用户数远小于参数量的场景 | 批次大小需要提前设置,灵活性稍差 |
5.3 注意事项
- 参数的唯一性:不管用哪种分发方式,都要确保参数不重复,尤其是需要登录的场景,重复的用户参数会导致测试结果不准。
- 上下文管理器的使用:打开文件、连接的时候一定要用上下文管理器,避免文件泄漏、连接泄漏的问题。
- 队列的大小:用
queue.Queue的时候,如果参数量很大,不要设置maxsize,否则队列满了会导致阻塞。 - 虚拟用户的
on_start方法:on_start方法是每个虚拟用户启动的时候执行一次,不要把参数加载的逻辑放在on_start里,否则每个虚拟用户都会加载一次参数,导致内存占用飙升。
六、总结
Locust参数化数据量过大的问题,本质上是加载和分发方式的问题,只要掌握了迭代器、队列、合适的存储格式这几个核心点,就能轻松解决。
加载的时候尽量用迭代器,不要一次性把所有数据都读进内存;分发的时候用队列或者批次迭代器,确保参数不重复、公平分配;存储的时候根据参数的结构选择合适的格式,结构化参数用CSV,有嵌套的用Parquet。
这些优化不仅能解决Locust的性能问题,还能提升整个测试的稳定性和准确性,不管是做什么类型的性能测试,都能用上。
评论
围绕“Locust测试场景中参数化数据量过大?优化Python数据加载与分发的性能”参与讨论