JSON 工具箱

JSON Lines 與 NDJSON 流式資料

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

進階約 10 分鐘發佈於 2026-10-30更新於 2026-10-30
JSON Lines 與 NDJSON 流式資料封面

結論:一次處理整批記錄用 JSON 陣列,需要串流追加與壞行隔離用 JSON Lines;只要還要整體校驗結構,就別換成行式格式。

當資料量超過記憶體,或者持續不斷地產生時,「一個大陣列」的結構就成了敵人。

一、格式規則

JSON Lines(.jsonl)與 NDJSON(.ndjson)在實踐裡幾乎是同義詞,規則都很短:

  1. 編碼 UTF-8,不加 BOM;
  2. 每行一個完整、合法的 JSON 值;
  3. 行之間用 \n 分隔,最後一行可以有也可以沒有換行;
  4. 行內不允許出現未轉義的換行——字串裡的換行必須寫成 \n;
  5. 空行通常被忽略,但嚴格實現應視為無效。
{"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' });
}

注意兩點:

  1. JSON.stringify 輸出不含換行(除非字串裡有 \n 被轉義成兩個字元),所以一行一定是一條記錄;
  2. 多程序同時追加時,小於 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 合併成陣列做預處理等於丟掉行式格式的全部優勢,只有一次性交付、資料能整體裝進記憶體時才值得這樣做。