2.16

查看英文版

2.16 性能工程

概述与动机

性能工程是一门技艺,通过测量而非直觉,有意识地使代码达到足够快的速度。本章聚焦于代码和组件层面:函数、循环、数据结构、查询、内存分配,以及单个服务如何消耗其时间。它与第 3.5 章相辅相成,后者处理系统层面的性能问题(横向扩展、负载均衡、容量和弹性)。当系统变慢时,第 3.5 章问的是你需要多少台机器;本章问的是为什么一台机器一开始就承担了这么多工作。你通常两者都需要,而代码层面的视角恰恰是大量成本和延迟真正隐藏的地方。

对大型团队而言,这门学科之所以重要,是因为性能会悄悄地衰退。没有哪一次提交会单独拖慢一个服务,但成千上万次微小的提交()每次增加一次数据库调用或一个无界循环()加在一起就会。如果没有一套共同的方法来测量、设定预算和把关性能,你只会在客户投诉或发布崩溃时才发现问题已经积重难返。一套方法能把性能从一场英雄式的救火行动,变成一种你持续守护的常规属性。

对企业而言,性能就是金钱:更快的代码意味着更少的机器、更低的云账单,以及无需过度配置就能满足延迟方面的服务水平协议(SLA)。对政府而言,性能就是可及性:一个能在信号较弱的移动网络下、在老旧手机上加载出来的页面,决定了一位公民究竟是完成了福利申请,还是中途放弃。公共系统还需要可复现的基准测试证据,因为采购和监管机构会要求你证明数据,而不只是口头断言。

核心原则

  • 先测量,再优化。 瓶颈几乎从来不在你猜测的地方。先分析(profile),再行动。
  • 避免过早优化。 Donald Knuth 的告诫依然成立:优化无关紧要的代码,只会牺牲清晰性而一无所获。
  • 将”足够快”定义为一个具体数字。 带有目标值和百分位数的性能预算,能把主观意见变成非黑即白的通过或失败判定。
  • 平均值会说谎;百分位数才讲真话。 用户感受到的是尾部(p99),而不是均值。
  • 算法层面的胜利胜过微调。 更优的复杂度等级,胜过任何常数因子上的精巧优化。
  • 延迟和吞吐量是不同的目标。 改善其中一个可能会恶化另一个;要清楚自己追求的是哪一个。
  • 要么诚实地做基准测试,要么干脆不做。 预热、方差和具有代表性的工作负载,是区分真实数据与虚构数据的关键。
  • 在 CI 中设置性能关卡,在生产环境中持续观察。 在合并之前发现的回归代价很低;被用户发现的回归代价高昂。

建议

先测量,动手前先做性能分析

这个领域中最古老的规则也是最常被忽视的:在优化之前先找到瓶颈。使用性能分析器(profiler),这是一种通过采样或插桩来观察运行中程序、展示其时间和内存花费在哪里的工具。要分析 CPU(哪些函数消耗了最多周期)、内存和内存分配(分配了什么、分配频率如何,因为频繁的内存分配会引发垃圾回收暂停),以及 I/O(等待磁盘、网络或数据库所花费的时间)。火焰图是一种堆叠式可视化图形,每个方块代表一个函数,其宽度代表所花费的时间,能让主要成本一目了然:要关注最宽的方块,而不是最深的调用栈。先优化成本最大的部分,再重新测量,达到预算后即可停止。这与第 9.2 章的可观测性实践相关联,因为生产环境中的性能分析胜过任何在笔记本电脑上做出的猜测。

同时也要警惕相反的错误。Knuth 那句话的完整表述是”过早优化是万恶之源”,他所指的正是那些诱使你为了想象中的速度而牺牲可读代码的细小低效之处。先写出清晰的版本,测量之后,只优化被性能分析器指认的代码。

用性能预算定义”足够快”的含义

速度本身并非抽象意义上的美德;它是一个你要么达成、要么错过的目标。设定一个性能预算:一个具体的限制,例如”p99 结账延迟低于 300 毫秒”或”该接口每次请求分配内存低于 1 MB”。将其与用户或业务能够感知的事物挂钩,并以百分位数而非平均值来表达,因为均值会掩盖真实用户所处的缓慢尾部。如果 1% 的请求耗时 5 秒,你的平均值可能看起来很正常,但相当一部分客户正在受苦。预算为团队提供了一个共享的、无可争辩的”完成”定义,也划出了一条回归会明显越过的界线。

