前陣子 Next.js 的 SSR 服務進了 crash loop:每個 Pod 大約 12 分鐘就因為 JavaScript heap out of memory 死一次,三小時內掛了 75 次。
我進行的第一個修復動作是把 heap 開大。結果只是把「12 分鐘掛一次」變成「100 分鐘掛一次」。
洩漏的東西還在那裡,只是撞牆的頻率變了。
一開始都往自己的程式碼找
追原因之前我懷疑三個地方,全部猜錯:
- 記憶體規格開太小? 開大了只是晚一點才掛。
- API 快取撐爆了? 抓 250 篇真實文章把快取灌到上限實測 → 7 MB。差兩個數量級。
- 最近升版的 sanitizer? 兩個版本並排跑 → 一模一樣。
三個懷疑都指向「我們自己寫的東西」。實際上是一個沒人動過的傳遞相依(transitive dependency),也就是沒寫在 package.json 裡,是其他套件需要它,安裝時才一起裝上去的那種。
前情提要:server 上為什麼會有 jsdom
jsdom 是在 Node.js 裡模擬一份瀏覽器 DOM 的套件。Node 本身沒有 document,要在 server 上操作 HTML 就得靠它。
最常見的用途是 XSS sanitizer。像 DOMPurify 這種套件本來設計給瀏覽器用,搬到 server 上就需要 jsdom 提供一個假的瀏覽器環境:
import createDOMPurify from 'dompurify'
import { JSDOM } from 'jsdom'
// 模組載入時建一次 Window,之後每個 request 都用同一個
const purify = createDOMPurify(new JSDOM('').window)
export const sanitize = (html) => purify.sanitize(html)這個寫法是所有 server-side sanitizer 的標準形狀:一個常駐的 Window,加上每次呼叫產生一份用完就丟的 Document。Window 建立成本高,沒人會每個 request 開一個。
20 行的證明
先講結論:jsdom ≥ 27 從一個常駐的 Window 解析出來的文件,只要裡面有任何一個帶 id 屬性的元素,整份文件就再也不會被回收。
完全不需要 DOMPurify,純 jsdom 就能重現。同一份文件解析 300 次,兩組唯一的差別是一個 id:
import { JSDOM } from 'jsdom'
import { retainedPerCall, row } from './lib/measure.mjs' // ← 測量工具,內容見文末
const body = '<p>Lorem ipsum dolor sit amet.</p>'.repeat(40) // ← 刻意不含任何 id
const parseRepeatedly = (html) => {
const { window } = new JSDOM('') // ← 一個 Window,重複使用,跟 server 上一樣
const parser = new window.DOMParser()
return () => parser.parseFromString(html, 'text/html')
}
row('no id', await retainedPerCall(parseRepeatedly(`<body>${body}</body>`)))
row('one <div id>', await retainedPerCall(parseRepeatedly(`<body>${body}<div id="ad1"></div></body>`)))jsdom 28.1.0 跑出來:
no id 6.88 KB/call
one <div id="ad1"> 189.73 KB/call ← 差了快 28 倍那個空的 <div id="ad1"> 本身沒有任何內容。它留下來的 190 KB 是整份文件。
這篇所有數字都出自這個 repo,一行 ./run-all.sh 會用 Docker 把 26.1.0 到 30.1.3 六個版本全部跑一遍,raw log 也 commit 在裡面。
換成正式站的內容更誇張:一篇 40 KB 的文章 HTML(26,815 個字元),每解析一次殘留 2.7 MB。每個使用者讀一篇文章,server 就永久少掉幾 MB。
為什麼社群覺得這只是「有點慢」
公開的 issue 裡,最小重現長這樣:
parser.parseFromString('<p>Hello</p>', 'text/html') // ← 玩具輸入不會有 id玩具輸入不會有 id。但真實世界的 CMS 內容裡到處都是:廣告版位、錨點連結、目錄跳轉,隨便一篇文章就有十幾個。
同一個缺陷,用 <p>Hello</p> 測看起來是「有點慢、記憶體多用一點」,用真實內容測是每次解析都留下整份文件。差兩個數量級。
我自己也差點掉進同一個坑。第一版合成 fixture(自己造的測試用 HTML)我寫了段落、表格、iframe、連結、圖片,量出來 7 KB/call,看起來人畜無害。原因是那份 fixture 裡剛好一個 id 都沒有。
一個一個排除,只剩 id
把可能的原因逐一排掉(jsdom 28.1.0):
元素種類無關,位置也無關(把那個 <div id> 從最後面搬到最前面,185.32,沒差)。class、data-*、name 三個對照組都乾淨。觸發條件只有 id 一個。
中間有一段差點把結論寫歪。我先用刪去法,把元素類型一個一個拿掉,發現「拿掉 <div> 洩漏就消失」。看起來乾淨俐落,問題就在 <div>。
還好接著做了最小重現:把內容包在 <div> 裡 → 完全沒有殘留。
那些 div 剛好是廣告版位,身上帶著 id。刪去法告訴你拿掉什麼會好,但被你拿掉的東西身上可能還黏著別的特徵,最小重現才是驗證因果的那一步。
一個 id 釘住的是整份文件
洩漏量隨文件大小成長,跟 id 數量幾乎無關:
40 段的文件,id 從 1 個加到 20 個,只從 185 變成 256。但文件從 40 段變成 160 段,186 直接變 586。
id 決定記憶體會不會 leak,文件大小決定 leak 多少。
保留路徑
解析 120 次之後用 v8.writeHeapSnapshot() 拍一張 heap snapshot,從那些還活著的 DOM 元素反向走訪回 GC root:
那個 Set 的底層陣列隨解析次數線性成長,沒有任何移除路徑(實際走出來的完整鏈在 repo 的 results/retainer-chain.txt)。
從這個形狀推斷:這是一個 id → element 的查詢快取,而它掛在 Window 上,跟著 Window 一起活著。同一個 Window 生出的每一份文件,都把自己帶 id 的元素塞進這個共用快取,文件用完了也拿不出來。而一個元素活著,它的 parentNode 就活著,整棵樹跟著活著。
寫這段的時候我沒讀 jsdom 原始碼,本以為是 getElementById 的快取。後來上游的修正把答案寫出來了:形狀猜對,名字猜錯。
它是 window.ad1 這種具名存取背後的追蹤器。瀏覽器裡帶 id 的元素可以直接用 window.<id> 拿到,jsdom 為了做到這件事,把共用同一個 Window 的每一份文件裡帶 id 的元素都登記進去,只在元素被移除時才清掉。但一份直接丟掉的文件,永遠不會觸發移除。
版本邊界:從 27 開始有,30.1 修好
同一份文件解析 300 次,唯一差異是一個 id:
| jsdom | 無 id | 有一個 id | 狀態 |
|---|---|---|---|
| 26.1.0 | 0.11 KB/call | 0.35 | 乾淨 |
| 27.0.0 | 6.90 | 181.09 | 回歸點 |
| 28.1.0 | 6.88 | 189.73 | |
| 29.1.1 | 0.11 | 161.14 | 基準被修好了,缺陷還在 |
| 30.0.1 | 0.11 | 161.14 | 最後一個有問題的版本 |
| 30.1.0 | -0.15 | 0.33 | 修好了 |
| 30.1.3(最新) | 0.10 | 0.37 | 乾淨 |
29 那一列值得多看一眼。無 id 的基準從 6.90 掉到 0.11,改善了 60 倍;有 id 的情況只從 189 降到 161。
版本對得上 jsdom 29.0.0 的一條 release note:
Improved
Documentinitialization performance by lazily initializing the CSS selector engine, avoiding ~0.5 ms of overhead perDocument.
上游確實在動這塊,但改善的是無 id 的基準,有 id 的情況沒修到。
有人回報過,只是沒人叫它記憶體洩漏
追到這裡我開始懷疑自己:這麼明顯的缺陷,怎麼可能全世界只有我遇到?
追下去發現有人回報過,只是換了幾種面貌:
- isomorphic-dompurify #368:長時間執行的程序記憶體無上限成長。已關閉,解法是 3.0.0 新增一個
clearWindow()。你不主動呼叫,問題就還在。 - DOMPurify #1089:同樣是 jsdom 環境下的洩漏,懷疑方向指向
NodeIterator。已關閉。 - jsdom #3985、#3936:兩張都開在 jsdom,但標題和內容都寫成「performance regression in v27」。至今 open。
機制上游看得見,時間點也對得上 27。但在 8 月之前,沒有人把它命名為記憶體缺陷。最接近的兩張被歸類成效能問題,唯一被當成記憶體問題處理的那張,出在下游的 wrapper 套件,用一個 opt-in API 結案。
搜尋「jsdom memory leak」找不到它,因為它不叫這個名字。
兩個月後重跑:已經修好了
我發文前按照自己的規矩,對最新版重跑一次。30.1.3 已經不會留下整份文件了,有 id 的情況回到 0.37 KB/call,跟 26.1.0 一樣乾淨。
一版一版往回試,修好的是 30.1.0(9/17 發佈)。修正是 jsdom #4236,關掉的是 #4234,另一個人在 8/12 回報的,跟我跑出第一組數字同一天,描述的是同一條保留路徑。6 天後就合併了。
這張 issue 終於用了對的名字。但 30.1.0 的 release note 有 67 條,修正寫在第 36 條:
Fixed elements in documents created with
DOMParserordocument.implementation.createHTMLDocument()appearing as named properties onwindowand being retained in memory.
沒有「leak」,也沒有「id」。卡在 27 到 30.0.1 的人掃過這份 changelog,大概不會知道這條跟自己有關。
解法:先升級,不能升再讓 Window 不要活那麼久
最直接的解法是升到 jsdom 30.1.0 以上,升完拿你實際部署的版本跑一次上面那 20 行。
如果因為別的相依卡在 27 到 30.0.x,快取掛在 Window 上,那唯一的出口就是讓 Window 不要活那麼久:
import createDOMPurify from 'dompurify'
import { JSDOM } from 'jsdom'
let purify, window
let count = 0
const renew = () => {
window?.close()
window = new JSDOM('').window
purify = createDOMPurify(window) // ← addHook / setConfig 要在這裡重設
count = 0
}
renew()
export const sanitize = (html) => {
if (count++ > 500) renew() // ← 舊 Window 連同它的 id 快取一起被回收
return purify.sanitize(html)
}clearWindow() 做的就是這件事,只是它把時機交給你決定。500 這個數字要看你的文件平均多大。上面的數字換算下來,2.7 MB × 500 差不多是 1.3 GB,對多數服務來說已經太大了,實務上會抓更低。
兩條測量紀律
這篇的數字能不能信,全看 harness。有兩個地方做錯就會量出一堆看起來很有說服力的垃圾:
export const tick = () => new Promise((r) => setImmediate(r))
export const settle = async () => {
if (typeof global.gc !== 'function') throw new Error('run node with --expose-gc')
global.gc()
await tick()
global.gc()
await tick()
global.gc()
}
export const retainedPerCall = async (run, n = 300, warmup = 25) => {
for (let i = 0; i < warmup; i += 1) {
run()
await tick()
} // ← 暖機,排掉 JIT 與 lazy module init
await settle()
const before = process.memoryUsage().heapUsed
for (let i = 0; i < n; i += 1) {
run()
if (i % 10 === 0) await tick() // ← 讓出 event loop
}
await settle()
return (process.memoryUsage().heapUsed - before) / n / 1024
}第一條:一定要 --expose-gc 並在測量前後強制 GC。少了這步,量到的數字會混進還沒被回收的垃圾,看起來就像洩漏。
第二條:迴圈裡必須讓出 event loop。jsdom 有部分清理掛在 finalizer 上,從頭到尾沒讓出 event loop 的迴圈裡 finalizer 永遠不會跑,量出來的洩漏在真實的 async server 上根本不存在。我第一版就是這樣炸出一個假數字的。
延伸閱讀
- 三成的 500 卡在剛好 10 秒:undici 預設的 connect timeout:另一起 SSR 事故,後端一樣全程健康,問題出在 Node 這一層的套件