3500万JSON文件引发的生产事故:批量处理与架构设计复盘
2026/9/19 14:00:29 网站建设 项目流程

目录里躺着3500万个JSON文件是什么概念?就是你让实习生“把项目里所有JSON读一遍、做格式转换”,他写了个三行脚本跑起来,然后整个服务器像被抽干了一样,磁盘IO拉满、内存飙到顶、日志疯狂刷屏。运维夺命连环call打过来的时候,你第一反应是实习生写出了死循环,第二反应是代码是不是把文件重复读了。等冷静下来一看,才发现那句经典结论又应验了:实习生没有错,错的是这3500万个JSON文件。

这标题不是段子,是真实发生的工程事故。我在不少群里见过类似的故事:有人把一个字段一个JSON文件的方式当成了“结构化存储”,有人把日志按天切成几百万个小文件还在用Python逐个解析,有人用字符串拼接拼JSON然后被乱码折磨到怀疑人生。这篇文章不打算骂实习生,也不打算阴阳怪气谁,而是回到问题本身,聊聊JSON文件为什么会变成灾难,以及在真实业务中,我们应该怎么设计、批量处理、排查和拯救这些到处泛滥的JSON文件。无论你是刚上手JSON的新人,还是被历史数据折磨过的老开发,这套复盘思路和实操方案应该都能直接用得上。

1. 事故复盘:服务器为什么“差点没救回来”

1.1 3500万这个数字意味着什么

很多人对文件数量没有体感,觉得3500万就是一个“很大的数”。但放在文件系统里,这个数字是非常恐怖的组合拳。首先,每个文件在Linux上都要占用一个inode,3500万个文件就是3500万个inode,如果你的磁盘分区inode耗尽,就算还有几十GB空间,系统也会报“No space left on device”,这是第一个坑。

接着是目录项和寻址开销。普通文件系统面对一个小目录里几万个文件已经会明显变慢,3500万文件如果还摊在一个目录里,打开目录、遍历目录项时消耗的资源会成倍增加。操作系统为了访问每个文件要解析路径、做磁盘寻址,机械硬盘在这种随机IO下基本是灾难,SSD虽然好一些,但大量小文件的读写仍然远低于大文件的顺序IO性能。换句话说,把数据拆成几千万个JSON文件,就等于人为制造了一个“随机IO陷阱”。

还有一个很多人忽略的点:遍历这么多文件本身就要花时间。Python的os.listdir在大目录下耗内存,glob模式匹配慢,os.walk最稳但也会因为inode数量多而卡顿。实习生如果直接一次性把所有文件路径加载到内存,再把所有文件内容读进列表里处理,那内存很容易爆炸。这不是他编码水平的问题,是这套数据组织方式根本不支持“全量批处理”这种基础操作。

1.2 错不在实习生,在于“把文件系统当数据库”

复盘到最后,技术负责人通常会意识到一个更扎心的事实:实习生只是把设计上的欠账暴露了出来。JSON文件本身没有错,它是目前最通用的数据交换格式之一,人类可读、跨语言解析、层级灵活。但JSON文件当成数据库用,而且是以“一业务记录一文件”的粒度来用,那就是在错误的存储形态上无限放大自己的问题。

文件系统擅长的是保存和读取“整体文件”,适合大文件顺序IO、适合按路径精确访问。数据库或列式存储擅长的是结构化查询、聚合分析、索引查找、事务保护。用文件系统存储几千万个小JSON文件时,你得到的全是文件系统的缺点,碰不到数据库的任何优点。查询一条记录要先定位文件路径,聚合一个字段要遍历全量文件,更新数据要重写整个文件,并发访问还要考虑锁和一致性问题。前期写入的时候怎么爽怎么来,后期读的时候就要怎么痛苦怎么还。

所以那句“实习生没有错”说的不是免责,而是提醒团队:出现问题先看根因,别拿执行者当替罪羊。把锅甩给实习生很容易,但第二天同样的坑还会被另一个人踩。真正要做的是重构存储结构、收敛文件数量、建立合理的数据分层,这才是让3500万JSON文件不再成为事故的解法。

2. JSON不是越多越好:数据结构设计的底层逻辑

