chundev
日期:2026-07-13 環境:Node.js 24、TypeScript、串流解析 4–8 GB 的鑑識工具 XML 匯出檔(Magnet AXIOM / Cellebrite UFED)
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²) 坑帶進生產環境。
目錄:
數位鑑識工具(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 之後的文字消失 | xy → y 遺失 | 資料遺失 |
| 3 | 某個位置之後**整份輸入**靜默消失,無任何錯誤 | –>/?>/]]> 被 write() chunk 邊界切開 | 資料遺失 |
| 4 | 含 DOCTYPE 的檔案整份解析不出東西 | <!DOCTYPE x> 被當註解、永遠等不到 –>` | 資料遺失 |
| 5 | 解析速度在特定檔案區段崩跌 100 倍、看似 hang 住 | 巨型無標籤文字節點(實測 2.97 MB 的 email 內文) | 效能 |
五個坑都不丟 error、不發 warning——症狀是「資料變少」或「跑不完」,在下游(資料庫計數、時限告警)才被發現。坑 3 和坑 5 只在串流分塊輸入時觸發,單元測試用單一字串餵入永遠測不到。
換引擎前掃過整個生態,實測結論如下:
| 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。
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 場景:
<」的輸入——fast-forward 的 miss 路徑(indexOf 回 -1)等於沒被測過(坑 5)。--> 這種多字元終止符被 chunk 切開的情境極罕見(坑 3)。換句話說:ltx 不是爛套件,它是被拿出了設計場景。它在 xmpp.js 生態被 116 個套件依賴、生產環境跑了十年以上——但那十年的輸入形態跟「4.6 GB 的鑑識報告 XML」完全不重疊。
理解坑 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;
}
};
兩個關鍵細節:
recordStart 是數字時發生。錄製狀態(TEXT、CDATA、ATTR_VALUE、TAG_NAME)的未完成內容會被帶走;但 ignore 狀態(註解、PI)把 recordStart 設成 undefined——它們的上下文什麼都不會被帶走,這就是坑 3 的結構性根源:--> 的前兩個字元留在上一個 chunk 裡,永遠回不來。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 累積。詳見邊界與陷阱。
最小重現:
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; // 恢復文字錄製
最小重現:
p.write("<a>x<!-- c -->y</a>"); // 實際只得到 "x","y" 遺失
p.write("<a>x<?pi data?>y</a>"); // 同樣只得到 "x"
根因與坑 1 完全相同:進入 STATE_IGNORE_COMMENT/STATE_IGNORE_INSTRUCTION 時 recordStart = undefined,退出時只設 state = STATE_TEXT,錄製起點沒恢復。
值得注意的是這個 bug 的「隱蔽梯度」:<!-- c --><a>x</a>(註解後緊跟標籤)完全正常——只有「註解後直接跟文字」才觸發。混合內容(mixed content)在資料型 XML 裡少見,所以小檔案測試極容易漏掉。
五個坑裡最危險的一個:觸發後不是掉一段文字,是掉檔案剩餘的全部內容,而且無任何錯誤。
最小重現:
// 同一份 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 狀態下 recordStart 是 undefined,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;
}
--> 吞掉全檔最小重現:
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 檔案的檔頭註解,讓下一個讀者知道邊界在哪。
這個坑最陰險:它不掉資料,它讓解析在特定檔案區段慢 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 必須用實際目標檔案」的直接證據。
坑 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 是同一個道理。
| 檔案 | 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 模式為純解析速度;表中取各自模式的代表值)
| 階段 | 換裝前(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 格式),預期端到端倍率會顯著更高。
| 資料來源 | 聲稱倍率 | 實測倍率 |
|---|---|---|
| ltx 官方 micro-benchmark | 5.7x | — |
| 真實檔案引擎層(本文) | — | 4.8–9.3x(依資料形態浮動) |
| 生產端到端(本文) | — | 1.84x(Amdahl 上限) |
micro-benchmark 的價值是「排序候選」,不是「預測生產收益」。生產收益 = 引擎倍率 × SAX 時間佔比,兩個數字都要自己量。
差分驗證和生產回退都依賴同一個前提:兩個引擎藏在同一個介面後面,一個 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 與屬性值兩個位置<b> 不當標籤)、與前後文字的相鄰關係「連續 text 事件合併後比較」是必要的正規化——兩個引擎的 text 分段策略是實作細節(sax-js 可能一個文字節點發多次 text),語意等價不要求分段一致。
單元測試的 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 的介面相容性——事件名映射(startElement → opentag)、payload 形態(屬性是 string map 還是 { value } 物件)、end 事件語意這些差分測試覆蓋不到的整合層。
上線後用已知答案的檔案重新解析,比對資料庫層的完整計數(本案 12 項:訊息/對話/通話/聯絡人/位置/帳號/…)。這是最終防線,抓的是「引擎正確但管線互動出問題」的情境——本案坑 5 正是在這一層現形(資料沒掉,但 20 分鐘跑不完)。
第一次部署前,第一、二、三層全綠——但 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 檔照樣翻 | 「真實資料」給人虛假的覆蓋感;不同工具、不同資料源的匯出形態差異極大 |
修 ltx 的方式是把 ~260 行的 parser vendor 進專案(TS 移植 + 修五個坑 + 差分測試鎖行為),而不是 fork 上游或等 PR merge:
unescapeXML)仍 import 上游——vendor 範圍愈小愈好,只 vendor 需要改的部分fs.createReadStream 給 Buffer。多位元組字元被 Buffer 邊界切開時,buf.toString() 會產生 U+FFFD。必須經 string_decoder(Node 內建)把不完整位元組留到下一個 chunk。ltx 的 write() 內建 data.toString()——直接餵 Buffer 是错的,adapter 層要自己 decodewrite() 會回 false 觸發上游 pause();同步解析的引擎恆回 true、永不發 drain。若管線的節流依賴 sax 的 drain(而不是自己的 watermark 機制),換引擎後可能失去唯一的背壓來源,大檔案時記憶體暴漲。換引擎前先盤點:誰在依賴 write() 的回傳值?indexOf 觸發 flatten 是 O(全長))。3 MB 節點的總 flatten 成本約 70 MB 複製,可接受;若資料形態有 100 MB 級單一節點,需要進一步改成 chunk list 累積、終止符只掃尾端視窗tag.local 恆等於 tag.name)、DOCTYPE internal subset 提早退出、無行號/欄號(錯誤定位能力弱於 sax-js)xmlns:true 的 QualifiedTag)→ sax-js 或 saxes,ltx 系不做 namespaceindexOf fast-forward 是高效 parser 的標準手法,但 miss 路徑必須顯式跳位,否則從 O(n) 退化成 O(n²)「成熟開源套件 + 新使用場景」的組合風險長期被低估。套件的可靠性是其歷史輸入分佈的函數——ltx 十年零事故與它五個基本 bug 並存,因為 XMPP stream 從不產生會觸發這些 bug 的輸入。把套件帶出設計場景時,等於把它的測試覆蓋歸零重算,此時「重新建立行為等價證明」的成本必須算進技術選型——本案中這個成本(差分測試 + digest 工具 + 四層閘門)約佔整個替換工程的一半,而它擋下了全部五個坑中的四個(第五個由「用目標檔 benchmark」的缺口漏進生產,也因此把這條寫進了規則)。
反過來看,這也是純 JS 生態的結構性優勢:同樣的五個坑若發生在 WASM 或 native binding 裡,本案的結局會是第三次「棄用、退回 sax-js、接受慢 9 倍」——事實上前兩次嘗試正是這樣結束的。可修性讓「有 bug 的快引擎」仍是可行選項,不可修性讓「有 bug 的快引擎」等於沒有選項。