性能优化与压测:怎样找到系统的真实瓶颈

从工作负载、压测模型和延迟分解出发,用 RED、USE、Trace、Profiler 与执行计划建立证据链,找到系统的第一个排队点,并验证优化没有把瓶颈转移成新的故障。

一次压测把 CPU 打到 90%,于是团队决定扩容;数据库出现几条慢查询,于是给 SQL 加索引;接口 p99 很高,于是把线程池调大。这些动作都可能有效,也可能只是碰巧改变了现场。CPU 高不等于 CPU 是瓶颈,慢 SQL 不等于数据库是主要矛盾,线程池排队也可能只是下游变慢后的结果。

性能问题的难点不在于“系统慢”,而在于同一症状可以由完全不同的路径产生:请求可能等连接、等锁、等磁盘、等垃圾回收、等下游返回,也可能根本没有进入被测系统,而是堵在负载机。若没有把等待时间和资源状态对上,优化就会变成参数试错。

本文解决一个具体问题:在给定工作负载和服务目标下,怎样找到限制吞吐继续增长、使尾延迟开始陡升的第一个排队点,并用实验确认因果关系。案例继续使用前两篇的活动报名系统。上一篇容量评估算出了流量和资源预算,这一篇负责验证那些系数,解释系统为什么只能到达某个容量。

一、先区分容量、性能与瓶颈

容量回答“在约束下最多能持续处理多少工作”,性能描述“完成这些工作需要多少时间和资源”。瓶颈则是当前工作负载下最先限制系统继续提升的环节。三个概念相互关联,却不能混用。

例如报名服务在 8,000 QPS 时 p99 为 180 ms,在 10,000 QPS 时 p99 上升到 900 ms,完成吞吐只增加到 8,600 QPS。此时容量拐点大约出现在 8,000 到 10,000 QPS 之间。接下来还要判断,限制来自应用 CPU、数据库热点行、连接池、网卡,还是某个同步下游。

瓶颈还与负载相关。详情读取以缓存和网络为主,报名写入受数据库事务和热点竞争限制,批量导出可能先耗尽堆内存。同一套服务可以同时拥有多个潜在上限,但在一次确定的实验中,总会有一个环节先形成不可忽略的队列。性能分析要找的是这个“当前第一个瓶颈”,而不是列出所有可能性。

优化目标必须包含约束

“把 QPS 提高一倍”不是完整目标。若通过无限排队完成更多请求,却把 p99 从 200 ms 拉到 8 s,对在线接口没有价值。若关闭幂等检查后吞吐上升,却产生重复报名,结果也不能接受。一次可验收的性能目标至少包含:

工作负载:报名提交 70%,结果查询 30%,热点活动占 60%
数据条件:报名表 1 亿行,缓存预热到生产命中率
流量目标:持续 20 分钟完成 8,000 request/s
延迟目标:p99 < 200 ms,p99.9 < 500 ms
错误目标:服务端错误率 < 0.1%,不变量错误为 0
资源约束:应用 CPU < 65%,数据库 CPU < 60%,无持续积压
故障条件:压测结束后 2 分钟内恢复,无连接泄漏和消息遗留

缺少其中任何一项,都可能用牺牲另一维度的方式“优化”出漂亮数字。尤其要保留正确性校验:报名人数不能超过库存,同一用户不能产生两个有效名额,事务失败不能留下半完成状态。

二、固定工作负载,避免比较两场不同的实验

优化前后只有一个变量改变,结果才具有解释力。实际压测经常同时换了机器、代码、数据量、流量脚本和缓存状态,最后只能知道新环境更快,无法知道为什么更快。

每次实验应固化一份 workload manifest:代码与配置版本、实例和依赖规格、API 比例、请求体大小、数据规模、key 分布、缓存命中率、测试时长、到达模型、超时与重试策略。报告中不需要堆满所有配置,但必须能据此复现。

平均请求会隐藏昂贵路径

假设 90% 请求命中本地缓存只需 2 ms,10% 请求访问数据库需要 80 ms,平均耗时约 9.8 ms。若测试数据重复使用少量 key,命中率提高到 99.9%,压测测到的只是缓存吞吐。上线后遇到更多冷 key,容量会突然下降。