2.1 JSON格式的硬性约束和易错点

先回到基础。JSON的全称是JavaScript Object Notation,虽然挂了JavaScript的名,但它早就成了跨语言的标准数据格式,基本上每种主流语言都有原生或第三方JSON库。JSON的核心数据类型只有六个:对象、数组、字符串、数字、布尔值、null。没有日期类型、没有二进制类型、没有Decimal精度类型。这意味着遇到时间字段你要自己定字符串格式,遇到大整数要小心精度丢失,遇到小数要掂量浮点误差。

写JSON看起来简单,但很多人会在细节上翻车。最典型的错误包括:最后一个元素后面加逗号,这在严格模式下会直接报错;字符串只用了单引号或没加引号,也是不合法的;注释和尾随逗号在标准JSON里被禁止,不少配置文件(比如某些TSConfig衍生场景)虽然支持带注释的JSON变体,但那已经不是标准JSON了;数字前的多余加号、十六进制写法、NaN和Infinity,也都不在标准范围内。

解析端还有个高频问题,就是“JSON是多层嵌套的字符串,不是任意字符串”。很多报错提示“unexpected token”或“Unexpected end of JSON input”,追根溯源都是输入根本不是合法JSON,可能是被截断的响应、被代理改写的内容、或者是服务端返回了错误页面的HTML。这时候不要怀疑解析库,先打开原始报文看一遍。

2.2 什么样的JSON是好JSON

好的JSON应该具备几个特征。第一是结构稳定,同样的字段永远在同样的位置出现,类型不漂移。最怕的就是同一个字段今天返回字符串明天返回数组后天直接消失,这种情况会让所有下游解析代码变成补丁叠补丁。第二是字段命名清晰,要么统一snake_case要么统一camelCase,不要混着来。第三是值尽量原子化,不要为了省空间搞花活,比如用“1,2,3”这种字符串代替数组,表面上省了几个字节,实际上每次使用都要手动split,徒增出错概率。

另外还有一层容易被忽视的规范是业务语义和结构语义分离。JSON里包含的数据应该是“可被程序理解的数据”,而不是“渲染用的模板片段”。把整个HTML片段包在一个JSON字段里,虽然也算合法JSON,但会让数据层和展示层耦合在一起,后续想换前端框架、做数据统计都极其痛苦。好JSON应该有“数据自解释”的潜质:一个完全不了解业务的开发看到这个JSON,也能大致猜到每个字段表达什么业务含义。

2.3 从文件到数据的“收敛”思路

如果已经有大量JSON文件了,下一步不是漫无目的地“迁移数据库”,而是先做收敛。收敛的第一步是减少文件数量:把同一天的记录合并成一个JSON或JSONL文件,把同一业务的配置合并成一个JSON文件,目录层次控制在两到三层以内。收敛的第二步是减少单文件体积:使用压缩格式,JSON文本的压缩比通常很高,gzip能压到原来的十分之一甚至更低,用空间换IO效率非常划算。收敛的第三步是减少重复解析:如果多个字段会在多处被反复使用,就抽出公共Schema或转成行式结构,避免每个下游都做一遍全量解析。

这几步做完,3500万个文件可能就变成几百几千个压缩文件,原本随机读取的噩梦变成了顺序扫描的常规操作。有人说这不够“大数据”,但真实业务里,大部分所谓大数据问题都是因为没做数据治理,硬生生把小数据堆成了大数据的样子。

3. 大规模JSON文件处理的实战路线

3.1 先盘库存:用单机命令快速摸底

遇到“可能有几百万甚至几千万JSON文件”的场景,第一步绝对不要写Python脚本直接开跑,先花几分钟用系统命令做摸底。我常用的三板斧是这样的。

第一板斧看数量,用find /data -type f | wc -l或者find /data -type f -name "*.json" | wc -l统计数量。如果目录巨大,find可能也要跑一阵子,但相比一次性加载到内存已经温和太多。第二板斧看分布,用find /data -type d | wc -l看目录数量,用du -sh /data看总体积,再用ls /data | head -50抽查文件命名规律。这步是为了判断这批JSON有没有“时间前缀/业务前缀”之类的规律,有规律就能按块处理。第三板斧做抽查,用head -c 2000 /data/sample.json看单个文件的前2KB,确认文件是单行JSON还是Pretty格式,有没有奇怪的BOM,是否包含明显垃圾数据。

