兩個容易混淆的不同問題
上一篇留給你一個鋒利的習慣:在看過測量的形狀之前,永遠別把它們塌縮成一個數字,因為分布的尾巴常常才是真正的故事。現在我們替那個形狀一直在回答的兩個問題命名,因為初學者老是把它們攪在一起。延遲(latency)是一次操作要花多久——單一呼叫從請求到回應的時間。吞吐量(throughput)是每單位時間多少次操作完成——每秒請求數、每秒位元組數、每秒影格數。它們聽起來像同一件事的兩種說法,其實不是;它們可以往相反方向移動,而最佳化其中一個可能毀掉另一個。
一個小畫面就能釘死這個差別。想像一個收銀台,只有一個收銀員,每位顧客剛好花 30 秒。每位顧客感受到的延遲是 30 秒,而吞吐量是每分鐘 2 位顧客。現在開十條結帳道。每位顧客仍然等 30 秒——延遲沒變——但店裡現在每分鐘清掉 20 位顧客:吞吐量翻了十倍,延遲卻維持不動。這兩者並沒有被綁在一起。你靠加上平行的容量提高了其中一個,卻沒碰到任何單筆交易要花多久。這正是延遲與吞吐量之分的核心:吞吐量常常是系統有多寬的性質,延遲則是穿過它的那單一條路徑的性質。
但把系統推得更用力,這兩者就開始互動,以一種讓人意外的方式。持續餵進比十條道能清掉的更多顧客,佇列就形成了;現在每位顧客的延遲不再是 30 秒,而是 30 秒加上他在隊伍裡等了多久。當你逼近最大吞吐量,延遲不會保持平坦——它先攀升、然後爆炸。這就是排隊現象在運作,也正是為什麼你永遠不能讀一個延遲數字而不問一句「在什麼負載下?」一個閒置時 1 ms 回應的服務,在它吞吐量天花板的 90% 處可能要花 200 ms。同樣的程式碼、同樣的機器——唯一改變的,是管子有多滿。
為什麼平均是錯的摘要
一旦延遲成為問題,平均就變得主動地誤導——而這正是上一篇那個警告兌現的地方。假設一個請求在常見情況下花 1 ms,但每一百次呼叫就有一次撞上慢路徑——一次觸發了分頁錯誤的堆積配置、一次快取冷的查找、一次切到另一個執行緒的短暫脈絡切換——於是花了 100 ms。平均大約是 2 ms,看起來很漂亮,卻沒有描述任何一個真實的請求:請求不是快的(1 ms)就是慢的(100 ms),從來不是平均報出的那個 2 ms。平均把一個雙峰的現實抹平成了一個單一數字,而那個數字恰恰藏起了你在乎的那些情況。
所以延遲要用百分位數(percentile)來報告,而不是平均。p50(中位數)是你有一半請求能勝過的值。p99 是 99% 的請求能勝過的值——等價地說,是最慢的那 1% 越過的門檻。p999(第 99.9 百分位)是每一千次裡最慢的那一次。把它們一起讀,才講出真正的故事:一個服務可能 p50 = 1 ms、p99 = 12 ms、p999 = 110 ms。中位數與高百分位之間的那段散布就是尾巴,也正是單一平均永遠給不了你看的那一部分。那句行話很精準:p99 就是「你最不走運的百分之一使用者所看到的延遲」。
為什麼尾巴比看起來更要緊
把尾巴揮手帶過很誘人——「只有 1% 的請求慢,誰在乎?」要在乎的理由是:真實的請求很少獨自旅行。現代系統會扇出(fan out):使用者一次點擊觸發一個頁面、平行地呼叫二十個後端服務,而那個頁面要等到那二十個裡最慢的回覆才算完成。如果每個後端都有一個漂亮的 p99,那麼全部二十個都落在它們快的 99% 裡的機率是 0.99 的二十次方——大約 0.82。所以大約 18% 的面向使用者的請求,會碰到至少一個後端的慢尾巴。罕見的每服務尾巴,已經變成了常見的使用者體驗。正是這種放大,讓尾端延遲成了真正主宰一個分散式系統感覺如何的那個指標。
P(page is fast) = (p99 fast fraction) ^ (number of backends) 1 backend : 0.99 ^ 1 = 0.99 -> 1% of pages hit a slow tail 20 backends: 0.99 ^ 20 = 0.818 -> ~18% of pages hit a slow tail 100 backends: 0.99 ^ 100 = 0.366 -> ~63% of pages hit a slow tail
尾巴勝過它的發生頻率還有第二個理由:慢路徑往往就是那個臭蟲。1 ms 的 p50 告訴你這段程式碼有能力很快;110 ms 的 p999 告訴你偶爾有什麼事出了大錯,而那個什麼,通常是一個具體、可修的機制——一把握太久的鎖、一次偶爾觸發配置器慢路徑的熱路徑配置、一次垃圾回收的暫停、一個週期性變冷的快取、一次在錯誤時刻發生的脈絡切換。尾巴正是你最不守規矩的程式碼路徑現身的地方。把 p999 追下去不是在拋光;那是在替你的平均彬彬有禮地對你藏起來的那場罕見災難除錯。
協同遺漏:你的尾巴測量如何說謊
現在來談這篇最重要、也最不明顯的概念,也是那個讓多數自製延遲數字一文不值的概念。想像標準的負載測試迴圈:送一個請求、等回覆、計時、立刻送下一個。它看起來很誠實。但看看當伺服器卡住 100 ms 時會發生什麼——一次漫長的配置、一次 GC 暫停、隨便什麼。你的迴圈正被擋住、在等那一個慢回覆,所以在那 100 ms 裡它完全沒送出任何新請求。你本來想每毫秒發一個請求;結果你只送了一個慢的、記下了單一個 100 ms 的樣本。那 99 個你本該在卡頓期間送出的請求——它們每一個也都會是慢的,因為伺服器整段時間都趴著——從未被送出、也從未被測量。
這就是協同遺漏(coordinated omission):你的測量用戶端跟它正試圖測量的那些卡頓協同了起來,恰恰遺漏了它本該捕捉的那些慢樣本。對尾巴而言,這個效應是毀滅性的。現實中包含一百個慢請求時,你卻只記下了一個 100 ms 的離群值;你的直方圖把那次卡頓顯示成 N 分之一的罕見事件,而真相是它主宰了一個 100 ms 的視窗。中位數看起來沒事、p99 看起來令人安心,而產生這些數字的那個系統會在正式環境裡垮掉。協同遺漏不是一個捨入誤差——它常規性地把真實的尾巴低估十倍甚至更多,而且它安靜地這麼做,用一些看起來無可挑剔的數字。
修法是:拿延遲去對照一個請求本該被送出的時程,而不是一個卡住的用戶端實際勉強做到的時程。如果你打算從一個固定的時鐘開始、每毫秒送一個請求,那麼一個本該在時刻 t 送出、卻拖到 t + 50 ms 才送出的請求,在伺服器看到它之前就已經承受了 50 ms 的延遲——而那段等待的時間必須被算進去。好的負載產生器(以及好的基準測試框架,本著可重現基準測試的精神)正是這麼做:它們事先把預定的送出時刻固定下來,並向每個請求收取它從本該開始的時刻起算的完整延遲。下面這些務實的規則能讓你保持誠實。
- 事先固定預定的送出時程(例如每毫秒一個請求),而不是只在上一個回覆到達後才送下一個請求。
- 從每個請求的預定開始時刻量它的延遲,而不是從一個卡住的用戶端終於勉強送出它的時刻量起——把卡頓造成的排隊延遲算進去。
- 用一個會修正協同遺漏的負載產生器(或者只有當你真實的用戶端確實會在每個回覆上阻塞時,例如一個單執行緒的命令列工具,才用閉迴路模型)。
- 在一個明確標示、固定的負載下報出百分位(p50、p99、p999),絕不報平均、也絕不在沒標示負載下報——一個沒有負載的延遲數字毫無意義。
調對的那個旋鈕
因為延遲與吞吐量是不同的問題,它們需要不同的修法——而一個幫到其中一個的改動可能傷到另一個,所以你在動任何東西之前,必須知道你在最佳化哪一個。批次處理(batching)是經典的例子。把許多小操作併成一個大的,攤平了每操作的固定額外開銷——一次系統呼叫而不是一百次、一次配置而不是很多次——這提高了吞吐量,有時提高得很戲劇化。但一個現在必須等湊滿一個批次的請求,在它的批次準備好之前就閒著,所以批次處理提高了個別請求的延遲。吞吐量升、延遲也升:一個貨真價實的取捨,而非免費的勝利。它是不是對的選擇,全看你的使用者感受的是每個請求的延遲、還是只有總體的速率。
加上平行——更多執行緒、更多核心、更多結帳道——是那個提高吞吐量、同時讓單一請求的延遲大致不動的招式,正是開頭那個十條結帳道的畫面。但這恰恰是下一篇接手的地方,因為平行並不會永遠擴展下去:無論你丟多少核心進去,你工作中那段串列的比例都會把加速封頂,這就是 Amdahl 定律。更糟的是,平行可能製造出它先前沒有的尾端延遲——一把共享鎖上的爭用、一個在錯誤時刻把核心輸給脈絡切換的執行緒、隨著更多執行緒爭奪快取與記憶體頻寬而增長的雜訊。吞吐量調校與尾端行為並不獨立;把系統拓寬可能拉長尾巴。
把它帶往下一步
把這條線收攏起來。延遲與吞吐量回答不同的問題——一個要花多久、每秒多少個——而它們可以各自獨立移動、甚至互相打架,所以任何效能工作的第一個動作,就是替「這裡哪一個才要緊、在什麼負載下」命名。對延遲而言,平均是一個謊言,它把一個雙峰的現實抹成一個描述不了任何真實請求的數字;報出百分位、讀 p50 與 p999 之間的落差,因為那個落差就是尾巴。尾巴的要緊遠超過它的發生頻率:扇出把一個罕見的每服務變慢,變成了常見的使用者體驗,而慢路徑通常是一個值得追捕的具體臭蟲。而最重要的是,要不信任你自己的尾巴測量:協同遺漏安靜地抹掉了一個卡住的用戶端未能送出的那些慢樣本,用看起來無可挑剔的數字把尾巴低估了一個數量級。
開頭那個畫面已經指向了這場論證的下一級。加上結帳道在不碰延遲的情況下抬高了吞吐量——直到佇列形成、延遲攀升。那個「直到」正是下一篇的全部主題。當你伸手去拿平行來提高吞吐量時,你實際上能買到多少加速,而那條曲線在哪裡趨平?答案不是「你要多少有多少」:你工作中那段串列的部分設下了一道硬天花板,而有一條精確的定律會告訴你它在哪。下一篇把那條定律——Amdahl 定律,以及它較樂觀的表親 Gustafson 定律——講得具體,好讓你在這裡追逐的吞吐量,與在那裡主宰它的極限相遇。