vatt'ghern jaskier's ballads
本文 2 個互動圖表在手機上以重點摘要呈現,互動版請以桌面瀏覽器開啟。

把一個 React monorepo 的 CI 從將近九分鐘壓到 2.7 分鐘,聽起來像是砸下一整支團隊重寫 pipeline。真正關鍵的那一刀,卻是發現整個 lint 有 76% 的時間都耗在同一條 eslint 規則上——import/no-cycle 在沒有深度上限的預設值下,把整張 import 圖反覆走了個遍。

一條 eslint 規則吃掉四分之三的 lint 時間——daily.dev 把 React monorepo 的 CI 砍七成

daily.dev 的前端是一個 React monorepo,裡面有三個主要 project:共用元件庫、web 應用、瀏覽器擴充。CI 每次跑滿要九分鐘,而九分鐘對開發節奏的傷害不是線性的——用他們自己的話說,那是「a long feedback loop, context switches (lots of them), frustration and overall degraded developer experience」。整趟優化只花掉一名工程師一天,最後把時間砍到 2.7 分鐘,約七成。這篇把那一天拆成幾個彼此獨立的動作:先量出瓶頸、找到吃掉時間的那條規則、改一行設定,再把所有能併行的都併行。順序本身就是重點。

先講結論裡最反直覺的一點:把 CI 砍七成,靠的不是更強的機器、也不是更聰明的快取,而是一次誠實的量測加上一個被忽略的預設值。這在 CI 優化裡是常態——真正貴的時間往往集中在一兩個你從沒懷疑過的地方,而不是均勻攤在整條 pipeline 上。下面每一節都對應那一天裡的一個具體動作,照它們發生的順序走。

TIMING=1:先量,再動

第一個念頭幾乎人人都會有:三個 project 本來是依序跑的,那就讓它們同時跑。這是最不用動腦的一刀,「The easiest win we could think of right off the bat is to run each project concurrently.」——把三個 project 併行之後,省下約一分鐘。接著他們把 lint 與 test 拆成兩個各自獨立的 job,讓測試不用等 lint 跑完、lint 也不用等測試,這一步再省下 3.5 分鐘。到這裡增益開始遞減,而且冒出一個刺眼的數字:拆開之後,光是 lint 這個 job 自己就要跑將近五分鐘。「We managed to cut 3.5 minutes, but why the hell does linting take almost 5 minutes?」lint 成了新的長桿。

這裡有個容易被跳過的轉折。前兩刀——併行 project、拆開 lint 與 test——省下的時間都來自「讓本來排隊的工作同時發生」,是純粹的結構調整,不需要理解任何一個工具內部在做什麼。但增益到這裡見頂了:wall-clock 已經等於最慢那條 job 的長度,而最慢的那條就是 lint。要再往下,就得打開 lint 這個黑盒,弄清楚將近五分鐘到底花在哪。多數團隊在這一步會憑直覺下手——換 parser、關掉型別檢查、砍掉一批檔案——而直覺在 eslint 上特別不可靠,因為不同規則的成本差距可以是好幾個數量級:純語法規則掃一遍 AST 就結束,牽涉跨檔分析或依賴圖的規則卻要在整個專案的關係上做圖遍歷。

這時候多數人會開始猜——是不是檔案太多、是不是 TypeScript 型別檢查太慢、是不是該換 parser。他們沒有猜,而是量。eslint 內建一個很少人開的旋鈕:把環境變數 TIMING 設成 1,它就會在跑完之後印出每一條規則各花了多少時間、佔比多少,由高到低排序。這是整趟優化裡最便宜、也最關鍵的一個動作——「If you run eslint with TIMING=1 it measures the time each rule takes.」

這個 TIMING 旋鈕不是外掛、不用改設定檔,它一直就在 eslint 裡,只是平常沒理由開。它把每一條規則的累計耗時、以及佔總時間的百分比列成一張表,由高到低排。對「找瓶頸」這件事,這正是你要的粒度:不是「lint 很慢」這種沒有下一步的結論,而是「哪一條規則很慢」。答案精確到規則層級,接下來該動哪裡就沒有懸念了。

# 讓 eslint 印出每條規則的耗時排行
$ TIMING=1 npx eslint .

Rule              | Relative
import/no-cycle   |   76.0%
其餘所有規則加總  |   24.0%

輸出一出來,答案沒有懸念。排行第一名一條規則就吃掉了整個 lint 執行時間的四分之三,其餘所有規則加起來才佔剩下的四分之一。這種分佈很好認:不是「整體偏慢」,而是「單點失血」。下面這張圖把那份 TIMING 輸出的形狀畫出來——你要找的不是一堆均勻的中等長條,而是一根壓過所有人的柱子。