请求需要按成本分层:成功和失败、缓存命中和回源、小对象和大对象、普通用户和热点用户、单行更新和范围扫描。业务比例不是装饰,它决定被测的是哪套系统。

数据规模会改变执行路径

空表上的索引、全在内存中的数据、刚启动且没有历史版本的 JVM,都不能代表生产。压测库需要接近真实的行数、索引基数和数据倾斜;长时间测试还要覆盖缓存淘汰、GC 周期、连接轮换、日志刷盘和消息积压。

生产数据不能原样搬入测试环境。可以按字段分布生成合成数据,保留热点比例、对象大小和基数,而不是保留真实身份信息。关键在于复现成本结构,而不是复现具体用户。

三、先把证据链接起来,再开始调参数

性能分析可以沿着一条由外到内的证据链推进:用户看到什么,哪类请求变慢,请求时间花在哪里,哪个资源出现排队,最终是哪段代码或哪条 SQL 消耗了资源。

从用户症状到瓶颈根因的性能证据链

入口层适合使用 RED:Rate、Errors、Duration,即请求速率、错误和延迟。资源层适合使用 Brendan Gregg 提出的 USE Method:对每一种资源检查 Utilization、Saturation、Errors,即利用率、饱和度和错误。两者分别回答“服务表现怎样”和“资源发生了什么”。

RED 先确认症状属于谁

总 p99 上升时,先按路由、状态码、实例、机房、租户、分片和请求类型拆分。若只有一个活动、一个分片或一个版本异常,集群平均值会把线索抹平。吞吐也要同时看收到、开始处理和完成三种口径:入口收到 1 万 QPS,服务只完成 8 千,差额正在排队、超时或被拒绝。

延迟直方图要用统一边界或可合并的直方图记录。实例 p99 的平均值不是集群 p99;一分钟 p99 的平均值也不是一小时 p99。应合并原始桶或可合并分布,再计算整体分位数。

USE 检查每一种有限资源

CPU、内存和磁盘之外,线程池、数据库连接池、文件描述符、socket backlog、锁、队列、缓存容量以及消息分区同样是有限资源。对每项资源问三个问题:

资源 利用率 饱和度 错误
CPU 每核使用率、运行时间 run queue、可运行线程数 throttling、机器检查错误
内存 工作集、堆与堆外 reclaim、swap、分配停顿 OOM、分配失败
磁盘 IOPS、吞吐、busy I/O queue、await timeout、介质错误
网卡 bytes/s、packets/s drop、重传、队列 连接错误、超时
连接池 active / max pending、等待时间 获取失败、泄漏
线程池 active / max queue size、queue age reject、任务异常

利用率说明资源忙不忙,饱和度说明是否已有工作在等。CPU 只有 40% 而数据库连接池 pending 持续增长,扩应用 CPU 不会改善问题;数据库 CPU 也可能只有 30%,但单个热点行上的锁队列已经饱和。

Trace 把端到端时间拆成服务时间与等待时间

分布式追踪应记录入口排队、应用处理、RPC、数据库、缓存和消息投递,单独一个总 span 提供不了这些边界。OpenTelemetry 将 trace、metric 和 log 视为不同信号;使用时应让它们能通过 trace ID、实例和业务维度相互跳转,避免仪表盘彼此割裂。

一次 300 ms 请求可能是:入口队列 120 ms、获取连接 80 ms、SQL 执行 60 ms、应用 CPU 15 ms、其余 25 ms。若只看 SQL,60 ms 的确偏慢,但主要损失已经发生在执行之前。时间分解能阻止团队过早钻进某一层。

四、压测模型决定你能不能看见过载

压测工具不是“并发数输入框”。最重要的选择之一,是用闭环还是开环生成负载。

闭环与开环压测模型的差异

闭环模型通常让每个虚拟用户完成一次请求后再发下一次。系统变慢时,虚拟用户也随之变慢,到达率自动下降。它适合模拟“用户等待响应后再操作”的场景,也容易在系统过载时减轻压力。

开环模型按计划速率发请求,不等待上一个请求完成。Grafana k6 的场景文档将 constant-arrival-rate 和 ramping-arrival-rate 归为 open model executors。秒杀开场、上游定时推送和 webhook 等外部到达,更适合开环模型,因为下游变慢不会让真实世界停止产生事件。

