Efficiency trades off against resiliency

Nelson Elhage

效率與韌性的取捨

原文由 Nelson Elhage 發布,訂閱此部落格

伺服器的 CPU 使用率,怎樣才算「剛好」?如果你看到一個設計良好、維運得當的服務的監控儀表板,在一、兩天的平均值下,我們應該期待看到多少 CPU 使用率?

這是一個非常概括的問題,很難說它會有單一的標準答案。不過很長一段時間以來,我大致都認為越高越好:我們應該盡可能追求接近 100% 的使用率。為什麼呢?因為任何低於 100% 的部分,都代表硬體容量被閒置了,也就是在浪費資源。如果一個服務沒有把 CPU 吃滿,我們大可把它搬到更小的執行個體上,或是在同一個節點上多跑一些其他工作。

事實證明,這種過於簡化的直覺往往並不正確。

假設我們真的達到了那個理想狀態,服務一直維持在接近 100% 的使用率。這時如果我們突然爆紅、湧入一波意料之外的流量,會發生什麼事?或者我們想部署一個需要每個請求多花一點 CPU 的新功能呢?

如果本來就已經是 100% 使用率,這時又有狀況讓負載再增加一點,我們就麻煩大了!以 100% 使用率在跑,代表我們完全沒有餘裕去吸收額外的負載。結果不是效能退化,就是得手忙腳亂地緊急擴充容量,或者兩者同時發生。

這個小小的例子,其實反映了一個非常普遍的現象:對效率的提升往往要以韌性為代價,而且系統被最佳化得越徹底,這種取捨就越明顯。

超過某個臨界點之後,想讓系統更有效率,就意味著要讓它變得更不具韌性,反過來說,要讓系統更強健,往往也會讓它變得比較沒效率(至少在短期內是如此)。這並不是說完全沒有雙贏的可能;有時候是有辦法把帕雷托前緣往外推的,修正一些「愚蠢的」效能臭蟲有時就能達到這種效果。但只要投入的優化超過一定程度,你終究會被迫做出取捨。

在此要特別說明,我所說的「韌性」,指的遠比單純的「可靠度」或「能不能保持在線」更廣泛;我指的是更廣義的「吸收或回應變化的能力」——各種變化都算在內,包括臭蟲或故障,也包括產品需求的改變、市場的變化、組織或團隊組成的變動等等。

此種取捨的幾個例子

這種取捨不只出現在容量規劃的領域。它幾乎適用於任何技術系統或組織的各個層面。以下是我曾觀察到的其他幾個例子:

冗餘

同一個服務跑多個執行個體,再搭配負載平衡器,讓其中任何一個執行個體故障時,流量都能自動轉導到其他執行個體上,這是極為常見的做法。在更成熟的組織裡,這種模式甚至會套用到整個資料中心層級,架構設計成即使整個資料中心失效,也能把流量導向其他資料中心。

要讓這種機制奏效,每個執行個體都必須保留足夠的備用容量,以承接從故障執行個體轉移過來的額外負載。在平時沒有發生故障的穩態下,這些容量就得閒置在那裡,或頂多拿去跑一些隨時可以捨棄的低優先級工作。我們想要的冗餘越高,平時就得閒置越多的容量。

最佳化

精巧的效能最佳化,往往是靠發掘並利用問題領域中特定的性質或結構來達成。把這些不變條件寫死在資料結構與程式碼架構裡,常常能帶來極大的效能提升。然而,你對特定假設的依賴越深,就越難去改變它們,這也使得被高度最佳化的程式碼,往往更難演進或加入新功能。

舉一個具體的例子,在我對 Sorbet 的回顧中,我提到我們當時決定型別推論只做區域性、單次遍歷,並把這個假設深深寫進程式碼與資料結構裡。這個選擇帶來了可觀的效率提升,但在某種意義上也讓系統變得更脆弱:許多其他型別系統具備的功能,在 Sorbet 的程式碼庫中會變得不可能實作,或是困難到不切實際,就是因為這些假設。我至今仍認為以那個專案而言這是正確的決定,但其中的取捨值得正視。

Hillel Wayne 把類似的特性稱為「聰明的程式碼(clever code)」,他將其定義為

利用對問題的已知知識的程式碼。

他也談到,這種意義下的聰明程式碼,往往很有效率,但有時也相當脆弱。

序列化格式

要找到比「直接把記憶體中的 structs 原樣複製到磁碟上」更有效率的序列化格式,恐怕很難;幾乎不需要任何程式碼,儲存或載入資料時的序列化成本也趨近於零。

然而,這會讓系統變得非常脆弱——哪怕只是新增一個欄位,就得重寫資料或做額外的特殊處理。而要在位元組順序或字組大小不同的機器之間共享資料,也會變成一大難題。

反過來說,如果把所有資料都寫成 JSON 或類似的通用容器,雖然在新增欄位、或讓新模組彼此溝通而不影響既有程式碼方面,會有極大的彈性,但代價就是在網路傳輸與序列化的 CPU 運算上付出可觀的額外開銷。

分散式系統

我最喜歡的系統論文之一是 COST 論文,它檢視了許多大數據平台,並觀察到其中許多平台雖然具備隨著硬體線性(或接近線性)擴展的優點,卻是以比經過調校的單執行緒實作還要荒謬地低效為代價。

