LOAD TESTING · QPS · P99 · LITTLE'S LAW · USL · BOTTLENECK

一点点加人,看系统在哪儿喘不过气

压测就是用工具假装成很多用户,同时请求你的系统,然后一点点加压。要回答三个问题:最多能扛多少?哪里最先撑不住?撑不住的时候是什么样子?几乎所有系统,压出来都是下面这个形状。

轻松区加人,吞吐跟着涨;响应时间几乎不变
饱和区吞吐到顶了;多出来的请求在排队,响应时间开始涨
下滑区互相抢、互相等,吞吐反而下降,响应时间飙升

示意数据:每个用户不停发请求、没有思考时间,系统有中等程度的争用和协调开销(参数见下面的实验台)。鼠标移到图上看每个点的数值。

压测机JMeter · Gatling · k6 · wrk

假装成成百上千个用户,按设定的节奏发请求,记下每个请求花了多久。

它自己也会累,也要盯着
被测系统应用 · 数据库 · 缓存 · 下游

一个请求要依次穿过好几道关,哪一道都可能先满。

环境越像生产,结论越可信
监控Prometheus · Grafana · APM

压测时同时看 CPU、GC、线程、连接池、慢 SQL,找到最先变红的那个。

没有监控,压测只得到一个数
瓶颈最窄的那段水管

整条链路的上限由最慢的一环决定。修好它,下一个就会冒出来。

压测的真正目标是找到它

压什么:盯住五个数

一次压测的报告,核心就是这五个数。只看其中一个,很容易得出错误的结论。

QPS / TPS吞吐量每秒处理多少个请求(或事务)。系统能干多少活。
RT · P99响应时间用户等了多久。看 P99:100 个请求里最慢的那 1 个也不超过它。
Concurrency并发数同一时刻正在处理中的请求有多少个。
Error rate错误率超时、报错、被限流的比例。通常要求低于 0.1%。
Utilization资源利用率CPU、内存、磁盘、网络、连接池用了几成。一般别超过 70%~80%。
前三个数绑在一起:利特尔法则
并发数 = 吞吐量 × 响应时间

1000 QPS × 0.2 秒 = 200 个请求同时在处理,正好是 Tomcat 默认的 200 个线程。响应时间一变慢,同样的吞吐就要更多并发,线程很快就不够用。

为什么不看平均值
98 个 50 ms1 个 500 ms1 个 3000 ms

平均 84 ms,看着很好;P99 是 500 ms,最慢的用户等了 3 秒。用户量一大,这 1% 就是成千上万个人。

六种压测,加压的方式不一样

小图是示意:横轴是时间,纵轴是压力,虚线是业务目标。

基准测试Benchmark

单个接口、很小的压力,量出“正常时有多快”,作为以后对比的基线。

回答:改代码后变快还是变慢了?
负载测试Load

一级一级加到目标压力,每级停几分钟,看各项指标是否达标。

回答:大促那天的量扛得住吗?
压力测试Stress

超过目标继续加,直到系统崩溃,找到极限,也看它怎么崩、能不能自己恢复。

回答:极限在哪?崩了以后会怎样?
稳定性测试Soak

用接近峰值的压力连续跑几个小时,看内存、连接、磁盘会不会慢慢漏光。

回答:能一直这样跑下去吗?
尖峰测试Spike

流量瞬间翻好几倍再落回来,看限流、扩容、排队能不能接住,过后能不能恢复。

回答:秒杀开始那一秒会怎样?
全链路压测Production

直接在生产环境,在真实流量(灰线)上叠加打了标记的压测流量,写进影子表,不影响真实数据。

回答:整条链路,真实环境下行不行?

为什么吞吐会到顶,甚至往下掉

通用扩展定律(USL)把原因归成两种:大家抢同一样东西(争用),以及大家要互相同步(协调)。调一调,看曲线怎么变。

吞吐(N) = λ·N / ( 1 + σ·(N−1) + κ·N·(N−1) )  N = 并发数,λ = 一个用户单独用时的吞吐(这里是 100 QPS)
σ 争用:只能一个一个来的部分
比如一把全局锁、大家改同一行库存、单线程的 Redis。加再多人,这部分也只能排队,吞吐有上限。
κ 协调:人越多,互相同步越多
比如缓存要保持一致、分布式锁来回通信、节点间同步数据。开销按人数的平方涨,会让吞吐过了峰值往下掉。

