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