数据STUDIO

AI 数据集神器 JSONL,真有这么好用?

Image

前段时间处理数据时,碰到一个挺常见的场景:原先只有几千条记录,直接放进 JSON 数组,读取、查找都很方便。后来文件变成了几十万条日志,每天还在不断追加,原来的处理方式就有点吃力了。

比如我们只想找当天的登录失败事件,或者把新生成的训练样本交给下一个程序。如果每次都要先读完一个大 JSON 数组,再开始处理,等得久不说,内存也可能跟着涨。

这种时候,可以考虑把数据存成 JSONL(JSON Lines)。它的想法很简单:每一行放一条完整的 JSON 记录,程序读到一行就能处理一行。写入时也方便,生成一条,就往文件末尾加一行。

JSON 周边其实还有不少格式。JSON5、JSONC 适合需要注释等宽松写法的配置场景;BSON 是 MongoDB 使用的二进制文档表示;MongoDB Extended JSON(EJSON)用于表达 BSON 的特殊类型;GeoJSON 面向地理数据。JSONL 关注的是另一件事:有很多条相互独立的记录,怎样把它们放进同一个文件或文本流。

先看一个简单例子。三条用户行为记录,写成 JSONL 是这样:

{"id": 1, "event": "login", "user": "Lin"}
{"id": 2, "event": "purchase", "user": "Mei"}
{"id": 3, "event": "logout", "user": "Lin"}

如果用 JSON 数组保存,同样的数据可以写成:

[
{"id": 1, "event": "login", "user": "Lin"},
{"id": 2, "event": "purchase", "user": "Mei"},
{"id": 3, "event": "logout", "user": "Lin"}
]

两种写法都很清楚。JSON 数组把三条记录组织成一份完整文档;JSONL 让每一行都成为独立、可以单独解析的 JSON 值。日志、数据集和批量导出里经常会碰到后一种需求。

01区别在记录边界,不在 JSON 语法

普通 JSON 有一个根值,可以是对象,也可以是数组。即使数组里放了上百万条记录,整个数组仍然是一份 JSON 文档。

JSONL 把这些记录拆成一行一条。程序可以先消费已经写好的部分,不用等文件里的所有记录都到齐。生产端想追加新数据,也只需要继续写下一行。

假设后台服务每隔几秒就生成一条事件,用 Python 追加记录其实很直接:

import json

event = {"id": 4, "event": "login", "user": "Chen"}

with open("events.jsonl", "a", encoding="utf-8") as f:
    f.write(json.dumps(event, ensure_ascii=False) + "\n")

这里 "a" 表示追加模式。新事件到达时,序列化成一行 JSON,再写入换行符,文件就能继续增长。相比维护一个完整 JSON 数组,这种方式更适合连续记录事件。

JSON Lines 的格式要求也不复杂:使用 UTF-8 编码,每一行都是合法 JSON 值,空行不算有效记录;通常用 \n 换行,\r\n 也能被接受。最后一条记录后建议留一个换行符,之后继续追加或拼接文件会更省事。

有个容易被忽略的地方:一行不一定非得是 JSON 对象。 JSON Lines 允许任意合法的 JSON 值,字符串、数字也可以单独占一行。只是业务日志和数据集通常有 id、timestamp、event 等字段,所以一行一个对象最常见。

自己用 splitlines() 搭配 json.loads() 也能实现“每行一个 JSON”,这没问题。要是文件只在一个小脚本里流转,可能就够用了。但当多个服务和团队都要读取同一批文件时,还得说清楚编码、空行、坏行和换行规则。JSON Lines 与 NDJSON 的公开约定,以及数据工具提供的读取选项,能减少这种重复沟通。

02逐行读取能省什么内存

先说一个特别容易误解的地方:换成 JSONL,并不代表内存一定会降很多。关键还是看程序怎么处理。

比如有一份 50 MB 的 JSON 数组。如果使用一次性加载的读取方法,程序不仅要读入原始内容,还要解析成对象和容器,实际内存峰值可能明显高于文件体积。具体占用要看数据结构和解析器,不能直接拿文件大小推算。

换成 JSONL 后,我们可以每次只读取一行,处理完就继续往下走:

import json

with open("events.jsonl", encoding="utf-8") as f:
for line_number, line in enumerate(f, start=1):
if not line.strip():
raise ValueError(f"第 {line_number} 行为空")

        record = json.loads(line)
print(record["event"])

这里特意把空行视为错误。如果业务需要跳过空行,可以调整实现,但最好让生产端和消费端对这个行为有统一约定。