协调遗漏会把尾延迟测得过于乐观

若压测线程发出请求后等待 5 秒才继续,它没有记录本该在这 5 秒内到达、却根本没有发出的请求。这就是 coordinated omission。结果中可能只有一个 5 秒慢请求,而真实开环流量下会有大量请求在同一段时间排队。

HdrHistogram提供 coordinated omission correction,但校正直方图只能帮助解释采样偏差,不能替代正确的负载模型。优先使用能够按目标到达率调度、并把未能按时启动的迭代显式报告出来的工具。

负载机也有容量上限

被测系统吞吐不再增加时,先确认负载机没有满。常见征兆包括压测机 CPU 饱和、连接端口耗尽、网卡打满、GC 停顿、请求调度滞后,以及实际发送速率低于计划速率。生成器应暴露 planned rate、actual start rate、dropped iterations 和客户端错误。

大规模测试可以使用多台生成器,但要同步时钟、划分数据、汇总直方图,并验证负载均衡没有让所有流量落到一个入口节点。压测机位于同机房会低估公网或跨地域延迟,位于过远区域又可能只测到网络,位置要与目标场景一致。

五、不要一上来就做全链路压测

不同层级的测试回答不同问题。把所有依赖一次性拉进来,场景更真实,却更难定位;只做微基准,结果精确,却无法代表系统。合适的方法是按问题选择层级,并让结果彼此校验。

测试层级 适合回答 不能直接推出
微基准 一个算法、序列化器或数据结构的单位成本 接口容量与线上 p99
单服务隔离测试 应用 CPU、GC、线程池和本地逻辑上限 真实依赖争用
依赖专项测试 数据库热点、缓存吞吐、队列消费能力 完整调用链容量
链路压测 端到端拐点、放大、资源耦合 单段代码为何昂贵
故障压测 超时、重试、降级和恢复是否有界 正常稳态最高吞吐

Java 微基准应使用 JMH一类能处理预热、死代码消除和测量误差的工具。直接在循环前后打印时间,很容易测到 JIT、常量折叠或日志,而不是目标代码。即便 JMH 显示序列化快了 30%,还要确认该函数在端到端时间中占比足够大;优化只占 1% 的路径,对整体帮助有限。

推荐顺序是:先用隔离测试建立组件上限,再用端到端测试观察交互,最后用故障实验验证保护机制。若全链路先发现数据库连接池等待,可回到数据库专项测试改变 key 分布和事务长度,确认等待由连接总数、慢查询还是锁竞争造成。

六、用阶梯压测找到拐点,而不是只测目标点

一次固定 8,000 QPS 的通过或失败,只能告诉团队一个点。阶梯压测把到达率逐段提高,例如每档持续十分钟:2,000、4,000、6,000、8,000、10,000 QPS。每一档都等待短暂预热后,记录完成吞吐、p50/p99/p99.9、错误率、队列、各资源 USE 指标和正确性检查。

拐点常有以下组合特征:

  • 到达率继续增加,完成吞吐开始变平;
  • p99 先于平均延迟明显上升;
  • 某个队列长度或最老任务年龄持续增加;
  • 某种资源饱和,或一个共享资源的等待时间上升;
  • 超时后重试放大流量,错误率与下游调用量一起增加。

不要把第一个错误出现的点当作唯一结论。系统可能在 8,000 QPS 时已经越过延迟 SLO,直到 11,000 QPS 才返回错误。对用户而言,容量边界在 8,000 附近;对保护机制而言,11,000 只是拒绝阈值。

一场完整压测包含四个阶段

预热:让 JIT、连接池、缓存和页缓存进入稳定状态
爬坡:逐档增加速率,寻找延迟与吞吐拐点
稳态:在目标负载持续运行,观察泄漏、GC、刷盘和积压
过载与恢复:短暂超过上限,再降回安全值,检查能否自行恢复

只跑五分钟通常看不到慢性问题。数据库检查点、日志轮转、缓存淘汰、对象晋升和消费者积压都有较长周期。稳态时长应覆盖这些周期,不能机械规定所有系统都跑一小时。

七、沿请求路径寻找“时间消失在哪里”

