Share Notes

chundev

View the Project on GitHub latteouka/share-notes

Node.js 解析多 GB XML:SAX 引擎從 sax-js 換到 ltx 的五個坑

日期:2026-07-13 環境:Node.js 24、TypeScript、串流解析 4–8 GB 的鑑識工具 XML 匯出檔(Magnet AXIOM / Cellebrite UFED)


TL;DR

sax-js 解析多 GB XML 只有 ~22 MB/s,換成 ltx(xmpp.js 的 SaxLtx parser)後同一份 4.6 GB 檔案從 187s 降到 20.2s(9.3x)。但 ltx 上游有四類靜默資料遺失 bug 加一類 O(chunk²) 效能坑——全部源於它為 XMPP stream 設計、從未被「XML 檔案」場景的輸入形態測過。結論:換 SAX 引擎的必要條件是「差分測試 + 事件流 digest 比對 + 用實際目標檔跑全檔 benchmark」,三者缺一不可;benchmark 用了替代檔案,就會像本文案例一樣把 O(chunk²) 坑帶進生產環境。

目錄:

  1. 背景
  2. 症狀速查
  3. 替代方案比較:2026 年的 Node.js SAX parser 生態
  4. 機制拆解:SaxLtx 為什麼快
  5. 五個坑的根因與修法
  6. 定位手法:從「看似 hang 住」到 2.97 MB 節點
  7. 量化驗證
  8. Adapter 層設計
  9. 驗證方法論:換引擎的四層閘門
  10. 常見誤解
  11. 邊界與陷阱

背景

數位鑑識工具(Magnet AXIOM、Cellebrite UFED)的報告匯出是單一巨型 XML:4–8 GB、三千萬級元素、含大量 CDATA 包裹的訊息內容。解析管線用 Node.js 串流 SAX(fs.createReadStream 64 KB chunk → SAX parser → 事件 handler → PostgreSQL COPY FROM STDIN),並有硬性時限(單檔案 10 分鐘內完成優先資料解析)。

Profiling 顯示瓶頸高度集中:SAX parser 內部佔 97–99% 的 CPU 時間,JS 事件 callback 只佔 1–3%。這代表加速只有一條路——換更快的 SAX 引擎。壞消息是:這條路先前已經翻車兩次(node-expat、sax-wasm,都在大檔案上靜默掉資料),本文的 ltx 是第三次嘗試,也翻了五次——差別在於 ltx 是純 JS,翻車後可以讀原始碼定位並修掉。

鑑識場景的特殊約束:資料完整性是絕對條件。掉一筆訊息不是效能 trade-off,是證據滅失。這決定了本文所有驗證手段的強度。


症狀速查:五個坑的錯誤表現

| # | 症狀 | 觸發條件 | 類型 | |—|——|———|——| | 1 | CDATA 之後、下個標籤前的文字消失 | <a><![CDATA[X]]> tail</a> → ` tail 遺失 | 資料遺失 | | 2 | 註解/processing instruction 之後的文字消失 | xyy 遺失 | 資料遺失 | | 3 | 某個位置之後**整份輸入**靜默消失,無任何錯誤 | –>?>]]>write() chunk 邊界切開 | 資料遺失 | | 4 | 含 DOCTYPE 的檔案整份解析不出東西 | <!DOCTYPE x> 被當註解、永遠等不到 –>` | 資料遺失 | | 5 | 解析速度在特定檔案區段崩跌 100 倍、看似 hang 住 | 巨型無標籤文字節點(實測 2.97 MB 的 email 內文) | 效能 |

五個坑都不丟 error、不發 warning——症狀是「資料變少」或「跑不完」,在下游(資料庫計數、時限告警)才被發現。坑 3 和坑 5 只在串流分塊輸入時觸發,單元測試用單一字串餵入永遠測不到。


替代方案比較:2026 年的 Node.js SAX parser 生態

換引擎前掃過整個生態,實測結論如下:

