当几十GB的JSON文件挡在面前:我的数据探索工具迭代之旅

摘要: 一个基于Python和Rust的快速查看超大json数据的科学工具. 工具地址: https://github.com/adrianJW421/JustNiceTools/tree/main/JsonPeeker

大家好,今天想和大家聊一个可能很多从事数据科学、AI或后端开发的同学都遇到过的场景:面对一个几十GB甚至更大的JSON文件,我们该如何是好?

这个问题的棘手之处在于,我们既不能用常规的文本编辑器打开它(会瞬间卡死),也无法用json.load()之类的标准库函数一次性读入内存(会导致内存溢出)。但我们又迫切地需要知道:“这该死的文件里到底是什么?” 它的数据结构是怎样的?有哪些字段?数据值长什么样?

最近,笔者就遇到了这么一个需求,需要调研一些类似 NVlabs/RoboSpatial 这样的数据集。于是,我萌生了写一个命令行小工具来解决这个问题的想法。这篇文章,就是想和大家分享我开发这个工具的历程, 这是一段充满了错误假设、失败尝试、柳暗花明和最终顿悟的迭代之旅。

1 起点:一个雄心勃勃的“通用采样器”

起初,我的想法很简单,甚至可以说有些天真。我希望做一个“通用采样器”,无论来的是JSON、PKL、XML还是TXT,我都能用一个统一的命令,从文件头部采样出几条数据,让我一窥究竟。

基于这个想法,我很快写出了第一版Python脚本。它的核心设计思想是“策略模式”:主程序负责解析命令行参数,然后根据文件的扩展名,将任务分发给专门处理该格式的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# data_sampler.py (示意代码)

def sample_txt(file, n):
# ... 逐行读取 ...
def sample_json(file, n):
# ... 流式解析 ...
def sample_pkl(file, n):
# ... 尝试多次 load ...

HANDLERS = {
'txt': sample_txt,
'json': sample_json,
'pkl': sample_pkl,
}

def main():
# ... 根据文件后缀调用对应的 handler ...

这个方案看起来不错,但很快我就在自我审视中发现了它的问题:

  1. 过于理想化:不同格式的内在逻辑天差地别。特别是对于二进制的PKL文件,如果它是由一个巨大的对象一次性dump出来的,理论上就不可能在不加载整个文件的前提下采样内部元素。通用,有时也意味着对特定场景的无力。
  2. 需求不明确:在数据探索的初期,我们最常遇到的其实是JSON。与其做一个面面俱到但处处是坑的“万金油”,不如先专注解决好一个问题。

于是,我决定收缩战线,专心致志地对付我们最常打交道的“拦路虎”——大型JSON文件。

2 第二站:聚焦JSON,做一个“自动侦探”

好了,现在我们的目标明确了:处理单个、巨大的、结构未知的JSON文件。

新的问题来了:一个“未知”的JSON文件,其顶层结构可能是怎样的?根据笔者的经验,无外乎三种:

  1. JSON Lines (.jsonl): 整个文件不是一个合法的JSON,但每一行都是一个独立的、合法的JSON对象。
  2. 根为数组 (Root Array): 整个文件是一个巨大的数组,形如 [ {obj1}, {obj2}, ... ]
  3. 根为对象 (Root Object): 整个文件是一个巨大的对象,形如 { "metadata": ..., "data": [...] }

