26 如何找到瓶颈:Amdahl 定律、剖析、基准测试的陷阱

开篇提问

先给你一个几乎所有性能优化项目都会犯的错误:

你的服务响应慢。你做了性能剖析(profiling),发现:

  • 函数 A 占总耗时的 30%
  • 函数 B 占总耗时的 5%

你只有一个星期时间,优化哪个?

直觉答案:优化 A(30% 那个)。

但请先回答一个问题:优化 A 能让整体快多少?

如果你把 A 优化到完全不耗时(提速无穷大),整体能快多少? 答案:1/(1-0.30) = 1.43 倍,也就是快 43%。

而如果你优化 B(5% 那个),把它优化掉一半(B 从 5% 降到 2.5%),整体只快 2.6%。

看起来还是该优化 A。但这里有个关键问题被忽略了:A 好不好优化?

  • 如果 A 已经是业界最优算法,你最多优化 10% → 整体只快 3%
  • 而 B 如果是个明显的错误实现(比如 O(n²) 可以改成 O(n)),你可能优化 90% → B 从 5% 降到 0.5%,整体快 4.7%

这还没完——真正的洞察在下一层:如果 A 是”根本不需要调用”的呢?把它整个删掉,整体快 43%;而 B 就算优化到零也只快 5%。

所以真正的答案是:看”收益 / 成本”比,而不是看”占比”。

先别急着往下看。你把这个开篇案例自己再推演一遍,特别想清楚这几个问题:

  1. “占比”和”可优化性”是两回事,为什么大多数人只看到了”占比”?
  2. “把它优化到零”和”它根本不该存在”,这两个动作的差别是什么?哪个收益更大?

这一章,我们把”找瓶颈”这件事讲透——包括最常见的几个陷阱,那些让无数优化项目白干一场的陷阱。

这一章的核心,可以用一句话概括:不要优化你没有测量过的东西,也不要测量那些不该存在的东西。


子主题一:Amdahl 定律——优化的天花板

第 6 章讲过 Amdahl 定律在并行计算里的应用,这里把它推广到所有优化工作:

如果某部分占总耗时的比例是 P,你把这部分优化掉的比例是 S(比如优化一半则 S=0.5),那么整体加速比 = 1 / ((1-P) + P/S)。

几个数字感受一下:

目标占比 P优化程度整体加速
30%完全消除1.43×
30%优化一半1.18×
10%完全消除1.11×
50%完全消除2.0×
90%完全消除10×

这个定律最重要的含义,是一条严厉的警告

如果你的优化目标只占 10%,就算你把它优化到完全免费,整体也只快 11%。

这就是为什么”先看占比,再动手”如此重要。 无数工程师花几周优化一个占 3% 的函数,最后整体提升 3%,还觉得”很有成就感”。

Amdahl 定律的反面也有价值:它告诉你要去”找大的”

  • 优化 50% 的部分,收益远超优化 10 个占 5% 的部分
  • 这就是”抓大放小”的数学依据

另一个推论:串行部分(无法并行的部分)决定了系统的天花板

在分布式系统里,那些”必须串行”的环节(协调、合并、全局锁)往往才是真正的瓶颈——而它们常常被忽视,因为它们看起来”很小”。

一个帮助你理解 Amdahl 定律的类比:煎饼店

你开了一家煎饼店,做一个煎饼要 2 分钟:摊面糊 1 分钟、加料 30 秒、打包 30 秒。

现在你想”提速一倍”,于是拼命优化”加料”环节——把 30 秒压到 15 秒。结果呢? 整个煎饼从 2 分钟变成 1 分 45 秒,只快了 12.5%。

为什么你明明把加料提速了一倍,整体只快了 12.5%? 因为加料只占 25% 的时间。真正的大头(摊面糊 1 分钟,占 50%)你没碰。

Amdahl 定律就是这道算术题的推广:你优化某一部分的收益,被它的”占比”死死限制住了。 要提速,先找到那个”占 50% 的摊面糊”,而不是那个”占 25% 的加料”。


子主题二:剖析(Profiling)—— 别猜,去测

既然要”看占比”,那怎么知道各部分占多少?答案:剖析(Profiling)。

“猜”和”测”的差距有多大?

大到惊人。无数次经验表明:工程师凭直觉猜出的”热点”,和实际的热点往往完全不同。

常见原因:

  • 你以为慢的地方(比如复杂的算法),调用次数很少
  • 真正慢的地方可能很不起眼(一次日志打印、一次序列化、一次意外的网络调用)
  • 慢的可能是”等待”而不是”计算”(锁等待、IO 等待)