Parser 實作 串流分塊 大檔實測結果 結論
sax-js 純 JS 逐字元 ~22–27 MB/s,資料 100% 正確 基準線:慢但可靠
node-expat C(libexpat)binding text/cdata 事件映射 bug,93.8 萬筆訊息只寫入 1,822 筆 ❌ 棄用
sax-wasm Rust → WASM 小檔(395 MB)100% 正確;7 GB 大檔子元素消失。上游 #113「lost tags in large file」、#117「lost text in large file」已修,但裝了含修復的 3.1.4 仍發生 ❌ 棄用
ltx(SaxLtx) 純 JS + indexOf fast-forward 本文主角:5 個坑,修完後 231 MB/s、digest 與 sax-js 完全相等 ✅ vendor + 修
saxen 純 JS(easysax 後繼) ❌ 只吃完整字串 未實測(無法串流 GB 級檔案;Node string 上限 ~512 MB) 備選(需自行切塊)
easysax 純 JS 同上,且少維護 跳過
@tuananh/sax-parser C++ N-API 未實測;ltx 官方 benchmark 中落後 ltx 約 1.8 倍 跳過

幾個 micro-benchmark 數據交叉印證 ltx 是純 JS 生態最快:

node-expat 和 sax-wasm 的共同教訓:native/WASM 引擎翻車時是黑盒——node-expat 的事件映射 bug 和 sax-wasm 的大檔 boundary bug 都無法在合理成本內定位根因,只能放棄。ltx 純 JS 約 260 行,五個 bug 全部讀原始碼定位、逐一修掉。引擎候選的「可審計性」在資料完整性至上的場景是一級篩選條件,不是 nice-to-have。


機制拆解:SaxLtx 為什麼快

sax-js 和 SaxLtx 都是 state machine,速度差 5–9 倍的原因在每字元成本

sax-js:逐字元迭代,每個字元經過完整的 XML 規格處理——大小寫正規化選項、namespace 解析(xmlns: true 時建 QualifiedTag 物件)、行號/欄號追蹤、entity 表查找。每字元十幾個分支。

SaxLtx:把「狀態不會改變的長區段」交給 String.prototype.indexOf 快轉:

// SaxLtx 的核心設計:fast-forward
case STATE_TEXT: {
  // 文字區段的下一個狀態轉移點必然是 '<'——直接跳過去
  const lt = data.indexOf("<", pos);
  if (lt !== -1 && pos !== lt) pos = lt;
  break;
}
case STATE_ATTR_VALUE: {
  // 屬性值的終點必然是引號——直接跳
  const quot = data.indexOf(attrQuoteChar, pos);
  if (quot !== -1) pos = quot;
  break;
}

indexOf 是 V8 的 native 實作(memchr 類的向量化掃描),比 JS 層逐字元 charCodeAt + switch 快一個數量級以上。XML 檔案的絕大多數位元組是文字內容和屬性值,fast-forward 讓這些位元組幾乎不經過 JS 層。

代價是三個設計假設,全部來自 XMPP 場景:

  1. stanza 小:XMPP 訊息幾 KB,永遠不會有「64 KB chunk 裡沒有任何 <」的輸入——fast-forward 的 miss 路徑(indexOf 回 -1)等於沒被測過(坑 5)。
  2. stream 裡沒有註解/DOCTYPE/CDATA 混排:XMPP stream 是純元素流——ignore 狀態(註解、PI)和 CDATA 的退出路徑沒被文字內容跟隨過(坑 1、2、4)。
  3. chunk 邊界寬容:XMPP 的 stanza 邊界通常對齊 TCP 封包,--> 這種多字元終止符被 chunk 切開的情境極罕見(坑 3)。

換句話說:ltx 不是爛套件,它是被拿出了設計場景。它在 xmpp.js 生態被 116 個套件依賴、生產環境跑了十年以上——但那十年的輸入形態跟「4.6 GB 的鑑識報告 XML」完全不重疊。

跨 write() 的狀態接力:remainder 機制

理解坑 3 和坑 5 需要先理解 SaxLtx 怎麼處理「一個語法單位橫跨兩次 write()」。它的做法是 remainder carry

