“過早優化是萬惡之源。”
唐納德·克努特在 1974 年寫下了這句話。它是軟體工程領域被引用最多的一句話,並且被用來為比該領域幾乎任何其他想法都多的真正糟糕的程式碼辯護。
這句話本身沒錯,但大多數開發者應用它的方式卻並非如此。
過早優化和基本的工程能力之間存在差異。在發展過程中,產業逐漸模糊了這種區別,導致生產系統讓使用者等待本應即時完成的事情。
以下是完整的句子,但幾乎沒人引用:
“我們應該在97%的情況下忽略小的效率提升:過早優化是萬惡之源。然而,我們不應該錯過那關鍵的3%的機會。”
他談的是微優化,例如循環展開、手動暫存器分配,以及在確定哪些路徑是熱點之前,就盡可能地從熱點路徑中擠出更多週期。他並沒有允許你編寫 N+1 個查詢、在登入頁面載入 400KB 的 JavaScript,或是將整個資料庫表載入到記憶體中,然後在應用程式層進行過濾。
「過早優化」這個概念已被過度延伸,遠遠超出了其最初的含義,以至於開發人員現在用它來為那些設計上就比較慢的程式碼辯護。
「績效」一詞下通常包含兩個完全不同的概念:
過早優化是指在不確定排序是否在關鍵路徑上之前,就花費三天時間手動調整排序演算法;是指在進行任何效能分析之前,就用彙編語言重寫函數;是指為了可能無關緊要的速度提升而犧牲程式碼的清晰度。
基本能力是指在付出相同努力的情況下,不會選擇明顯較便宜的選擇,而是選擇明顯較昂貴的選擇。
這兩者並不相同。一種需要你預知未來,另一種只需要你熟悉你的工具。
寫一個每次迭代都會查詢資料庫的循環,這不是你為了提升效能而推遲的決策,而是你現在就犯的錯誤。當你只需要兩個欄位時,卻使用SELECT *選擇所有列,這不是你忽略的最佳化,而是你新增的不必要工作。
沒人會把木匠在擰螺絲前預先鑽孔稱為過早優化。那隻是技術嫻熟的表現。
讓我們具體說明一下。這些並非特殊情況或微妙的權衡取捨。這些模式會降低應用程式的執行速度,而且沒有任何相應的好處。
const posts = await db.query('SELECT * FROM posts');
for (const post of posts) {
post.author = await db.query(
'SELECT * FROM users WHERE id = ?', [post.author_id]
);
}
如果你有 200 個帖子,那就需要執行 201 次查詢。如果你的資料庫往返時間為 2 毫秒,那麼在應用程式的整個生命週期內,每個使用者都會在每次請求中額外增加 402 毫秒的純等待時間。
這個修復方法並非最佳化,而是使用 JOIN 操作,這正是關係型資料庫在 1970 年設計之初就應該具備的功能。
SELECT posts.*, users.name, users.avatar
FROM posts
JOIN users ON users.id = posts.author_id;
一次查詢,完成。這不是性能上的妥協。在任何情況下,201 次查詢都不會比 1 次查詢更好。
const user = await db.query('SELECT * FROM users WHERE id = ?', [id]);
return { name: user.name, email: user.email };
你獲取了所有列,包括密碼雜湊值、加密的恢復程式碼、完整地址、偏好設定資料塊以及你的模式在過去三年中累積的其他三十個字段。你只使用了其中兩個。
每增加一列,就意味著網路傳輸需要更多字節,分配更多內存,以及耗費更多時間進行序列化和反序列化。更重要的是,如果之後有人向該表中加入敏感列, SELECT *語句會導致應用程式靜默地破壞資料或洩漏資料。
選擇你需要的東西。始終如此。
// These two things don't depend on each other
const user = await getUser(userId);
const settings = await getSettings(userId);
const permissions = await getPermissions(userId);
每個await語句都會等待前一個呼叫完成,然後再開始下一個呼叫。如果每個 await 語句耗時 50 毫秒,那麼你實際上花了 150 毫秒來完成原本 50 毫秒就能完成的工作。
const [user, settings, permissions] = await Promise.all([
getUser(userId),
getSettings(userId),
getPermissions(userId)
]);
三個並發請求,一次等待。這並非微優化,而是理解非同步程式碼的工作原理,這是所有編寫非同步程式碼的人都應該具備的基本素質。
一個包含 8000 個選項的下拉式選單。一個包含 50000 行的表格。一個聊天窗口,它將自 2019 年以來的所有訊息都載入到 DOM 中。
瀏覽器需要建立、設定樣式、佈局並繪製每一個節點,然後也將它們保存在記憶體中。滾動變得卡頓,互動也會出現延遲,使用者體驗明顯變差。
虛擬化、分頁和視窗化技術都存在。它們並非什麼驚天動地的效能最佳化技術,而是處理無限大小清單的正確預設方案。
// Called on every request
const countries = await db.query('SELECT * FROM countries ORDER BY name');
共有195個國家。這份名單幾十年來幾乎沒有改變。每次頁面載入時,你都會造訪該資料庫。
使用 24 小時 TTL 的緩存,或者甚至只是在啟動時加載一個內存常數,成本幾乎為零,並且完全消除了查詢。這並非過早決策。這只是讀取資料並做出顯而易見的判斷。
說實話,執行緩慢的程式碼通常也能正常工作。使用者會感覺到延遲,而開發者感覺不到延遲,因為他們是在本機上測試,資料庫也只有 50 行資料。功能照常發布,而速度慢的問題最終會落在其他人頭上。
還有一種更微妙的力量在運作。現代框架和 ORM 使得編寫低效程式碼變得極為容易。 ActiveRecord 的懶載入、每個 GraphQL 解析器都存取資料庫、React 元件獨立於其同級元件進行資料擷取等等。這些工具固然出色,但它們也使得產生 N+1 次查詢變得輕而易舉,而無需編寫任何明確循環。
這些工具並不能讓你免於了解它們代表你做了什麼。這仍然是你的責任。
「以後再優化」這句話用來形容快取策略、查詢調優和基礎設施擴充是合理的。但用來形容選擇更少的列、批次處理資料庫呼叫或並行執行獨立任務則不合理。
評判標準不是“是否按時交付”,而是“是否按時交付且沒有明顯的浪費”。
先進行效能分析再進行最佳化是正確的。在編寫程式碼之前了解程式碼的功能也是正確的。這兩者並不衝突。你不需要效能分析器就能知道 200 次資料庫查詢肯定比 1 次多。
當你在寫程式碼時,如果想知道某些地方是過早優化還是基本操作,請問自己一個問題:
我需要測量什麼才能知道它的速度更慢嗎?
如果答案是肯定的,那就先完成這個功能,稍後再進行測量。這就是克努特原則的實際應用。
如果答案是否定的,如果較慢的方案顯然是設計上就較慢,而較快的方案編寫時間相同,那麼發布較慢的版本就不是出於優化原則,而只是不做工作而已。
您的用戶會感受到這種差異。效能分析工具只是幫助您在地圖上找到它。