我發現這是一種常見的取捨。分散式運算框架在能夠透過擴充來處理近乎任意的工作負載這點上,是很有彈性與韌性的。有人部署了沒有效率的程式碼,它可以靠擴充來扛住;硬體故障也能自動處理。需要處理更多資料?只要加更多硬體就好(通常還能透過某種自動擴展機制自動完成)。

另一方面,精心撰寫的單節點解決方案往往會快得多(有時甚至快上 10 到 100 倍!),但也脆弱得多:如果資料集再也放不進單一節點,或是需要執行貴上 10 倍的分析,又或是團隊裡的新手不小心在緊密的內層迴圈裡提交了緩慢的程式碼,整個系統就可能垮掉或無法完成任務。

小團隊與大型組織的對比

小團隊——包含一人「團隊」——可以非常有生產力、非常有效率。團隊越小,溝通成本就越低,也越容易讓每位工程師的腦中都保有豐富的共同脈絡。需要寫的文件比較少,需要溝通的異動也比較少,花在 onboarding 與培訓新成員上的時間也更少,諸如此類。小團隊靠「仔細思考、努力嘗試」這類策略,往往能走得比大團隊更遠,也比較不需要依賴像 linter 或謹慎的防禦性抽象設計這類工具。

在條件合適的情況下,一個設計得當、由經驗豐富的工程師組成的小團隊,有時能大致匹敵規模大上 10 倍的團隊的整體產出——這是效率上極大的提升!

然而,小團隊要脆弱得多,對於組織、技術版圖或專案業務需求的變化,也更缺乏韌性。如果一個 4 人團隊有一人離開,產能馬上就少了 25%——更糟的是,團隊本身就很少練習招募與 onboarding 新成員,而大量的知識與文件也只存在於剩下成員的腦海裡。

同樣地,如果業務重心轉移或要推出新產品,需要團隊的系統支援更多功能或其他開發工作,很容易就會超出團隊所能負荷的容量,而想快速擴編團隊也會因為同樣的原因而困難重重。

自動化與人工作業的取捨

一般來說,讓機器來執行一項任務,會比讓人手動執行更有效率——更便宜、更快,而且往往也更可靠。

然而,人類具有無窮的適應能力,而機器(無論是實體機器還是軟體系統)則要死板、僵化得多。有人在迴路中的系統,在即時回應環境變化或突發事件方面,會有更多選項。

即使我們保留同樣的人力,只是用自動化來加速他們的部分工作,我們仍可能面臨自動化依賴的風險:人類會過度依賴自動化而產生不當的信任,或是他們在沒有自動化時獨立運作的能力逐漸退化,以至於在需要切回「手動」時,已經無法適當地接手。

餘裕

上面這些具體的觀察,很多其實都是在談餘裕(slack)(不是那套通訊軟體)。

擁有適量餘裕的系統——至少在短期、簡化的分析下——依定義就是沒有效率的:那些餘裕代表被「閒置」的時間或資源,本來還可以拿去產生更多產出。

但若把眼光放遠,那些餘裕正是韌性的關鍵:系統中的「彈性空間」讓它得以承受小的擾動或失誤;開發者或維運人員可以利用這些餘裕來即時介入,處理突如其來的負載,或在問題變得嚴重、影響到外部之前就先解決掉。

一個毫無餘裕的系統,只要一切正常就很有效率,但也非常脆弱,只要日常運作模式稍有變化,就會很快崩潰。

結論

我試著列舉了一些具體的例子,說明效率與可靠性如何在許多面向相互牴觸、彼此取捨,或至少朝相反的方向施加壓力。希望我已經讓你相信,這是一個相當普遍的現象,即使在某些尚未出現嚴格取捨的情況下,這兩種價值仍會傾向於彼此對立,並指向不同的決策。

可惜的是,光是這個觀察本身,很少能告訴我們該如何處理某個特定的系統。當我們檢視某個系統,初步分析顯示它對投入的資源利用得沒有效率時,若不更深入地檢視,我們無從判斷它究竟是一團混亂、充滿糟糕設計選擇的產物,還是那種表面上的沒效率,其實是在支撐著龐大的冗餘、彈性與餘裕,讓它得以抵禦未來可能出現的各種變化。我們需要更仔細地觀察,而幾乎總是需要針對特定團隊與特定問題的領域專業。

再者,有時確實有免費的午餐。有些設計選擇或決策的確能把帕雷托前緣往外推,而不只是在前緣上移動位置。這在已經高度最佳化的系統中或許很少見,但我們也不能忽視這種可能性。而且很多系統本來就還沒被最佳化到那種程度!

此外,設計空間中的最佳點會因系統而異。有時候,可靠度或韌性至關重要,我們理當容忍表面上看起來極大的沒效率。但有時候,極致的效率才是正確的目標:也許是因為我們的利潤太薄、別無選擇,或是因為我們對自身領域以及系統所面臨需求的穩定性有足夠的信心,相信不會遭遇太劇烈的變化。

所以,我大抵只能呼籲我們身為工程師、設計者與系統的觀察者,在工作中保持對這種取捨及其意涵的意識。當我們指責某個系統浪費、沒效率時,值得先停下來想想,那些「浪費」究竟換來了什麼。當我們著手最佳化某個系統時,先停下來弄清楚現有系統中的關節與彈性所在,哪些是至關重要的,並盡力保留它們。當我們為某個系統、團隊或組織設定追求效率的指標或目標時,也要意識到,若沒有其他制衡的力量,我們很可能同時也在要求系統變得更脆弱、更不堪一擊。

本文章由 muse-spark-1.2-contributor 進行翻譯

留言