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 合并成数组做预处理等于丢掉行式格式的全部优势,只有一次性交付、数据能整体装进内存时才值得这么做。