TIMING=1 · 每條規則佔 lint 執行時間的比例 import/no-cycle 76% 所有其他規則加總 24% 一根柱子壓過全場——瓶頸是單點,不是普遍偏慢
比例取自 daily.dev 的 TIMING=1 輸出:import/no-cycle 一條就佔 76%,剩下所有規則共用 24%。柱長依比例繪製。

import/no-cycle:把整張 import 圖走過一遍

先說這條規則為什麼存在。循環相依——A 匯入 B、B 又直接或間接匯回 A——在 JavaScript 裡不是語法錯誤,卻是一顆定時炸彈:模組在求值時是有順序的,環裡的某個模組可能在另一個還沒初始化完成時就讀到它的 export,拿到 undefined;bundler 也難以乾淨地排序與 tree-shake。import/no-cycle 就是在 CI 階段把這種環擋在合併之前。它的價值沒有爭議,問題純粹出在「找環的成本」被一個預設值放大了。

為什麼偏偏是這一條?import/no-cycle 的工作是找出模組之間的循環相依——A 匯入 B、B 匯入 C、C 又回頭匯入 A 這種環。要確認一個模組有沒有落在環裡,唯一的辦法就是從它出發,沿著 import 這條有向邊一路往下走,看會不會繞回起點。問題出在「往下走多深」。這條規則的預設深度是無上限:「by default this rule max depth is set to no limit, which, as you can see, is very inefficient.」在一個彼此高度交織的 monorepo 依賴圖上,無上限意味著它要把從每個模組出發、任意長度的 import 路徑全部展開一遍。

路徑數量隨著允許的深度增加,膨脹得比節點數量快得多。依賴圖裡只要出現「菱形」結構——A 透過 B 和 C 兩條路都能到 D——路徑數就會在每一層相乘。下面這個小工具用一張固定的依賴圖示範這件事:拖動深度滑桿,看規則要走過的 import 路徑數怎麼變。深度放到無上限時,它要走的路徑數是深度 3 的三倍以上;而這還只是從單一模組出發的一趟,實際上規則會對每個檔案各跑一次。

嚴格說,找環時會記錄走過的節點以免真的無限繞圈,所以單看一個模組的一趟 DFS 不會爆炸成無限;真正的成本來自「路徑」而非「節點」——同一個下游模組會被許多條不同的上游路徑重複經過,每一條都算一次工作。下面的小工具刻意把計數落在「走過的路徑數」上,正是為了呈現這一點:節點只有 11 個,深度放到無上限時要走的路徑卻有 41 條,而深度 3 只有 13 條。

拖動深度滑桿、按 play 看 DFS 展開 · 依賴圖有 11 個模組

max depth 3
已走過路徑 0 同一張圖 深度 3 → 13 條 · 無上限 → 41 條
固定的 11 節點依賴圖。深度上限決定 DFS 能走多長的 import 鏈;深度 3 走 13 條路徑、無上限走 41 條。真正的 import/no-cycle 會對每個模組各跑一趟,總成本大致是模組數乘上每趟路徑數。

固定的 11 節點依賴圖

無上限深度時路徑數隨深度膨脹;maxDepth 壓到 3 只截掉最深最多的路徑,仍涵蓋多數循環。

把這件事放大到一整個 repo 就清楚了:規則對每個模組都要做一趟這樣的展開,模組數是 N,每趟要走的路徑數又隨圖的密度成長,總工作量大致落在「N 乘上每趟路徑數」這個量級——這正是它會隨 codebase 變大而急速惡化的原因。合理的推測是,daily.dev 的三個 project 併在一起之後,共用元件庫被大量模組匯入,讓 import 圖夠密、夠深,才把這條規則推到 76% 的佔比。這部分屬於機制上的推斷,來源只明確講到「無上限深度非常沒效率」與「佔 76%」這兩個事實。

值得點出的是,這種成本不是「圖大一點、慢一點」的線性關係。每加深一層允許的搜尋,能延伸的路徑數就按分支因子往上乘;再乘上「對每個模組都重跑一趟、彼此之間不共用中間結果」這件事,整體就往二次方甚至更糟的方向走。這也是為什麼同一條規則在小專案裡幾乎沒有存在感,一旦 monorepo 把三個 project 的依賴圖接在一起、共用元件庫被上百個模組匯入,它就突然變成整個 lint 裡最貴的一項。要強調的是,「每模組各跑、不共用結果」的這層細節是機制推斷,來源本身只確認了無上限深度非常沒效率。

max depth = 3:截斷最深、最多的那些路徑

