前陣子 Next.js 的 SSR 服務進了 crash loop:每個 Pod 大約 12 分鐘就因為 JavaScript heap out of memory 死一次,三小時內掛了 75 次。

我進行的第一個修復動作是把 heap 開大。結果只是把「12 分鐘掛一次」變成「100 分鐘掛一次」。

洩漏的東西還在那裡,只是撞牆的頻率變了。

一開始都往自己的程式碼找

追原因之前我懷疑三個地方,全部猜錯:

  1. 記憶體規格開太小? 開大了只是晚一點才掛。
  2. API 快取撐爆了? 抓 250 篇真實文章把快取灌到上限實測 → 7 MB。差兩個數量級。
  3. 最近升版的 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):

jsdom 28.1.0 逐一換屬性的結果:帶 id 的四個案例都殘留約 186 KB/call,class、data-*、name 三個對照組都停在 7 KB 附近
jsdom 28.1.0 逐一換屬性的結果:帶 id 的四個案例都殘留約 186 KB/call,class、data-*、name 三個對照組都停在 7 KB 附近

元素種類無關,位置也無關(把那個 <div id> 從最後面搬到最前面,185.32,沒差)。class、data-*、name 三個對照組都乾淨。觸發條件只有 id 一個。

中間有一段差點把結論寫歪。我先用刪去法,把元素類型一個一個拿掉,發現「拿掉 <div> 洩漏就消失」。看起來乾淨俐落,問題就在 <div>。

還好接著做了最小重現:把內容包在 <div> 裡 → 完全沒有殘留。

那些 div 剛好是廣告版位,身上帶著 id。刪去法告訴你拿掉什麼會好,但被你拿掉的東西身上可能還黏著別的特徵,最小重現才是驗證因果的那一步。

一個 id 釘住的是整份文件

洩漏量隨文件大小成長,跟 id 數量幾乎無關:

左圖固定 1 個 id、改變文件大小,殘留從 85.56 長到 586.58;右圖固定 40 段、把 id 從 1 個加到 20 個,殘留只從 185.10 到 256.14
左圖固定 1 個 id、改變文件大小,殘留從 85.56 長到 586.58;右圖固定 40 段、把 id 從 1 個加到 20 個,殘留只從 185.10 到 256.14

40 段的文件,id 從 1 個加到 20 個,只從 185 變成 256。但文件從 40 段變成 160 段,186 直接變 586。

id 決定記憶體會不會 leak,文件大小決定 leak 多少。

保留路徑

解析 120 次之後用 v8.writeHeapSnapshot() 拍一張 heap snapshot,從那些還活著的 DOM 元素反向走訪回 GC root:

GC root 留住 Window;以該 Window 為 key 的 WeakMap 指向一個以 id 字串為 key 的 Map,裡面的 Set 裝著帶該 id 的元素,每個元素都留住整份文件
GC root 留住 Window;以該 Window 為 key 的 WeakMap 指向一個以 id 字串為 key 的 Map,裡面的 Set 裝著帶該 id 的元素,每個元素都留住整份文件

那個 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 26.1.0 兩種情況都乾淨,27.0.0 起有 id 的情況跳到 181 KB/call,30.0.1 仍是 161,30.1.3 回到 0.37
jsdom 26.1.0 兩種情況都乾淨,27.0.0 起有 id 的情況跳到 181 KB/call,30.0.1 仍是 161,30.1.3 回到 0.37
jsdom無 id有一個 id狀態
26.1.00.11 KB/call0.35乾淨
27.0.06.90181.09回歸點
28.1.06.88189.73
29.1.10.11161.14基準被修好了,缺陷還在
30.0.10.11161.14最後一個有問題的版本
30.1.0-0.150.33修好了
30.1.3(最新)0.100.37乾淨

29 那一列值得多看一眼。無 id 的基準從 6.90 掉到 0.11,改善了 60 倍;有 id 的情況只從 189 降到 161。

版本對得上 jsdom 29.0.0 的一條 release note:

Improved Document initialization performance by lazily initializing the CSS selector engine, avoiding ~0.5 ms of overhead per Document.

上游確實在動這塊,但改善的是無 id 的基準,有 id 的情況沒修到。

有人回報過,只是沒人叫它記憶體洩漏

追到這裡我開始懷疑自己:這麼明顯的缺陷,怎麼可能全世界只有我遇到?

追下去發現有人回報過,只是換了幾種面貌:

機制上游看得見,時間點也對得上 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 DOMParser or document.implementation.createHTMLDocument() appearing as named properties on window and 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 上根本不存在。我第一版就是這樣炸出一個假數字的。

延伸閱讀