在做微优化之前先追求算法效率

最大、最廉价的收益来自算法效率()即工作量如何随输入规模增长而增长,通常用大 O 表示法来描述(一种对增长速率进行分类的方法,因此 O(n log n) 的排序算法的可扩展性远优于 O(n²) 的排序算法)。一个在十个元素时不起眼的嵌套循环,到了一万个元素时就会变成一场灾难。在手工调优一个热点函数之前,先问问它是否在根本上做了太多工作:是否存在意外的 N+1 查询、本应是哈希查找却变成线性扫描,或者本可以被记忆化(memoize)的重复计算。这与第 2.13 章的算法基础相关联。再多的常数因子调优也无法挽救错误的复杂度等级。

区分延迟与吞吐量,重视尾部延迟

延迟是指一次操作所花费的时间;吞吐量是指单位时间内完成的操作数量。二者并非同一个目标,优化其中一个可能会损害另一个。批处理能提升吞吐量,但会增加批次中第一个项目的延迟;增加并行工作者能提升吞吐量,但可能因资源争用而恶化尾部延迟。要判断你的用户真正需要的是哪一个。并且始终关注尾部:p95 和 p99 延迟,即最慢的 5% 和 1% 的请求,因为在规模化场景下,一个用户会发起许多次请求,因而经常会碰上尾部延迟。要报告百分位数,对其设置告警,并为其设定预算。

了解并行的局限

在进行并行化时,请记住阿姆达尔定律:增加处理器所带来的加速比,会受限于必须串行执行的那部分工作占比。如果一项工作中有 10% 天生是串行的,那么无论增加多少核心,加速比都无法突破 10 倍。并发(将工作组织成可以独立推进的任务)与并行(真正同时执行这些任务)都会带来实实在在的复杂性,从竞态条件到协调开销皆是如此。在假定增加线程数就能解决问题之前,先测量串行部分的占比,并坦然承认:最简单的正确版本往往已经足够快。

使用缓存和数据局部性,同时正视其代价

缓存,即近期或代价高昂的计算结果的快速存储,是你手中最强大、也最危险的性能工具。Phil Karlton 的那句妙语()计算机科学中有两大难题,缓存失效和命名()是一句警告:陈旧的缓存会给出错误的答案,而失效逻辑正是隐蔽 bug 滋生的地方。要有意识地使用缓存、设置过期时间,并在优化命中率之前先想清楚正确性方面的保证。在最底层,引用局部性()将一起使用的数据在内存中彼此靠近存放()利用了 CPU 缓存层级结构,能够在不改变算法的情况下将代码提速数倍,其原理是把缓存未命中变成缓存命中。正因如此,连续数组胜过指针跳转式的结构。这与第 3.4 章中的数据布局选择有交集。

诚实地进行基准测试,并对微基准测试保持怀疑

撒谎的基准测试比没有基准测试更糟,因为它会带来虚假的信心。测量之前先进行预热,这样你测的才是稳态行为,而不是一次性的启动开销和即时编译(JIT)过程。运行多次迭代并报告方差,而不是报告单次侥幸得到的数字。使用具有真实数据规模和分布的、具有代表性的工作负载,因为在玩具输入上做微基准测试,测出来的往往是编译器删除你的测试代码的能力,而非代码的真实速度。要警惕那些经典陷阱:优化器证明某个值未被使用并将其消除、运行时把循环提到外面,或者缓存在基准测试中是热的、而在生产环境中却是冷的。如果存疑,就测量整条路径,而不是孤立的函数。

在 CI 中设置性能关卡,并在生产环境中持续观察

让性能成为流水线所守护的一项属性。将性能测试加入第 2.4 章所述的策略中,设置回归关卡:当关键基准测试或预算的恶化超过阈值时,使构建失败。这样可以在缓慢劣化合并进主干之前将其拦截。然后借助第 9.2 章的遥测手段在生产环境中闭环:持续对照预算跟踪真实的延迟百分位数、分配速率和慢查询,因为生产流量总能发现基准测试从未设想过的情形。