摸底之后你手头就有一张“库存画像”:文件总数、目录层数、总大小、单文件大小分布、命名规律、内容形态。拿这张画像去和团队讨论存储方案,比直接写一个遍历脚本靠谱得多。很多时候抽完样你就发现,实际上只有几十个GB数据,完全没必要用分布式,单机加个DuckDB或SQLite就能搞定。

3.2 批量解析的正确姿势:生成器、分批、异常捕获

处理几万级、几十万级JSON文件时,最好的方式是用Generator逐条产出,避免一次性把所有文件路径或文件内容压进内存。核心思路是永远只持有当前正在处理的这一小批数据,处理完就释放。

我用Python处理这类任务时,流程大概是这样的:先写一个遍历文件的生成器,用os.walk逐个产出文件路径;再写一个读取函数,每次打开一个文件、读取内容、解析JSON、产出解析后的Python对象;最后写业务处理函数,对流里的每条记录做转换、筛选或入库。整个过程像流水线,内存峰值基本稳定在几十MB以内,文件再多也只是时间问题,不会内存爆炸。

import os import json from typing import Iterator, Any def iter_json_files(root_dir: str) -> Iterator[str]: """递归产出所有 .json 文件的路径""" for dirpath, dirnames, filenames in os.walk(root_dir): for filename in filenames: if filename.endswith(".json"): yield os.path.join(dirpath, filename) def iter_json_objects(root_dir: str) -> Iterator[Any]: """逐个产出 JSON 文件解析后的对象,带异常兜底""" for file_path in iter_json_files(root_dir): try: with open(file_path, "r", encoding="utf-8") as f: data = json.load(f) yield data except (json.JSONDecodeError, UnicodeDecodeError, OSError) as e: # 记录坏文件,但别中断整个任务 print(f"[warn] failed to parse {file_path}: {e}") # 使用示例 for obj in iter_json_objects("/data/json_root"): # 这里写业务逻辑,比如筛选、转换、入库 pass

这套模板的关键在于“异常捕获不能丢”。真实落地的数据文件里总有那么几个损坏的、被截断的、编码不对的,如果你让它中断整个任务,最后还得从头再来。稳妥的做法是把错误文件路径记录到单独的error.log里,处理完后再集中修复。

3.3 工程选型:什么时候该上DuckDB、什么时候用Jackson

Python适合中小规模批处理,但是当JSON文件数量从几万升到几百万,并且需要做聚合、筛选、联表分析时,就要换个思路了。我自己在本地分析大量JSON时最喜欢用DuckDB,它内置了JSON扩展,可以直接把JSON文件当表查,甚至支持嵌套JSON字段的路径提取和类型转换。你不用把数据全部导入,直接对文件跑SQL,效率比Python手写循环高一大截。

SELECT json_extract_string(data, '$.user_id') AS user_id, json_extract_string(data, '$.action') AS action, count(*) AS cnt FROM read_json_auto('/data/json_root/**/*.json') GROUP BY user_id, action ORDER BY cnt DESC LIMIT 100;

如果数据是Java技术栈,而且量级大到需要流式处理,那Jackson是绕不开的选择。JsonParser配合readValueAs可以一次性解析极大JSON流而不至于把整个对象树加载到内存。ObjectMapper提供了大量配置项,FAIL_ON_UNKNOWN_PROPERTIESACCEPT_SINGLE_VALUE_AS_ARRAY、时间格式定制等,这些配置如果不提前设置好,接第三方接口时很容易踩到反序列化失败。

选择工具的原则很简单:单机、中小量级、需要灵活分析,优先Python加DuckDB;服务端高并发、强类型、需要入库,优先Java加Jackson;浏览器端和前端处理,优先原生JSON.parse加schema校验库。没有银弹,别拿一把锤子敲所有钉子。

3.4 一个完整的“3500万JSON”处理示例

