微服务高并发压测实战:用Locust精准定位性能瓶颈
2026/9/9 16:32:14 网站建设 项目流程

做微服务这块儿久了,你会发现上线前最大的焦虑往往不是功能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异步ActorScala 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.json

locustfile.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_场景_版本号这种格式。因为性能问题往往不是一次压测就能定位完的,往往需要多轮对比,没有记录就没有对比,没有对比就谈不上"精准"。等你真正遇到线上事故需要回溯时,这些压测记录会比任何回忆都可靠。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询