权衡:利与弊

方案优点缺点
凭直觉立即优化感觉富有成效;偶尔能侥幸命中通常调优错了代码;增加复杂度却毫无收益
先测量,再优化瞄准真正的瓶颈;有据可依需要工具和纪律;启动较慢
缓存延迟和吞吐量收益显著失效相关的 bug;数据陈旧;内存开销
增加并行度提升并行工作的吞吐量受阿姆达尔定律限制;资源争用;并发相关的 bug
微优化压榨常数因子收益上限很低;损害可读性;常常只是噪声
算法改进收益随输入规模扩大需要分析;有时需要较大规模的重写
CI 性能关卡尽早、低成本地阻止回归不稳定的基准测试会削弱信任;需要稳定的环境

核心矛盾在于投入与回报之间的权衡,而解决之道就是测量。性能优化工作的收益递减非常明显:第一次基于性能分析的修复可能让延迟减半,第十次可能只削减百分之一,代码复杂度却翻了一倍。解决办法是:在手头没有数据、也没有预算目标之前,拒绝进行优化。通过测量找出值得修复的问题,一旦达到预算就停下来,而不是为了速度本身无休止地追逐下去。

与团队讨论的问题

  1. 你的关键路径是否有书面的性能预算,并以百分位数的形式表达? 许多团队只是模糊地觉得”应该快一点”,却没有任何人可以据以判定失败的具体数字,这意味着在出问题之前,性能不属于任何人的职责。像”p99 低于 300 毫秒”这样的预算,能让目标变得具体,让评审者有据可依,并把一次回归变成一个可见的事件,而不是一场缓慢的滑坡。这在大型团队中尤为重要,因为延迟会经由许多人之手悄悄累积,而没有任何一位作者能看到累积后的整体成本。带上你当前的延迟数据,问问自己报告的是能让人心安的平均值,还是讲真话的百分位数。如果你说不出”足够快”对应的具体数字,那就是首先要解决的问题。

  2. 你上一次优化某个东西时,是性能分析器告诉你该看哪里,还是你在猜? 众所周知,瓶颈往往不在经验丰富的工程师所预期的地方,把时间花在调优错误的代码上,等于双重损失:一次是工作本身的损失,一次是增加的复杂度的损失。一种先做性能分析的文化,会把精力花在真正有回报的地方,让清晰的代码保持原样。请团队回忆最近三次性能修复,看看每一次是从测量开始,还是从直觉开始。考虑一下你是否能在生产环境或接近真实的预发布环境中做性能分析,因为笔记本电脑上的分析结果可能严重误导你。答案会揭示你的性能工作究竟是工程实践,还是民间传说。

  3. 今天,是什么阻止了一次性能回归进入生产环境? 对于一个正在成长的团队来说,诚实的答案往往是”客户投诉”()这意味着用户就是你的回归测试。当基准测试或预算发生恶化时,一道能使构建失败的 CI 关卡,能在修复成本还很低、作者还记得自己改了什么的时候就抓住问题。讨论一下你的基准测试是否足够稳定,可以作为关卡的依据,因为一个总是”狼来了”的不稳定性能测试,最终会被忽视或被禁用。也谈谈你们在生产环境中监控什么,因为有些回归只会在真实流量和真实数据下才会显现。目标是让性能成为系统自动守护的一项属性,而不是要在一次事故中才重新发现的东西。

  4. 你们在每条关键路径上优化的是延迟还是吞吐量,有没有人把这个选择写下来? 这是两个方向相反的不同目标:批处理和并行工作者能提升吞吐量,却可能给单个请求增加延迟,因此凭直觉进行优化的团队常常选错方向,让用户等待,只为节省一份根本没人短缺的机器时间。在大型团队中,这种风险会被放大,因为一个小组为批量吞吐量而调优某个共享服务,另一个小组却依赖它来获得交互式的低延迟,双方都不知道对方的目标是什么。带上每条路径的实际使用模式(交互式请求还是后台批处理)、当前的百分位延迟,以及你所需要的持续吞吐量,然后明确地决定优化方向,而不要任由默认方向自然形成。对于受 SLA 约束的企业或政府系统,要明确协议是针对哪个指标写的,因为在你的仪表盘看起来一切正常的同时,优化了未被度量的那个轴,仍可能违反合同。

  5. 你怎么知道你的基准测试衡量的是真实工作,而不是被优化器删掉的测试代码? 撒谎的基准测试比没有更糟,因为它会给团队带来虚假的信心,而回归最终还是会上线。团队经常只是从一次针对玩具输入的冷启动运行中报告一个侥幸得到的数字,而这测的其实是启动开销、即时编译,以及编译器移除未使用代码的能力,而非用户实际遇到的行为。带上一个示例基准测试,仔细追问它:它是否预热了、是否运行了多次迭代、是否报告了方差、是否使用了具有代表性的数据规模和分布,以及是否设法避免了对其结果的死代码消除。与之相冲突的是:诚实的基准测试编写和运行起来都比快速的微基准测试更慢,因此需要就哪些地方可以接受廉价的近似、哪些地方必须严谨达成一致。在公共或受监管的场景中,采购方和监管机构会要求你重现这些数字,此时应把设备、工作负载和环境连同结果一并记录下来,使这个结论可以被验证,而不仅仅是被断言。

  6. 当性能工作和功能开发争夺同一批工程师的时间时,你们如何决策,谁掌握预算裁量权? 性能优化的收益递减非常明显,第一次基于性能分析的修复可能让延迟减半,而第十次可能只削减百分之一,代码复杂度却翻倍,如果没有明确规则,往往是嗓门最大的人或最近的截止日期获胜。这里存在真实的相互制约:未修复的性能债务会悄悄累积,日后弥补的成本会越来越高;然而一旦追求速度超出预算,就会挤占产品路线图,并增加拖慢未来工作的复杂度。带上每条关键路径当前的预算状态、维持现状在机器成本或转化率损失方面的估算,以及下一次优化的边际收益,让这个权衡建立在证据之上,而非压力之上。对于大型企业或政府项目,要明确谁拥有性能预算的所有权、谁有权批准为此投入工程时间,因为一个没有人负责守护的目标,只会悄悄地被侵蚀。