利用率越高,排队越长
利用率 50%
1 倍
利用率 70%
2.3 倍
利用率 80%
4 倍
利用率 90%
9 倍
利用率 95%
19 倍

排队时间是处理时间的几倍(单队列模型:利用率 ÷ (1 − 利用率))。80% 以后曲线就竖起来了,所以容量规划一般只用到 70%~80%。

调用的服务越多,越容易碰上慢的
调 1 个
1%
调 10 个
9.6%
调 50 个
39.5%
调 100 个
63.4%

一个请求要并行调 N 个服务、全部返回才算完。每个服务都有 1% 的慢请求,那么至少碰上一个慢的概率是 1 − 0.99N。下游各自的 P99 会变成你的 P50。

压测机怎么发请求,会改变你看到的结果
闭环:等回来再发下一个

10 个虚拟用户。系统卡住 1 秒,只有 10 个请求挨了这 1 秒,压测机也跟着停下不发了,报告里的 P99 看着还行。JMeter 线程组、wrk 默认这样。

开环:按固定速度发,不管回没回来

每秒 100 个。同样卡 1 秒,这 1 秒里来的 100 个请求全部挨等,P99 立刻暴露。真实用户就是这样来的。k6 的 constant-arrival-rate、Gatling 开环注入、wrk2 是这种。

瓶颈实验台:最窄的那段水管

一个“查商品详情”接口,请求要依次穿过下面几道关。每道关能扛的 QPS 不一样,系统的上限就是最小的那个。目标是扛住 10,000 QPS。打开右边的优化,看瓶颈怎么一个接一个地换地方。

应用实例(加机器)
优 不改代码 · 代 花钱,数据库扛不住时没用
给查询加索引优 几乎零成本 · 代 写入稍慢,占磁盘
加 Redis 缓存(命中 90%)优 挡住九成查询 · 代 数据可能不一致,要防击穿和雪崩
只读从库(读写分离)
优 读能力成倍涨 · 代 主从延迟,刚写完可能读不到
连接池大小
优 改个配置 · 代 数据库压力跟着变大
响应开 gzip 压缩优 响应小 4 倍 · 代 每个请求多花 CPU

横轴是对数刻度,每一格差 10 倍;虚线是目标 10,000。数字是示意,每台 4 核、每次查库占连接 5 ms(全表扫描 50 ms)、每个响应 20 KB、带宽 1 Gbps。

