怎样从超大 JSON 文件里取值,又不用专门写脚本
为了从一个大到打不开的 JSON 导出文件里取一个值,你并不需要临时写个解析器。从 JSON 里取数据是一个「查询」问题,而 jq、JSONPath 和 DuckDB 正好回答它——一直管到大得放不进内存的文件。
你手上有一个 JSON 文件,还有一个关于它的问题。哪些账号是不活跃的?那条失败记录的 ID 是多少?每种类型的事件各有多少条?文件有 80 MB——滚不动,也一眼看不完——于是你的第一反应是打开编辑器,写下 data = json.load(open("data.json")),开始写循环。可这个问题你只会问这一次,为了拿到一个数字却要搭这么多脚手架,实在不值。而且文件一旦够大,json.load 会一直吃内存,甚至还没给出答案就先崩了。
换个角度就能省下一下午:从 JSON 里取值是一次查询,而不是一段程序。 JSON 和数据库一样有自己的查询语言,而且都能在一行命令里跑完。这篇文章讲三种,它们合起来能覆盖大多数场景——jq、JSONPath 和 DuckDB——分别在什么时候用,以及当文件真的塞不进内存时该怎么办。
我们要用的文件
把它叫做 events.json:顶层是一个对象,里面装着一个 events 数组。示例里只印了三条事件;请你想象这个数组里有二十万条。
{
"generatedAt": "2026-07-17T09:00:00Z",
"events": [
{ "id": "e_1001", "type": "login", "userId": 42, "ok": true, "ms": 128 },
{ "id": "e_1002", "type": "upload", "userId": 42, "ok": false, "ms": 940 },
{ "id": "e_1003", "type": "login", "userId": 7, "ok": true, "ms": 96 }
]
}
我们要回答的,正是实际工作里真会遇到的那几类问题:读取顶部附近的某个值、从每一条记录里抽出某个字段、只保留满足条件的记录,以及对整个数组做计数或求平均。
jq:默认答案
jq 是一个小巧的命令行程序,它解析 JSON,再在上面跑一个过滤器——它是个单文件的可执行程序,用 brew install jq、apt install jq 或从 jqlang.org 就能装上。过滤器就是一个描述「答案长什么样」的表达式,而最简单的过滤器就是一条路径。读取顶层的单个值,写法和你在代码里写的路径几乎一样:
jq '.generatedAt' events.json
# "2026-07-17T09:00:00Z"
那对引号是 jq 在告诉你:结果是一个 JSON 字符串。如果你想要的是纯文本——好接着传给别的命令——就加上 -r 输出原始文本:
jq -r '.generatedAt' events.json
# 2026-07-17T09:00:00Z
.events[] 会遍历数组,把每个元素依次送进管道后面的部分。所以「每条事件的类型」就是一条中间带遍历的路径:
jq -r '.events[].type' events.json
# login
# upload
# login
过滤用 select,只有条件成立时它才让这条记录通过。取出所有失败事件的 ID:
jq -r '.events[] | select(.ok == false) | .id' events.json
# e_1002
从左往右读:取每条事件,留下 ok 为 false 的,再输出它的 id。计数就是把结果用 [ ] 包起来重新收成一个数组,再取 length:
jq '[.events[] | select(.type == "login")] | length' events.json
# 2
四个小零件——一条路径、用来遍历的 []、用来过滤的 select、用来计数的 length——已经能回答大文件引出的多数问题。而当你只想快速数一数某个类别时,jq 配上两个经典的 Unix 工具,比上面任何写法都省事:
jq -r '.events[].type' events.json | sort | uniq -c | sort -rn
# 2 login
# 1 upload
读一个比内存还大的文件
上面这一切背后藏着一条上限。默认情况下 jq——和 json.load 一样——会先把整个文档读进来、在内存里建好整棵树,然后才开始求值。对 80 MB 的文件这没问题;可一旦文件比你的内存还大,就不行了,而且再巧的过滤器也救不了:解析器必须先把这个值拿在手里,过滤器才跑得起来。
有两样东西能带你越过这道坎。第一样是文件的形状。如果导出的是 NDJSON——每行一个 JSON 对象,外面没有包裹数组——那么内存占用主要取决于单条记录的大小、而不是整个文件的大小,所以无论堆进来多少条,占用都保持平稳,因为工具逐行读取、处理完就丢掉:
# events.ndjson:每行一个对象
jq -r 'select(.ok == false) | .id' events.ndjson
它的内存占用由最大的那一条记录决定,而不是文件大小,所以文件再长也保持平稳。如果导出格式由你掌控,那么把「一个巨大数组」改成 NDJSON,是一个非常关键的改动;从此以后,逐步处理它就变得顺畅了。
如果你只能对着一个并非自己生成的超大数组,就得从磁盘读、而不是把整棵树建起来。jq 自带的 --stream 模式正是这么做的:它边走边输出 [路径, 值] 事件,而 fromstream(1 | truncate_stream(...)) 这个惯用法会把顶层数组一次重建一个元素,于是内存保持有界:
# big.json 是一个巨大的顶层数组——流式处理它的元素,而不是整个加载
jq -rn --stream 'fromstream(1 | truncate_stream(inputs)) | select(.ok == false) | .id' big.json
动手前有两条边界值得说明:这种写法只适用于顶层是数组的情况,而且它是为「把值取出来」设计的,不是为「把值组合起来」设计的。它很底层,也容易写错,所以一旦任务超出「取个字段」——尤其是要做真正的聚合——DuckDB(见下文)或者像 Python 的 ijson 这样的流式解析器更好维护。只有当文件真的塞不下时才动用这些;只要塞得下,直接用 jq 更简单。
JSONPath:一条能到处粘贴的路径
jq 有自己的语法,值得学,但它只在装了 jq 的地方才存在。JSONPath 是个更小的点子——一条路径表达式,仅此而已——在许多语言、工具、编辑器和 API 客户端里都有它的实现。如果你写过 $.store.book[0].title,那就是 JSONPath。它能干净地对应到同样那几个问题:
| 问题 | jq | JSONPath |
|---|---|---|
| 顶层的某个值 | .generatedAt | $.generatedAt |
| 每条记录里的某个字段 | .events[].type | $.events[*].type |
| 满足条件的记录 | .events[] | select(.ok == false) | $.events[?(@.ok == false)] |
| 从这些记录里再取该字段 | .events[] | select(.ok==false) | .id | $.events[?(@.ok == false)].id |
这里的取舍很实在,值得挑明。JSONPath 只做选取——它指向一些值,把它们交回给你。它不转换、不重塑、也不聚合。没有哪条标准 JSONPath 能表达「按类型分组求 ms 的平均值」,因为分组和求平均本就不是选取。所以经验法则是:当你想要某个位置上的值时用 JSONPath——尤其当这个表达式还得带进代码或配置、而那里并不欢迎多一个 jq 依赖的时候;而一旦答案需要用选出来的东西再拼出点新东西,就立刻改用 jq。
关于可移植性有一点要提醒:JSONPath 已经在 2024 年有了正式的 IETF 标准 RFC 9535,但许多库比它更早、或各自加了扩展——而过滤语法(?(@.ok == false))恰恰是它们分歧最大的地方:空格、引号、函数支持都可能不同。本文示例采用的是 jsonpath-plus 等库常见的写法(下面那个求值器用的就是它);在别处依赖某个表达式之前,先查一下目标实现的文档。
分组、求平均和连接:SQL over JSON
当问题变成「每种各有多少」「平均值是多少」或「这里的哪些也出现在另一个文件里」时,你已经离开了选取,做的是分析。jq 也能做——按类型分组并对 ms 求平均,就是一个表达式:
jq '.events
| group_by(.type)
| map({ type: .[0].type, count: length, avgMs: (map(.ms) | add / length) })' events.json
它管用,临时问一次也够。但一旦聚合变复杂——多个分组键、和另一个文件做连接、按聚合结果排序——SQL 才是真正为这件事设计的语言,而 DuckDB 能在命令行里直接对 JSON 跑 SQL(同样是个单文件程序:brew install duckdb,或从 duckdb.org 获取)。把它指向文件,在 SQL 里把数组展开:
duckdb -c "
SELECT e.type, count(*) AS n, round(avg(e.ms)) AS avg_ms
FROM (SELECT unnest(events) AS e FROM read_json_auto('events.json'))
GROUP BY e.type
ORDER BY n DESC"
# ┌────────┬───┬────────┐
# │ type │ n │ avg_ms │
# ├────────┼───┼────────┤
# │ login │ 2 │ 112.0 │
# │ upload │ 1 │ 940.0 │
# └────────┴───┴────────┘
read_json_auto 会对文件采样,自动推断出各列及其类型,你不用事先声明什么;unnest 再把 events 数组展开成一条事件一行,而 e.type 则伸进每个结构体里取字段。注意这里没有先来一步 jq '.events' > tmp.json。这很关键:先用 jq 抽取会把整个文件读进内存,对一个大到装不下的文件来说恰恰适得其反,所以让 DuckDB 直接从磁盘读原文件。(如果文件本来就装得进内存,那先用 jq 抽一下也没问题——只是这不适合真正超大的情况。)DuckDB 对文件大小比「jq 全量进内存」的流水线宽容得多,不过一个庞大而单一的顶层对象仍然要被解析一遍;最友好的超大格式,是 NDJSON 或一个能逐行扫描的顶层数组。除此之外,凡是你能对数据库表写的——WHERE、GROUP BY、把两个 JSON 文件 JOIN 起来、按计算列 ORDER BY——在这里都能直接对 JSON 文件用,也不用声明什么 schema。
陷阱:用 grep 找一个值
有一条看着很诱人、实则悄悄失灵的捷径:grep '"userId"' events.json。它在小样本上像是能用,到真文件上就把你坑了。grep 匹配的是文本的「行」,而 JSON 并不是按行组织的——值可以和它的键不在同一行,一个对象可以横跨二十行、也可以一行都不占,而压缩过的文件只有一行,于是 grep 要么把整份都返回、要么什么都不返回。它也分不清一个键,和一个恰好含有相同字符的字符串值。只要结构一重要——而「把这个键上的值给我」完全就是结构的事——你就需要能把 JSON 当结构、而不是当文本来读的工具,而 jq、JSONPath 和 DuckDB 正是这样的工具。
当表达式跟你较劲时的调试
上面的过滤器都很短,因为问题都很干净。真实的问题会变得棘手——一个带三个条件的 select、一条你拿不准的嵌套路径、一个老是放错位置的括号——而每改一次表达式就拿 80 MB 的文件重跑一遍,又慢又摸黑。这正是浏览器工具唯一真正派上用场的地方。把一小片有代表性的数据粘进 JSONPath 与 jq 求值器,两个引擎都会随你打字实时运行,还有一个实时的匹配计数,立刻告诉你这个过滤器捞到的是三条还是三万条——这是快速逼近正确表达式的办法。它把输入放在浏览器内存里,所以适合有代表性的样本,而不是整整一 GB;等表达式对了,再用磁盘上的 jq 拿它去跑完整文件。
当它解析不了时
上面这些都假定文件是合法的 JSON。导出文件常常差那么一点——下载被截断、多了个尾逗号,或者 NDJSON 与数组搞混:某个工具每行输出一个对象,你却把它喂给一个期待单个数组的程序(或者反过来)。解析器会在任何查询开跑之前就失败,所以先查语法:jq . file.json 会通读整个文档,如果解析失败,就报出第一处错误的行与列,通常这就足够你揪出那个跑偏的字符或搞错的形状。修好它,再查询。
到底什么时候才该写脚本
对于只读的问题,查询更划算。而当这件事不再是一个「问题」、而变成一条「流程」时,脚本就值回票价了:用单个 JOIN 表达不了的逻辑去连接好几个文件、拿每条记录去外部 API 查一遍、做转换后再把结果写回去,或者任何你会定时跑、还得维护的东西。这条界线大致是:如果代码一打印出答案你就想删掉它,那它本该是一次查询;如果下周你还会再跑一遍,那就写成脚本。
该用哪种方法
| 你想要 | 就用 |
|---|---|
| 任何只读问题——过滤、抽字段、重塑 | jq |
| 一条能到处粘贴、指向一个或多个值的路径 | JSONPath($.a.b[*].c) |
| 成规模的分组、求平均、连接、计数 | DuckDB(SQL over JSON) |
| 快速做个类别直方图 | jq -r '…' | sort | uniq -c | sort -rn |
| 调试一个棘手的表达式 | 浏览器里的 JSONPath 与 jq 求值器 |
| 比内存还大的文件 | NDJSON 配 jq;做分析用 DuckDB,定点抽取用 jq --stream |
| 它解析不了 | 用 jq . 定位出错的行与列 |
「写个脚本」这个条件反射,源于把 JSON 当成了一个编程问题。而它多数时候是个查询问题——哪些记录匹配、这个键上的值是什么、每种各有多少——一旦这样看待,一趟十分钟的绕路就缩成了一行代码,等它给出答案,你随手就删。