发现拐点后,选取拐点前后两档比较,而不是只观察最严重的过载现场。过载后大量超时和重试会制造次生噪声,反而遮住最初的队列。

可以把端到端时间近似拆为:

response_time = admission_wait
              + thread_or_eventloop_wait
              + connection_wait
              + downstream_service_time
              + lock_wait
              + cpu_time
              + runtime_pause
              + network_time

同一段时间不能随意相加:数据库执行 span 内可能同时包含远端 CPU、I/O 和锁等待;应用 wall time 也包含下游等待。分解用于定位等待发生在哪个边界,并与该边界的饱和度相互印证,不要求所有数字机械相加。

用排队点判断因果方向

假设入口线程池队列上升,同时数据库连接等待也上升。连接池位于更下游,先看连接为什么长时间不归还;入口队列可能只是它的上游结果。若数据库执行时间稳定,连接获取等待却增加,可能是连接池太小,也可能是应用拿到连接后执行了非数据库工作。需要 trace 中连接持有区间和池指标共同判断。

有力的证据通常是三件事同时成立:现象随负载稳定复现;指标显示一个明确资源进入饱和;改变该资源或对应代码后,拐点按预期移动。只有相关性,没有受控实验,还不能确认根因。

八、CPU 高时怎样找到耗时代码

CPU 接近饱和、run queue 上升且吞吐受限时,才进入 CPU 路径分析。先分清用户态、内核态、容器 throttling 和 steal time。容器看到 100% 可能只是 1 core quota 已满,宿主机仍很空;高系统态可能来自网络、锁或频繁系统调用。

采样 profiler 比在代码中手工打点更适合全局定位。CPU flame graph 中横向宽度表示采样占比,不表示时间顺序。宽栈说明 CPU 样本集中在那里,需要结合业务价值判断是必要工作、重复工作还是可替换实现。

async-profiler 的模式说明区分 CPU、wall-clock、allocation 和 lock 等事件。CPU profile 找“谁在运行”,wall-clock profile 能看到睡眠和阻塞线程,allocation profile 找高频对象分配,lock profile 找锁竞争。接口 wall time 很高但 CPU profile 很薄时,继续盯 CPU 火焰图不会得到答案,应转向 wall、lock、trace 或下游指标。

典型 CPU 浪费包括重复序列化、正则回溯、过多日志格式化、低效压缩、加密、对象映射以及无界循环。修改后要重新采样,确认目标栈占比下降;只看总 CPU 下降可能受流量或命中率变化影响。

九、内存和 GC 问题还要看分配速率

堆占用高不必然有问题,稳定保留的大缓存可以占用大量内存。更关键的是分配速率、存活率、停顿时间、回收后基线和容器总工作集。若每秒分配数 GB 临时对象,GC 即使每次很短,也会持续消耗 CPU;若回收后基线不断上升,才需要怀疑泄漏或无界缓存。

观察 GC 时应把暂停与请求延迟时间轴对齐。全局停顿可以造成整批请求长尾,分代回收和并发阶段也会改变 CPU。常见改进包括减少临时对象、避免一次性加载大结果集、限制批量大小、流式处理响应,以及给本地缓存设置容量和淘汰策略。

调整堆大小不是通用答案。堆太小会频繁回收,堆太大可能拉长某些停顿并提高单实例故障影响。最终配置要在目标负载下验证,且包含堆外直接内存、线程栈、mmap 和 sidecar,防止 JVM 指标正常而容器被 OOMKill。

十、数据库慢,要区分执行、等待与排队

数据库瓶颈至少有四类:SQL 本身读取过多数据、事务争锁、连接入口排队、存储或日志提交受限。它们可能都表现为接口中 database span 变长,处理方式却不同。

先按归一化 SQL 聚合次数、总时间、p99、rows examined、返回行数、锁等待和临时表,再检查最昂贵语句。MySQL 的 EXPLAIN展示优化器计划;EXPLAIN ANALYZE会实际执行语句,并给出迭代器的估算行数、实际行数、首行时间、执行时间和循环次数。生产使用前必须评估语句副作用与负载,不能对昂贵写操作随意执行分析。

加索引只适合执行路径确实扫描过多的情况。若慢在热点库存行锁,索引不会让互斥写并行;若慢在连接池等待,继续增加池大小可能把排队从应用转移到数据库;若慢在 redo 刷盘,则要检查事务批次、日志盘和持久性要求。