行业视角

初创企业。 交付速度胜过流程,因此要抵制重写和宏大性能框架的诱惑。花一个下午的时间,用性能分析器针对用户实际抱怨的路径进行分析,修复其中成本最大的部分(通常是 N+1 查询或意外的线性扫描),并在 CI 中加入一个轻量级的百分位数预算,防止这项成果悄悄回退。把深入优化留到有真实数字(而非直觉)表明代码确实太慢的那一刻。

小型企业。 在没有专职性能专家、预算又紧张的情况下,善用你已经在付费使用的工具:运行时自带的性能分析器、托管平台仪表盘中的延迟百分位数,以及数据库内置的查询分析器。设定一两个与客户能感知到的事物挂钩的简单预算,例如页面加载时间或结账时间,把预算被突破视为购买更高配置或修复最差查询的信号,而不是启动一个你根本无力投入人手的调优项目。

企业。 在机群规模下,性能就是直接成本,因此应将其作为一门共享学科来治理:统一的性能分析工具、与业务指标挂钩的百分位数预算,以及在各团队中一致应用的 CI 回归关卡,避免任何一个团队的缓慢劣化拉高整体云账单。持续对照基线跟踪各服务的延迟、内存分配和吞吐量,并保留可复现的基准测试证据,因为在大规模机群上降低 30% 的 CPU 占用,是一项值得审计、也值得用来抵御 SLA 罚则的持续性节约。

政府。 性能是一种可及性保障:一个能在弱网络下的老旧手机上加载出来的页面,决定了一位公民是否能完成福利申请。要针对真实存在的低端设备和限速网络设定明确的预算,并发布可复现的基准测试结果,记录设备、网络和工作负载信息,使采购和监管机构能够验证数据,而不是仅凭信任接受。相较于供应商的口头断言,应优先选择透明、可审计的测量方式,并要求供应商提供同等可复现的证据。

示例

