
1. 先搞清楚 10W qps 这个数字到底在压什么看到“10W qps”这个目标很多人的第一反应是去找一台配置更高的机器或者直接堆更多压测节点。我最初也这么想后来发现这条路走不通——问题不在机器数量上而在于绝大多数人对“qps”这个指标的理解从第一步就偏了。10W qps 指的是被测服务每秒成功处理的请求数不是压测工具每秒发出的请求数。这两个数字在被压垮的场景里能差出三四倍。压测工具自己报了 10W被测服务实际只扛住了 3W剩下 7W 全堵在连接队列、网关限流或者超时重试里最后你拿着一份漂亮的报告去扩容上线照样崩。所以做分布式压测,第一件事不是配置 slave,而是把度量口径定死以服务端侧的入口计数为准,压测端的数字只作为参照。这篇内容面向的是已经会用 Jmeter 跑单机脚本、但一上分布式就各种数据对不上、slave 掉线、结果汇总失真的同学。我会把 10W qps 这个量级下,分布式压测从机器规划、参数调优、脚本改造、结果校准到踩坑排查的完整链路拆开讲。里面涉及的具体数值都是基于常见 Linux 环境和 Jmeter 5.x 的实践经验,你拿去当起点再按自己的环境微调即可,不要直接照抄某一个数字就当成真理。先给一个整体的量级感,便于你后面判断自己卡在哪一层:环节常见瓶颈表现是否容易成为 10W 瓶颈压测机 CPU / 网络slave 自身 CPU 打满、网卡软中断堆积极易Jmeter 堆内存与 GC压测中途 Full GC、TPS 曲线出现规律性凹陷极易脚本本身开销断言、正则提取、日志打印吃掉大半性能极易被测服务连接数大量 TIME_WAIT、accept 队列溢出常见中间网络设备端口耗尽、NAT 会话表满常见结果汇总链路master 汇总瓶颈、.jtl 文件写盘 IO 打满中等这张表建议先存着,后面每排查一个问题都回来对一眼,能省掉大量盲猜时间。下面按实际动手顺序展开。2. 压测机与网络的前置账:算清楚再动手2.1 单台 slave 到底能扛多少 qps这个数字没有标准答案,但有一个可操作的估算方法。Jmeter 的每个线程在发请求时,CPU 开销主要来自三块:HTTP 客户端(默认 HttpClient4)、取样器逻辑、以及结果收集。在关闭所有监听器、只保留必要断言的裸压场景下,一台 8 核 16G 的普通云主机,单机跑到3000~5000 qps是比较现实的区间;如果脚本里有正则提取、JSON 断言、BeanShell,这个数字会直接掉到 1000 以下。也就是说,想凑 10W qps,纯按 4000 一台算,至少需要 25 台 slave,再留冗余,30 台起步。这个规模对个人来说不现实,所以实际工程里通常做两件事:一是把单机能效提上去,二是降低目标口径(比如只对核心接口压 10W,或者接受 6~8W 的实测值)。我见过太多方案写着“30 台 slave 压 10W”,实际跑起来每台只有一千多,最后发现是脚本里挂了个“察看结果树”。单机能效提升的具体动作,按收益排序:关闭 GUI 运行,一律jmeter -n -t plan.jmx -l result.jtl。删除所有监听器,包括聚合报告;需要实时看数就用后端监听器推到外部时序库,或者压完再离线分析。jmeter.properties里关掉jmeter.save.saveservice.*中不需要的字段,只留 timeStamp、elapsed、label、responseCode、success、bytes 六个。断言能删就删,必须保留的用 JSR223 Groovy 并开启编译缓存,不要用 Beanshell。日志级别调到 WARN 以上,log4j2.xml里把org.apache.jmeter的级别压下去。2.2 端口耗尽与内核参数,比加机器更划算10W qps 意味着每秒 10W 个新建连接(如果你没开 keep-alive)。Linux 默认的本地端口范围大约 2.8 万个,加上 TIME_WAIT 默认 60 秒回收,理论上一台机器每秒能发起的连接数被死死限制在几百到一千多。这就是为什么很多人压到 2000 qps 就上不去,而 CPU 才 30%。需要调整的几项:# 扩大本地端口范围 sysctl -w net.ipv4.ip_local_port_range1024 65535 # 允许 TIME_WAIT 状态的 socket 被新连接复用 sysctl -w net.ipv4.tcp_tw_reuse1 # 加快 TIME_WAIT 回收(不宜过小,否则可能影响对端) sysctl -w net.ipv4.tcp_fin_timeout15 # 增大连接队列 sysctl -w net.core.somaxconn65535 sysctl -w net.ipv4.tcp_max_syn_backlog65535 # 文件句柄 ulimit -n 1000000注意:tcp_tw_recycle在新版内核里已经被移除,网上很多老教程还在写,照抄会直接报错。另外tcp_tw_reuse只在客户端主动发起连接时生效,对服务端无意义,别搞反了。除了端口,网卡多队列和软中断是另一个隐形天花板。单队列网卡在 5W pps 左右就会出现softirq单核打满,表现为top里si占比飙高而业务 CPU 不高。检查方法:cat /proc/interrupts | grep eth0 mpstat -P ALL 1如果某个 CPU 核的软中断明显高于其他核,就需要开 RPS/RFS 或者确认网卡多队列是否已启用。这部分在云主机上往往不可控,所以选压测机时优先挑支持多队列的实例规格,别在最低配的入门款上死磕。2.3 分布式拓扑:master 不要兼着发压Jmeter 的分布式模型是 master-slave(官方叫 controller-worker),master 负责分发脚本和汇总结果,slave 负责实际发压。一个高频错误是让 master 同时承担一部分压测任务,看起来省了一台机器,实际会让结果汇总严重失真——master 的 CPU 被压测线程占满后,汇总和心跳都会延迟,slave 端会误判为失联。我的建议是 master 独立一台,配置不用高(4 核 8G 够),但要保证它到所有 slave 的网络稳定。slave 数量多的时候,把 slave 分批次启动,每批 10 台左右,避免 master 在同一秒被几十个 RMI 连接打爆。网络层面还有一个容易忽略的点:slave 到被测服务的链路要和真实用户链路接近。如果压测机和被测服务在同一可用区,延迟只有 0.2ms,测出来的 TPS 会显著高于真实场景。要么在报告里明确标注这是内网理想值,要么刻意引入延迟模拟真实分布。3. 脚本改造:把每一分 CPU 都留给发压3.1 从“能跑通”到“能高效跑”的六个动作单机脚本跑通和分布式高效压测,中间隔着一次彻底的脚本瘦身。我按改造前后的对比列一下,这些都是实测能直接影响单机 qps 的操作:改造项改造前改造后单机 qps 变化结果收集保留察看结果树全部删除80% 以上断言方式Beanshell 断言JSR223 Groovy(缓存编译)30%参数化CSV 每请求读一次一次读取 内存变量15%提取器正则提取器每请求编译JSR223 缓存 Pattern10%日志INFO 级别WARN 级别10%HTTP 客户端Java 默认实现HttpClient4 连接池复用20%具体到配置项,HTTP请求取样器里勾选KeepAlive,并在jmeter.properties中确认:# 复用连接,避免每请求三次握手 httpclient4.time_to_live60000 httpclient4.max_body_size10485760 # 关闭自动重定向,压测不需要跟随 httpsampler.auto_redirectsfalse # 关闭MD5等无谓开销 httpsampler.embedded_url_refalseCookie 管理器如果不是业务必须,直接删掉,它会为每个线程维护一个 Cookie 容器,线程数上万时内存开销很可观。3.2 线程组与加压曲线的设计10W qps 不是一步到位的,需要一条可控的加压曲线。用Stepping Thread Group 或者 Concurrency Thread Group比普通线程组更合适,因为普通线程组是“瞬间拉满”,会在启动瞬间形成冲击尖峰,把被测服务的连接队列打满,后面反而测不准。一个可用的阶梯策略:每 30 秒增加一批并发,每批的增量按“目标 qps ÷ 单线程 qps”反推。假设目标 10W,单线程预估 50 qps,那总线程数约 2000;分 10 批,每批 200 线程,30 秒一档,总爬坡 5 分钟,然后稳定压 10~15 分钟。Ramp-up 期间的数据要单独标记,分析时剔除,否则平均值会被拉低,看不出稳态表现。还有一个细节:线程数的分配要均匀落在各 slave 上。Jmeter 分布式模式下,脚本里的线程数是“每台 slave 都按这个数跑”,不是总和的均分。所以脚本里写 2000 线程、30 台 slave,实际总并发是 6 万。这个坑我踩过,当时看着 30 台机器都在跑,纳闷为什么 qps 和理论差 30 倍,翻文档才发现这个语义。正确做法是脚本按“单台 slave 的线程数”来配置,或者用-G参数在启动时动态指定。# 按单机 70 线程启动,30 台合计 2100 并发 jmeter -n -t plan.jmx -R slave01,slave02,... -Gthreads70 -Gduration9003.3 参数化文件的分发陷阱CSV 数据文件在分布式模式下有个经典问题:master 只分发 jmx 脚本,不分发依赖的 csv。所以每台 slave 的对应路径下必须提前放好同样的数据文件,并且路径要写成相对路径或统一绝对路径,否则 slave 端会报文件不存在,而 master 端的日志未必会把这个错误透出来。数据量也要控制。10 万行 CSV 在 30 台机器上各加载一份,内存和 IO 都有压力。更稳的做法是把数据切成 N 份,每台 slave 只加载自己那一份,通过__threadNum或者系统属性区分;或者干脆用RandomString、UUID这类函数在线生成,省掉文件 IO。如果业务需要唯一值(比如下单 ID),务必保证各 slave 生成的 ID 不冲突。用“slave 序号 时间戳 自增”的组合最省事:// JSR223 前置处理器,Groovy def slaveId System.getProperty(slave.id, 00) def seq vars.getObject(seq) ?: new java.util.concurrent.atomic.AtomicLong(0) def id slaveId - System.currentTimeMillis() - seq.incrementAndGet() vars.put(orderId, id)启动时给每台 slave 传不同的-Jslave.id,冲突问题就解决了。4. slave 批量拉起与远程执行的工程化4.1 手工一台台敲命令是最先该被淘汰的30 台 slave,如果靠 SSH 逐台登录、逐台执行jmeter-server,光是启动和确认状态就要半小时,而且中间某台没起来你根本不知道。这一步必须脚本化。一个最小可用的批量拉起脚本,思路是:并发 SSH 到所有 slave、后台启动 jmeter-server、然后用一次端口探测确认存活。#!/bin/bash SLAVES$(cat slave_list.txt) for h in $SLAVES; do ssh -o ConnectTimeout5 $h \ nohup /opt/jmeter/bin/jmeter-server \ -Dserver_port1099 \ -Djava.rmi.server.hostname$h \ /var/log/jmeter-server.log 21 done wait # 存活探测 for h in $SLAVES; do timeout 3 bash -c echo /dev/tcp/$h/1099 \ echo $h OK || echo $h FAIL done关键在-Djava.rmi.server.hostname。这个参数不写,slave 注册到 master 的地址可能是容器内网 IP 或者 127.0.0.1,master 就连不上,报错通常是Connection refused或者No route to host,而且报错信息非常不直观。多网卡的机器尤其要注意。4.2 slave 端的 JVM 参数必须单独调很多人只调 master 的jmeter脚本里的堆大小,忘了 slave 端。slave 才是真正吃内存的一方,线程数上千时,默认的 1G 堆根本不够,表现是压测跑到一半 slave 直接 OOM 退出,master 端只看到“连接丢失”。slave 端建议在jmeter-server启动脚本里显式指定:# 8核16G 的 slave,给 8G 堆比较稳 HEAP-Xms8g -Xmx8g -XX:MaxMetaspaceSize512m # 用 G1,避免大堆下的长暂停 JVM_ARGS-XX:UseG1GC -XX:MaxGCPauseMillis200 \ -XX:HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath/var/log/jmeter/heap.hprof-Xms和-Xmx设成一样,避免堆动态扩缩带来的额外开销。G1 的MaxGCPauseMillis不要设得太激进(比如 20ms),否则 GC 会频繁触发,反而拖低吞吐。另外,每台 slave 的时钟必须同步。分布式结果汇总依赖时间戳对齐,如果某台机器差了几十秒,合并后的 TPS 曲线会出现诡异的错位。用chronyd或ntpd保证偏差在 100ms 内,启动前用date快速抽查一遍。4.3 master 汇总端不要成为新瓶颈master 收到的结果是所有 slave 回传的原始样本。30 台 slave 各 4000 qps,就是 12 万条/秒的样本写入。这个量级下,master 的磁盘写入会成为瓶颈,表现是 TPS 曲线整体偏低且抖动,但 slave 端自身 CPU 并不高。对策有三个:结果文件写到 tmpfs 或内存盘,压完再落盘分析。12 万条/秒的文本写入,普通云盘的 IOPS 撑不住。关闭不必要的保存字段,只留六个核心字段,样本体积能压掉 60%。分片汇总,让每组 slave 各自输出 jtl,压完后离线合并,而不是全部实时回传给 master。离线合并用简单的脚本就行,不必依赖 GUI:import glob, csv fields [timeStamp,elapsed,label,responseCode,success,bytes] with open(merged.jtl,w,newline) as out: w csv.writer(out) w.writerow(fields) for f in glob.glob(slave_*.jtl): with open(f) as fh: r csv.reader(fh) next(r) # 跳过表头 for row in r: w.writerow([row[0],row[1],row[2],row[3],row[4],row[7]])合并时注意各文件的表头顺序可能因配置不同而不同,最好按字段名索引而不是硬编码列号。5. 结果失真:分布式压测最隐蔽的一类问题5.1 TPS 对不上,先怀疑统计口径分布式压测最典型的困惑是:每台 slave 自己报的 TPS 加起来,比 master 聚合报告里的总数少一大截。原因通常有三个:一是聚合报告的“吞吐量”是按整个测试时长平均的,包含了 ramp-up 和 ramp-down 阶段。如果你压了 15 分钟,前 5 分钟在爬坡,那平均值自然被拉低。要算稳态 TPS,必须把时间窗口切到稳态区间。二是各 slave 的启动时间不同步。master 分发脚本是串行的,30 台 slave 全部启动完成可能相隔几十秒。这段时间里先启动的已经在压了,后启动的还没开始,合并后的曲线开头是斜的。解决办法是给脚本加一个“同步等待”机制,比如让所有线程在某个绝对时间点(如启动后 60 秒)统一开始发压。三是 slave 掉线后数据丢失。某台 slave 中途 OOM 或者网络抖动,master 只是在日志里记一笔,聚合报告里这台机器的数据就没了,总数凭空少一截,而你从报告界面完全看不出来。所以必须核对启动时的 slave 数量和结束时的 slave 数量是否一致,不一致的结果一律作废重跑。5.2 响应时间的分布比平均值重要得多10W qps 场景下,平均值几乎没有参考价值。因为长尾请求会被海量快请求稀释,平均 20ms 看起来很美,但 P99 可能是 3 秒,真实用户已经在骂了。分析时必须看分位数。Jmeter 原生报告对分位数的支持有限,离线分析更靠谱。下面这段用纯 Python 算分位数,不依赖额外库:import csv lat [] ok 0 total 0 with open(merged.jtl) as f: for row in csv.DictReader(f): total 1 if row[success] true: ok 1 lat.append(int(row[elapsed])) lat.sort() def p(q): if not lat: return 0 idx int(len(lat) * q) - 1 return lat[max(idx, 0)] print(f样本 {total}, 成功率 {ok/total:.4f}) print(fP50{p(0.5)} P90{p(0.9)} P95{p(0.95)} P99{p(0.99)} P999{p(0.999)})把这段逻辑固化成一个小脚本,每次压完自动跑,形成固定的分析口径,比每次在界面上点来点去可靠得多。提示:成功率不到 99.9% 的压测结果,讨论 TPS 意义不大。先看错误码分布,把失败原因分类(超时、限流、连接拒绝、业务异常),再决定是优化服务还是调整压测参数。我见过把限流返回当成成功来算 TPS 的,数字漂亮但完全没有意义。5.3 一个真实的排查链路复盘讲一个我印象比较深的 case,过程有代表性。目标 8W qps,30 台 slave,压测跑到第 4 分钟开始,TPS 从 7.5W 掉到 5W 左右,同时 slave 端 CPU 都没有明显升高。第一步,看是不是 master 的问题。查 master 的磁盘 IO 和 CPU,发现 iowait 有上升但不严重,排除。第二步,看 slave 端日志。逐台 grepOutOfMemory、Connection reset,发现 3 台 slave 有java.net.SocketException: Too many open files。检查ulimit -n,发现启动脚本里设了,但jmeter-server是在另一个 shell 会话里拉起的,没继承到。这是第一个根因。第三步,补齐 ulimit 后重跑,还是掉。这次看被测服务侧,发现服务端的 accept 队列有溢出计数(netstat -s | grep -i listen),同时大量连接停在SYN_RECV。查中间链路,发现压测机和被测服务之间有一层负载设备,它的新建连接数有上限,超过后开始丢 SYN。第四步,把 keep-alive 打开。之前脚本里没勾,每个请求都是新连接,连接数被打到每秒 8W。开启连接复用后,新建连接数降到每秒几千,问题消失,TPS 稳定在 7.8W。这个 case 的价值在于:掉 TPS 但 CPU 不高,基本可以断定瓶颈在连接层或网络设备,而不是计算资源。沿着这个方向查,比盲目加机器有效得多。另外它暴露了一个工程习惯问题——ulimit这类环境设置必须写进 slave 的启动脚本内部,而不是靠人工在执行前source一遍,否则换个人操作就漏了。6. 让这套流程可复用:参数模板与验收清单6.1 一份可直接改用的配置基线把前面散落的参数收拢成一份基线,新建压测任务时按需增删:配置项建议值作用slave 堆大小物理内存的 50%,上下限一致避免 OOM 与堆扩展开销GC 策略G1,MaxGCPauseMillis200大堆下控制暂停单机线程数100~200(视脚本复杂度)平衡并发与 CPU保存字段6 个核心字段降低写盘与汇总压力连接复用KeepAlive 开启,TTL 60s规避端口与连接队列瓶颈结果落盘tmpfs 或独立盘避免 IO 瓶颈干扰汇总方式分片 离线合并减轻 master 负担时钟同步偏差 100ms保证时间轴对齐这份基线不是教条,比如脚本极简时单机线程数可以拉到 500,单机 qps 也能到七八千;但脚本里有大量提取和断言时,线程数要相应下调,不然 CPU 全耗在脚本逻辑上。6.2 压测前的十分钟检查清单真正能减少返工的,是开始前那十分钟的固定检查。我把它做成了一个清单,每次照着过:所有 slave 的jmeter-server均已启动,端口 1099 可达,数量与预期一致。每台 slave 的ulimit -n在启动脚本内已设为 100 万,用cat /proc/$(pgrep -f jmeter)/limits抽查。数据文件、依赖 jar 已在所有 slave 的同路径下就位,数量一致。各 slave 时钟偏差在 100ms 内。master 的结果输出路径指向内存盘,磁盘剩余空间充足。被测服务侧的监控(入口 QPS、连接数、错误率)已打开,并且能实时看到。压测脚本里线程数是“单机值”,不是总数。ramp-up 阶段和稳态阶段的边界时间点已记录,便于后续切窗口分析。第 6 条特别值得强调。没有服务端监控的压测等于盲压。压测端的数字只能告诉你“发了多少”,服务端的数字才告诉你“接住了多少”。两边对不上时,服务端的 connection、accept queue、错误码分布就是唯一可靠的路标。6.3 关于 10W 这个目标的一点个人看法做了这么多轮分布式压测,我现在的判断是:10W qps 本身不是难点,难的是证明这 10W 是真实有效的。把 30 台机器拉起来、脚本跑通,一两天就能搞定;但要拿出一份经得起推敲的报告,说清楚这 10W 里有多少成功、P99 是多少、瓶颈卡在哪、换个环境还能不能复现,需要的是上面这一整套口径和流程的约束。还有一点体会是,压测的价值往往不在于那个峰值数字,而在于压的过程中被迫暴露出来的问题:某个连接池上限、某个没关的日志、某台机器的 ulimit。这些在低并发下永远不会显形,一旦量级上去就集中爆发。所以每轮压测结束后,我会把当轮所有异常和对应的根因记成一条,积累下来就是一份属于自己环境的“高频故障图谱”,下次再上量级时,照着图谱先排查一遍,能省掉大半天。最后分享一个小习惯:每次压测的 slave 列表、JVM 参数、内核参数、脚本版本、结果文件,全部打成一份快照归档,命名带上日期和目标值。压测结果不可复现是常态,而快照是唯一能让别人(或者三个月后的自己)重新跑出同样数字的依据。这个动作花不了五分钟,但省下的解释成本非常可观。