知道了機制,修法就只有一行。既然成本來自無上限地往下追,那就給它一個上限。eslint-plugin-import 的 issue #2348 討論的正是這件事,而作者的判斷是:「So, setting the max depth to 3 should fix the issue and cover most cases.」注意這句話本身是有保留的——是「應該能修好」「涵蓋大多數情況」,不是「保證抓到所有循環」。深度上限是一個明確的取捨:把搜尋截在三層,等於放棄偵測那些要繞四層以上才成環的相依,換來的是不必再展開最深、數量也最龐大的那批路徑。

// .eslintrc —— 給 import/no-cycle 一個深度上限
"rules": {
  "import/no-cycle": ["error", { "maxDepth": 3 }]
}

從前面那個小工具能直接讀出這個取捨的形狀:深度從無上限收到 3,被砍掉的正是第四、第五層那些數量最多的路徑,而淺層的環——實務上絕大多數的循環相依都是兩三個模組互咬——一個都沒漏。這一改把整體 CI 時間拉到 3.3 分鐘。真正深的循環相依本來就少見,而且往往是設計問題、值得另外用工具全量掃一次,不該讓每次 CI 都為它們付這筆錢。

把深度上限設成 3,背後其實是一個關於「循環相依長什麼樣」的經驗判斷。實務上絕大多數的環都短:兩個模組互相 import、或三個模組串成一圈,通常是 barrel file(index.ts 把整個資料夾 re-export)或共用型別檔被雙向引用造成的。要繞四層、五層才首尾相接的環很罕見,而且真出現時多半代表模組邊界本身設計得有問題——那是一次性用全量工具好好盤點的對象,不是每次 push 都要 CI 幫你重算的東西。深度 3 就是在這個分佈上劃線:把最常見、最該被擋下的環留在偵測範圍內,把長尾丟出去。

從九分鐘的角度看,這一行 maxDepth: 3 是整趟投資報酬率最高的一改:只動一個設定值、風險局限在「可能漏掉極深的環」這個明確且罕見的範圍,卻把 lint 從近五分鐘拉回可接受的長度。它之所以能這麼便宜,前提全在前一步——TIMING=1 已經把責任精確地指到這條規則上,於是不必猜、也不必大改,只要把那個從沒人動過的預設值補上一個合理的界。

把 project、lint、test 從串行拆成併行

規則修好之後,剩下的增益回到結構層面:哪些工作彼此不相依、卻還在排隊等對方。前面已經做過兩刀——三個 project 併行、lint 與 test 拆開——但把整條 pipeline 攤平來看,還能再擠。下面這個滑桿把「之前」與「之後」的 pipeline 疊在一起:左邊是所有東西串成一條、九分鐘的長條,右邊是拆成互不等待的併行 job。拖動中線就能看出,時間不是靠哪一步變快,而是靠不再互相等待。

把 pipeline 想成一張工作的相依圖就清楚了:lint 不需要測試的產物,測試也不讀 lint 的結果,三個 project 的建置彼此獨立。凡是圖上沒有邊相連的節點,原則上都能同時跑,能省下的時間上限,就是「把它們排成一列的總長」減掉「其中最長那一條」。之前它們被排成一條龍,純粹是 CI 設定順手寫成串行,而不是真有相依。這類增益的好處是零風險——不改任何一行產品程式碼、也不動任何規則語意,只是把排程講清楚。

這也是為什麼「拆 job」這一步能一次省下 3.5 分鐘卻幾乎沒有代價:被拆開的 lint 與 test 從頭到尾就沒有共用任何中間狀態,之前讓它們排隊只是浪費。真正需要動腦的,永遠是那些看起來獨立、其實藏著隱性相依的工作——好在 CI pipeline 的相依關係通常寫在設定檔裡、攤在明面上,比程式碼裡的相依好認得多。

拖動中線比較串行與併行的 pipeline · before 9 分鐘 / after 2.7 分鐘

before · sequential lint test |-------------- 9 min --------------| 一條龍:後面的等前面的 after · parallel lint (maxDepth 3) test workers |--- 2.7 min ---| 互不等待:同時開跑
左半是串行 pipeline(9 分鐘),右半是併行後(2.7 分鐘)。長條比例為示意;時間數字取自 daily.dev。

左半是串行 pipeline(9 分鐘),右半是併行後(2.7 分鐘)

lint 與 test 從串行改併行、測試切給多個 worker,CI 從 9 分鐘壓到 2.7 分鐘。

把整趟優化列成一張表,就能看清七成是怎麼一刀一刀疊出來的——沒有哪一步靠魔法,每一步都對應一個具體、可獨立驗證的動作。

一名工程師、一天,把 9 分鐘拆成一連串獨立的動作。
步驟動作來源記錄的變化
起點整條 pipeline 串行跑9 min
各 project 併行三個 project 同時跑而非依序-1 min
lint / test 拆開兩者變成互不等待的 job(但 lint job 本身仍近 5 min)-3.5 min
import/no-cycle maxDepth=3截斷最深的 import 路徑檢查→ 3.3 min
test 依 timing 切到多 worker每個 worker 跑等長的時間→ 2.7 min