我的想法是,让工具像一个侦探一样,先“探查”一下文件头部,根据线索(比如第一个非空字符是[还是{,或者前几行是否能被独立解析)来判断它属于哪种格式,然后再采取对应的、内存高效的采样策略。

这个“探查-分类-采样”的逻辑,催生了我的第二个工具。

这个方案比第一个要务实得多,但它依然依赖于“启发式猜测”,这让我总觉得不够稳妥。如果一个文件的结构稍微复杂一点,比如核心数据数组深埋在好几层对象之下,我的“侦探”可能就束手无策了。

有没有一种方法,能让我确切地知道整个文件的结构,而不是去猜呢?

3 技术岔路:安装我们的“瑞士军刀”

就在我思考如何获取文件“结构地图”的时候,一个非常实际的工程问题摆在了面前。我发现了一个极其强大的工具 genson-rs,它可以用Rust编写,性能极高,能够流式地读取一个巨大的JSON文件并为其生成一个权威的JSON Schema。

“这不就是我想要的‘数据结构schema预览’吗!”我心想。

但要在我们团队的远程开发服务器上安装它,又遇到了经典难题:我没有root权限

这里,我想和大家分享一下这个“题外话”的解决方案,因为它对于很多同学来说可能非常有用。

  1. 安装Rust: Rust的官方安装工具 rustup 对非root用户极其友好。它会把所有东西都安装在你的家目录下(~/.cargo~/.rustup),完全不污染系统环境。一条命令就搞定,非常优雅。
  2. 安装genson-rs: 有了Rust的包管理器Cargo,安装就更简单了:cargo install genson-rs
  3. 配置环境变量: rustup 会自动帮你把 ~/.cargo/bin 添加到PATH。确保这一步生效,genson-rs 就能在任何路径下被调用了。

4 第四站:手握Schema“地图”,却在寻宝路上屡屡翻车

好了,现在我手握 genson-rs 这把利器,思路也清晰了起来:

  1. 先用 genson-rs 扫描一遍10GB的文件,生成一份权威的Schema。这相当于“制图”。
  2. 然后,我的Python脚本读取这份Schema,分析出所有数组(通常是我们需要的数据所在)的路径。
  3. 最后,再次流式读取10GB文件,根据路径,精准地把每个数组的第一个元素给“捞”出来,作为样本。

这个两阶段的方案,在理论上是天衣无缝的。然而,现实很快就给了我一记重拳。

4.1 第一次翻车:聪明反被聪明误

我最初写的Python脚本,为了追求所谓的“极致性能”——只读取文件一次来提取所有路径的样本——设计了一个极其复杂的、基于底层事件流的状态机。结果经过测试,它完全错了!对于嵌套的复杂结构,我的状态机逻辑一塌糊涂,输出的结果牛头不对马嘴。

教训一:在正确性得到保证之前,任何对性能的优化都是空中楼阁。KISS (Keep It Simple, Stupid) 原则永不过时。

4.2 第二次翻车:被Python类型系统“背刺”

吸取了教训,我放弃了复杂的状态机,回归到了最稳健的方法:对Schema中找到的每一个数组路径,都独立地调用ijson库最高级的items()接口去获取其第一个元素。这个方法虽然会多次启动文件读取(但每次都只读一小部分),可它保证了结果的正确性。

然而,当我满心欢喜地运行脚本时,程序在最后一步——将结果写入JSON文件时,崩溃了。

1
TypeError: Object of type Decimal is not JSON serializable

这是一个经典的Python问题。ijson为了保持数字的最高精度,会将JSON中的浮点数解析成Python的Decimal对象。而Python标准库的json模块,却不认识这个Decimal是何方神圣,导致序列化失败。

好在解决方案很简单,在ijson.items()调用时,加上use_float=True参数即可。

5 “AHA”时刻:我们一直在用错误的方式解决正确的问题

经过一番折腾,我的工具终于能跑通了。但测试一份新的数据和运行结果后,情况是这样的:

  • genson-rs 生成的Schema,描述的是一个单一对象的结构。
  • 我的工具分析Schema后,也只在其中找到了一个深埋的数组路径。
  • 最终提取到的样本,是一个空数组。

这太奇怪了!一番推演和排查后,我们终于迎来了整个过程的“啊哈!”时刻:

我们处理的根本不是一个标准的JSON文件,而是一个JSON Lines文件!

之前的种种问题,瞬间都有了答案。genson-rs看到一个JSON Lines文件,自然会认为我们要的是每一行那个对象的Schema。而我的Python脚本,却固执地认为整个文件是一个单一的JSON文档,ijson解析器在读完第一行后遇到换行符,自然就出错了。我们一直在用处理“标准大JSON”的屠龙刀,去解决一个“JSON Lines”的问题。

教训二:在动手解决问题之前,花再多时间去准确地定义和识别问题本身,都是值得的。

6 终点:一个智能统一的探索工具

这次顿悟,让我意识到,一个真正好用的工具,不应该依赖于用户的先验知识,而应该自己具备识别问题的能力。而我也额外发现了一条关键情报:genson-rs 其实有专门处理JSON Lines (--delimiter newline) 和根为数组 (--ignore-outer-array) 的参数!

这一下,所有的技术拼图都凑齐了。我最终构建了一个集大成者的、智能统一的工具:

  1. 探测 (Probe): 脚本先自己当“侦探”,用极小的开销判断文件到底是JSON Lines, 还是根为数组, 或是根为对象。
  2. 生成Schema (Generate Schema): 根据探测结果,用最合适的参数去调用genson-rs,确保拿到的Schema永远是关于核心数据单元的“地图”。
  3. 提取样本 (Extract Samples): 同样根据探测结果,采取最高效的策略。如果是JSON Lines,直接读前N行就完事了;如果是根为数组或对象,再动用我们的流式解析大法。

最终,这个工具能够稳健地处理各种常见的大型JSON文件,先在终端清晰地打印出它认知到的数据“骨架”,然后将一份可靠的数据样本报告保存到文件中。

7 总结与反思

从一个模糊的想法,到一个屡屡碰壁的探索,再到最终一个相对完善的解决方案,这段经历让笔者收获良多。我想,最重要的可能不是最终的那段代码,而是过程中的一些思考:

  1. 明确问题永远是第一位:我们花费了大量时间去优化一个针对错误问题的解决方案,如果一开始就识别出JSON Lines格式,一切都会简单得多。
  2. 简单优于复杂:那个被我抛弃的、自作聪明的状态机方案,就是一个深刻的教训。稳健、可读、正确的代码,远比那些看似高效却脆弱的“黑科技”要有价值。
  3. 拥抱工具,但要理解工具genson-rs是我们的破局关键,但真正让它发挥威力的是我们对其参数的深入理解。善用工具,更要精通工具。
  4. 迭代与反馈是王道:如果没有朋友的测试和反馈,我可能至今还陷在自己设计的错误逻辑里。开放、协作的探讨,是技术路上最好的加速器。

希望我这段踩坑、爬坑的经历,能对大家在日常工作中处理类似问题时,带来一些启发和帮助。如果你对这个工具有任何想法或建议,也随时欢迎交流。

当几十GB的JSON文件挡在面前:我的数据探索工具迭代之旅

https://nerozac.com/2025/10/15/当几十GB的JSON文件挡在面前/

作者

Jiawei Li

发布于

2025-10-15

更新于

2025-10-15

许可协议