JSON Lines 與 NDJSON 流式資料
結論:一行一條 JSON,換來的是可追加、可流式、壞一行不毀全量;代價是沒有整體結構,校驗要逐行做。

結論:一次處理整批記錄用 JSON 陣列,需要串流追加與壞行隔離用 JSON Lines;只要還要整體校驗結構,就別換成行式格式。
當資料量超過記憶體,或者持續不斷地產生時,「一個大陣列」的結構就成了敵人。
一、格式規則
JSON Lines(.jsonl)與 NDJSON(.ndjson)在實踐裡幾乎是同義詞,規則都很短:
- 編碼 UTF-8,不加 BOM;
- 每行一個完整、合法的 JSON 值;
- 行之間用
\n分隔,最後一行可以有也可以沒有換行; - 行內不允許出現未轉義的換行——字串裡的換行必須寫成
\n; - 空行通常被忽略,但嚴格實現應視為無效。
{"time":"2026-10-30T10:41:45+08:00","level":"info","message":"boot"}
{"time":"2026-10-30T10:41:46+08:00","level":"warn","message":"slow query","costMs":812}
{"time":"2026-10-30T10:41:47+08:00","level":"error","message":"timeout","traceId":"a1b2"}
兩個格式的細微差別:JSON Lines 官網更強調「一行一個值,值可以是任意 JSON(含標量)」, NDJSON 規範額外要求「不接受空行、不接受頂層標量」這類更嚴格的實現約定。工程上按更嚴格的那套寫就不會出錯。
二、和 JSON 陣列的對比
| 維度 | JSON 陣列 | JSON Lines |
|---|---|---|
| 追加一條記錄 | 要重寫整個檔案(改結尾的 ]) |
直接 append 一行 |
| 流式讀取 | 需要流式解析器跟蹤括號層級 | 按行切分即可 |
| 單條損壞 | 整個檔案不可解析 | 隻影響那一行 |
| 整體校驗 | 一次校驗全部 | 只能逐行校驗 |
| 壓縮 | gzip 後體積相近 | 同左,且可按塊並行壓縮 |
| 表達巢狀 | 天然支援 | 每行內部可巢狀,行之間無關係 |
| 常見消費者 | 所有 JSON 工具 | 日誌系統、大資料管道、LLM 訓練集 |
結論很直接:資料是「一批記錄」還是「一個文件」,決定用哪種。
三、讀取:按行流式處理
Node 裡最省事的寫法是 readline:
import { createReadStream } from 'node:fs';
import { createInterface } from 'node:readline';
/**
* 逐行讀取 JSON Lines 檔案,對每條記錄執行回撥。
* @param filePath 檔案路徑
* @param onRecord 單條記錄的處理函式
* @returns 統計結果,含成功與失敗行數
*/
async function readJsonLines(
filePath: string,
onRecord: (record: unknown, lineNumber: number) => void,
): Promise<{ okCount: number; badCount: number }> {
const lineReader = createInterface({
input: createReadStream(filePath, { encoding: 'utf8' }),
crlfDelay: Infinity,
});
let okCount = 0;
let badCount = 0;
for await (const rawLine of lineReader) {
const line = rawLine.trim();
if (!line) {
continue;
}
try {
onRecord(JSON.parse(line), okCount + badCount + 1);
okCount += 1;
} catch {
badCount += 1;
}
}
return { okCount, badCount };
}
這段程式碼的記憶體佔用只與單行大小相關,和檔案總大小無關——這是行分隔格式最大的價值。
3.1 只取需要的欄位時,別先 JSON.parse
如果每條記錄有 50 個欄位而你只要 3 個,JSON.parse 會為 50 個欄位都建物件。
可用流式解析器(stream-json、clarinet)只提取目標欄位,代價是實現複雜度上升。
判斷標準:單條記錄小於幾百 KB 時,直接 JSON.parse 通常仍是更划算的選擇。
3.2 中文與 BOM
Windows 上生成的 .jsonl 常帶 BOM,第一行 JSON.parse 必然失敗。讀取時先剝 BOM,或流式讀第一塊時判斷前三個位元組。
四、寫入:追加與原子性
import { appendFile } from 'node:fs/promises';
/**
* 追加一條 JSON Lines 記錄。
* @param filePath 目標檔案
* @param record 待寫入的記錄物件
* @returns 寫入完成的 Promise
*/
async function appendRecord(filePath: string, record: unknown): Promise<void> {
const line = `${JSON.stringify(record)}\n`;
await appendFile(filePath, line, { encoding: 'utf8' });
}
注意兩點:
JSON.stringify輸出不含換行(除非字串裡有\n被轉義成兩個字元),所以一行一定是一條記錄;- 多程序同時追加時,小於 PIPE_BUF(Linux 通常 4096 位元組)的單次
write是原子的,超過就不保證。 高併發場景應改為「每程序一個檔案,落盤後合併」,或用帶事務的儲存。
五、壞行怎麼處理
這是行分隔格式必須提前決定的策略:
| 策略 | 適用場景 | 代價 |
|---|---|---|
| 跳過並計數 | 日誌、埋點等容忍缺失的資料 | 靜默丟資料,必須配告警 |
| 立即失敗 | 計費、賬單等不可丟資料 | 一行壞資料阻塞整個批次 |
| 分流到死信檔案 | 需要事後補數 | 多一份儲存與回溯流程 |
無論選哪種,都要記錄行號與原始內容片段,否則事後無法定位。行號在 JSON.parse 的錯誤資訊裡是沒有的,只能自己維護。
六、壓縮與分片
.jsonl用 gzip 壓縮比通常很高(欄位名重複),推薦.jsonl.gz;- gzip 是按塊壓縮,天然支援流式解壓,不必解壓整個檔案;
- 大檔案按
1GB左右切片,命名帶序號,便於並行處理與斷點續傳; - 不要對
.jsonl做「合併成陣列」的預處理,那等於放棄了它的全部優勢。
七、與其他大資料格式的取捨
| 格式 | 結構 | 是否帶 schema | 適用 |
|---|---|---|---|
| JSON Lines | 每行獨立 | 無(需外部約定) | 日誌、匯入匯出、訓練資料 |
| CSV | 表格 | 無 | 報表、Excel 互動 |
| Parquet | 列式 | 有 | 分析型查詢、超大離線資料集 |
| Avro | 行式 | 有 | Kafka 訊息、需要 schema 演進 |
JSON Lines 的優勢是任何語言、任何工具都能立刻讀寫;劣勢是沒有 schema 與列式壓縮。 資料量上到單機放不下、又要頻繁按列查詢時,就該考慮 Parquet 了。
八、檢查清單
- [ ] 檔案字尾統一(
.jsonl或.ndjson),不混用; - [ ] 寫入端保證「一條記錄一行」,字串內換行已轉義;
- [ ] 讀取端剝 BOM、跳過空行、維護行號;
- [ ] 明確定義壞行策略,並有監控指標(壞行數 / 總行數);
- [ ] 大檔案按體積切片,配合 gzip 與並行讀取;
- [ ] 有欄位契約時額外提供 schema,逐行校驗。
常見問題
如何判斷該用 JSON 陣列還是 JSON Lines?
看資料是「一批記錄」還是一個文件。需要追加記錄、串流讀取時就該用 JSON Lines,因為追加只需在檔案末尾寫一行,讀取按行切分即可;需要一次性整體校驗結構時仍是 JSON 陣列更合適,因為陣列能一次校驗全部,而 JSON Lines 只能逐行校驗。
為什麼單條壞記錄不會毀掉整個檔案?
JSON Lines 每行是一個獨立、完整的 JSON 值,解析天然按行進行,所以單條損壞只影響那一行。JSON 陣列一旦某處語法出錯,整個檔案都無法解析,這正是行式格式在日誌、埋點等情境裡的核心價值。
能否直接把 JSON Lines 轉成一個 JSON 陣列?
可以,按行讀取、逐行 JSON.parse 後收集成陣列即可,但這樣做必須把全部內容讀進記憶體,同時放棄串流處理與按行追加的優勢。把 .jsonl 合併成陣列做預處理等於丟掉行式格式的全部優勢,只有一次性交付、資料能整體裝進記憶體時才值得這樣做。