this.write = function write(data) {
  let pos = 0;
  if (remainder) {
    data = remainder + data;                            // 上次的殘尾接到本次開頭
    pos += !parseRemainder ? remainder.length : 0;      // 已掃過的部分跳過,不重掃
    parseRemainder = false;
    remainder = null;
  }
  // ... state machine 主迴圈 ...
  // 迴圈結束後:把未完成的錄製區段留給下一次
  if (typeof recordStart === "number" && recordStart <= data.length) {
    remainder = data.slice(recordStart);
    recordStart = 0;
  }
};

兩個關鍵細節:

  1. carry 只在 recordStart 是數字時發生。錄製狀態(TEXT、CDATA、ATTR_VALUE、TAG_NAME)的未完成內容會被帶走;但 ignore 狀態(註解、PI)把 recordStart 設成 undefined——它們的上下文什麼都不會被帶走,這就是坑 3 的結構性根源:--> 的前兩個字元留在上一個 chunk 裡,永遠回不來。
  2. parseRemainder 決定殘尾要不要重掃。一般情況殘尾只是「還沒錄完的內容」,pos 直接跳過(不重掃,O(1));只有終止符可能被切開時(如 CDATA 尾端出現孤立的 ])才設 parseRemainder = true 讓下一輪從頭掃。修坑 3 時就是借用這個既有機制:ignore 狀態在 miss 時手動塞 remainder = 尾端 N-1 字元 + parseRemainder = true

這個機制同時解釋了為什麼巨型文字節點「正確但有成本」:3 MB 的文字節點會以 remainder = 已累積內容 的形式在 47 個 chunk 之間反覆 concat。V8 的 ConsString 讓 concat 本身是 O(1),但下一輪的 indexOf 會觸發 flatten(O(累積長度))——3 MB 節點的總 flatten 成本約 70 MB 記憶體複製,可接受;100 MB 級節點則需要改用 chunk list 累積。詳見邊界與陷阱


坑 1:CDATA 結束後的文字被吞

最小重現:

const SaxLtx = require("ltx/lib/parsers/ltx.js");
const p = new SaxLtx();
p.on("text", (t) => console.log(JSON.stringify(t)));
p.write("<a><![CDATA[X]]> tail</a>");
p.end();
// 實際輸出: "X"
// 預期輸出: "X" 和 " tail"(sax-js 兩者都給)

根因(ltx@3.1.2 lib/parsers/ltx.js):

case STATE_CDATA:
  if (c === 93 /* ] */) {
    if (data.substr(pos + 1, 2) === "]>") {
      const cData = endRecording();   // ← recordStart 在這裡被設成 undefined
      if (cData) this.emit("text", cData);
      state = STATE_TEXT;             // ← 回到文字狀態,但 recordStart 沒有恢復
    }
  }

endRecording() 取出錄製區段後把 recordStart 設為 undefined。回到 STATE_TEXT 後,錄製起點不存在,接下來的文字直到下一個 < 都不會被記錄——到 <endRecording()undefined,一個 text 事件都不會發。

修法:CDATA 終止後跳過 ]]> 三個字元並重新開始錄製:

state = STATE_TEXT;
pos += 2;                  // 跳過 "]>"(迴圈的 pos++ 會再前進一格)
recordStart = pos + 1;     // 恢復文字錄製

坑 2:註解/processing instruction 結束後的文字被吞

最小重現:

p.write("<a>x<!-- c -->y</a>");   // 實際只得到 "x","y" 遺失
p.write("<a>x<?pi data?>y</a>");  // 同樣只得到 "x"

根因與坑 1 完全相同:進入 STATE_IGNORE_COMMENTSTATE_IGNORE_INSTRUCTIONrecordStart = undefined,退出時只設 state = STATE_TEXT,錄製起點沒恢復。

值得注意的是這個 bug 的「隱蔽梯度」:<!-- c --><a>x</a>(註解後緊跟標籤)完全正常——只有「註解後直接跟文字」才觸發。混合內容(mixed content)在資料型 XML 裡少見,所以小檔案測試極容易漏掉。


坑 3:終止符被 chunk 邊界切開 → 之後整份輸入被吞

