最慢的那個回答成了你的回答
在前幾篇導覽裡,我們學會把資料中心看成一台電腦,並用請求層級平行與 MapReduce 這套模式,把一份工作散開到成千上萬台便宜的節點上。這種散開(fan-out)對總吞吐量是好事,但它藏著一個陷阱,而這整篇就是在談這個陷阱。當你打一個搜尋查詢,前端伺服器不會自己回答;它會把你的請求拆成許多片,送往也許數百台葉節點伺服器,每一台各握有索引的一小片。你的最終答案,要等到那些碎片裡的最後一片回來,才能拼湊得出來。你等的不是那台普通的機器——你等的是最慢的那一台。
想像你派一百個朋友各去一百間圖書館各借一本書,要在一家咖啡館碰頭後才能開工。只要有一間圖書館午休關門,你整個專案就得乾等——不是等那九十九趟順利的,而是等那一趟慢的。這就是尾端延遲(tail latency)的精髓:分布最尾端那少數倒楣請求的回應時間,也就是第 99、99.9 或 99.99 百分位。平均值(一個典型請求的回應時間)看起來很漂亮;真正讓人痛的,在尾巴。
慢,是從哪裡來的
為什麼在這個特定請求上,一百台裡會有一台明明很健康卻偏偏慢?老實的答案是:因為十幾個平凡的理由同時發生,而且全都很難預料。那台機器可能正忙著服務別人的查詢;背景的垃圾回收暫停可能讓它凍結了幾毫秒;它的 CPU 可能為了散熱而降頻;一次磁碟尋軌、或一次受限於記憶體的停頓剛好落在最糟的時刻;又或者它前面的網路佇列短暫塞滿了。這些沒有一個算是故障——節點是活的、答案是對的,只是一瞬間分了心。而當數以百萬計的事情共用硬體時,這種一瞬間的分心無可避免。
這就是為什麼尾端延遲在結構上不同於一個臭蟲。你沒辦法靠寫出更乾淨的程式碼把它消滅,因為它源自共用:一台機器上有許多租戶、一條佇列裡有許多請求、許多核心搶同一個記憶體控制器。架構師的思維於是從「讓每個請求盡可能快」轉向「確保沒有任何單一請求能毀掉整批」。這個重新框定——從追求平均速度,轉向圍堵最壞情況——是這整篇最重要的觀念,它也呼應了整座倉儲層級的原則:你要為慢尾巴的行為來設計,而不是為又快又平凡的中段。
把整棟建築縫在一起的網路
所有那些散開,都跑在資料中心網路上,而它的形狀直接左右了尾巴。這個層級結構是實體的、很容易想像。幾十台伺服器像書架上的書一樣插在一個機架(rack)裡;每個機架頂端有一台機架頂端交換器(top-of-rack switch),用很短的銅纜把它們以全速彼此相連。機架再聚成一排排、一個個叢集,由更高層的匯聚交換器與骨幹交換器串起,越往上越是用光纖。同一機架裡的兩台伺服器,一個快速跳躍就能對話;不同叢集裡的兩台,卻得爬上樹再爬下來,穿過更多交換器、更長的距離——所以你的資料相對於需要它的地方擺在哪裡,如今是頭等的架構考量。
spine / core switches <- fiber, fewest, most shared
/ | \
aggregation switches <- ties rows of racks together
/ | | \
[ToR] [ToR] [ToR] ... [ToR] <- one switch per rack
| | | | | | | | | | | |
srv srv srv ... <- ~20-40 servers per rack
In-rack hop: server -> ToR -> server (1 switch, ~us)
Cross-cluster: server -> ToR -> agg -> spine -> agg -> ToR -> server
(5+ switches; farther = more queueing = fatter tail)還有一個老實的皺褶:上層連結通常是超額認購的。要蓋一個每台伺服器都能同時對其他每一台全速猛灌的網路,光纖與交換器的代價是天文數字,所以設計者刻意讓越高層的頻寬,少於它下方所有連結的總和——一個典型的超額認購比例可能是 4 比 1 甚至更高。這個賭注是:不會所有人同時橫越整棟建築對話,而這通常成立。但當一陣橫越叢集的流量真的撞在一起時,那些共用的上層連結就會壅塞、佇列填滿,然後——你猜對了——尾端延遲偏偏在系統最忙的時候腫大。那個便宜的、商用級網路,是一種刻意的經濟取捨,不是疏忽。
馴服那條長尾巴
如果慢節點無可避免,解方就不是禁止慢,而是拒絕等它。最優雅的招數是對沖請求(hedged request):先把工作送往一個複本,如果在某個時限內(比如第 95 百分位的時間)還沒回應,就把同一個請求的第二份送往另一個複本,誰先回來就用誰。因為慢事件通常是短暫且互不相關的,兩個複本剛好同一瞬間都慢的機率小得可憐——兩個小機率相乘,得到的是一個更小的數。你只多付幾個百分點的工作量,就能把最壞的尾巴大幅砍掉。這是把冗餘用在及時性上,而不是用在正確性上。
- 把請求送往複本 A,並啟動一個短計時器,設在接近典型回應時間(例如第 95 百分位)的長度。
- 如果 A 在計時器響之前回答了,就用它的結果,大功告成——完全沒有額外成本。
- 如果計時器先響,就把同一個請求的第二份散往複本 B(一台不同的機器,最好是不同的機架)。
- A 與 B 誰先回來就用誰,再取消落敗的那一個,讓它別再白費工。
對沖只是最搶眼的招;工具箱要更寬。你可以讓一個請求在多數葉節點回覆的那一刻就傳回一個夠好的答案,悄悄丟掉最後幾個落後者——寧可接受稍微不那麼完整的結果,也不要一個慢的結果,這是一種刻意的優雅降級。你可以把複本放在不同機架,這樣單一壅塞的交換器就無法拖慢所有副本。你可以讓請求佇列保持短小,並讓互動式流量優先於批次工作。而你也可以單純地、不間斷地量測尾巴:盯著第 99.9 百分位而非平均值的團隊,才是那種能在使用者之前就發現那台一跛一跛的節點的人。
為什麼這重塑了我們對速度的看法
在單機那幾個階段裡,效能指的是鐵律:執行時間 = 指令數 x CPI x 週期時間,而你靠縮小其中一個因子來加速程式。在倉儲規模下,這條鐵律對每個節點依然成立,但使用者看得到的數字是一個分布,不是單一值,而它的尾巴是由共用、佇列與網路塑造的——這些都是任何編譯器最佳化都搆不著的東西。頭條指標於是悄悄地從純粹的吞吐量,轉向負載下可預測的延遲。一個平均很快、但偶爾爛得要命的服務,對真實使用者來說,就是一個慢服務。
在繼續之前,兩個老實的提醒。第一,對沖與冗餘是靠花掉額外的容量與能量來換取更低的尾端延遲,所以永遠存在著「成本對尾巴」的取捨——你不會對所有東西都對沖,只對那些延遲真正要緊的請求才這麼做。第二,網路標榜的尖峰頻寬,就像 CPU 標榜的尖峰浮點運算量一樣,是個在真實、突發、超額認購的流量下鮮少達成的最佳情況;尖峰與實際交付之間的落差,是這整個階段反覆出現的主題。下一篇會從延遲轉向某件更無情的事:在這麼多機器之下,永遠都有東西壞著,我們必須為持續不斷的故障而設計,而不是假裝它不存在。