数据库诊断应至少保留以下对照:

应用连接获取等待 ↔ 连接池 active/pending
SQL wall time       ↔ CPU time + I/O wait + lock wait
估算扫描行数        ↔ 实际 rows / loops
提交耗时            ↔ redo/binlog fsync 与复制状态
集群平均负载        ↔ 最热分片、最热表和最热 key

十一、缓存、网络和异步系统也会制造“应用慢”

缓存命中率下降时,应用 CPU 可能不高,数据库请求却成倍增加。需要同时看命中率、回源 QPS、对象大小、热点 key、连接等待和节点延迟。Redis 的延迟监控可以帮助识别慢命令、fork、过期和持久化等延迟事件,但客户端排队、网络和连接池仍需在应用侧观测。

网络问题不能只看平均 RTT。重传、丢包、DNS、TLS 握手、连接重建和大响应都会增加长尾。若 trace 显示下游 server span 很短,client span 却很长,差额可能位于客户端队列、服务发现、网络或两端时间同步误差。抓包不是第一步,先用连接和网卡指标缩小范围。

异步化也不会消灭工作,只是把用户等待变成队列积压。接口响应变快后,要检查消息生产是否可靠、消费者 lag 是否持续增长、最老消息多久,以及最终结果是否仍满足业务时限。只优化入口 p99,却让报名确认从 1 秒变成 10 分钟,是把性能问题移出了仪表盘。

十二、线程池与队列:满只是结果,等待才是线索

线程数增大有时能提高 I/O 密集任务的并发,但超过下游能力后会增加内存、上下文切换和争用。线程池不能脱离任务服务时间、下游并发和 deadline 单独调优。

至少观测 active、pool size、queue length、queue age、task duration、reject 和任务 deadline。queue length 表示有多少任务,queue age 表示最老任务等了多久;对在线请求,后者更接近用户影响。一个长度为 100 的队列可能几十毫秒清空,也可能等待数十秒。

有界队列和拒绝策略属于正确性设计。无界队列在短测中几乎不报错,代价是过载时不断积累已经超时的工作,最终耗尽内存。过载文章会专门讨论限流与熔断;性能压测在这里要验证队列达到上限后是否快速拒绝、上游是否停止无意义重试,以及负载下降后队列能否及时清空。

十三、完整案例:报名接口 p99 为什么突然越过一秒

活动报名系统的目标是持续 8,000 QPS,p99 小于 200 ms。团队使用开环阶梯测试,每档十分钟,热点活动占 60%,报名表预置一亿行。结果如下:

到达率 完成吞吐 p99 错误率 应用 CPU DB CPU
4,000 3,998 72 ms 0.00% 31% 24%
6,000 5,995 104 ms 0.01% 43% 32%
8,000 7,910 410 ms 0.08% 52% 38%
10,000 8,240 1.8 s 3.2% 55% 41%

若只看 CPU,会认为两边都有余量。RED 显示 8,000 QPS 开始违反延迟目标;USE 显示应用线程池队列和数据库连接等待同时增长。Trace 进一步把 410 ms 分成:线程池等待 90 ms、连接等待 170 ms、事务 125 ms、其他 25 ms。

团队没有立即调大两个池。数据库等待事件显示多数事务在竞争同一个活动库存行,事务内部还同步写入一条较大的审计记录。热点锁延长连接占用,连接归还变慢,随后把等待向上传播到线程池。根因链条是:

热点库存行串行竞争
  → 审计写入延长事务与锁持有
  → 数据库连接占用时间增加
  → 连接池 pending 增长
  → 应用线程等待并形成入口队列
  → p99 上升、客户端超时重试

改动把不参与库存正确性判断的审计事件写入同事务 Outbox,提交后异步展开;库存条件更新和报名记录仍在同一事务,业务不变量没有放松。微基准不能验证这个改动,团队用相同 manifest 重跑阶梯压测。

结果是 8,000 QPS 的事务 p99 从 125 ms 降到 48 ms,连接等待从 170 ms 降到 18 ms,接口 p99 降到 142 ms。到 11,000 QPS 时,吞吐再次变平,这次应用 CPU 达到配额且 run queue 上升,CPU profile 显示资格规则反序列化成为最宽栈。

