做微服务这块儿久了,你会发现上线前最大的焦虑往往不是功能bug,而是那个永恒的问题:这套服务在高并发压力测试下到底能扛住多少?我去年负责的一个订单中台项目,功能测试全部通过,结果上线大促前用Locust一压,短短三分钟就把一个隐藏的全链路超时问题给逼出来了。如果当时没做这轮精准压测,线上事故基本跑不掉。微服务架构里的性能瓶颈识别能力,真不是靠感觉和经验拍脑袋能解决的,它需要一套能复现、能量化、能定位的实战方法,而这正是Locust这种工具最擅长的地方。
很多人以为压力测试就是把请求量拉满、看服务挂不挂,其实这只是在"做压力测试",而不是"做精准压力测试"。真正的难点在于:压测场景是否符合真实流量模型、压测脚本里的用户行为是否有业务逻辑、压测机本身有没有成为瓶颈、最后的数据和报告能不能指导代码优化。这篇文章我就拿实际项目里的踩坑过程,把这些环节一个一个拆开讲。
1. 微服务压测的地基:先搞懂我们到底在压什么
1.1 高并发场景不是单点压测
微服务架构里,一次用户请求往往会经过网关、鉴权服务、业务服务、缓存、消息队列和数据库等多个节点。单点压测只能告诉你"自己负责的这个服务还能扛",但没法回答"全链路能撑多少并发"。真实的线上故障,绝大多数发生在服务之间的依赖调用上:A服务觉得没问题,B服务的连接池先满了;数据库连接数还没到瓶颈,Redis的响应时间已经飙了。
所以我建议你在做压测前,先把目标拆成两类。第一类是单服务压测,目的是快速验证某个服务的容量上限和参数调优效果,比如线程池大小、数据库连接数、缓存过期策略。第二类是全链路压测,目的是模拟真实用户从入口到后端的完整请求路径,找出跨服务的瓶颈点。Locust对两类场景都支持,但脚本设计思路完全不同。
另外还要区分两个容易混淆的概念:并发数和TPS。并发数是在系统中同时活动的用户数,TPS是每秒完成的事务数。二者不是等同的,在压测报告里如果只看一个参数,很容易被误导。举个简单例子,1000个并发用户,每个用户平均思考时间2秒,那TPS大概在500左右,而不是1000。后面讲脚本设计时,这个"思考时间"就是Locust里的wait_time,它决定了你的压测有多接近真实流量。
1.2 为什么选Locust而不是清一色线程池压测
很多团队一提起压测,第一个想到的是JMeter或者基于线程池实现的自研压测工具。不是说它们不好,而是在高并发场景下,线程模型有它的尴尬之处:每个线程都对应一个系统线程,1000个并发用户就需要1000个线程,JVM默认栈空间按1MB算,光线程开销就很大,而且频繁的上下文切换也会白白消耗CPU。
Locust的思路是基于协程的。它底层用gevent实现并发,每个模拟用户都是一个greenlet协程,单机就可以轻松模拟成千上万个并发用户。这一点在模拟微服务高并发调用时特别有用,因为你可以把测试机的大部分资源留给真正的HTTP请求,而不是消耗在线程调度上。
当然,选工具不能只看并发模型,还要看脚本表达能力。Locust直接写Python脚本,意味着你可以像写业务代码一样组织压测场景:循环、条件判断、动态参数、调用其他库、读取CSV文件、断言响应内容、记录自定义指标,这些都不需要靠插件实现。对熟悉Python的团队来说,维护成本比配置重型测试工具低很多。
1.3 压测工具选型表
这里给一份自己在选型时的对比思路,不吹不黑,按需选择就好。
| 工具 | 并发模型 | 脚本语言 | 分布式支持 | 适合场景 |
|---|---|---|---|---|
| Locust | 协程 | Python | 主从模式 | 高并发、场景化复杂、需要二次开发 |
| JMeter | 线程 | Java/GUI配置 | 分布式 | 简单HTTP测试、团队不写代码 |
| Gatling | 异步Actor | Scala DSL | 支持 | 偏性能测试工程师、追求高吞吐 |
| 自研压测脚本 | 线程/协程 | 任意 | 看实现 | 特定协议、内部中间件压测 |
选择背后的逻辑其实很简单:如果你的压测场景是"登录、浏览、下单"这种带业务逻辑的链路,或者需要动态生成并发数据、根据上游响应调整请求参数,那Locust这种代码优先的工具会省心很多。如果你的场景就是一批固定URL的GET请求,JMeter的图形化配置可能更快。工具没有绝对优劣,关键是场景匹配。
2. Locust的并发模型与脚本设计:协程才是关键
2.1 User、Tasks和等待时间的搭配
Locust最核心的抽象就是User类。一个User代表一个虚拟用户,它在压测过程中会不断执行Tasks集合里的任务,每个任务之间通过wait_time控制间隔。
先看一个最基础的用户类:
from locust import HttpUser, task, between class WebUser(HttpUser): wait_time = between(0.5, 3) @task def get_home(self): self.client.get("/") @task(weight=3) def get_products(self): self.client.get("/products")这个脚本里,between(0.5, 3)表示每个虚拟用户在两次请求之间随机等待0.5到3秒,用来模拟真实用户的操作间隔。weight=3表示这个任务被选中的概率是基础任务的三倍。实际项目中,千万不要把所有请求都设置为同样的频率,否则压出来的流量模型是平的,和真实用户行为差别很大。
这里有一个很关键的点:wait_time不是用来限制RPS上限的,它是在模拟用户思考时间。如果你把wait_time设成0,每个用户会像机器一样连续发请求,测出来的其实是"极限压榨值",而不是真实业务下的容量值。我一般会同时跑两轮:一轮wait_time极短,用来测系统绝对上限;一轮按业务统计设置思考时间,用来评估真实容量。
2.2 动态参数与前后端关联
真实业务中,很多接口不是独立的。用户要先登录拿token,然后带着token去查询订单,订单详情里的id又会影响下一个接口的请求。如果我们把这些依赖关系做成静态数据,压测结果基本没有说服力。
Locust里处理动态参数最常用的方式是在on_start里初始化用户状态:
from locust import HttpUser, task class OrderUser(HttpUser): def on_start(self): resp = self.client.post("/api/login", json={ "username": "test_user", "password": "123456" }) self.token = resp.json()["token"] self.headers = {"Authorization": f"Bearer {self.token}"} @task def get_orders(self): self.client.get("/api/orders", headers=self.headers)这样做的好处是每个虚拟用户启动时都会独立登录一次,拿到属于自己的token,后续请求都带着这个凭证,更贴近真实用户的行为。但注意,如果登录接口本身负载很大,压测脚本里的大量登录请求会让压测流量失真。解决方法是区分两种模式:一种是把登录当作被测链路的一部分,这种模式适合压全链路;另一种是压测开始前预先准备一批token,用token池的方式在请求时随机选用,这种模式适合只压业务接口。
2.3 接口链路的建模
微服务压测里最有价值的场景,往往是接口链路,而不是单个接口。比如"登录-查询商品-加购物车-下单",任何一个环节出了问题,整条链路就失败。Locust里可以用SequentialTaskSet来保证任务顺序执行。
from locust import HttpUser, task, between, SequentialTaskSet class OrderFlow(SequentialTaskSet): def on_start(self): self.token = self.login() def login(self): resp = self.client.post("/login", json={...}) return resp.json()["token"] @task def step1_query(self): self.client.get("/api/goods", headers=self.headers) @task def step2_cart(self): self.client.post("/api/cart", json={"goods_id": 1}, headers=self.headers) @task def step3_submit(self): self.client.post("/api/order", json={"cart_id": 1}, headers=self.headers) class OrderUser(HttpUser): tasks = [OrderFlow] wait_time = between(1, 3)这种情况下,每一个执行OrderFlow的用户都在模拟完整的业务路径,链路中任何一个接口的延迟上涨,都会直接影响后续接口的成功率和整个流程的完成时间。这种设计远比单独压几个接口更接近真实事故的触发方式。
3. 贴近生产的脚本实战:从登录到Kafka消息投递
3.1 一键启动的压测工程结构
项目里的压测脚本不会只有一个文件,因为要管理多套场景、公共配置和测试数据。我的习惯是把压测工程做成一个独立仓库,结构大概是这样的:
locust_test/ ├── requirements.txt ├── run_local.sh ├── run_master.sh ├── run_worker.sh ├── locustfile.py ├── config/ │ └── env.py └── data/ ├── users.csv └── goods.jsonlocustfile.py是入口,config里按环境区分测试地址和账号体系,data目录放需要读取的测试数据。脚本里通过--config指定环境配置,避免每次压测都改代码。
3.2 阶梯加压与场景切换
高并发压测最忌讳一上来就把并发数拉满。突然的大流量冲击容易让服务触发熔断或限流,但这样测出来的结果很混乱,你分不清系统是"本来就不行"还是"被阶梯打崩的"。更合理的做法是逐步加压,观察服务在哪个并发区间开始出现延迟拐点。
Locust的Web界面可以动态调整用户数和生成速率,但如果你想在脚本里做自动化阶梯加压,可以用load_shape类:
class StepShape(LoadTestShape): time_limit = 600 spawn_rate = 20 def tick(self): run_time = self.get_run_time() if run_time < 60: return (100, spawn_rate) elif run_time < 120: return (300, spawn_rate) elif run_time < 180: return (600, spawn_rate) elif run_time < 240: return (1000, spawn_rate) return None这个Shape定义了一个6分钟的压测计划:前60秒100并发,第二个60秒300并发,第三个60秒600并发,最后60秒1000并发,然后停止。通过这种方式,你能清楚地看出每个并发阶段的响应时间变化曲线,而不是拿到一个"平均下来还不错"的糊在一起的数据。
3.3 Kafka高并发消息处理场景的压测脚本
微服务架构里,消息队列几乎是标配,尤其是Kafka。很多人只压HTTP接口,忽略了消息生产者和消费者的吞吐能力。服务端可能HTTP接口响应很快,但Kafka生产者发送消息时因为批量大小、acks配置、网络缓冲等原因,存在完全不同的瓶颈。
在Locust里压Kafka生产者,可以直接用kafka-python客户端,把它封装成一个任务:
from locust import User, task, between, events from kafka import KafkaProducer import json import time class KafkaBenchUser(User): wait_time = between(0.01, 0.05) def on_start(self): self.producer = KafkaProducer( bootstrap_servers=["kafka-1:9092", "kafka-2:9092"], value_serializer=lambda v: json.dumps(v).encode("utf-8"), acks="1", linger_ms=10, batch_size=32768 ) @task def send_msg(self): start = time.time() msg = {"order_id": 123456, "amount": 99.9} future = self.producer.send("order_topic", value=msg) future.get(timeout=5) total = (time.time() - start) * 1000 events.request.fire( request_type="KAFKA", name="producer_send", response_time=total, response_length=0, exception=None )这里用events.request.fire主动上报耗时数据,让Locust把消息发送耗时也统计到压测报告里。这样你就可以在同一个压测报告里看到HTTP接口指标和Kafka生产指标,方便做整体评估。
有一点要特别注意:KafkaProducer本身是线程安全的,但在Locust的协程模型下,每个虚拟用户持有独立的producer会比较安全,不会出现共享连接上的并发写冲突。但在大量用户场景下,producer数量太多也会占用文件描述符,所以实际项目里我更推荐先压测一个共享producer池的模型,再对比独立producer模型,找到最贴合生产配置的方式。
4. 精准压测的隐藏坑:数据隔离、资源瓶颈与结果失真
4.1 并发用户不等于虚拟用户数
这个坑我见过太多次了。有人在Locust里设置了1000个用户,发现TPS只有200,就以为服务只能扛200。但其实他忘了看wait_time——用户都在等待时间里"思考",根本没有发请求。还有人在脚本里直接用了time.sleep(),它会把整个协程挂起,导致并发数上升缓慢,压测结果一片平坦。
Locust统计的并发数是在注册的所有User对象数量,不是当前正在发请求的数量。如果你想确认"当前正在执行的请求数",应该去看Locust Web界面里的"Current RPS"和"Current Response Time",而不是看User Count。
4.2 数据竞争与脏数据导致的结果失真
另一个高频问题是测试数据互相影响。比如订单接口压测,每个用户如果都用同一个商品ID下单,可能出现库存扣减冲突、唯一索引冲突、锁等待,这些异常会把服务的真实瓶颈掩盖掉。测试结果看起来是数据库报错,实际是测试数据设计不合理。
我的做法是:先准备一个数据池,比如用户CSV、商品ID列表、优惠券码等,在任务里通过随机或hash取模的方式分配数据。还可以在on_start时根据虚拟用户的编号初始化专属数据:
import csv from locust import HttpUser, task class DataUser(HttpUser): def on_start(self): self.order_id = self.gen_order_id() def gen_order_id(self): # 从预先构造的ID区间里取 return 10000 + self.environment.runner.user_count * 1000 + random.randint(0, 999)这里面其实还有一个很容易被忽略的细节:如果压测脚本里用了random.randint,那每个用户产生的数据是随机的,但并发量很大时随机分布可能导致部分数据被高频选中。所以在正式压测前,最好先跑一个低并发的小批量验证,检查服务端日志里出现的数据分布是否均匀。
4.3 别忘了观测压测机自身
压测工具本身也会成为瓶颈,尤其在高并发场景下。如果压测机的CPU、内存、网络连接数被打满,你看到的RPS和响应时间都不是被测服务的真实表现,而是压测机的。这个被很多人忽略了。
我习惯在压测过程中同时盯三块指标:
| 观测对象 | 关键指标 | 判断标准 |
|---|---|---|
| 被测服务 | CPU使用率、GC次数、连接池占用 | 判断服务是不是真的到了瓶颈 |
| 依赖中间件 | Redis命中率、Kafka消费延迟、DB连接数 | 定位瓶颈到底在哪一层 |
| 压测机 | CPU、内存、文件描述符、发压机网络带宽 | 排除压测机自身成为瓶颈 |
发现压测机CPU超过80%时,别继续加用户数了,先去开两个worker把负载分摊出去。Locust本身支持分布式,但很多人只在单机上加大并发,最后把压测机和被测服务一起压垮,得到一份完全没有参考价值的报告。
5. 分布式压测:主从模式与一套可操作的参数方案
5.1 主从模式踩坑记录
Locust的分布式模式其实很简单:一台机器跑Master,其他机器跑Worker,Worker启动时指定Master的地址,Master负责聚合所有Worker的数据。
启动命令大概是这样的:
# Master节点 locust -f locustfile.py --master --expect-workers=4 --web-port=8089 # Worker节点(4台机器分别执行) locust -f locustfile.py --worker --master-host=192.168.1.100这里有几个坑必须要说。第一个是--expect-workers参数,如果你不设置它,Master会一直等待Worker连接,而且Web界面上的数据会一直显示为0,新手容易误以为自己脚本写错了。第二个是版本一致性:Master和Worker的Locust版本必须一致,否则会出现反序列化异常。第三个是防火墙和端口,Worker需要能访问Master的5557端口(默认通信端口)和5558端口(消息推送端口),很多人压测时本地没问题,放到跨机房环境就连接不上,多半是端口没放开。
5.2 资源预留与压测前的Checklist
分布式压测不是无脑加机器。每台Worker能跑多少用户,取决于你脚本的复杂度、请求的大小、以及被测服务的响应速度。我的经验是:单台4核8G的Worker,跑简单HTTP GET请求时,能稳定支撑2000到3000个协程用户;如果脚本里有复杂的加解密、密集计算或者大量JSON解析,这个数字要打折。
压测前我一般会把这份Checklist过一遍:
- 测试环境数据是否已准备完整,是否存在唯一键冲突
- 是否关闭了非业务日志的输出,避免日志写盘成为瓶颈
- 服务端的超时时间、重试次数是否和线上配置一致
- 缓存策略是否和线上一致,避免压测时每次都打穿透数据库
- Worker机器的时间是否与服务端保持一致,方便日志定位
这份Checklist看起来不起眼,但它能救你。我见过有人在测试环境里把debug日志开着跑了半小时压测,结果瓶颈从服务逻辑变成了日志文件IO,报告毫无参考价值。
6. 压测报告的解读和误判排雷
6.1 核心指标:RPS、响应时间分位数和错误率
压测结束后,Locust给出的报告看起来信息量很大,但大多数人只看了"Average response time"和"Total requests"就开始写结论。这是一个很危险的习惯。平均值在高并发场景下容易被极端值拉偏,比如999个请求都是10ms,1个请求是5秒,平均值也是14.9ms,看起来还不错,但真实用户体验已经很差了。
我建议关注三个核心指标,并且把它们放到一张表格里做对比:
| 指标 | 作用 | 建议阈值 |
|---|---|---|
| 90%响应时间 | 绝大多数用户的体验 | 一般要求低于500ms |
| 99%响应时间 | 尾部用户是否被拖累 | 不能超过1.5秒 |
| 错误率 | 系统稳定性底线 | 不能超过0.1% |
具体阈值看业务场景,但核心逻辑是一样的:平均响应时间只是参考,分位数才是决定是否上线的重要依据。还有一点,Locust报告里的RPS是聚合值,如果你的压测场景有多个任务混跑,我建议在脚本里为每个请求单独设置name参数,这样报告里就能按接口维度拆分瓶颈,而不是一锅粥。
self.client.get("/api/orders", name="orders_list")6.2 错误排查的典型链路
压测过程中报错,不要直接看"Exception: ConnectionError"就完事,要顺着链路往下查。我自己的排查顺序是这样的:先看是本机的连接问题还是服务端的连接问题,再去看服务端日志里有没有对应的超时记录,接着看中间件指标是否异常,最后回到代码里排查连接池配置是否合理。
这里分享一个我之前排过的真实案例:压测一个订单服务,并发到800时错误率突然飙到15%,Locust里全是ConnectTimeout。乍一看好像服务扛不住了,但服务端CPU才40%,数据库连接也没满,日志里也没有超时。后来查才发现,是服务端调用下游服务时,下游服务的线程池满了,导致上游等待时间过长,连接被Locust判为超时。这就说明,压测里看到的错误不能只盯着被测服务本身,上下游依赖任何一个环节都可能成为隐形瓶颈。
另有一个常见误判:很多人看到压测报告里错误率为0,就觉得系统稳了。但如果你压测脚本里没有加响应断言,即使服务返回了500错误页,Locust也可能认为请求成功了,因为HTTP 500在TCP层面也是正常响应。所以脚本里一定要加断言,至少要检查状态码和关键字段:
with self.client.get("/api/order/detail") as resp: if resp.status_code != 200: resp.failure("status code error") elif "code" not in resp.json(): resp.failure("missing code field")这样才不会把一个故障服务当成健康服务来评估。
最后再分享一个我个人很受益的小习惯:每次压测前,把这一轮的脚本、压测参数、服务端配置变更记录清楚,压测后把原始CSV报告归档命名成2025xx-xx_场景_版本号这种格式。因为性能问题往往不是一次压测就能定位完的,往往需要多轮对比,没有记录就没有对比,没有对比就谈不上"精准"。等你真正遇到线上事故需要回溯时,这些压测记录会比任何回忆都可靠。