几种剖析方式

1. CPU Profiling(最常用)

  • 采样:定期中断程序,记录当前的调用栈
  • 统计”哪些函数出现在栈顶(正在运行)的次数最多”
  • 输出:火焰图(Flame Graph)
  • 回答:“CPU 时间花在哪了”

2. Memory Profiling

  • 记录内存分配的位置和大小
  • 回答:“内存在哪分配了?有没有泄漏?”

3. Blocking / Contention Profiling

  • 记录阻塞在锁、channel、IO 上的时间
  • 回答:“程序在等什么?”
  • 这是最被低估、但往往最有价值的一种剖析——因为现代服务里,“等待”常常比”计算”占比更大

4. 分布式追踪(第 14 章)

  • 跨服务的耗时分布
  • 回答:“这个请求在哪个服务/哪一步最慢”

剖析的关键原则

  1. 剖析生产环境(或尽可能接近生产的负载)——开发环境的数据量和访问模式往往完全不同
  2. 剖析真实负载——合成负载可能触发完全不同的代码路径
  3. 对比剖析——“正常时”和”异常时”各剖一次,对比差异,比单独看一份剖析有用得多

一个帮助你理解”为什么猜不准”的类比:找家里丢的钱

你发现每个月钱都花得很快,凭直觉你猜”肯定是吃饭花太多”。于是你拼命省饭钱。

结果一记账(= 剖析),你发现:真正的大头是”每天一杯奶茶 + 各种会员自动续费”——那些”看起来不起眼”的小钱,加起来比饭钱还多。

“猜”之所以不准,是因为你只会关注”显眼”的(大额支出、复杂算法),而忽略”不起眼但高频”的(奶茶、日志、序列化)。 而 profiling 的价值,就是把你从”显眼的偏见”里拉出来,用数据告诉你真相。


子主题三:基准测试(Benchmark)的六大陷阱

找到瓶颈后,你要验证优化效果——这就需要基准测试。而基准测试,是一个”错误率极高”的领域。

陷阱一:测了错误的东西(最常见)

  • 你测了”单次函数调用耗时”,但真正的瓶颈是”调用次数太多”
  • 你测了”算法A vs 算法B”,但数据量根本不在同一个量级
  • 防御:先明确”你要优化的指标是什么”(P50?P99?吞吐?内存?)

陷阱二:没有预热(Warm-up)

  • JIT 语言(Java、JS)需要预热才能达到峰值性能
  • 缓存需要预热
  • 防御:跑几轮预热,丢弃预热阶段的数据

陷阱三:测试环境不真实

  • 在笔记本电脑上测服务端性能
  • 数据量太小(1 万条数据测出来的结论,在 1 亿条时不成立)
  • 没有其他负载干扰(生产环境有 GC、有其他进程、有网络抖动)
  • 防御:尽可能贴近生产环境

陷阱四:编译器优化掉了你的测试代码

  • 经典:你测一个函数,但返回值没被使用,编译器直接把整个调用优化掉了
  • 防御:确保结果被使用(比如累加到一个 volatile 变量、或输出)

陷阱五:方差被忽略

  • 只跑一次,得到一个数字,就下结论
  • 防御:多次运行,看分布(中位数、P99、方差)。如果两次运行的差距比你要验证的优化还大,那你的测量毫无意义。

陷阱六:微基准脱离真实场景(最隐蔽)

  • 你测出”算法 A 比算法 B 快 10 倍”,但在真实系统里,这个函数只占 0.1% 的时间
  • 防御:微基准的结果,必须放回端到端的场景里验证
  • 这条和 Amdahl 定律直接相关:微基准的收益 × 占比 = 真实收益

一个帮助你理解”微基准陷阱”的类比:测单次举重 vs 测马拉松

你测出”我能举 100 公斤”,于是在简历上写”我很强壮”。但你要参加的是马拉松——举重能力几乎无关紧要。

这就是微基准的陷阱:你在一个”脱离真实场景”的指标上测出了漂亮数字,但这个指标在真实场景里的权重,可能小到可以忽略。 微基准测的是”举重”(单点极值),而真实系统关心的是”马拉松”(端到端、有各种干扰、有长尾)。


子主题四:找瓶颈的系统化方法

把前面的内容串成一个可执行的流程:

第一步:确认指标

你要优化的到底是什么?

  • 延迟(P50 还是 P99?)
  • 吞吐
  • 资源占用(内存、CPU、成本)
  • 必须先明确指标,否则你会优化错方向

第二步:分解,找到”大块”