结合前面的讨论,如果你真的遇到3500万JSON,我会建议按这个路线处理:第一步,把文件按业务和时间维度合并成JSONL(JSON Lines)格式,每行一个JSON对象,这样可以大大减少文件数量,同时保留JSON的语义灵活性。第二步,用DuckDB或SQLite建立索引和中间表,把高频查询字段抽成结构化列,把非高频的大字段留作JSONB或TEXT,后续需要时再解析。第三步,用数据校验工具(如Python的jsonschema库)对关键字段做规范校验,提前发现脏数据。

import json import jsonschema from jsonschema import Draft7Validator schema = { "type": "object", "properties": { "user_id": {"type": "string"}, "event_time": {"type": "string", "format": "date-time"}, "amount": {"type": "number"} }, "required": ["user_id", "event_time", "amount"] } validator = Draft7Validator(schema) def process_line(line: str): try: obj = json.loads(line) except json.JSONDecodeError: return None errors = list(validator.iter_errors(obj)) if errors: # 记录 error return None return obj

这一步的最大价值不是“更快的解析”,而是给数据建立规范和护栏。很多项目数据量失控的根源就是没有规范,大家想怎么存就怎么存。加Schema校验虽然会带来一点点性能开销,但对于数据资产的长期健康来说,这笔开销花得太值了。

4. 高频JSON报错排查手册与避坑技巧

4.1 那些年我们一起遇到过的JSON错误

下面这个表整理了我在社群和实际项目中看到的高频JSON报错,每一类背后都有对应的原因和排查思路。

报错提示常见场景排查思路
Unexpected token<in JSON请求返回的是HTML错误页用curl查看原始报文,检查代理或网关
Unexpected end of JSON input响应被截断或文件不完整检查Content-Length,重试或断点续传
JSONDecodeError: Expecting property name enclosed in double quotes用了单引号或裸字符串把源码改成标准JSON,或换宽松解析器
JsonMappingException: No suitable constructorJava Bean缺少无参构造检查POJO,加默认构造方法和setter
Failed to deserialize json body…missing field请求体缺少必填字段对照接口文档补字段,或加默认值/忽略配置
JSON parse error: Cannot deserialize value of type int类型不匹配,字符串赋给数字字段检查字段类型定义,开启ACCEPT_EMPTY_STRING_AS_NULL_OBJECT
Unexpected character (',' )末尾逗号或连续逗号查找逗号并删除
unterminated string字符串引号没闭合用能高亮的编辑器或jq格式化定位

排查这些问题的核心原则是:先看原始数据,再做代码级猜测。很多人一看到JSON解析报错就开始改代码,改了半天发现数据是坏的,浪费时间。把原始报文打印出来,甚至直接丢到jq里面跑一下,往往一眼就能看出问题。

4.2 编码、BOM和大JSON的三个隐形坑

编码问题是最容易被忽视的。JSON标准要求UTF-8,但现实中有大量带BOM的UTF-8文件,Python的json.load在读取带BOM的文件时可能会把\ufeff带到字符串前面,尤其当你用open(file_path, encoding="utf-8-sig")和普通utf-8不一致时,整个字段的匹配都会出问题。前端JSON.parse遇到BOM也会报错,经常表现为“第一个字段名解析错误”。解决办法就是统一在读取端用utf-8-sig编码,或者在反射前用strip('\ufeff')清掉。

第二个坑是非ASCII字符的编码,中文、emoji、生僻字都可能因错误编码变成或乱码。写入JSON时,json.dumps要设置ensure_ascii=False,否则中文会变成\uXXXX转义序列,人眼没法读,传输体积也更大。读取时,别自作主张转成GBK,除非你能确认源数据的真实编码,否则一律UTF-8。

第三个坑是超大JSON导致的单点故障。一个JSON文件动辄几GB,这种文件本身就不适合直接用json.load一次性读入,会把整个对象树压在内存里。这种情况应该采用流式解析,比如用ijson库或用JsonParser分批处理,或者干脆先做一步预处理,把超大JSON切分成多个有意义的子集。还有一个比较隐蔽的问题是超大JSON的Int类型精度,超过2^53-1的数字在JavaScript和部分JSON库里会丢失精度,对ID这类字段建议在源头就转成字符串。

