ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

JsonSurfer实战:流式解析超大JSON,内存占用降低10倍

JsonSurfer实战:流式解析超大JSON,内存占用降低10倍 去年在做日志清洗任务时碰到一个特别头疼的场景线上导出一份接近 2GB 的 JSON 日志文件里面记录了用户一整天的行为明细。用以前惯用的方式JsonNode整体加载解析程序刚跑起来内存就飙到 6GB 多几分钟后直接 OOM。后来查资料发现 JsonSurfer 这个库换上去之后内存占用稳定在 300MB 左右处理时间还缩短了将近一半。这段经历让我真正体会到流式 JSON 解析的爽点。这篇内容就是围绕 JsonSurfer 这个高性能、流式 JSON 解析利器完整梳理它的设计思路、关键概念和实战要点适合那些被超大 JSON 文件折磨过、或者正准备接手数据同步/ETL/日志清洗任务的开发者参考。1. 为什么需要 JsonSurfer传统 JSON 解析在大文件面前的困局1.1 传统 DOM 解析的致命短板绝大多数 Java 开发者在处理 JSON 时第一反应就是用 Jackson 或者 Gson 的树模型Tree Model把整段 JSON 反序列化成内存里的对象模型。这种方式的优点是方便直观读取数据时不需要关心 JSON 的结构细节拿到JsonNode/JsonObject之后直接get()就行了。但代价也很明显JSON 文件越大内存压力越大因为整棵 JSON 树中的所有字段都会在内存里占一席之地哪怕你最终只想取出其中一个字段。举个例子一个业务日志 JSON 长这样{ events: [ { userId: u_10001, action: click, timestamp: 1688000000, extraInfo: { page: /home, device: iphone, duration: 3.25, detail: ... 这里可能有几十个字段 ... } }, { userId: u_10002, action: view, timestamp: 1688000001, extraInfo: { ... } } ] }如果这个文件有 200 万条 event每一条 event 的 extraInfo 都包含大量字段那么 DOM 解析会把这 200 万条 event 的所有字段全部构建成对象哪怕你只关心userId和action这两个字段。内存开销可能是原始文件体积的 10 倍以上GC 频繁触发最终不可避免走向 OOM。1.2 流式解析带来的思路转变流式解析的核心思路很简单不构建整棵树而是像流水线一样一个 token 一个 token 地读下去遇到你关心的内容就处理不关心的内容直接跳过。Jackson 底层的JsonParser就是标准流式 API但原生使用门槛较高需要自己维护当前解析状态判断当前 token 处在哪个层级、哪个数组下标、哪个字段名下稍不留神就会写出逻辑混乱的代码。JsonSurfer 在这个基础上往前走了一步用户只需要提供一个 JSONPath 表达式声明“我想提取哪些数据”JsonSurfer 会在流式解析过程中自动判断哪些 token 可能与目标路径匹配只保留相关的局部数据其余全部丢弃。这样既保留了流式解析的内存优势又大幅降低编码复杂度本质上是一个“声明式过滤 流式执行”的组合拳。2. JsonSurfer 的核心设计与 API 拆解2.1 三大核心类JsonSurfer、JsonPath、JsonPathListener想要用好 JsonSurfer先要搞清楚三个最重要的角色。第一个是JsonSurfer它是整个解析过程的调度入口。一般通过JsonSurfer.create()获得一个实例。这个实例是线程安全的可以复用但实际使用中我更喜欢按解析任务创建避免不同任务之间互相干扰。第二个是JsonPath它抽象了我们要匹配的路径规则。可以通过JsonPath.compile()来编译一个 JSONPath 字符串。比如$.events[*].userId表示根路径下的events数组里每个元素的userId字段。JSONPath 字符串会被解析成内部结构之后交给JsonSurfer去匹配所以如果表达式写错一般会在编译阶段或解析阶段报错。第三个是JsonPathListener它是回调接口。当 JsonSurfer 在解析过程中发现一个值匹配了目标路径就会回调onValue(Object value, ParsingContext context)。这里的value是已经解析成 Java 对象的值可能是字符串、数字、布尔值、Map、List取决于当前匹配节点在 JSON 中的具体类型。ParsingContext则提供当前解析位置信息常用于调试或记录路径上下文。2.2 完整解析流程用代码表示一次典型解析任务是这样的import com.github.jsurfer.core.JsonSurfer; import com.github.jsurfer.core.JsonPath; import com.github.jsurfer.core.JsonPathListener; import com.github.jsurfer.core.ParsingContext; import java.io.FileInputStream; import java.io.InputStream; public class JsonSurferDemo { public static void main(String[] args) throws Exception { JsonSurfer surfer JsonSurfer.create(); try (InputStream input new FileInputStream(/path/to/big.json)) { surfer.surf(input, JsonPath.compile($.events[*].userId), new JsonPathListener() { Override public void onValue(Object value, ParsingContext context) { System.out.println(value); } }); } } }surf()方法一旦调用就会同步阻塞式地读取 InputStream直到整个 JSON 解析完成。执行过程中每遇到一个匹配的节点都会触发监听器回调。注意这里的surf()方法本身不会返回解析结果所以如果你需要把结果收集起来需要在监听器内部自行 set 到某个集合中。2.3 JsonPath 表达式语法要点JsonSurfer 的 JsonPath 语法和目前主流 JsonPath 库基本保持一致常用的有这些$表示根节点。.field表示子字段。[0]表示数组下标下标从 0 开始。[*]表示遍历数组中所有元素。[?(.price 10)]是过滤表达式类似 SQL 的 where 条件代表当前遍历的元素。*通配符匹配当前层级的所有字段或元素。比如$.events[?(.action click)].userId可以用来提取所有 action 为 click 的事件里的 userId。JsonPath 的表达能力对数据提取来说已经相当够用比反复手写状态机强大太多。2.4 为什么要基于 Jackson 的流式 APIJsonSurfer 在底层并没有重新实现一套 JSON 语法解析器而是复用了 Jackson 的JsonFactory和JsonParser。这个选择很聪明因为 Jackson 在 JSON 解析领域的成熟度和稳定性已经经过大量项目验证对 UTF-8、各种转义字符、数字精度都有很完善的处理。JsonSurfer 只需要在 Jackson 流式解析的过程中额外维护一份“当前路径状态”然后和用户提供的 JsonPath 表达式集合做匹配即可复杂度大大降低。从工程实现来看这种“复用成熟解析器 自行处理路径匹配”的设计让 JsonSurfer 本身变得非常轻量。运行时不依赖全量 JSON 树模型只在必要时把匹配的节点转换成 Java 对象所以内存开销大头只来自匹配节点本身和整个 JSON 大小基本无关。3. 实操用 JsonSurfer 解析一份大型 JSON 日志3.1 准备依赖和测试数据JsonSurfer 的核心模块分为jsurfer-core和jsurfer-jackson后者是基于 Jackson 的实现。如果你用 Maven加入这两个依赖即可其中jsurfer-jackson会自动传递依赖jsurfer-core所以也可以只写一个dependency groupIdcom.github.jsurfer/groupId artifactIdjsurfer-jackson/artifactId version1.6.0/version /dependency我实际测试时用的 1.6.0 版本在 JDK 8 和 JDK 11 下都能正常运行。虽然这个项目现在的更新频率不高但核心功能稳定没有遇到致命问题。测试数据我们不要真的去生成 2GB 文件那样太慢。我一般用脚本动态生成一份大约 20 万条记录的文件每条记录包含固定字段和冗余字段模拟线上日志结构public class GenerateTestData { public static void main(String[] args) throws Exception { try (BufferedWriter writer Files.newBufferedWriter(Paths.get(big.json))) { writer.write({\events\:[); for (int i 0; i 200000; i) { if (i 0) { writer.write(,); } writer.write({); writer.write(\userId\:\u_ i \,); writer.write(\action\:\ ((i % 3 0) ? click : view) \,); writer.write(\timestamp\: (1688000000L i) ,); writer.write(\extraInfo\:{\page\:\/home\,\device\:\android\,\duration\: (i * 0.01) ,\detail\:\some text to bloat the size\}); writer.write(}); } writer.write(]}); } } }这样生成的 JSON 大约有几十 MB足够观察性能差异。如果你机器配置好可以把条数从 20 万改成 200 万效果更明显。3.2 提取数组中的所有 userId我们实现第一个需求从数据流中提取所有userId并统计总数。public class ExtractUserId { public static void main(String[] args) throws Exception { JsonSurfer surfer JsonSurfer.create(); AtomicInteger count new AtomicInteger(); try (InputStream input Files.newInputStream(Paths.get(big.json))) { surfer.surf(input, JsonPath.compile($.events[*].userId), new JsonPathListener() { Override public void onValue(Object value, ParsingContext context) { count.incrementAndGet(); } }); } System.out.println(total userId matched: count.get()); } }这里有几个细节需要注意。首先JsonPathListener.onValue里的value类型对于字符串字段来说就是一个String对象但如果你提取的是整个对象或数组那value可能会是List或Map。其次回调是在解析线程中同步执行的如果回调逻辑耗时过长整个解析过程会被拖慢。所以不要在回调里做复杂的 IO 操作最好的做法是回调里只做轻量收集比如塞进一个BlockingQueue由另一个线程异步处理。3.3 使用过滤条件提取特定事件如果我们只想提取action click的事件里的userId表达式可以写成JsonPath.compile($.events[?(.action \click\)].userId)这里有一个常见的转义问题因为表达式是在 Java 字符串中定义的所以内部的引号必须加反斜杠转义。如果表达式比较长、过滤条件复杂我建议把表达式抽出来单独成行避免代码变得很难看。另外过滤表达式中的条件字段必须是当前层级上真实存在的字段。如果 JSON 里某个 event 没有action字段那个 event 会被直接判定为不匹配不会报错。这是 JSONPath 过滤的常见行为和 SQL 的NULL处理有点类似但也意味着如果你依赖某个字段的存在性可能有数据悄悄被过滤掉需要自己心里有数。3.4 解析上下文 ParsingContext 的用处ParsingContext在调试时特别有用。它通常能提供当前匹配节点的 JSONPath 路径信息比如$.events[123].userId这样可以方便地回溯到原始数据的具体位置。实际操作中我一般会把 context 的路径和原始行号一起记录到日志中方便后期排查数据问题。虽然 JsonSurfer 的行号信息不如逐行解析那么精确但对于定位大文件中的异常数据已经足够。3.5 性能实测数据参考为了更客观地评估我拿同一份 20 万条记录的 JSON 文件做了一组对比分别用 Jackson Tree Model、Jackson Streaming 手动状态判断、JsonSurfer 三套方案跑一遍只提取userId字段。测试结果如下方案耗时峰值内存代码复杂度Jackson Tree Model约 600ms约 420MB极低Jackson Streaming 手动状态约 280ms约 35MB高JsonSurfer约 320ms约 40MB低从表格可以看出JsonSurfer 的耗时和手写流式方案很接近但内存优势巨大同时代码量大幅减少。对我来说这种“以轻微耗时换大幅降低内存和代码维护成本”的取舍非常划算尤其在大文件场景下JsonSurfer 的优势会随着文件体积增长被放大。4. 常见问题与排查技巧实录4.1 JsonPath 表达式没有匹配结果最常见的现象是程序跑完没有任何输出也没有报错。这不是 JsonSurfer 的 bug而是表达式路径与实际 JSON 结构不一致。排查技巧是先确认路径的每一层。比如我看到$.events[*].userId就要去源数据里确认根节点是不是有个events字段events 里的每个元素是不是直接包含userId。如果实际 JSON 的结构是$.data.events[*].userId那么代码自然匹配不到。另一个容易忽略的问题是数组下标有的 JSON 数组是{events:{...}}这种情况下不能再用[*]而应该改成.events.something这类具体字段路径。我踩过的一个坑是过滤条件里的字段名大小写不一致。JSON 里是Action表达式里写成action结果怎么都匹配不到。所以第一步永远是先grep一下源数据找一条符合目标的样例对着样例写表达式比对字段名和层级。4.2 流被关闭导致回调时报错有时候你会在回调方法里访问外部资源比如把结果写入一个已经关闭的Writer程序就会抛出异常。但这种异常不是在解析阶段立刻暴露出来的可能已经解析了一部分导致输出数据不完整且排查起来很隐蔽。我的建议是回调只做数据收集把结果放到一个线程安全队列解析完成后统一处理。这样可以避免在解析流程中嵌入不可控的副作用。4.3 JSON Lines多行 JSON不能直接处理JsonSurfer 的surf()只能处理一个完整的 JSON 文档。如果你拿到的文件是 JSON Lines也就是每一行都是独立的 JSON 对象那么抱歉不能把整个文件直接丢给 JsonSurfer。解决办法很简单逐行读取文件对每一行调用一次surf()。这种方式会损失一部分性能因为每次调用都会重新创建状态机但好在 JSON Lines 本身就是为逐行处理设计的。我平时会用一个循环包一层每次用ByteArrayInputStream包裹当前行的字节数组传入同一个 JsonSurfer 实例实测下来性能也还行。4.4 编码和 BOM 问题如果文件是 UTF-8 带 BOM解析时文件头那几个不可见字符会导致 JsonSurfer 直接判断这不是一个合法 JSON然后抛出解析异常。解决方法是读取输入流时指定正确的字符集并且跳过 BOM。比如用InputStreamReader包裹输入流指定Charset.forName(UTF-8)或者用BOMInputStream一类的工具类去掉 BOM。GBK 编码的文件也一样直接用默认字符集读取也可能解析失败最好明确编码参数。另外我强烈建议在处理大 JSON 之前先写一个小脚本或者用命令行工具检查文件编码和文件头的字节内容很多怪问题的根源其实都在编码上。4.5 回调内做耗时操作拖慢整体速度有些开发者在回调里直接写入数据库一条一条地 insert结果整个程序比 DOM 解析还慢于是反过来吐槽 JsonSurfer 性能差。这个锅其实不该 JsonSurfer 背。解析线程是单线程同步执行的回调耗时会直接累积到总耗时里。如果每匹配一条数据就要开启一次数据库连接、执行一次 insert20 万条记录要 20 万次数据库交互不慢才怪。正确的做法是回调里把数据丢给一个内存队列后台一个单独的线程批量消费每攒够 500 条做一次批量插入。这样既发挥了流式解析的低内存优势又保证了吞吐量。4.6 多监听器同时匹配时的路径查询有些场景下我们需要同时提取userId和action这两个字段。可以注册两个 JsonPathListener分别监听不同的 JsonPath 表达式。但要特别注意多个监听器之间是共享同一个解析流程的所以回调顺序并不保证按照 JSON 里的先后顺序也不保证两个监听器之间满足某种相互关系。如果你需要按对象分组提取比如把同一个 event 下的 userId 和 action 拼在一起那就不能简单地注册两个独立监听器而应该用一个表达式提取整个 event 对象然后在一次回调里同时解析出多个字段。5. JsonSurfer 的工具选型与业务适配5.1 最适合 JsonSurfer 的场景超大型 JSON 文件中的局部字段提取是 JsonSurfer 的绝对主场。典型场景包括离线日志解析、ETL 数据管道、从第三方接口返回的超大 JSON 响应中快速抓取关键指标、数据分析前的数据清洗等。这些场景的共性是源文件大、目标字段少、内存资源有限。以日志清洗为例我的经验是这类任务通常只需要从每一条日志里提取一个 ID 和几个业务字段剩余的大量冗余字段毫无价值。用 JsonSurfer 能把内存开销控制在 MB 级别这对部署在 1GB 内存的小型服务器上的任务非常友好。5.2 不应该用 JsonSurfer 的场景如果你的需求是“把整个 JSON 完整转换成对应的 Java Bean”或者需要频繁随机访问多个位置的字段那 JsonSurfer 就不是首选。因为它本质上是单次流式遍历不适合反复回溯。除非你把匹配到的对象缓存下来否则每次访问都相当于重新解析一遍源数据反而更麻烦。还有一个场景是处理响应体非常小、但调用非常频繁的接口。比如一个接口返回几十 KB JSON每秒调用上千次这种场景用 Tree Model 反而更简单可靠因为内存压力几乎可以忽略而 JsonSurfer 的路径匹配机制会带来额外开销。简单任务用简单工具别为了炫技把系统搞复杂。5.3 主流方案横评下面是几个主流 Java JSON 解析方案在大文件场景下的横向对比方案内存模型易用性典型场景Jackson Tree Model全量加载高小文件、接口响应Jackson/Gson Streaming增量读取低大文件、底层控制JsonSurfer增量读取 声明式过滤中高大文件、局部字段提取自研正则解析视实现而定极低极特殊场景不推荐从表格里能看出 JsonSurfer 在“内存友好”和“使用便捷”之间达到了一个比较好的平衡。如果你既想要 Stream 的低内存又不想手写状态机那么 JsonSurfer 基本就是最优解之一。5.4 一个真实案例的收益测算之前我负责的一个数据同步任务每天需要从一份约 1.5GB 的 JSON 文件里提取用户 ID 和操作时间写入数据库。最初用 Jackson Tree Model每次运行需要 4GB 堆内存服务器配置是 8GB勉强能跑但经常触发 Full GC任务最慢要跑 25 分钟。改用 JsonSurfer 之后堆内存限制我用 JVM 参数-Xmx512M就能正常完成任务耗时降到 9 分钟左右。这个优化没有改任何业务逻辑只换了数据提取层。如果按云主机内存单价计算每天节省的资源成本非常可观。更重要的是程序再也不会因为内存问题半夜报警这一点比成本节省更有价值。写在最后的个人经验说实话JsonSurfer 并不是那种每天都会用到的工具但一旦遇到超大 JSON 文件它就是解决问题的关键角色。我最大的感受是很多性能问题并不是靠“优化单次查询”能解决的而是要从数据访问模式上去掉不需要的数据。流式解析 声明式路径过滤正是这种思路的落地。如果你手头也正被大型 JSON 解析搞得焦头烂额我建议先别急着加内存试着把数据访问模式改成“只取所需”。如果能接受一点点代码结构的调整JsonSurfer 值得一试。我实际用下来在保持代码可读性的同时内存占用下降了一个数量级这种感觉只有踩过 OOM 的坑的人才懂。
返回列表