假设要统计当天各类事件出现的次数,我们可以逐行读取,只维护一个计数字典。换个需求,要把数据清洗后写入数据库,也可以每处理一批就提交一次,再释放这批对象。这样的处理方式才容易控制内存。

反过来,如果写了 records.append(record),把所有记录都存进列表,内存仍然会随数据量增长。下游如果必须做全量排序、复杂关联,同样可能需要额外空间。再极端一点,单条 JSON 记录本身非常大,程序也得有足够内存处理这一行。

普通 JSON 也有自己的增量读取工具。Python 里有 ijson,Node.js 里有 stream-json,可以逐步解析大型 JSON 文档,而不必一次构造完整对象树。

比如上游给你的是一个包含元数据和嵌套数组的复杂文档,整个结构要保留,只是其中的记录太多。这时可以先看看增量 JSON 解析器。JSONL 更适合从一开始就能够拆成独立记录的数据。两种办法都值得了解,具体选哪个看数据结构和下游需求。

03工具支持要按工具确认

平时使用 pandas 的话,读 JSONL 很方便。read_json 支持 lines=True,再加上 chunksize 就能一批批处理:

import pandas as pd

for chunk in pd.read_json(
"events.jsonl",
    lines=True,
    chunksize=100_000,
):
    process(chunk)

比如导出几百万条订单记录,之后要做字段清洗、去掉无效数据,再分批写入数据库,这种写法就比较实用。100_000 是示例批大小,实际选多少要考虑每条记录的字段数量、转换步骤以及机器内存。pandas 文档说明,chunksize 需要搭配 lines=True;不指定分块时,数据会一次性读取。

如果团队在用 Spark,还要留意它的 JSON 读取选项。Spark 的 JSON 数据源默认按 JSON Lines 处理;如果上游给的是一整个跨多行排版的 JSON 文档,就要使用 multiLine。

这个差异很容易踩坑。比如同事把 JSON 数组格式化得很漂亮,存成几十行文本。你拿 Spark 的默认读取方式去处理,发现解析结果不符合预期,问题可能就出在输入结构与读取参数没对上。

Hugging Face Datasets 的 JSON 加载器也支持 JSON/JSONL,并提供 streaming=True 等迭代式访问方式。假设我们有十万条文本分类样本,每条记录包含 text 和 label,按行保存后,可以逐条或逐批加载、清洗、转换。具体训练框架需要什么字段,仍要按框架自身的格式要求准备。

这里也别把 streaming 和低内存完全画等号。后面如果又做了大规模 shuffle、缓存或预处理,资源占用照样会变化。

同样的文件交给数据库、ETL 服务或其他语言的读取器时,最好查一下它们各自支持哪些选项。有的默认按行读,有的需要手动开启;空行、错误行要不要跳过,也未必统一。

04JSON Lines 常见的应用场景

第一个就是日志和事件。

服务每收到一次请求,或者用户完成登录、下单、退出,就产生一条记录。这样的数据天然可以独立写入。

比如我们只想统计今天出现了多少次 login 事件,就可以逐行扫描日志,更新计数即可。如果想观察新写入的记录,也可以从文件末尾持续跟读。正式环境还需要处理时间戳、字段版本、日志轮转和坏行恢复,但逐行文件至少让采集、导出和排查更直接。

第二个是机器学习训练数据和评测结果。

假设要整理十万条问答样本,每条包含问题、参考答案和标签。可以把每个样本保存成一行 JSON,之后逐条检查缺失字段、过滤异常样本,或者分批转换成训练框架接受的结构。

模型评测也很适合举例。一次评测有几万道题,模型完成一题,就写入一条结果。任务中途停止,已经完整写好的行仍能被读取。至于如何断点续跑、如何避免重复处理,得靠样本 ID、任务状态和额外的恢复逻辑,JSONL 只是给结果提供了方便的保存方式。

第三个是数据迁移和 ETL。

比如从一个旧业务系统批量导出客户数据,清洗完交给新系统。我们可以分批拉取数据,输出成 JSONL,再让下游逐行做字段映射。如果某条记录格式有问题,也容易定位到文件行号,再决定失败退出、记录错误或继续处理。

不过字段名、类型、空值规则还是得提前约定。created_at 到底存字符串还是时间戳,缺少手机号该怎么表示,都不由 JSONL 自动决定。

第四个是大数据处理的中间文件。