五個坑裡最危險的一個:觸發後不是掉一段文字,是掉檔案剩餘的全部內容,而且無任何錯誤

最小重現:

// 同一份 XML,唯一差別是 write() 的切點
run(["<a><!-- c --><b>y</b></a>"]);        // ✅ 正常:open a, open b, text y, ...
run(["<a><!-- c --", "><b>y</b></a>"]);    // ❌ 只得到 ["open:a"],之後全部消失
run(["<a><?pi ?", ">y</a>"]);              // ❌ 同樣只剩 ["open:a"]

根因:ignore 狀態的終止判定用「回看前兩個字元」:

case STATE_IGNORE_COMMENT:
  if (c === 62 /* > */) {
    const prevFirst = data.charCodeAt(pos - 1);   // pos=0 時 → NaN
    const prevSecond = data.charCodeAt(pos - 2);  // 前一個 chunk 的字元不在 data 裡
    if ((prevFirst === 45 && prevSecond === 45) || ...) {
      state = STATE_TEXT;
    }
  }

-- 在 chunk A 結尾、> 在 chunk B 開頭時,chunk B 的 charCodeAt(-1)NaN,永遠匹配不到 -->。而 ignore 狀態下 recordStartundefined,ltx 的 remainder 機制(把未完成區段帶到下一個 chunk)只在 recordStart 是數字時運作——所以前一個 chunk 的 -- 直接蒸發,註解永不終止,state machine 卡在 ignore 狀態把檔案剩餘內容全部吃掉。

用 64 KB chunk 串流 4.6 GB 檔案時,任何一個註解有 2/65536 的機率跨切點。機率低但後果是整檔滅失——這種「低機率 × 災難後果」的 bug 比高機率 bug 更難從測試發現、更容易在生產環境變成懸案。

修法:ignore 狀態的 fast-forward 在終止符不在本 chunk 時,carry 尾端 terminator.length - 1 個字元到下一個 chunk 重掃:

case STATE_IGNORE: {
  const t = ignoreTerminator;              // "-->" / "?>" / "]]>" / ">"
  const end = data.indexOf(t, pos);
  if (end === -1) {
    const keep = Math.min(t.length - 1, data.length - pos);
    remainder = keep > 0 ? data.slice(data.length - keep) : "";
    parseRemainder = true;                 // 下一個 write 從 pos=0 重掃 carry 區
    pos = data.length;
  } else {
    pos = end + t.length - 1;              // 停在終止符最後一個字元
  }
  break;
}

坑 4:DOCTYPE 被當註解、等待 --> 吞掉全檔

最小重現:

p.write("<!DOCTYPE note><a>x</a>");
// 實際:整份輸入無任何事件

根因:ltx 對 <! 開頭的處理只分兩類——<![CDATA[ 進 CDATA,其他全部進 IGNORE_COMMENT,退出條件是 -->]]>

} else if (c === 33 /* ! */) {
  if (data.substr(pos + 1, 7) === "[CDATA[") {
    recordStart = pos + 8;
    state = STATE_CDATA;
  } else {
    recordStart = undefined;
    state = STATE_IGNORE_COMMENT;   // <!DOCTYPE 也進這裡,等 "-->"
  }
}

<!DOCTYPE note> 以單一 > 結尾,沒有 -->,state machine 從此卡死。XMPP stream 沒有 DOCTYPE,所以上游十年沒人踩到。

修法:進入 ignore 時依前綴決定終止符——<!----><![]]>、其他(DOCTYPE 等宣告)→ ><??>。附帶把上游三個 ignore 狀態(COMMENT / INSTRUCTION / 死碼的 IGNORE_CDATA)合併成單一「終止符參數化」的 IGNORE 狀態,坑 3 的 carry 機制也只要寫一次。

已知限制:DOCTYPE internal subset(<!DOCTYPE x [ <!ENTITY ...> ]>)會在第一個 > 提早退出。資料型 XML 匯出不含 internal subset,此限制可接受——但要寫進 vendored 檔案的檔頭註解,讓下一個讀者知道邊界在哪。


坑 5:indexOf miss 不跳位 → O(chunk²) 效能崩跌

