服务器的 “正确” CPU 利用率水平是什么?如果你查看一个设计良好且运行良好的服务的监控仪表板,我们希望在一天或两天的平均值中看到什么样的 CPU 利用率?
这是一个非常普遍的问题,而且不清楚它是否应该有一个单一的答案。也就是说,长期以来,我一般认为更高的利用率总是更好:我们应该尽量接近 100% 的利用率。为什么?因为低于 100% 的任何利用率都代表未使用的硬件容量,这意味着我们在浪费资源。如果一个服务没有达到其 CPU 的最大利用率,我们可以将其迁移到一个更小的实例上,或者在该节点上运行一些额外的工作。
事实证明,这种简单的直觉很少是完全正确的。
假设我们达到了理想状态,我们的服务运行在接近 100% 的利用率。如果我们意外地变得火爆,接收到意外的流量激增,会发生什么?或者如果我们想要部署一个在每个请求中需要额外 CPU 的新功能呢?
如果我们处于 100% 的利用率,然后发生某些事情增加了负载,那么我们就麻烦了!以 100% 的利用率运行使我们没有空间来吸收增量负载。我们要么以某种方式降级,要么不得不匆忙添加应急容量,或者两者兼而有之。
这个简单的例子是一个非常普遍现象的实例:效率的提升往往与弹性相互权衡,而我们对系统的优化越深入,这种权衡往往变得越糟糕。
超过某个点,使系统更高效将意味着使其弹性降低,反之,构建稳健性往往使系统效率降低(至少在短期内)。这并不是说没有双赢的情况;有时可以将帕累托前沿向外移动;修复 “愚蠢” 的性能缺陷有时会产生这种效果。然而,经过一定程度的努力后,你将被迫做出权衡。
在这里提到的 “弹性”,重要的是要注意我指的是比 “可靠性” 或 “保持运行能力” 更广泛的东西;我指的是 “应对或响应变化的能力” 的更一般的概念 —— 各种变化,包括错误或故障的变化,但也包括产品需求的变化、市场的变化、组织或团队组成的变化等等。
这种权衡的例子#
这种权衡发生在超出容量规划的领域。它几乎适用于任何技术系统或组织的几乎每个层面。以下是我观察到的一些其他地方:
冗余#
运行多个服务实例并设置负载均衡器以便在任何实例失败时,负载会透明地路由到其他实例是非常常见的。在复杂的组织中,这种模式可能应用于整个数据中心的层面,架构允许整个数据中心失败并将其负载路由到其他地方。
为了使这项工作正常进行,每个实例必须有足够的备用容量来吸收来自故障实例的增量负载。在稳态下,在没有故障的情况下,该容量必须处于闲置状态,或者在最佳情况下,服务于可以在瞬间被丢弃的低优先级工作。我们想要的冗余越多,我们必须在稳态下保持的闲置容量就越多。
优化#
复杂的性能优化通常通过利用问题域的特定属性或结构来实现。通过将不变性嵌入数据结构和代码组织中,你通常可以获得巨大的性能提升。然而,你越是依赖特定的假设,它们就越难以改变,这使得高度优化的代码往往更难以演变或添加功能。
作为一个具体的例子,在我对 Sorbet 的反思中,我谈到我们如何决定类型推断将是局部的且单遍的,并将这一假设嵌入到我们的代码和数据结构中。这个选择带来了显著的效率提升,但在某种意义上使系统变得更加脆弱:一些竞争类型系统具有的许多特性在 Sorbet 代码库中将是不可实现或难以实现的,因为这些假设。我仍然相信这是该项目的正确选择,但这些权衡是值得承认的。
Hillel Wayne,提到一个类似的属性称为 “聪明代码”,他将其定义为
利用关于问题的知识的代码。
他也谈到这种 “聪明代码” 往往是高效的,但有时是脆弱的。
序列化格式#
很难找到比 “直接将内存中的structs
复制到磁盘” 更高效的序列化格式;几乎不需要代码,并且保存或加载数据的序列化成本接近于零。
然而,这导致了一个脆弱的系统 —— 即使添加一个新字段也需要重写数据或其他特殊处理。而在不同字节序或字长的机器之间共享数据就变得具有挑战性。
另一方面,将所有数据写成 JSON 或其他类似的通用容器为添加新字段或新模块之间的通信创造了无尽的灵活性,而不会影响现有代码,但代价是在线路和 CPU 工作中序列化和反序列化数据流的开销巨大。
分布式系统#
我最喜欢的系统论文之一是COST论文,它考察了许多大数据平台,并观察到它们中的许多具有与可用硬件 (近) 线性扩展的理想特性,但代价是效率比调优的单线程实现低得多。
我发现这是一个常见的权衡。分布式计算框架灵活且具有弹性,能够通过扩展来处理近乎任意的工作负载。它们可以通过扩展来处理某人部署的低效代码,并透明地处理硬件故障。需要处理更多数据?只需添加更多硬件(通常是透明的,使用某种自动扩展)。
另一方面,经过精心编码的单节点解决方案往往会更快(有时快 10-100 倍!),但更脆弱:如果数据集不再适合一个节点,或者如果你需要执行 10 倍更昂贵的分析,或者如果团队中的新工程师不知情地在紧密的内部循环中提交了慢代码,整个系统可能会崩溃或无法执行其工作。
小团队与大组织#
小团队 —— 包括 “团队” 中的一个人 —— 可以非常高效和富有生产力。团队越小,沟通开销越少,每个工程师头脑中保持丰富的共享上下文就越容易。写文档的需求更少,沟通变更的需求更少,花在新成员入职和培训上的时间更少,等等。小团队可以通过 “仔细思考并努力尝试” 等策略走得更远,而大型团队往往需要依赖更少的工具,如代码检查器和小心的防御性抽象设计。
在适当的情况下,小团队在仔细设计和经验丰富的工程师的帮助下,有时可以大致匹配 10 倍于其规模的团队的原始产出 —— 这是效率的巨大提升!
然而,小团队对组织、技术环境或项目业务需求的变化要脆弱得多,弹性较差。如果一个人离开一个 4 人的团队,你的带宽就减少了 25%—— 更糟的是,团队几乎没有招聘和培训新成员的经验,大量知识和文档仅存在于剩余成员的头脑中。
同样,业务重点的变化或新产品的推出需要团队的系统提供更多功能或其他开发,超出团队的支持能力相对容易,快速扩展团队将面临同样的挑战。
自动化与人工流程#
一般来说,让机器执行任务比让人手动执行更高效 —— 更便宜、更快,且通常更可靠。
然而,人类是无尽的适应者,而机器(包括物理机器和软件系统)则更加脆弱且固执。一个有人的系统在应对突发情况或意外事件时有更多的选择。
即使我们保留所有相同的人,但通过自动化来加速他们工作的某些部分,我们也面临自动化依赖的风险,即人类过于依赖自动化并不适当地信任,或者他们在没有自动化的情况下的功能能力萎缩,以至于在需要时无法适当地 “手动” 介入。
余地#
许多这些具体观察实际上是关于余地(不是软件产品)的观察。
具有健康余地的系统 —— 至少在短期内,从简单的分析来看 —— 根据定义是低效的:这些余地是时间或资源,正在 “闲置”,本可以产生输出。
不过,从更广泛的角度来看,这些余地是弹性的关键:系统中的 “余地” 使其能够应对小的干扰或意外;开发人员或操作人员可以利用这些余地介入并处理意外负载或在问题变得灾难性或外部可见之前解决潜在问题。
一个没有余地的系统在正常工作时是高效的,但在任何变化出现时都会变得脆弱,并迅速崩溃。
结论#
我试图触及一些具体的例子,其中效率和可靠性相互对立,并相互权衡或至少在相反的方向上施加压力。希望我已经说服你这是一个广泛的现象,即使在可能没有严格权衡的情况下,这两个价值也往往会相互对立并建议不同的决策。
不幸的是,仅仅这一观察通常并不能告诉我们该如何处理任何_特定_系统。如果我们正在查看某个系统,初步分析表明它在输入的使用上效率低下,我们无法在不更仔细观察的情况下判断它是一个功能失调和糟糕设计选择的巢穴,还是这种表面的低效率支持着巨大的冗余、灵活性和余地,使其能够应对可能出现的任何变化。我们需要更仔细地观察,几乎总是需要在特定团队和特定问题上具备领域专业知识。
此外,有时确实存在 “免费午餐”。某些设计选择或决策确实将帕累托前沿向外移动,而不仅仅是沿着它移动。它们在优化良好的系统中可能很少见,但我们不能忽视它们的可能性。许多系统尚未得到很好的优化!
此外,设计空间中的最佳点因系统而异。有时可靠性或弹性至关重要,我们有理由容忍巨大的一级低效。然而,有时,极端效率是正确的目标:也许我们的利润足够薄,使其成为我们唯一的选择,或者也许我们对领域的稳定性和对系统需求的信心足够强,以至于我们相信不会面临任何过于剧烈的变化。
因此,主要是我所能做的就是呼吁我们作为工程师、设计师和系统观察者,意识到这种权衡及其影响。当我们指责一个系统浪费和低效时,值得停下来问问这种 “浪费” 可能带来了什么。当我们着手优化一个系统时,暂停一下,了解当前系统中的关节和灵活性在哪里,哪些是至关重要的,并尽力保护这些。当我们为系统、团队或组织设定效率的指标或目标时,让我们意识到,缺乏对立压力的情况下,我们可能也在要求系统变得更加脆弱和脆弱。