改动记录

    常见瓶颈速查

    压测时最先变红的,一般是下面八个之一。每个都写了:你会看到什么、为什么、用什么查、怎么解。

    应用 CPU 打满
    看到
    CPU 90% 以上,QPS 上不去,响应时间跟着涨
    原理
    每个请求要花固定的 CPU 时间,上限 ≈ 核数 × 1000 ÷ 每个请求的 CPU 毫秒数。线程太多还会频繁切换,白白耗 CPU
    定位
    top -H 找最忙的线程,async-profiler 画火焰图,看是 JSON 序列化、正则、加解密还是打日志
    解法
    优化热点代码(最省钱);结果能复用的加缓存;加机器(最快,但花钱)
    GC 频繁、停顿长
    看到
    P99 一阵一阵地抖,CPU 有一块花在 GC 上,严重时 OOM
    原理
    请求越多,临时对象产生越快,Young GC 越频繁;对象活得久就进老年代,Full GC 时所有线程停下来等
    定位
    GC 日志、jstat -gcutil、JFR 录一段;内存泄漏用 jmap 导出堆再分析
    解法
    少造临时对象、别一次查太多数据;调堆大小;换 G1 或 ZGC(停顿短,但多占 CPU 和内存)
    线程池、连接池排队
    看到
    CPU 不高,响应时间却很高;连接池等待数不为 0;Tomcat 忙线程到 200
    原理
    利特尔法则:每个请求占得越久,需要的线程和连接越多;池子满了就排队(数据库第 01 篇)
    定位
    Actuator 指标 hikaricp.connections.pending、tomcat.threads.busy,jstack 看线程卡在哪
    解法
    缩短占用时间(最好,比如把外部调用挪出事务);适当调大池子(只是把压力挪到下游)
    数据库
    看到
    慢查询变多,数据库 CPU 或磁盘 IO 很高,等锁超时
    原理
    没索引就全表扫描;大家改同一行要排队等行锁(数据库第 02 篇);数据库连接数有上限
    定位
    慢查询日志、EXPLAIN、performance_schema.data_locks
    解法
    加索引、改 SQL;加缓存挡读;读写分离;最后才是分库分表
    缓存
    看到
    Redis 某一个节点 CPU 100% 或带宽打满;或者缓存一过期,数据库瞬间被打爆
    原理
    Redis 用一个线程执行命令,热门 key 全落在同一个分片;大 key 一次传很多数据;同一批 key 同时过期,请求全涌向数据库
    定位
    redis-cli --hotkeys、--bigkeys、SLOWLOG GET、命中率监控
    解法
    热 key 加一层本地缓存(Caffeine);拆大 key;过期时间加随机数;缓存重建加互斥锁
    下游依赖
    看到
    自己 CPU 很闲,线程却全卡在 HTTP 调用上
    原理
    等下游的时间也占着自己的线程;下游一慢,自己的线程很快被占满,接着整条链路一起慢
    定位
    链路追踪(SkyWalking 等)看每一段耗时;jstack 里大量线程停在 socketRead
    解法
    设超时;熔断降级;不需要马上要结果的改成异步(@Async);给慢下游单独一个线程池,隔离开
    网络
    看到
    带宽曲线顶平;或者大量 TIME_WAIT,新连接建不上
    原理
    1 Gbps ≈ 125 MB/s,20 KB 的响应最多约 6000 QPS。短连接每次都要握手,主动关闭的一方留 TIME_WAIT 60 秒,本机可用端口默认只有约 2.8 万个
    定位
    sar -n DEV 1 看带宽,ss -s 看连接状态
    解法
    响应压缩、少返回无用字段;HTTP keep-alive 和连接池复用连接;静态资源放 CDN
    锁和热点数据
    看到
    加机器 QPS 不涨,甚至下降;某一行数据(比如秒杀库存)的更新特别慢
    原理
    大家抢同一把锁或同一行,只能一个一个来,就是上面 USL 里的 σ
    定位
    jstack 里大量 BLOCKED 线程;数据库 sys.innodb_lock_waits
    解法
    缩小加锁范围;库存拆成多段分别扣;Redis 预扣 + 消息队列异步落库

    常见解决方案,各有代价

    没有白给的优化。大致按“先便宜后昂贵”排列:先查代码和 SQL,再加缓存,最后才动架构。

    方案解决哪类瓶颈好处代价
    优化代码CPU、GC+ 最省钱,一直有效− 要先定位清楚,花人力
    加索引、改 SQL数据库+ 成本最低,效果常常最大− 写入变慢一点;要会看执行计划
    调大线程池、连接池排队+ 改个配置就行− 只是把压力挪到下游;池子太大互相抢 CPU 和锁
    加缓存(本地、Redis)读多写少+ 快几十倍,挡住大部分数据库流量− 数据一致性;穿透、击穿、雪崩;占内存
    加机器(水平扩容)应用 CPU、线程+ 不改代码,见效最快− 花钱;只对无状态服务有效;下游扛不住时反而压得更狠
    读写分离读多+ 读能力随从库数量增加− 主从延迟,刚写完可能读不到
    异步化、消息队列削峰突发流量、慢操作+ 尖峰被摊平,主流程变快− 结果不是马上可见;消息丢失、重复、乱序都要处理
    限流超过承载的流量+ 系统不会被打垮− 一部分用户直接被拒绝
    熔断降级下游变慢或挂掉+ 一个下游出事不会拖垮全部− 功能打折,只能返回兜底数据
    分库分表写多、数据量大+ 写能力和存储都能扩− 复杂度最高:跨库查询、分布式事务、扩容迁移

    一次压测怎么做

    压测不是“跑一下看个数”。前面的准备决定结论能不能信,后面的监控决定能不能找到瓶颈。

    1定目标峰值 QPS(比如日常峰值 × 3)、P99 < 200 ms、错误率 < 0.1%
    2备环境和数据机器规格、配置、数据量尽量像生产
    3写脚本按真实比例混合接口,参数随机化,下游用挡板
    4预热低压跑几分钟,让 JIT、缓存、连接池就位
    5阶梯加压每级停 3~5 分钟,等指标稳定再加
    6盯监控找瓶颈应用、JVM、中间件、系统、压测机一起看
    7优化后再压改一处压一次,写下容量结论
    工具脚本发请求方式好处不足
    JMeter图形界面配置(.jmx)默认闭环(线程组)生态最全,HTTP、JDBC、MQ 都能压,上手快一个线程一个用户,单机并发有限;正式压测要用命令行模式
    GatlingJava / Kotlin / Scala 代码开环、闭环都支持异步发请求,单机能压很高;报告好看要写代码
    k6JavaScript开环、闭环都支持脚本轻,适合放进 CI 每次跑不是 Node.js,npm 包不能直接用;以 HTTP 为主
    wrk / wrk2命令行 + Luawrk 闭环,wrk2 固定速率极轻,单机就能打出很高 QPS;wrk2 修正了延迟统计只适合单个 HTTP 接口,做不了复杂业务流程
    LocustPython以闭环为主Python 写脚本灵活,分布式简单单进程性能一般,要多开 worker
    云压测服务(如阿里云 PTS)控制台配置都支持不用自己准备压测机,能从多个地域打出很大流量花钱;压内网服务要先打通网络
    系统
    top / htopvmstat 1iostat -x 1sar -n DEV 1ss -s
    JVM
    jstat -gcutiljstackJFRArthasasync-profiler
    应用
    Actuator + MicrometerPrometheusGrafanaSkyWalking
    中间件
    MySQL 慢查询日志Redis INFO / SLOWLOGMQ 消费堆积

    JDK 8 和 JDK 17

    压测的方法两边一样。不同的是默认 GC、自带的诊断工具,以及 Spring Boot 的观测方式。

    JDK 8 · Boot 2

    JDK 8
    • 默认 GC 是 Parallel,吞吐优先、停顿较长,压测时 P99 容易抖;很多项目手动换成 G1
    • JFR 在 OpenJDK 8u262 之后才有,之前多靠 jstack、jstat、Arthas
    • 8u191 之前识别不了容器的 CPU 和内存限制,放进 Docker 里堆和线程数可能算错
    • Actuator + Micrometer 出指标,链路追踪多用 Spring Cloud Sleuth 或 SkyWalking 探针

    JDK 17 · Boot 3

    JDK 17+
    • 默认 G1;ZGC 可用于生产,停顿通常不到 1 毫秒,P99 更稳,代价是多占一些 CPU 和内存
    • JFR 内置,-XX:StartFlightRecording 打开,压测时录一段,开销很小
    • Boot 3 用 Micrometer Observation 和 Micrometer Tracing,替代了 Sleuth
    • JDK 21 + Boot 3.2 开虚拟线程后,Tomcat 线程不再是瓶颈,但连接池、数据库、下游照样是
    没变的:利特尔法则上限由最窄的一环决定看 P99 和错误率利用率 80% 以后排队陡增压测机也要监控

    常踩的坑

    压测最怕的不是压不上去,而是压出一个好看但不真实的数。

    压测机自己先累趴了

    压测机的 CPU、带宽或本地端口先到顶,测出来的是压测机的上限。同时监控压测机,不够就多台分布式压。

    只看平均响应时间

    平均 50 ms 可能藏着 1% 的 3 秒。报告里一定要有 P95、P99,最好还有 P999 和最大值。

    闭环压测把卡顿藏起来了

    系统一卡,压测机也停下来等,慢请求被少算了。模拟互联网流量用固定到达速率:k6 constant-arrival-rate、Gatling 开环、wrk2。

    结果好得不真实

    所有请求都用同一个用户、同一个商品 ID,全命中缓存;数据库里只有几千行。参数要随机,数据量要接近生产。

    刚开始几分钟数据特别差

    没预热:JIT 还没编译热点代码,缓存是空的,连接池还在建连接。先低压跑几分钟,这段不计入结果。

    QPS 很高,其实一半是报错

    被限流、500 错误返回得特别快,把 QPS 撑高了。吞吐量一定要和错误率一起看,只算成功的请求。

    压测真的发了短信、扣了钱

    短信、支付这类下游没挡住。测试环境用挡板(模拟服务);生产压测要给流量打标记,写影子表,在出口拦住。

    单接口压得很好,一上线就挂

    多个接口抢同一个连接池、同一个数据库,只有混在一起压才暴露。按线上真实比例做混合场景压测。

    压 10 分钟没事,跑一晚上就 OOM

    内存泄漏、连接泄漏、日志写满磁盘,要跑久了才出现。上线前做几个小时以上的稳定性压测。

    测试环境的结论直接套到生产

    机器规格、配置、数据量、网络都不一样。尽量同规格;按比例折算时留足余量,比如只用到压测上限的 70%。