一次优化如何让瓶颈从数据库锁迁移到应用 CPU

第二个瓶颈不是第一次实验失败。任何系统都有下一个限制;第一次优化成功的证据,正是旧队列消失、容量拐点右移,并出现与更高吞吐相匹配的新边界。此时是否继续优化,取决于业务目标。目标只有 8,000 QPS 且保留足够故障余量时,可以停止,而不是为排行榜数字继续增加复杂度。

十四、用受控实验闭合每一个优化假设

一次可靠的优化记录可以压缩成六项:

  1. 现象:在哪个负载下,哪个 SLO 先失败;
  2. 证据:哪个队列、等待或资源进入饱和;
  3. 假设:哪条因果链解释这些证据;
  4. 改动:只改变能验证假设的最小变量;
  5. 结果:相同 workload 下吞吐、延迟、错误和资源怎样变化;
  6. 副作用:成本、正确性、恢复、下游和新瓶颈是否可接受。

不要一次提交“索引 + 缓存 + 线程池 + JVM 参数 + 扩容”。即使结果变好,也无法知道哪个改动有效,哪个只是增加风险。必要时先用可逆实验验证:临时隔离昂贵逻辑、改变一个池上限、将一部分请求导向新实现,再决定长期设计。

优化结果至少比较四组数字

same load:相同负载下,延迟和资源成本是否下降
same resource:相同资源下,满足 SLO 的吞吐是否提高
same throughput:相同吞吐下,单位请求成本是否下降
overload/recovery:超过上限后,错误是否有界且能恢复

只比较最高 QPS 容易作弊:增加机器、放宽超时或牺牲 p99 都能让数字上升。单位请求 CPU 时间、数据库操作数、网络字节和成本能说明效率是否真的提高。

优化上线后还要用小流量或灰度验证生产分布。实验室很难完整复现多租户噪声、真实热点、跨区网络和后台任务。上线指标必须与压测指标同名同口径,否则无法判断结果是否迁移成功。

十五、常见误区

性能优化中很昂贵的一类错误,是把症状直接当成原因:

  • 看到 CPU 高就扩容,没有检查配额、run queue 和 CPU 栈;
  • 看到 CPU 低就认为有余量,忽略锁、连接和单线程资源;
  • 只看平均延迟,没有观察 p99、p99.9 和分布变化;
  • 用闭环并发压测外部到达系统,过载时请求率反而下降;
  • 只记录已发请求,忽略生成器未按计划发出的迭代;
  • 测试数据过小、key 过于均匀或缓存命中率失真;
  • 一次修改多个变量,得到结果却无法解释因果;
  • 调大线程池、连接池和队列,把等待推向下游;
  • 只验入口响应,没有验异步积压与业务不变量;
  • 只跑正常稳态,没有验证过载后的恢复;
  • 优化到达目标后仍追逐极限,增加不必要的复杂度。

十六、一份从现象到结论的执行清单

1. 写明业务目标、工作负载、数据规模和正确性约束
2. 固定版本、资源、key 分布、缓存状态与到达模型
3. 检查负载机实际发送率、调度滞后和自身资源
4. 阶梯提升负载,记录吞吐、分位延迟、错误与恢复
5. 用 RED 确认哪类请求、实例、分片首先异常
6. 用 Trace 分解服务时间、排队与下游等待
7. 用 USE 检查每个有限资源的利用率、饱和度和错误
8. 根据证据选择 CPU、内存、锁、数据库、缓存或网络工具
9. 写出可证伪的因果假设,只改变一个关键变量
10. 重跑同一工作负载,验证拐点、单位成本和正确性
11. 超过新上限并降载,检查拒绝、积压和自动恢复
12. 保存报告、profile、配置与原始分布,形成版本基线

性能工程应交付一条可复查的证据链,而非停在火焰图或“单机十万 QPS”的数字上:什么工作负载触发了什么现象,等待在哪一层形成,哪项资源先饱和,改动为什么能改变它,以及新的边界在哪里。容量评估给出待验证的预算,性能测试校准预算;后续的缓存、消息队列、热点和高可用设计,都会复用这套方法判断机制是否确实改善了系统。

参考资料