把端到端的耗时,按环节分解:

  • 用分布式追踪看跨服务的耗时分布
  • 用 profiling 看进程内的耗时分布
  • 目标:找到占比最大的 1-2 个环节

第三步:对每个”大块”深挖一层

  • 是”计算”还是”等待”?
  • 如果是计算:算法复杂度?数据布局?缓存命中?
  • 如果是等待:等什么?锁?IO?网络?GC?

第四步:问”能不能不做”(最高杠杆的问题)

  • 这个调用必要吗?
  • 能不能缓存?
  • 能不能提前算(预计算)?
  • 能不能批量做?
  • 能不能换到别的时机(离线/异步)?
  • 这一步的收益,通常远超任何”把它做得更快”的优化

第五步:提出假设,量化验证

  • 假设:“我认为瓶颈是 X,因为 Y”
  • 验证:改一下(或用实验验证),测量变化
  • 没有验证的优化,等于没做

第六步:警惕”优化了一个,冒出另一个”

系统是个整体,优化一处可能让别处成为新瓶颈。优化后要重新测量端到端指标,而不是只看你优化的那一块。

第四步为什么是”最高杠杆”

第 1-3 步是”把它做得更快”,第 4 步是”能不能不做”。这两个动作的天壤之别在于:

  • “做得更快”:最多把成本降到零(极限是”完全不耗时”),而且往往要付出大量努力。
  • “不做”:直接消除整块成本,收益立竿见影,而且往往是”改一行配置”的代价。

这就是”定义问题”(第 25 章)在性能领域的直接应用:在优化之前,先问”这个东西需不需要存在”。 一个”每分钟跑一次却从没人看结果”的定时任务,你把它优化到再快,也不如直接删掉它。


子主题五:几个典型的”瓶颈盲区”

有些瓶颈特别容易被忽视,值得单独列出来:

盲区一:N+1 查询

循环里逐条查数据库,而不是一次批量查。这是 ORM 用户最常见的性能杀手,而且在数据量小的时候完全看不出来。

盲区二:序列化 / 反序列化

JSON 序列化、protobuf 编解码——这些看起来”不起眼”的操作,在高频调用下可能占 CPU 的 20-30%。

盲区三:日志

日志打印(尤其是同步写、或者日志里有大量字符串拼接、反射)在高 QPS 下代价惊人。很多服务的性能问题,最后发现是日志打太多。

盲区四:GC 压力

频繁分配导致频繁 GC。CPU profile 上可能看不到(因为 GC 有自己的线程),但”GC 时间占比”这个指标会暴露它。

盲区五:锁竞争 / 上下文切换

CPU 使用率不高,但延迟很高 → 很可能在等锁或频繁上下文切换。用 blocking profiling 看。

盲区六:意外的网络调用

某个”看起来是本地调用”的函数,实际上在内部发起了网络请求(比如 DNS 解析、某个 SDK 的隐式上报)。用追踪看,这些会暴露出来。

盲区七:冷启动 / 首次调用开销

第一次调用要初始化、要建连接、要 JIT 编译——这在”平均延迟”里看不出来,但在 P99 里很明显。

这些盲区的共同特征

你注意到没有,这七个盲区有一个共同特征:它们都是”看起来不重要、但其实很重要”的东西。 日志、序列化、N+1、DNS、冷启动——没有一个是”高大上”的复杂算法,但每一个都能在你没注意的时候,吃掉你 20-30% 的性能。

这印证了开篇那句:真正慢的地方往往很不起眼。 而它们之所以是”盲区”,恰恰是因为它们太不起眼,你从没想过要去”看”它们。


演进脉络

把”找瓶颈”的方法演进串起来,你会看到一条”从经验到数据”的主线。

  • 1967:Amdahl 定律提出,为并行计算的收益设定了理论上限。
  • 1970s:性能剖析工具出现(Unix 的 prof、gprof)。
  • 1990s-2000s:更精细的剖析技术(采样剖析、硬件性能计数器 perf)。
  • 2010s:火焰图(Brendan Gregg)让剖析结果变得直观可读;分布式追踪(Dapper、Zipkin、Jaeger)让跨服务瓶颈可见;持续剖析(Continuous Profiling)兴起。
  • 现代:eBPF 让无侵入、低开销的生产环境剖析成为可能;可观测性平台把指标/日志/追踪/剖析整合。

主线:找瓶颈的方法,是从”经验猜测”走向”数据驱动”,从”看平均值”走向”看分布”,从”进程内”走向”跨服务”,从”事后剖析”走向”持续剖析”。

