效率与韧性不可兼得
原文由 Nelson Elhage 于 发布,订阅该博客
服务器的 CPU 利用率,多少才算“合适”?如果观察一个设计良好、运行稳定的服务的监控面板,在一两天的平均值里,我们会希望看到怎样的 CPU 利用率?
这是个非常宽泛的问题,似乎也不该有一个统一答案。不过,很长一段时间里,我一直认为越高越好:我们应该尽可能让利用率接近 100%。为什么?因为低于 100% 就意味着有硬件能力被闲置,也就是在浪费资源。如果一个服务没有跑满 CPU,我们完全可以把它迁到更小的实例上,或在同一台机器上再跑一些别的工作。
事实证明,这种简单的直觉很少是完全正确的。
假设我们真的实现了这个理想状态,服务几乎跑到了 100% 利用率。这时如果我们意外走红、流量突然暴涨会怎样?或者我们想上线一个新功能,每个请求都要多消耗一点 CPU,又会怎样?
如果已经跑在 100% 利用率上,再有任何导致负载上升的变故,我们就麻烦了!满负荷运行意味着我们没有任何余地去吸收新增的负载。结果要么是服务在某些方面降级,要么得手忙脚乱地紧急扩容,或者两者兼有。
这个简单的例子其实体现了一种非常普遍的现象:效率的提升往往要以韧性为代价,而且系统被优化得越极致,这种权衡通常就越严重。
超过某个临界点后,让系统更高效就意味着让它更不具韧性,反之,要让系统更健壮,往往也会让它变得更低效(至少在短期内是如此)。这并不是说没有双赢;有时候我们确实可以把帕累托前沿向外推移;修复一些“愚蠢的”性能缺陷有时就能起到这种效果。但投入到一定程度之后,你就不得不做出取舍。
这里所说的“韧性”,需要说明的是,我指的远不止“可靠性”或“保持在线的能力”;我指的是一个更广义的概念——“吸收或应对变化的能力”——各种各样的变化,既包括缺陷或故障,也包括产品需求的变化、市场的变化、组织或团队人员构成的变化,等等。
这种权衡的例子
这种权衡并不只出现在容量规划领域。它几乎适用于任何技术系统或组织的几乎每一个层面。下面是我观察到的其他一些例子:
冗余
同时运行一个服务的多个实例,并通过负载均衡器实现某个实例故障时流量能透明地路由到其他实例,这是极其常见的做法。在更成熟的组织里,这种模式甚至会应用到整个数据中心的层面,通过相应的架构让某个数据中心整体故障时,其流量也能被路由到其他数据中心。
要让这种机制生效,每个实例都必须有足够的空闲容量来承接故障实例转移过来的增量负载。在没有发生故障的稳态下,这些容量就只能闲置,或者最多用来跑一些可以随时丢弃的低优先级任务。想要的冗余越多,就必须在稳态下闲置越多的容量。
优化
复杂的性能优化往往是通过利用问题领域特定的性质或结构来实现的。把某些不变量固化到数据结构和代码组织中,往往能带来巨大的性能提升。然而,你对特定假设的依赖越深,这些假设就越难改动,这也使得经过重度优化的代码往往更难演进、更难添加新功能。
举个具体的例子,在我关于 Sorbet 的回顾中,我谈到我们当时决定类型推导只做局部、单遍推导,并把这一假设固化到了代码和数据结构中。这一选择带来了可观的效率提升,但在某种意义上也让系统变得更脆弱:一些竞争性类型系统所具备的许多功能,在 Sorbet 的代码库中会因为这些假设而变得不可能实现,或是实现成本高到难以承受。我仍然确信这对那个项目而言是正确的选择,但其中的权衡值得正视。
Hillel Wayne 把类似的特性称为“巧妙的代码”,他将其定义为
利用对问题本身的了解的代码。
他也谈到,这种意义上的巧妙代码往往很高效,但有时也很脆弱。
序列化格式
很难找到比“直接把内存中的 structs 原样拷贝到磁盘”更高效的序列化格式了;几乎不需要任何代码,保存或加载数据的序列化开销也接近于零。
然而,这会让系统变得很脆弱——哪怕只是新增一个字段,也需要重写数据或做其他特殊处理。而在字节序或字长不同的机器之间共享数据,也会成为难题。
反过来说,把所有数据都写成 JSON 或类似的通用容器,会为新增字段或让新模块在不影响现有代码的情况下相互通信带来极大的灵活性,但代价是在网络传输和序列化、反序列化数据流的 CPU 开销上付出不小的成本。
分布式系统
我最喜欢的系统论文之一是 COST 论文,它研究了多个大数据平台,并指出其中许多平台都具备随可用硬件(近似)线性扩展这一令人向往的特性,但代价是其效率比精心调优的单线程实现低得离谱。
我发现这是一种常见的权衡。分布式计算框架在能够通过扩容来处理近乎任意的工作负载这一点上,是灵活且具有韧性的。它们可以通过横向扩展来消化某人部署的低效代码,也能透明地应对硬件故障。需要处理更多数据?只需增加硬件(通常还能通过某种自动扩缩容透明地完成)。
反过来说,精心编写的单机方案往往会更快(有时快 10 到 100 倍!),但也脆弱得多:如果数据集再也放不进单台机器,或者需要执行开销大 10 倍的分析,又或者团队中的新同学无意中在关键的内层循环里提交了低效代码,整个系统就可能崩溃或无法完成任务。
小团队与大组织
小团队——包括只有一人的“团队”——可以极其高产和高效。团队越小,沟通开销就越少,也越容易让每个工程师脑中都保有丰富的共享上下文。需要编写的文档更少,需要就变更进行沟通的次数更少,花在新成员入职和培训上的时间也更少,等等。相比大团队,小团队靠“认真思考、努力尝试”这类策略就能走得更远,往往也更少需要依赖 linter 以及谨慎的防御性抽象设计这类工具。
在合适的条件下,一个拥有精心设计和经验丰富工程师的小团队,有时甚至能大致匹敌规模是其 10 倍的团队的原始产出——这是效率上的巨大提升!
然而,小团队要脆弱得多,对组织、技术环境或项目业务需求变化的韧性也更差。如果一个 4 人团队中有一人离职,你的人手就会一下子减少 25%——更糟的是,团队在招聘和培训新成员方面几乎没有经验,大量知识和文档只存在于剩下成员的脑海中。
同样,如果业务重心发生变化或要发布新产品,需要团队的系统提供大量新功能或其他开发,就很容易超出团队的承载能力,而出于同样的原因,快速扩张团队也会成为难题。
自动化与人工流程
一般来说,让机器来执行任务比让人手动完成更高效——更便宜、更快,而且往往也更可靠。
然而,人类具有无穷的适应能力,而机器(无论是实体机器还是软件系统)则要脆弱和刻板得多。有人参与其中的系统,在临场应对环境变化或突发事件时会有多得多的选择。
即使我们保留所有原有的人手,只是用自动化来加速他们工作中的部分环节,也会面临自动化依赖的风险,即人类过度依赖自动化并产生不当的信任,或是在没有自动化的情况下工作的能力逐渐退化,以至于在需要时已无法恰当地切换到“手动”介入。
余量
上面这些具体的观察,很多归根结底都是关于余量(不是指那款软件产品)的观察。
拥有健康余量的系统——至少在短期内、按简单的分析来看——按定义就是低效的:那些余量就是被“闲置”的时间或资源,本可以用来产生更多产出。
然而,从更广阔的视角看,这种余量恰恰是韧性的关键:系统中留有“回旋余地”,才能让系统应对小的扰动或意外;开发者或运维人员可以利用这些余量来介入处理突发负载,或在问题演变成灾难性、对外可见的故障之前解决潜在隐患。
一个毫无余量的系统,只要还能运转就很高效,但却很脆弱,一旦其常规运行模式发生任何变化,就会迅速崩溃。
结论
我试图列举了一些具体的例子,来说明效率与可靠性如何相互冲突、相互制衡,或至少在相反的方向上产生拉力。希望我已经让你相信这是一种普遍现象,即使在某些尚未出现严格权衡的情况下,这两种价值取向也往往会相互对立,并指向不同的决策。
遗憾的是,仅凭这一观察,很少能告诉我们对于某个具体的系统该怎么做。如果我们审视某个系统,初步分析显示它对投入的使用效率低下,那么不做更深入的研究,我们就无法判断它究竟是充斥着失灵和糟糕设计选择的地方,还是那种表面上的低效其实在支撑着巨大的冗余、灵活性和余量储备,从而让它能够抵御未来可能出现的任何变化。我们需要更仔细地观察,而且几乎总是需要针对特定团队和特定问题的领域专业知识。
此外,有时确实存在免费的午餐。有些设计选择或决策的确能把帕累托前沿向外推移,而不仅仅是让我们沿着它移动。在已经高度优化的系统中,这种机会可能很少,但我们不能忽视其可能性。而且许多系统本来就还没有被优化到那种程度!
另外,设计空间中的最优点因系统而异。有时可靠性或韧性至关重要,我们理应容忍巨大的显性低效。而有时,极致的效率才是正确的目标:也许我们的利润已经薄到别无选择,又或许我们对所在领域的稳定性以及系统所面临需求的稳定性有足够的信心,相信不会遭遇过于剧烈的变化。
所以,归根结底,我所能做的,主要是呼吁我们作为系统的工程师、设计者和观察者,能够带着对这种权衡及其影响的自觉去工作。当我们指责一个系统浪费、低效时,不妨停下来想一想,那种“浪费”究竟换来了什么。当我们着手优化一个系统时,不妨先弄清楚现有系统中的关节点和灵活性在哪里,其中哪些至关重要,并尽力保留它们。当我们为某个系统、团队或组织设定追求效率的指标或目标时,要意识到,在没有其他制衡力量的情况下,我们很可能同时也在要求系统变得更加脆弱和易碎。
随机一篇博客
评论
登录后参与讨论