初创企业。 一个小型 SaaS 团队注意到他们的仪表盘感觉运行迟缓,一度想用更快的框架将其重写。但他们转而花了一个下午的时间使用性能分析器和火焰图,结果发现 70% 的请求耗时来自单个接口()它为每一行数据都发起一次数据库查询,这是典型的 N+1 模式。他们用一次批量查询取而代之,延迟从 1.2 秒降到 90 毫秒,并在一个轻量级的 CI 基准测试中加入 200 毫秒的 p99 预算,防止这项成果悄悄回退。没有重写,只用了一个下午,收益却提升了十倍。

企业。 一个零售平台运行着数千个实例,其云账单主要由一项推荐服务占据。一次性能分析行动发现,频繁的内存分配导致了大量的垃圾回收暂停,此外缓存的命中率也很差。通过针对局部性调整数据结构、并修复缓存键的设计,团队将每次请求的 CPU 占用降低了 40%,从而能够用少 40% 的机器来处理相同的流量。这笔节省在几周内就抵消了工程投入,而此前偶尔被突破的 p99 延迟 SLA 现在也能稳稳达标,避免了合同罚则。

政府。 某国家税务机关必须为使用老旧设备和缓慢农村网络的公民提供服务。团队设定了明确的预算:在限速的 3G 网络条件下,报税页面必须在低端手机上于 3 秒内变得可交互。他们对页面进行性能分析,削减了阻塞交互的工作,并发布了可复现的基准测试结果,记录设备、网络和工作负载信息,使监管机构和无障碍审计人员能够验证这一结论,而不是仅凭信任接受。在这里,性能不是成本杠杆,而是让服务对所有人都可用的一种可及性保障。

商业理由:动机、投资回报率与总拥有成本

性能工程的回报体现在三本账上。第一本是基础设施成本:更快的代码能用更少的机器完成同样的工作,对于大规模机群而言,降低 30% 的 CPU 占用是一项直接、持续的节约,远远超过一次性的工程投入。第二本是收入与满意度:延迟与转化率、流失率和用户信任度相关,因此削减尾部延迟是一个增长杠杆,而不仅仅是一项例行维护工作。第三本是规避的风险:违反 SLA 会招致罚则,而在负载下崩溃的发布会带来声誉损害和救火成本。

其总拥有成本并不高,且主要集中在前期投入:你需要投入性能分析工具、一个稳定的基准测试环境和 CI 关卡,再加上撰写预算和解读分析结果的纪律。更大、更隐蔽的成本在于另一种选择所带来的后果:性能债务会悄悄累积,在发布之后再为一个缓慢的系统补上速度,远比持续地加以守护昂贵得多。向领导层论证时,要用他们自己的计量单位来表达:把延迟换算成转化率或公民办事完成率,把 CPU 换算成每月的云支出,把回归关卡换算成避免发生的事故数量。最有力的论点是:逐次提交地守护性能成本低廉,而等它腐坏之后再去挽回则代价惨重。

反模式与陷阱

  • 不做性能分析就优化。 调优的不是瓶颈所在的代码,真正的成本却原封未动。
  • 过早优化。 为了想象中的速度而牺牲清晰性,而性能分析器本不会标记出这部分代码。
  • 只报告平均值。 用令人安心的均值掩盖痛苦的尾部;用户感受到的是 p99,而不是平均值。
  • 微基准测试的表演。 数字来自一个被优化器”半删除”的玩具工作负载,既没有预热,也没有报告方差。
  • 没有失效方案的缓存。 一味追求命中率,却提供陈旧或错误的数据。
  • 想当然地认为增加线程就有帮助。 忽视阿姆达尔定律和串行部分占比,最终深陷资源争用之中。
  • 没有回归关卡。 让用户充当性能测试,因为 CI 中没有任何东西在守护预算。
  • 优化了错误的方向。 用户需要的是低延迟,却用批处理换来了吞吐量,或者反之。