4.3 命令行调试JSON的正确姿势

调试JSON我离不开三个工具:jqpython -m json.tooljlessjq是JSON处理的神器,过滤字段、修改结构、提取数组元素都是它的拿手活。最常用的几个命令是jq .格式化输出、jq '.items[] | {id: .id, name: .name}'做字段提取、jq -r输出原始字符串方便进一步处理。如果文件特别大,jq也能配合流式模式工作,不至于直接卡死。

# 格式化并着色输出 jq . data.json # 提取每个元素的 id 和 name jq '.items[] | {id: .id, name: .name}' data.json # 把 JSON 数组里所有 name 字段拼接成字符串列表 jq -r '.items[].name' data.json | sort -u

python -m json.tool适合快速验证JSON有效性,在命令行里跑一下就能知道文件是不是合法JSON,还能顺带把格式规范化。界面端如果数据大,浏览器按Ctrl+A复制到剪贴板时,地址栏打开文件等方式有时会遇到“JSON未格式化”的显示问题,其实是因为浏览器MIME类型识别成了text/plain而不是application/json,或者文件本身有语法错误。想要浏览器漂亮地格式化,可以用Chrome的JSON Formatter插件,或者直接用jq .格式化后输出到新文件再打开。

jless是一个终端里的JSON浏览器,支持折叠展开、快速搜索,比滚动屏幕看几百行Pretty JSON舒服得多。排查深层嵌套结构的时候,这几个工具配合起来效率翻倍。

4.4 业务场景彩蛋:labelme多边形转txt、书源JSON、音乐源JSON

聊了这么多工程层面的东西,再说几个实战中非常典型的JSON应用,你会发现JSON的身影比想象中广泛。比如labelme标注工具保存的标注文件就是JSON格式,里面有shapes数组,每个shape有points多边形的顶点坐标。很多人需要把它转成YOLO训练需要的txt标签格式,这个转换过程本质就是解析JSON、按类别映射坐标、再归一化写出。

import json with open("label.json", "r", encoding="utf-8") as f: label = json.load(f) for shape in label["shapes"]: label_name = shape["label"] points = shape["points"] # 计算多边形包围盒并归一化 all_x = [p[0] for p in points] all_y = [p[1] for p in points] x_min, x_max = min(all_x), max(all_x) y_min, y_max = min(all_y), max(all_y) # 写入 yolo txt 格式

另外像“书源JSON”和“音乐源JSON”这类灰色文艺圈的热门资源,本质也是一大坨JSON配置。它们被传播成“2026有效书源JSON最新版”,靠的就是JSON的可读性和解析便利性。但这类配置最大的问题在于“失效快、来源杂”,导入之后经常出现解析失败或字段缺失。原因很可能是JSON里有注释、有尾随逗号、编码不是UTF-8、或者字段名被改过。排查方式同上面一样:先拉下来用jq格式化看看报不报错,能格式化说明结构没问题;格式化失败就先定位语法错误,别一个个核对字段。

5. 写在最后:把锅还给设计者

如果你现在接手了一个全是JSON文件的老系统,别急着骂前人,也别急着让实习生背锅。先做摸底,再做收敛,最后建立规范。JSON这种格式太灵活了,灵活到很多团队把它当成了“怎么方便怎么来的百宝箱”,最后被自己的灵活反噬。解决之道不是抛弃JSON,而是用工程手段管住它:文件数量要收敛、结构要有Schema、解析要做异常兜底、调优要有工具链。实习生只是执行了一次全量扫描,真正的问题在那3500万个JSON文件的组织方式上。

我个人在实际处理这类历史包袱时,最大的体感是:花在摸底和设计上的时间,永远比硬写脚本的时间值钱。看到一个大目录别上来就跑全量程序,先find统计、先head抽查、先拿十个文件验证思路,看清楚了再动手。记住,JSON本身不会害你,让你吃亏的是不管不顾地堆数据。把数据结构设计好、把工具链理顺,就算下一次又冒出来3500万个JSON文件,你也能淡定地说一句:来吧,我有方案了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询