4. 测试脚本编写:基于Python + Locust编写基础压测脚本
好,到了真正动手的环节了。前面我们聊了那么多理论、架构、指标,说白了都是为了这一刻——把脚本跑起来,看看系统到底扛不扛得住。
我个人习惯用Locust来做压测。为什么?因为它够轻量,写起来快,而且用Python写脚本,对我们做量化的人来说太友好了。你想想看,我们平时写策略、写回测框架,哪个不是Python?
4.1 为什么选Locust?
我记得第一次做交易系统压测时,团队里有人提议用JMeter。嗯,JMeter确实强大,但配置起来太繁琐了。尤其是模拟下单、撤单这种有状态的操作,你得搞一堆前置处理器、后置处理器,光看着就头大。
Locust就不一样了。它本质上就是一个Python库,你写一个类,继承HttpUser,然后在里面定义任务。每个任务就是一个普通的Python方法,想怎么控制逻辑就怎么控制。
核心优势:
- 纯Python编写,零配置门槛
- 支持协程,单机就能模拟大量用户
- 内置Web UI,实时看结果
- 任务之间可以共享状态,适合模拟交易流程
4.2 基础脚本结构
我们先搭一个最基础的架子。别急着写复杂逻辑,先把框架跑通再说。
from locust import HttpUser, task, between
import json
class TradingUser(HttpUser):
# 每个用户执行任务之间的等待时间(模拟思考时间)
wait_time = between(0.5, 2.0)
def on_start(self):
"""用户启动时执行一次,用于登录"""
# 我在项目中遇到过,如果不做登录态管理,
# 压测出来的结果全是401,白忙活一场
payload = {
"username": "test_user",
"password": "test_pass"
}
resp = self.client.post("/api/login", json=payload)
if resp.status_code == 200:
self.token = resp.json().get("token")
self.client.headers.update({
"Authorization": f"Bearer {self.token}"
})
print(f"用户登录成功,token: {self.token[:20]}...")
else:
print(f"登录失败: {resp.status_code}")
@task(3) # 权重为3,表示这个任务被选中的概率更高
def query_balance(self):
"""查询账户余额"""
resp = self.client.get("/api/account/balance")
if resp.status_code != 200:
print(f"查询余额失败: {resp.status_code}")
@task(2)
def query_positions(self):
"""查询持仓"""
resp = self.client.get("/api/account/positions")
# 这里可以加一些断言,但我建议压测时别加太多,
# 否则会影响性能数据
if resp.status_code == 200:
positions = resp.json().get("positions", [])
# 简单记录一下持仓数量
if len(positions) > 0:
pass # 实际项目中可以记录到日志
@task(1)
def place_order(self):
"""模拟下单"""
order = {
"symbol": "BTCUSDT",
"side": "buy",
"type": "limit",
"price": 50000.0,
"quantity": 0.01
}
resp = self.client.post("/api/order", json=order)
if resp.status_code == 200:
order_id = resp.json().get("order_id")
# 把订单ID存起来,后面撤单要用
if not hasattr(self, "order_ids"):
self.order_ids = []
self.order_ids.append(order_id)
# 只保留最近10个订单,防止内存泄漏
if len(self.order_ids) > 10:
self.order_ids.pop(0)
@task(1)
def cancel_order(self):
"""模拟撤单"""
if not hasattr(self, "order_ids") or len(self.order_ids) == 0:
# 没有可撤的订单,就跳过
return
order_id = self.order_ids.pop(0)
resp = self.client.delete(f"/api/order/{order_id}")
if resp.status_code != 200:
print(f"撤单失败: {order_id}, 状态码: {resp.status_code}")
小技巧:我在做压测时,习惯把每个任务的权重调成跟真实用户行为一致。比如真实用户可能80%的时间在查询,10%在下单,10%在撤单。那权重就设成8:1:1。这样压出来的数据才有参考价值。
4.3 模拟真实交易流程
上面那个脚本虽然能跑,但太简单了。真实交易场景下,用户的行为是有顺序的:先查行情,再下单,然后查订单状态,最后可能撤单或者等成交。
我建议用@task装饰器配合SequentialTaskSet来实现流程控制。
from locust import HttpUser, task, between, SequentialTaskSet
class TradingFlow(SequentialTaskSet):
"""模拟一个完整的交易流程"""
def on_start(self):
self.order_id = None
self.symbol = "BTCUSDT"
@task
def step1_query_market(self):
"""第一步:查询行情"""
resp = self.client.get(f"/api/market/ticker?symbol={self.symbol}")
if resp.status_code == 200:
ticker = resp.json()
self.current_price = float(ticker.get("lastPrice", 50000))
print(f"当前价格: {self.current_price}")
@task
def step2_place_order(self):
"""第二步:下单"""
order = {
"symbol": self.symbol,
"side": "buy",
"type": "limit",
"price": self.current_price * 0.99, # 比当前价低1%
"quantity": 0.01
}
resp = self.client.post("/api/order", json=order)
if resp.status_code == 200:
self.order_id = resp.json().get("order_id")
print(f"下单成功,订单ID: {self.order_id}")
@task
def step3_query_order(self):
"""第三步:查询订单状态"""
if self.order_id is None:
return
resp = self.client.get(f"/api/order/{self.order_id}")
if resp.status_code == 200:
status = resp.json().get("status")
print(f"订单状态: {status}")
# 如果已经成交,就不撤单了
if status == "filled":
self.order_id = None
@task
def step4_cancel_order(self):
"""第四步:撤单(如果还没成交)"""
if self.order_id is None:
return
resp = self.client.delete(f"/api/order/{self.order_id}")
if resp.status_code == 200:
print(f"撤单成功: {self.order_id}")
self.order_id = None
class TradingUser(HttpUser):
wait_time = between(1, 3)
tasks = [TradingFlow]
注意:我曾经犯过一个错误——在压测脚本里加了大量的print语句。结果压测还没跑起来,控制台先被日志淹没了,CPU直接飙到100%。压测脚本本身也会消耗资源,记得把日志级别调高,或者只在调试时打开。
4.4 参数化与数据隔离
每个虚拟用户应该用不同的数据,不然就变成所有人在抢同一个订单,压出来的结果会失真。
我一般会在on_start里给每个用户分配一个独立的账户。
import random
import string
class TradingUser(HttpUser):
wait_time = between(0.5, 2.0)
def on_start(self):
# 生成随机用户名,确保每个用户独立
suffix = ''.join(random.choices(string.ascii_lowercase, k=8))
self.username = f"load_test_{suffix}"
# 登录
payload = {
"username": self.username,
"password": "test123"
}
resp = self.client.post("/api/login", json=payload)
if resp.status_code == 200:
self.token = resp.json().get("token")
self.client.headers.update({
"Authorization": f"Bearer {self.token}"
})
# 初始化账户资金(如果有初始化接口的话)
self.client.post("/api/account/init", json={
"balance": 100000.0 # 每个用户10万初始资金
})
@task
def random_order(self):
"""随机下单,模拟不同用户的交易行为"""
symbols = ["BTCUSDT", "ETHUSDT", "BNBUSDT"]
sides = ["buy", "sell"]
order = {
"symbol": random.choice(symbols),
"side": random.choice(sides),
"type": "limit",
"price": round(random.uniform(40000, 60000), 2),
"quantity": round(random.uniform(0.001, 0.1), 4)
}
self.client.post("/api/order", json=order)
4.5 运行与监控
脚本写好了,怎么跑?我推荐两种方式:
| 方式 | 命令 | 适用场景 |
|---|---|---|
| Web UI模式 | locust -f trading_test.py --web-host 0.0.0.0 |
调试脚本、小规模压测 |
| 无头模式 | locust -f trading_test.py --headless -u 100 -r 10 --run-time 10m |
自动化压测、CI/CD集成 |
无头模式参数说明:
-u 100:模拟100个并发用户-r 10:每秒启动10个用户(爬坡速率)--run-time 10m:运行10分钟
我的经验:刚开始压测时,别一上来就上1000个用户。先跑10个,看看系统响应时间。没问题了再慢慢往上加。我见过太多人一上来就把系统压崩了,结果连问题出在哪都不知道。
4.6 避坑指南
嗯,最后分享几个我踩过的坑:
- 连接池耗尽:Locust默认每个用户一个连接,但如果你在任务里频繁创建新的HTTP会话,连接池会很快耗尽。解决办法是用
self.client,它内部已经做了连接复用。 - 状态污染:不同用户之间不要共享全局变量。我见过有人用全局列表存订单ID,结果所有用户都在操作同一个列表,数据全乱了。
- 超时设置:交易系统一般对延迟要求很高。建议在
self.client里设置超时时间,比如self.client.timeout = 5。如果5秒没响应,直接算失败,别傻等。 - 数据清理:压测结束后,记得清理测试数据。我曾经忘了清理,结果生产环境里多了几万条测试订单,被运维骂了一顿。
脚本写好了,下一步就是跑起来看结果了。不过那是下一节的内容,我们先把脚本调通再说。