成熟度模型

  • 第 1 级,初始(Initiate): 只有在出问题时才会关注性能。没有预算、没有性能分析习惯、没有基准测试。优化全凭直觉猜测,平均值是唯一有人报告的指标。
  • 第 2 级,发展(Develop): 部分团队会在事故期间做性能分析并维护少量基准测试,但做法并不统一,取决于个人的积极性。一两条关键路径存在非正式的预算,部分仪表盘上出现了百分位数,但没有任何机制能在回归上线之前将其拦截,各团队也各自重新发明一套做法。
  • 第 3 级,标准化(Standardize): 关键路径都带有书面的百分位数预算,性能分析是文档化的、公认的优化前第一步。CI 中包含带回归关卡的性能测试,诚实基准测试的规则(预热、方差、代表性数据)被写成文档并在全组织范围内强制执行,每个团队遵循的是同一套方法,而不是各自的做法。
  • 第 4 级,管理(Manage): 组织将性能作为一项受控属性来度量。延迟百分位数、吞吐量、内存分配速率和慢查询数量在生产环境和 CI 中都对照明确的基线加以跟踪,回归是依据阈值量化判定的,而不是靠争论,预算与转化率或云支出等业务指标挂钩,一旦被突破就会触发有数据支撑的决策。基准测试证据具有可复现性,并连同设备、工作负载和环境信息一并留存,以供审计。
  • 第 5 级,协同(Orchestrate): 性能在整个组织范围内被持续改进并深度融合。预算设定、性能分析、诚实的基准测试和火焰图分析都是常规技能,回归关卡稳定且值得信赖,生产环境和 CI 的数据能自动闭环。组织会随着流量、硬件和业务优先级的变化调整预算,把精力重新分配到回报最高的路径上,并将性能作为一项持续存在的属性加以守护,而非一场周期性的专项行动。

讨论思路

  1. 你目前哪些关键路径拥有书面的、基于百分位数的预算,哪些只是靠”但愿没事”来维系?
  2. 上一次性能分析器给你带来意外发现是什么时候?它让你对”时间都花在哪里”的假设有了什么新认识?
  3. 你的基准测试是否会预热、报告方差、使用具有代表性的数据,还是实际上测的是优化器本身?
  4. 你在哪些地方用增加机器来掩盖本可以通过一次性能分析行动降低成本的代码?
  5. 对于你并行度最高的工作负载,其串行部分占比是多少?阿姆达尔定律是否已经限制了你所追求的加速比?
  6. 如果一位队友合并了一个使 p99 延迟翻倍的变更,需要多久才会有人注意到?他们又会通过什么方式发现?

关键要点

  • 先测量,再优化;瓶颈很少在你猜测的地方,过早优化只会牺牲清晰性而没有任何收益。
  • 将”足够快”定义为一个百分位数预算,因为平均值会掩盖真实用户所处的尾部。
  • 优先选择算法层面的胜利(更优的大 O 等级)而非微调,并弄清楚你需要的是延迟还是吞吐量。
  • 尊重并行的局限(阿姆达尔定律),也正视缓存的隐患(失效与数据陈旧)。
  • 用预热、方差和具有代表性的工作负载诚实地进行基准测试,并对微基准测试保持怀疑。
  • 在 CI 中设置性能关卡(第 2.4 章),并在生产环境中持续观察(第 9.2 章);这与第 3.5 章的系统层面视角互为补充。
  • 对企业而言性能就是成本,对政府而言性能就是可及性,持续守护它成本低廉,而事后补救则代价高昂。

参考文献与延伸阅读

  • Brendan Gregg,Systems Performance: Enterprise and the Cloud(性能分析、火焰图与方法论)。
  • Brendan Gregg,BPF Performance Tools(Linux 上的实用可观测性与性能分析)。
  • Donald E. Knuth,“Structured Programming with go to Statements”(ACM Computing Surveys,1974 年):过早优化这一格言的出处。
  • Donald E. Knuth,The Art of Computer Programming(算法分析与复杂度)。
  • Thomas H. Cormen、Charles E. Leiserson、Ronald L. Rivest 和 Clifford Stein,Introduction to Algorithms(大 O 表示法与算法效率)。
  • Gene M. Amdahl,“Validity of the Single Processor Approach to Achieving Large-Scale Computing Capabilities”(1967 年):阿姆达尔定律的出处。
  • Ulrich Drepper,“What Every Programmer Should Know About Memory”(内存层级结构与数据局部性)。
  • Martin Kleppmann,Designing Data-Intensive Applications(系统中的延迟、吞吐量与尾部行为)。
  • Aleksey Shipilev,“JMH and the pitfalls of microbenchmarking”(托管运行时上的诚实基准测试实践)。
  • Ilya Grigorik,High Performance Browser Networking(面向低带宽用户的客户端与网络性能)。