原始事件写成 JSONL,方便查看和交换;后面要进行大规模列式查询时,再转换为 Parquet 等格式,也是合理的工作方式。Spark 等计算工具可以利用逐条记录的边界读取数据,但涉及列式扫描和分析性能,仍要考虑实际任务特点。没必要让一种文件格式包办所有环节。

另外,消息队列和流式 API 需要单独说一下,因为经常有人把它们与 JSONL 混在一起。

消息队列本来就有自己的消息边界,一条消息的 payload 可以是一个 JSON 对象。只有在导出消息、归档日志或交换逐行文本文件时,才通常需要考虑 JSONL。

SSE(Server-Sent Events)也有自己的事件行和分隔规则。事件的 data 可以包含 JSON,但整个 SSE 流不能直接按 JSONL 的一行一个 JSON 值来解析。所以看到“流式 JSON”这个词,最好先确认接口到底怎么划分消息,再选合适的客户端解析方式。

05什么时候继续用普通 JSON

有些场景用 JSON 已经很顺手,就没必要再折腾。

例如普通 REST API 返回用户资料,里面有个人信息、权限、设置等层级字段。调用方本来就希望收到一份完整响应,用常规 JSON 解析器直接处理最方便。换成 JSONL,反而要多约定一套逐行读取流程。

配置文件也差不多。应用启动时需要一次读取数据库连接、日志级别和功能开关,整个文件通常不大,普通 JSON 的对象层级非常直观。要是想在配置里加注释或尾逗号,可以考虑 JSONC、JSON5,但必须确认工具链支持相应语法。

再比如前端一次要展示几十条商品信息,接口用 JSON 数组就很自然。数据规模可控、调用方总要完整获取时,没有必要为了“流式”两个字改变文件格式。

同样是 50 MB 的文件,有的小脚本处理起来吃力,有的服务完全可以接受。比文件大小更重要的是几个问题:这批数据会不会继续增长?单条记录能否独立消费?下游是否必须拿到全量?有没有现成的增量解析工具?

把这些问题先弄清楚,比给 JSON 和 JSONL 设一个固定的容量分界线更实用。

06JSONL 与 NDJSON:接近,但约定不只差后缀

你可能还看到过 NDJSON(Newline-Delimited JSON)。它和 JSON Lines 的使用方式非常接近,都是按换行分隔 JSON 文本。

两者都有公开文档,常用扩展名和媒体类型写法有所不同。JSON Lines 常用 .jsonl,其格式说明提到 application/jsonl 目前尚未标准化;NDJSON 的规范建议使用 .ndjson 和 application/x-ndjson。

JSON Lines 允许每行放任何合法 JSON 值。NDJSON 规范要求每条 JSON 文本符合 RFC 8259,记录内部的换行和回车必须转义。很多工具把两者视为相近的输入格式,但是否要求每行必须为对象、是否跳过空行、出错后继续还是停止,实际依赖具体实现。

比如 A 系统给你一个 events.jsonl,B 系统的导入接口写着“支持 NDJSON”。先拿几条真实样本测试编码、换行和错误处理,再处理几 GB 的正式文件,会比只看扩展名放心。

07为什么这个格式容易被忽略

JSONL 确实简单到几乎不需要学:一行一条 JSON,按行处理。

写临时脚本时,自己规定换行和调用 json.loads() 就能工作。但一份文件开始跨语言、跨服务流转,很多小细节会突然变得重要。比如上游多写了一个空行,下游是否报错;或者拼接文件时第一份末尾没有换行,两条记录就挤到了一起。

所以它的实用价值更多体现在数据交接上。记录边界清楚,已有工具能读取,也容易定位问题。

当然,如果数据是复杂的单一 JSON 文档,可以用增量解析器;如果主要用于列式分析,可以考虑 Parquet;如果接口本来定义的是完整 JSON 请求或响应,就继续遵循接口规范。按实际工作流选格式,往往比先争论哪种格式先进更有效。

08写在最后

下次拿到一份很大的 JSON 文件,我觉得可以先和同事确认三件事:记录能不能独立处理,文件是不是会持续追加,后面的程序能不能一条或一批处理完就释放。

如果都能做到,JSONL 值得试一下。日志、训练样本、评测结果、ETL 中间文件和批量导出,都是比较典型的场景。

如果文件表达的是一份完整的层级文档,或者调用方本来就需要整体拿到,普通 JSON 可能更省事。只是因为文件太大而遇到内存问题,也别忘了增量 JSON 解析器这条路。

最终还是看数据怎么产生、怎么交给下一步,以及代码有没有真正按行或按批消费。选对记录边界,后面的处理流程通常就会简单不少。

Image