這個坑最陰險:它不掉資料,它讓解析在特定檔案區段慢 100 倍,外觀跟 hang 住無法區分。而且它通過了所有資料完整性驗證——單元測試、digest 比對全綠,因為它「只是慢」。

發現過程:部署後生產環境(K8s parse worker)解析 4.6 GB 檔案,前 1 GB 正常,之後停滯 20+ 分鐘無進展;同一份檔案在本機開發環境卻只要 20 秒。差異變因逐一排除後,用「每 200 MB 回報 throughput」的 benchmark 定位到崩跌點在 1.0–1.1 GB 區段,再用位元組掃描找到元凶:一個 2.97 MB、完全沒有 < 字元的文字節點(email 匯出的信件內文)。

根因:回頭看 fast-forward 的 miss 路徑:

case STATE_TEXT: {
  const lt = data.indexOf("<", pos);
  if (lt !== -1 && pos !== lt) pos = lt;   // ← miss(-1)時「不跳」
  break;
}

indexOf 回 -1 時 pos 不動,落回逐字元迭代——但 fast-forward 在迴圈裡,每個字元位置都會重新執行一次 indexOf,每次都掃到 chunk 尾、每次都回 -1。64 KB 無 < 的 chunk = 64K 次迭代 × 平均 32 KB 掃描 = 每 chunk 約 2×10⁹ 次字元比較。2.97 MB 的節點橫跨 47 個 chunk,總量約 10¹¹ 次比較,直接停滯數分鐘。STATE_ATTR_VALUE 的引號搜尋同病。

修法一行:

if (lt === -1) { pos = data.length; break; }  // 整個 chunk 都是文字:跳尾

跳到 chunk 尾後,未完成的文字由既有的 end-of-write remainder carry 機制帶到下一個 chunk,正確性不變。修完後同一區段從停滯變成 273–345 MB/s。

為什麼本機測不出來:本機 benchmark 用了另外兩份真實檔案(Cellebrite 匯出,124 MB 和 431 MB)——它們標籤密集、最長無 < 區段遠小於一個 chunk,miss 路徑根本不會進入。檔案大小不是重點,資料形態(最長無標籤區段)才是。這是「benchmark 必須用實際目標檔案」的直接證據。


定位手法:從「看似 hang 住」到 2.97 MB 節點

坑 5 的定位過程用了兩個十行等級的腳本,模式可複用於任何「串流處理在大檔案上莫名變慢」的場景。

第一步:把黑盒變成進度條。 在串流迴圈裡加「每 N MB 回報一次視窗吞吐量」:

src.on("data", (chunk) => {
  bytesRead += chunk.length;
  parser.write(chunk);
  if (bytesRead - lastReport >= 200 * 1024 * 1024) {
    const windowMBps = (bytesRead - lastReport) / 1024 / 1024 / windowSeconds;
    console.log(`${(bytesRead / 1024 ** 3).toFixed(2)} GB | ${windowMBps.toFixed(1)} MB/s`);
    lastReport = bytesRead;
  }
});

實測輸出直接圈出案發區段——0.98 GB 之後視窗回報停止,代表崩跌點在 0.98–1.18 GB 之間:

0.59 GB | window 234.1 MB/s
0.78 GB | window 169.6 MB/s
0.98 GB | window 247.9 MB/s
(此後數分鐘無輸出——解析仍在進行,但吞吐量崩跌兩個數量級)

第二步:掃描案發區段的資料形態。 懷疑是「無標籤長區段」後,直接對該 byte range 找「最長的無 < 連續 run」:

// 掃 0.98–1.75 GB 區間,追蹤最長的 no-'<' run
const stream = fs.createReadStream(path, { start, end, highWaterMark: 4 * 1024 * 1024 });
let run = 0, maxRun = 0, maxRunEnd = 0;
stream.on("data", (buf) => {
  let idx = 0;
  while (idx < buf.length) {
    const lt = buf.indexOf(0x3c, idx);          // '<'
    if (lt === -1) { run += buf.length - idx; break; }
    run += lt - idx;
    if (run > maxRun) { maxRun = run; maxRunEnd = pos + lt; }
    run = 0; idx = lt + 1;
  }
  pos += buf.length;
});
// 輸出:max run without <: 2.97 MB, ends at 1.117 GB  ← 與吞吐崩跌位置吻合

