
做微服务这块儿久了你会发现上线前最大的焦虑往往不是功能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(weight3) def get_products(self): self.client.get(/products)这个脚本里between(0.5, 3)表示每个虚拟用户在两次请求之间随机等待0.5到3秒用来模拟真实用户的操作间隔。weight3表示这个任务被选中的概率是基础任务的三倍。实际项目中千万不要把所有请求都设置为同样的频率否则压出来的流量模型是平的和真实用户行为差别很大。这里有一个很关键的点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: fBearer {self.token}} task def get_orders(self): self.client.get(/api/orders, headersself.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, headersself.headers) task def step2_cart(self): self.client.post(/api/cart, json{goods_id: 1}, headersself.headers) task def step3_submit(self): self.client.post(/api/order, json{cart_id: 1}, headersself.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_serializerlambda v: json.dumps(v).encode(utf-8), acks1, linger_ms10, batch_size32768 ) task def send_msg(self): start time.time() msg {order_id: 123456, amount: 99.9} future self.producer.send(order_topic, valuemsg) future.get(timeout5) total (time.time() - start) * 1000 events.request.fire( request_typeKAFKA, nameproducer_send, response_timetotal, response_length0, exceptionNone )这里用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其他机器跑WorkerWorker启动时指定Master的地址Master负责聚合所有Worker的数据。启动命令大概是这样的# Master节点 locust -f locustfile.py --master --expect-workers4 --web-port8089 # Worker节点4台机器分别执行 locust -f locustfile.py --worker --master-host192.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个请求都是10ms1个请求是5秒平均值也是14.9ms看起来还不错但真实用户体验已经很差了。我建议关注三个核心指标并且把它们放到一张表格里做对比指标作用建议阈值90%响应时间绝大多数用户的体验一般要求低于500ms99%响应时间尾部用户是否被拖累不能超过1.5秒错误率系统稳定性底线不能超过0.1%具体阈值看业务场景但核心逻辑是一样的平均响应时间只是参考分位数才是决定是否上线的重要依据。还有一点Locust报告里的RPS是聚合值如果你的压测场景有多个任务混跑我建议在脚本里为每个请求单独设置name参数这样报告里就能按接口维度拆分瓶颈而不是一锅粥。self.client.get(/api/orders, nameorders_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_场景_版本号这种格式。因为性能问题往往不是一次压测就能定位完的往往需要多轮对比没有记录就没有对比没有对比就谈不上精准。等你真正遇到线上事故需要回溯时这些压测记录会比任何回忆都可靠。