--split-by=timings:讓每個 worker 跑等長的時間

最後一段增益來自測試切分。lint 修好之後,測試成了新的長桿,而測試天生好切——一個檔案跟另一個檔案之間沒有相依,可以撒到好幾個 worker 上同時跑。難的不是「切」,而是「切得平均」:如果隨便按檔案數量均分,某個 worker 分到幾個又慢又重的測試檔,它跑完的時間就是整批的時間,其他 worker 早早跑完在空等。daily.dev 用的是 Jest 加 CircleCI,讓 CircleCI 依照歷史耗時來分配——「split by time, which tries to balance the time each worker runs.」按過去每個檔案實際花的時間去湊,讓每個 worker 的總時間盡量相等。

# 先讓 Jest 列出所有測試檔,再交給 CircleCI 依 timing 切分
TEST=$(./node_modules/.bin/jest --listTests)

echo "$TEST" | circleci tests run \
  --command="xargs ./node_modules/.bin/jest --ci --runInBand \
    --reporters=default --reporters=jest-junit --" \
  --split-by=timings

機制上,jest --listTests 把測試檔清單吐出來,circleci tests run 讀進這份清單、依照 --split-by=timings 把它們分派到各個 worker,每個 worker 再用 xargs 把分到的檔案餵給 Jest 跑。CircleCI 之所以能「依 timing」切,是因為它保存了每個測試檔上一次跑的耗時;沒有歷史資料時它會退回按檔名切,累積幾次之後才會收斂到平衡。下面這張圖畫出這個流程——清單進去、依歷史耗時分派、每個 worker 拿到總時間相近的一堆檔案。

為什麼一定要「依 timing」而不是「按檔案數量」平均分?因為測試檔的耗時分佈是長尾的:多數檔案幾百毫秒就跑完,少數整合測試、或跑真實 DB 的檔案可能吃掉好幾秒。按檔數均分時,只要某個 worker 剛好抽到兩三個重檔,它就會遠遠落後,而整批測試的完成時間等於最慢那個 worker——其他人早就跑完在空等。依歷史耗時分派則是拿每個檔案上次真正花的時間去做裝箱,讓每個 worker 的總時間盡量拉平,把這個 straggler 效應壓到最小。

切分之後,測試從「一台機器上一個一個跑」變成「幾份總時長相近的清單各自並行」,而 Jest 這端幾乎不用改——它本來就吃一份檔案清單,差別只在清單是誰、以什麼標準切出來的。這種「工具負責跑、CI 負責分」的分工,讓切分策略日後要調整(加 worker、換切法)都不必回頭碰測試本身。

jest --listTests auth.test.ts feed.test.ts post.test.ts card.test.ts ext.test.ts 其餘 N 個 circleci tests run --split-by=timings worker 0 worker 1 worker 2 每條長度相近 依歷史耗時分派,讓最慢的 worker 盡量不拖累整批(worker 數為示意)
清單交給 CircleCI,依 --split-by=timings 把測試檔分成總時間相近的幾份。daily.dev 一開始把 parallelism 設為 1,保留之後往上加 worker 的空間。

他們一開始刻意把 parallelism 設為 1——也就是先接好切分的管線、但只用一個 worker,把「未來要橫向擴充時只改一個數字」的路鋪好,而不是一次到位開一堆機器。這是很務實的取捨:切分的價值要等測試量再長大才會兌現,先把機制備著、成本先不付。所有動作疊起來,CI 從將近九分鐘落到 2.7 分鐘,「It reduced CI time from almost 9 minutes to 2.7 minutes」,而整趟只「required one day of a single engineer」。

這套做法不綁 daily.dev 的技術選型。TIMING=1 是 eslint 內建、任何用 eslint 的專案都能跑;import/no-cycle 的 maxDepth 選項寫在 eslint-plugin-import 的文件裡;依耗時切分測試在 CircleCI 之外也有對應機制(GitHub Actions 的 matrix、各家 test-splitting 外掛)。真正可以帶走的,是把「憑感覺優化」換成「先量再改」的習慣,以及對「預設值不等於合理值」保持警覺——尤其是那些會隨 codebase 成長而放大成本的圖遍歷型規則。

一天工程換來的:一條 2.7 分鐘的 pipeline,把 feedback loop 收短到可以一直開著跑;但更值錢的是那個順序——先用 TIMING=1 量出瓶頸落在哪條規則,再決定動不動它,而不是一開始就埋頭堆 CI 機器。最貴的那筆時間,往往藏在一個你從沒調過的預設值裡。