兩個位置吻合(崩跌起點 ≈ 巨型節點起點)即完成定性。修復後同一腳本驗證:案發區段從停滯變成 273–345 MB/s,全檔 20.2s 跑完。

方法論:吞吐量的「視窗值」比「平均值」重要——平均值把局部崩跌稀釋掉(前 1 GB 正常 + 後面停滯,平均看起來只是「有點慢」),視窗值直接指出案發座標。這跟資料庫 profiling 用 p99 而非 mean 是同一個道理。


量化驗證

引擎層 benchmark(本機,Apple Silicon,64 KB chunk 串流)

檔案 sax-js ltx(修復後) 加速 事件計數 digest
Cellebrite 124 MB(136 萬元素) 5.6s(22.3 MB/s) 0.77s(161.5 MB/s) 7.2x open/close/attr 完全相等 ✅ 相等
Cellebrite 431 MB(428 萬元素) 20.5s(21.1 MB/s) 4.3s(100.8 MB/s) 4.8x 完全相等 ✅ 相等
AXIOM 4.6 GB(3,252 萬元素) 187s(25.0 MB/s) 20.2s(231.5 MB/s 9.3x 完全相等(32,517,904 opens) ✅ 相等

(integrity 模式含 sha256 開銷,timing 模式為純解析速度;表中取各自模式的代表值)

生產環境端到端(K8s parse worker,4.6 GB AXIOM 檔)

階段 換裝前(sax-js) 換裝後(ltx) 變化
Account 掃描 39s 38s 持平(本就不是 SAX bound)
Priority 解析 744s 404s -46%,回到 10 分鐘時限內
資料計數(12 項 ground truth) 基準 全數吻合 ✅ 零遺失

注意端到端只有 1.84x,遠小於引擎層的 9.3x——Amdahl 定律:該管線 SAX 只佔總時間 ~60%,其餘是 DB 寫入(COPY)、實體去重的 round-trip 等不受引擎影響的部分。SAX 縮到極小後,這些部分成為新地板。反過來說,SAX 佔比 97–99% 的另一條管線(Cellebrite 格式),預期端到端倍率會顯著更高。

micro-benchmark 與實測的落差

資料來源 聲稱倍率 實測倍率
ltx 官方 micro-benchmark 5.7x
真實檔案引擎層(本文) 4.8–9.3x(依資料形態浮動)
生產端到端(本文) 1.84x(Amdahl 上限)

micro-benchmark 的價值是「排序候選」,不是「預測生產收益」。生產收益 = 引擎倍率 × SAX 時間佔比,兩個數字都要自己量。


Adapter 層設計:讓兩個引擎可以熱切換

差分驗證和生產回退都依賴同一個前提:兩個引擎藏在同一個介面後面,一個 env var 就能切換。SAX「事件模型」聽起來標準,實際上連事件名都沒有標準——adapter 層要抹平的差異比想像多:

介面點 sax-js(createStream(true, { xmlns: true }) ltx SaxLtx Adapter 對策
開始標籤事件 "opentag",payload 是 QualifiedTag "startElement" (name, attrs) 映射事件名 + 重組 payload
屬性形態 attributes.k = { value, name, local, uri } 物件 attrs.k = "字串" 依下游存取模式決定:防禦式存取(a?.k?.value ?? a?.k)可直接吃平面 map;硬存取 .value 的下游需要包一層 { value }
CDATA 獨立 "cdata" 事件(不走 "text" CDATA 內容走 "text" 下游若同時掛 text/cdata 且處理相同 → 直接轉發 text 即可
結束事件 "end"(stream 語意) 無(end() 只是停用 write) adapter 的 end() 自行 emit "end"
輸入型別 Buffer 或 string(內建 StringDecoder) 只吃 string(data.toString() 會把跨界多位元組打成 U+FFFD) adapter 持有 StringDecoder("utf8"),Buffer → string 的殘位元組跨 write 保留
Backpressure write() 可能回 false,之後發 "drain" 同步解析,恆回 true 上游若依賴 drain 節流要另尋機制;恆 true 時 drain 分支自然死路,不需模擬
錯誤語意 strict mode 對格式錯誤發 "error" 靜默容錯 轉發 parser error 事件;需要 fail-fast 時 adapter 自行補驗證

Adapter 本體 ~40 行:

export class LtxSaxStream extends EventEmitter {
  private readonly parser = new LtxSaxParser();          // vendored、修完五個坑的版本
  private readonly decoder = new StringDecoder("utf8");

  constructor() {
    super();
    this.parser.on("startElement", (name, attributes) => {
      this.emit("opentag", { name, local: name, attributes });
    });
    this.parser.on("endElement", (name) => this.emit("closetag", name));
    this.parser.on("text", (text) => this.emit("text", text));
  }

  write(chunk: Buffer | string): boolean {
    const str = typeof chunk === "string" ? chunk : this.decoder.write(chunk);
    if (str.length > 0) this.parser.write(str);
    return true;   // 同步解析,無背壓
  }

  end(): void {
    const rest = this.decoder.end();   // 沖出殘餘位元組
    if (rest.length > 0) this.parser.write(rest);
    this.parser.end();
    this.emit("end");
  }
}

/** 引擎工廠:env var 是生產環境的即時回退開關 */
export function createSaxStream() {
  if (process.env.SAX_ENGINE === "sax") return sax.createStream(true, { xmlns: true });
  return new LtxSaxStream();
}

工廠函式是整個回退策略的核心:生產環境發現異常時,設一個 env var 重啟就退回舊引擎,不需要 rollback 部署。管線裡所有 stream 建立點(包括 worker_threads 內的)都必須走工廠,否則會出現「主流程換了、thread 裡沒換」的半套狀態。


驗證方法論:換引擎的四層閘門

三次引擎替換(node-expat、sax-wasm、ltx)全部遭遇靜默資料遺失之後,收斂出這套閘門。核心思想:SAX 引擎的正確性不能靠信任,只能靠與已知正確引擎的行為等價證明。

第一層:差分單元測試

新引擎(經 adapter 包裝後)與 sax-js 對同一輸入的事件流必須等價。重點覆蓋:

「連續 text 事件合併後比較」是必要的正規化——兩個引擎的 text 分段策略是實作細節(sax-js 可能一個文字節點發多次 text),語意等價不要求分段一致。

第二層:真實大檔的事件流 digest 比對

單元測試的 fixture 再全面也是人工想像的輸入。真正的閘門是拿真實的 GB 級檔案,把兩個引擎的完整事件流餵進 sha256,要求 digest 完全相等

// 正規化規則(兩個引擎共用):
// 1. 連續 text 合併,遇非 text 事件才 finalize
// 2. whitespace-only 文字節點略過(排版縮排無語意)
// 3. 屬性鍵排序後逐一餵入 hash
const flushText = () => {
  if (pendingText.trim() !== "") hash.update("T " + pendingText + " ");
  pendingText = "";
};
const onOpen = (name, attrs) => {
  flushText();
  hash.update("O " + name + " ");
  for (const k of Object.keys(attrs).sort()) hash.update("A " + k + " " + attrs[k] + " ");
};

一份 4.6 GB 檔案 = 3,252 萬元素 + 33 億字元文字的窮舉比對,覆蓋強度是單元測試的百萬倍。digest 不等時再用計數器(open/close/textChars/attrChars)縮小差異範圍。

第三層:既有測試套件跑雙引擎

下游 handler 的既有測試套件(本案 115 個測試)透過 env var 切換引擎各跑一次。這驗證的不是引擎本身,是 adapter 的介面相容性——事件名映射(startElementopentag)、payload 形態(屬性是 string map 還是 { value } 物件)、end 事件語意這些差分測試覆蓋不到的整合層。

第四層:生產資料 ground truth

上線後用已知答案的檔案重新解析,比對資料庫層的完整計數(本案 12 項:訊息/對話/通話/聯絡人/位置/帳號/…)。這是最終防線,抓的是「引擎正確但管線互動出問題」的情境——本案坑 5 正是在這一層現形(資料沒掉,但 20 分鐘跑不完)。

血的教訓:benchmark 檔案的選擇

第一次部署前,第一、二、三層全綠——但 benchmark 用的是另外兩份「真實但非目標」的檔案。目標檔就在硬碟上,只因為「都是真實鑑識匯出、應該差不多」而沒跑。結果坑 5 在生產環境現形,白費一輪部署加 20 分鐘的停滯排查。

規則:第二層的 digest 比對和全檔 benchmark,必須包含實際要處理的目標檔案。 「同類型的真實檔案」不等價——本案兩份 Cellebrite 檔標籤密集,最長無標籤區段不到 1 KB;目標 AXIOM 檔因為含 email 匯出,單一文字節點達 2.97 MB。資料形態差異就是 bug 觸發條件的差異。


常見誤解

誤解 實際情況 為什麼會搞混
「十年老套件、百萬下載量,不會有基本 bug」 ltx 五個坑全是「基本」場景:CDATA 後接文字、註解後接文字、DOCTYPE。它們在 ltx 的設計場景(XMPP stream)裡不存在,十年零觸發 下載量證明的是「在既有使用場景下可靠」,不是「在你的場景下可靠」
「WASM/native 一定比純 JS 快」 修復後的純 JS ltx 跑 231 MB/s;sax-wasm 有大檔資料遺失且無法審計;node-expat 有事件映射 bug。跨 boundary 的序列化成本和黑盒風險常抵銷語言層優勢 「編譯語言快」的直覺忽略了 V8 對熱路徑(indexOf = 向量化 native scan)的優化程度
「micro-benchmark 5.7x = 生產快 5.7x」 生產端到端 1.84x。生產收益 = 引擎倍率 × SAX 時間佔比(Amdahl) micro-benchmark 只測 parser 本體,生產管線還有 DB、網路、其他 CPU 工作
「換引擎 = 換 import 路徑」 事件名不同(startElement vs opentag)、屬性 payload 形態不同(string map vs { value } 物件)、backpressure 語意不同(write() 恆回 true vs 會回 false + drain 事件)、錯誤語意不同(lenient vs strict)——需要 adapter 層 + 明確的介面對照表 SAX「事件模型」看似標準,實際上連事件名都沒有標準
「用真實大檔測過就安全」 檔案大小不是觸發條件,資料形態才是(最長無標籤區段、CDATA 密度、註解位置、chunk 切點)。431 MB 的 A 檔全綠,4.6 GB 的 B 檔照樣翻 「真實資料」給人虛假的覆蓋感;不同工具、不同資料源的匯出形態差異極大

邊界與陷阱

Vendoring 的取捨

修 ltx 的方式是把 ~260 行的 parser vendor 進專案(TS 移植 + 修五個坑 + 差分測試鎖行為),而不是 fork 上游或等 PR merge:

其他陷阱

何時不用本文的解法


要點整理

重點速查

更大的圖景

「成熟開源套件 + 新使用場景」的組合風險長期被低估。套件的可靠性是其歷史輸入分佈的函數——ltx 十年零事故與它五個基本 bug 並存,因為 XMPP stream 從不產生會觸發這些 bug 的輸入。把套件帶出設計場景時,等於把它的測試覆蓋歸零重算,此時「重新建立行為等價證明」的成本必須算進技術選型——本案中這個成本(差分測試 + digest 工具 + 四層閘門)約佔整個替換工程的一半,而它擋下了全部五個坑中的四個(第五個由「用目標檔 benchmark」的缺口漏進生產,也因此把這條寫進了規則)。

反過來看,這也是純 JS 生態的結構性優勢:同樣的五個坑若發生在 WASM 或 native binding 裡,本案的結局會是第三次「棄用、退回 sax-js、接受慢 9 倍」——事實上前兩次嘗試正是這樣結束的。可修性讓「有 bug 的快引擎」仍是可行選項,不可修性讓「有 bug 的快引擎」等於沒有選項。


參考資料