而贯穿始终的核心教训只有一条:不要优化你没有测量过的东西。

一个值得你带走的”方法论洞察”

这一章的演进主线里,藏着一个更普适的规律:“找瓶颈”这件事,本质是”消除认知偏差”的过程。

  • 你猜瓶颈在哪 → 有偏差(只会关注显眼的、熟悉的)
  • 你测瓶颈在哪 → 用数据纠正偏差
  • 你看分布(P99 而不是平均)→ 纠正”平均值掩盖长尾”的偏差
  • 你持续剖析 → 纠正”只在出问题时才看”的偏差

所有好的工程方法论,本质都是在对抗人脑的固有偏差。 这和第 25 章”敏锐”的本质(建立清晰的正常基准)、第 27 章”思维模型”(对抗确认偏误、幸存者偏差)是同一个主题:人脑不可靠,所以要引入工具和方法,让数据说话。


本章思考题

【思考题】

  1. Amdahl 定律在性能优化领域告诉我们什么?为什么”优化占比小的部分”是最大的浪费?

  2. 为什么”猜瓶颈”经常猜错?四种剖析方式各回答什么问题?哪一种最被低估?

  3. 基准测试的六大陷阱是什么?请重点说明”微基准脱离真实场景”这个陷阱。

  4. 找瓶颈的系统化流程是什么?其中收益最高的一步是哪一步,为什么?

  5. “瓶颈盲区”(N+1、日志、序列化等)有什么共同特征?这印证了什么?

【参考答案】

  1. Amdahl 定律(推广版):若某部分占比 P、优化掉比例 S,整体加速 = 1/((1-P)+P/S)。含义:若优化目标只占 10%,即便优化到完全免费,整体也只快 11%。所以”优化占比小的部分”是最大浪费——花几周优化占 3% 的函数,整体只提升 3%。类比煎饼店:把”加料”(占 25%)提速一倍,整体只快 12.5%,因为真正大头是”摊面糊”(占 50%)。反面推论:要去”找大的”,优化 50% 的部分收益远超优化 10 个占 5% 的部分;且串行部分(协调、合并、全局锁)虽看起来小,却决定了系统天花板,最容易被忽视。

  2. 猜错原因:以为慢的复杂算法其实调用次数少;真正慢的可能很不起眼(日志、序列化、意外网络调用);慢的可能是”等待”而非”计算”。四种剖析:CPU Profiling(CPU 时间花在哪)、Memory Profiling(内存在哪分配、有无泄漏)、Blocking/Contention Profiling(程序在等什么)、分布式追踪(请求在哪个服务/步骤最慢)。最被低估的是 Blocking/Contention Profiling——现代服务里”等待”常常比”计算”占比更大,锁等待、IO 等待才是真正的瓶颈。类比找家里丢的钱:猜”吃饭太贵”,记账才发现是”每天奶茶+自动续费”这类不起眼但高频的小钱。

  3. 六大陷阱:测了错误的东西(先明确指标是 P50/P99/吞吐/内存)、没有预热(JIT 和缓存需预热,丢弃预热数据)、测试环境不真实(笔记本测服务端、数据量不足、无其他负载干扰)、编译器优化掉测试代码(确保结果被使用)、方差被忽略(多次运行看分布,两次差距大于待测优化则测量无意义)、微基准脱离真实场景(最隐蔽:测出快 10 倍但该函数只占 0.1% 时间,真实收益 = 微基准收益 × 占比)。类比:测出”能举 100 公斤”就以为强壮,但要参加的是马拉松——单点极值在真实场景里权重可能小到可忽略。

  4. 流程:确认指标 → 分解找到大块 → 对大块深挖(计算还是等待)→ 问”能不能不做” → 提出假设并量化验证 → 重测端到端(警惕瓶颈转移)。收益最高的是第四步”能不能不做”:这个调用必要吗、能缓存吗、能预计算吗、能批量吗、能异步/离线吗——“不做”的收益通常远超任何”做得更快”的优化,因为优化最多把成本降到零,而”不做”是直接消除整块成本(呼应第 25 章的问题定义)。

  5. 共同特征:都是”看起来不重要、但其实很重要”的东西——日志、序列化、N+1、DNS、冷启动,没有一个是高大上的复杂算法,但每一个都能悄悄吃掉 20-30% 的性能。这印证了开篇那句”真正慢的地方往往很不起眼”,它们之所以是”盲区”,恰恰因为太不起眼、你从没想过要去”看”它们。这也说明:找瓶颈的本质是”消除认知偏差”,好的方法论都在对抗人脑”只关注显眼事物”的固有偏差。