【问题标题】:Improving performance when using jq to process large files使用 jq 处理大文件时提高性能
【发布时间】:2020-10-30 16:53:39
【问题描述】:

用例

我需要将 JSON 数据的大文件 (~5G) 拆分为带有 newline-delimited JSON 的较小文件,以节省内存(即,无需将整个 JSON blob 读入内存)。每个源文件中的 JSON 数据是一个对象数组。

很遗憾,源数据不是 newline-delimited JSON,在某些情况下,文件中根本没有换行符。这意味着我不能简单地使用split 命令通过换行符将大文件拆分成更小的块。以下是源数据如何存储在每个文件中的示例:

带有换行符的源文件示例。

[{"id": 1, "name": "foo"}
,{"id": 2, "name": "bar"}
,{"id": 3, "name": "baz"}
...
,{"id": 9, "name": "qux"}]

不带换行符的源文件示例。

[{"id": 1, "name": "foo"}, {"id": 2, "name": "bar"}, ...{"id": 9, "name": "qux"}]

以下是单个输出文件所需格式的示例:

{"id": 1, "name": "foo"}
{"id": 2, "name": "bar"}
{"id": 3, "name": "baz"}

当前解决方案

我可以通过使用jqsplit 来达到预期的结果,如SO Post 中所述。多亏了jq streaming parser,这种方法的内存效率很高。下面是实现预期结果的命令:

cat large_source_file.json \
  | jq -cn --stream 'fromstream(1|truncate_stream(inputs))' \
  | split --line-bytes=1m --numeric-suffixes - split_output_file

问题

上面的命令使用~47 mins 来处理整个源文件。这似乎很慢,尤其是与sed 相比,后者可以更快地产生相同的输出。

以下是一些性能基准,用于显示 jqsed 的处理时间。

export SOURCE_FILE=medium_source_file.json  # smaller 250MB

# using jq
time cat ${SOURCE_FILE} \
  | jq -cn --stream 'fromstream(1|truncate_stream(inputs))' \
  | split --line-bytes=1m - split_output_file

real    2m0.656s
user    1m58.265s
sys     0m6.126s

# using sed
time cat ${SOURCE_FILE} \
  | sed -E 's#^\[##g' \
  | sed -E 's#^,\{#\{#g' \
  | sed -E 's#\]$##g' \
  | sed 's#},{#}\n{#g' \
  | split --line-bytes=1m - sed_split_output_file

real    0m25.545s
user    0m5.372s
sys     0m9.072s

问题

  1. sed 相比,jq 的处理速度是否会变慢?有道理 jq 会慢一些,因为它在后台进行了大量验证,但慢 4 倍似乎并不正确。
  2. 我能做些什么来提高jq 处理这个文件的速度吗?我更喜欢使用jq 来处理文件,因为我相信它可以无缝处理其他行输出格式,但鉴于我每天要处理数千个文件,很难证明我观察到的速度差异是合理的。

【问题讨论】:

  • jq 的“流式解析器”被认为很慢(并且可能比需要的慢),但 jq 版本之间可能存在差异。在 jq 领域内,最好的办法是确定哪个 jq 版本最慢。如果您有时间,值得检查 1.5、1.6 和 master 版本。不用说,如果您每天要处理这么多文件,那么尝试以合理的格式为您提供这些文件可能是值得的。
  • @dmitry - jtc 能帮上忙吗?
  • sed 命令的一些建议:避免使用cat,没有任何用处并且占用时间...如果输入为ASCII,则使用LC_ALL=C,将大大加快...如果输入不'根本没有换行符,使用GNU awk会更好,因为它可以使用自定义记录分隔符...awk -v RS='}, ' -v ORS='}\n' 'NR==1{sub(/^\[/, "")} RT; END{sub(/]\n/, "\n"); printf "%s", $0}'(同样,如果输入为ASCII,则使用LC_ALL=C
  • @peak 我将检查1.51.6master,然后报告。我完全同意以合理的格式提供文件。这些来自我们与另一家公司管理的 API 集成。他们知道文件格式问题,希望他们能尽快解决这个问题,但与此同时,我们需要处理现有的问题。
  • @peak,我打算只在下一个版本中在jtc 中添加streamed parsing(它会是多线程的,所以我希望它会是快),所以目前不可能在jtc 中以内存高效的方式做到这一点:(

标签: json sed split jq


【解决方案1】:

jq 的 streaming 解析器(使用 --stream 命令行选项调用的解析器)故意牺牲速度以减少内存需求,如下面的指标部分所示。 jstream 是一个实现不同平衡的工具(似乎更接近您正在寻找的工具),其主页是 https://github.com/bcicen/jstream

在 bash 或类似 bash 的 shell 中运行命令序列:

cd
go get github.com/bcicen/jstream
cd go/src/github.com/bcicen/jstream/cmd/jstream/
go build

将生成一个可执行文件,您可以像这样调用它:

jstream -d 1 < INPUTFILE > STREAM

假设 INPUTFILE 包含一个(可能是巨大的)JSON 数组,上面的行为类似于 jq 的 .[],带有 jq 的 -c(紧凑)命令行选项。事实上,如果 INPUTFILE 包含一个 JSON 数组流,或者一个 JSON 非标量流,也是这种情况......

说明性时空指标

总结

对于手头的任务(流式传输数组的顶级项):

                  mrss   u+s
jq --stream:      2 MB   447
jstream    :      8 MB   114
jq         :  5,582 MB    39

言辞:

  1. space: jstream 在内存方面是经济的,但不如 jq 的流解析器。

  2. time: jstream 运行速度比 jq 的常规解析器稍慢 但比 jq 的流解析器快 4 倍左右。

有趣的是,两个流解析器的空间*时间大致相同。

测试文件的表征

测试文件由一个包含 10,000,000 个简单对象的数组组成:

[
{"key_one": 0.13888342355537053, "key_two": 0.4258700286271502, "key_three": 0.8010012924267487}
,{"key_one": 0.13888342355537053, "key_two": 0.4258700286271502, "key_three": 0.8010012924267487}
...
]
$ ls -l input.json
-rw-r--r--  1 xyzzy  staff  980000002 May  2  2019 input.json

$ wc -l input.json
 10000001 input.json

jq时代和夫人

$ /usr/bin/time -l jq empty input.json
       43.91 real        37.36 user         4.74 sys
4981452800  maximum resident set size

$ /usr/bin/time -l jq length input.json
10000000
       48.78 real        41.78 user         4.41 sys
4730941440  maximum resident set size

/usr/bin/time -l jq type input.json
"array"
       37.69 real        34.26 user         3.05 sys
5582196736  maximum resident set size

/usr/bin/time -l jq 'def count(s): reduce s as $i (0;.+1); count(.[])' input.json
10000000
       39.40 real        35.95 user         3.01 sys
5582176256  maximum resident set size

/usr/bin/time -l jq -cn --stream 'fromstream(1|truncate_stream(inputs))' input.json | wc -l
      449.88 real       444.43 user         2.12 sys
   2023424  maximum resident set size
 10000000

jstream时间和mrss

$ /usr/bin/time -l jstream -d 1 < input.json > /dev/null
       61.63 real        79.52 user        16.43 sys
   7999488  maximum resident set size

$ /usr/bin/time -l jstream -d 1 < input.json | wc -l
       77.65 real        93.69 user        20.85 sys
   7847936  maximum resident set size
 10000000

【讨论】:

    【解决方案2】:

    限制

    一般情况下,JSON 需要使用能够理解 JSON 的工具进行解析。您可以破例并遵循这些建议,前提是您确信:

    • 您有一个包含 flat JSON 对象(如在用例中)的数组,没有嵌套对象。

    • 花括号在对象内部的任何位置都不存在,这意味着您没有任何类似这样的内容:{id:1, name:"foo{bar}"}


    使用外壳

    如果满足以上条件,就可以使用shell转成JSONL拆分成更小的文件,比JSON解析或者全文处理快很多倍。此外,它几乎是无记忆的,特别是如果您使用带有或不带有 sedawk 的 core-utils。

    即使是更简单的方法:

    grep -o '{[^}]*}' file.json
    

    会更快,但需要一些内存(小于jq)。

    而且你试过的sed命令很快,但是需要内存,因为sed,流编辑器,是逐行读取的,如果文件根本没有换行符,它会加载所有的到内存中,sed 需要流的最大行大小的 2-3 倍。但是,如果您首先使用换行符拆分流,使用诸如trcut 等核心工具,那么内存使用率将非常低,并且性能非常好。


    解决方案

    经过一些测试,我发现这个更快且无记忆。除此之外,它不依赖于对象之外的额外字符,例如逗号和几个空格,或者单独的逗号等。它只会匹配对象{...}并将它们每个打印到一个新行。

    #!/bin/sh -
    LC_ALL=C < "$1" cut -d '}' -f1- --output-delimiter="}"$'\n' |\
        cut -sd '{' -f2 | sed 's/^/{/' > "$2"
    

    要拆分 JSONL,请使用 -l 而不是 -c,以确保不拆分任何对象,请使用以下内容:

    split -l 1000 -d --additional-suffix='.json' - path/to/file/prefix
    

    或全部一起

    #!/bin/sh -
    n=1000
    LC_ALL=C < "$1" cut -d '}' -f1- --output-delimiter="}"$'\n' |\
        cut -sd '{' -f2 | sed 's/^/{/' |\
        split -l "$n" -d --additional-suffix='.json' - "$2"
    

    用法:

    sh script.sh input.json path/to/new/files/output
    

    将在所选路径中创建文件 output1.json、output2.json 等。

    注意:如果您的流包含非 UTF-8 多字节字符,请删除 LC_ALL=C,这只是一个小的速度优化,没有必要。

    注意:我假设输入根本没有换行符,或者像您的第一个用例中那样使用换行符。为了概括并在文件中的任何位置包含任何换行符,我添加了一个小的修改。在这个版本中,tr 最初会截断所有换行符,对性能几乎没有影响:

    #!/bin/sh -
    n=1000
    LC_ALL=C < "$1" tr -d $'\n' |\
        cut -d '}' -f1- --output-delimiter="}"$'\n' |\
        cut -sd '{' -f2 | sed 's/^/{/' > "$2"
    

    测试

    以下是一些测试结果。它们具有代表性,所有处决的时间都相似。

    这是我使用的脚本,输入了 n 的各种值:

    #!/bin/bash
    
    make_json() {
        awk -v n=2000000 'BEGIN{
            x = "{\"id\": 1, \"name\": \"foo\"}"
            printf "["
            for (i=1;i<n;i++) { printf x ", " }
            printf x"]"
        }' > big.json
        return 0
    }
    
    tf="Real: %E  System: %S  User: %U  CPU%%: %P  Maximum Memory: %M KB\n"
    make_json
    
    for i in {1..7}; do
        printf "\n==> "
        cat "${i}.sh"
        command time -f "$tf" sh "${i}.sh" big.json "output${i}.json"
    done
    

    我在与jq 一起测试时使用了小文件,因为它会提前进入交换。然后只使用有效的解决方案来处理较大的文件。

    ==> LC_ALL=C jq -c '.[]' "$1" > "$2"
    Real: 0:16.26  System: 1.46  User: 14.74  CPU%: 99%  Maximum Memory: 1004200 KB
    
    
    ==> LC_ALL=C jq length "$1" > /dev/null
    Real: 0:09.19  System: 1.30  User: 7.85  CPU%: 99%  Maximum Memory: 1002912 KB
    
    
    ==> LC_ALL=C < "$1" sed 's/^\[//; s/}[^}]*{/}\n{/g; s/]$//' > "$2"
    Real: 0:02.21  System: 0.33  User: 1.86  CPU%: 99%  Maximum Memory: 153180 KB
    
    
    ==> LC_ALL=C < "$1" grep -o '{[^}]*}' > "$2"
    Real: 0:02.08  System: 0.34  User: 1.71  CPU%: 99%  Maximum Memory: 103064 KB
    
    
    ==> LC_ALL=C < "$1" awk -v RS="}, {" -v ORS="}\n{" '1' |\
        head -n -1 | sed '1 s/^\[//; $ s/]}$//' > "$2"
    Real: 0:01.38  System: 0.32  User: 1.52  CPU%: 134%  Maximum Memory: 3468 KB
    
    
    ==> LC_ALL=C < "$1" cut -d "}" -f1- --output-delimiter="}"$'\n' |\
        sed '1 s/\[//; s/^, //; $d;' > "$2"
    Real: 0:00.94  System: 0.24  User: 0.99  CPU%: 131%  Maximum Memory: 3488 KB
    
    
    ==> LC_ALL=C < "$1" cut -d '}' -f1- --output-delimiter="}"$'\n' |\
        cut -sd '{' -f2 | sed 's/^/{/' > "$2"
    Real: 0:00.63  System: 0.28  User: 0.86  CPU%: 181%  Maximum Memory: 3448 KB
    
    # Larger files testing
    
    ==> LC_ALL=C < "$1" grep -o '{[^}]*}' > "$2"
    Real: 0:20.99  System: 2.98  User: 17.80  CPU%: 99%  Maximum Memory: 1017304 KB
    
    
    ==> LC_ALL=C < "$1" awk -v RS="}, {" -v ORS="}\n{" '1' |\
        head -n -1 | sed '1 s/^\[//; $ s/]}$//' > "$2"
    Real: 0:16.44  System: 2.96  User: 15.88  CPU%: 114%  Maximum Memory: 3496 KB
    
    
    ==> LC_ALL=C < "$1" cut -d "}" -f1- --output-delimiter="}"$'\n' |\
        sed '1 s/\[//; s/^, //; $d;' > "$2"
    Real: 0:09.34  System: 1.93  User: 10.27  CPU%: 130%  Maximum Memory: 3416 KB
    
    
    ==> LC_ALL=C < "$1" cut -d '}' -f1- --output-delimiter="}"$'\n' |\
        cut -sd '{' -f2 | sed 's/^/{/' > "$2"
    Real: 0:07.22  System: 2.79  User: 8.74  CPU%: 159%  Maximum Memory: 3380 KB
    
    

    【讨论】:

      【解决方案3】:

      我认为thanasisp 的answer 很棒,涵盖了很多问题。使用cut 非常巧妙地解决了sed 的内存问题。

      你试过的sed命令很快,但是需要内存,因为sed,流编辑器,是逐行读取的,如果文件根本没有换行符,它会全部加载到内存中,sed需要流最大行大小的2-3倍

      但是,如答案中所述,sed 脚本仅适用于非常简单的 JSON 对象(除了表示第一级对象的结尾之外,没有嵌套对象和任何地方都没有 }

      更高级的sed 脚本

      这可以通过更复杂的sed 脚​​本来改进,除了已发布的句柄模式。

      [{"id": 1, "name": "foo"}
      ,{"id": 2, "name": "bar"}
      ,{"id": 3, "name": "baz"}
      ,{"id":4, "name": 10}
      ,{"id":5, "name":"\\\" },{"}
      ,{"id": {"a":6}, "name": 10}]
      

      我们主要通过利用{/}" 成对出现这一事实来实现这一点。

      json-newline-json.sed

      #!/bin/sed -nf
      
      # Skip empty lines
      /^$/d
      
      # From first line to first line starting with [
      0 , /\[/ {
      # Replace opening [ if exists
      # + stripping leading whitespace
          /^[[:space:]]*\[/  s@^[[:space:]]*\[@,@
      }
      
      # Line starts with comma
      /^\,/ {
      # Strip it  
      }
      
      # Start of loop
      : x
      
      # Save to hold
      h
      
      # delete all chars except " and \
      s@[^"\\]@@g
      # Delete all reverse solidi and non-escaped " chars
      s@\(\\"\|\\\)@@g
      
      # Even match
      /^\(""\)\+$/ {
          # Fetch hold
          g
          # Delete everything between ".."
          s@"[^"]*"@@g
          # Delete all chars except {}
          s@[^{}]@@g
          # Match even {} pairs
          /^\([{}][{}]\)\+$/ {
              # The hold space contains our assembled ,{..} object
              g
              # Strip the leading comma
              s@^\,@@
              # Print
              p
              # Skip to next line
              d
          }
          # The hold space contains our partial ,{.. object
          g
          N
          s@\n@@
          t x
      }
      
      # Odd match
      /^\(""\)*"$/ {
          # The hold space contains our partial ,{.." object
          g
          # Fetch the next line to append
          N
          # Delete the newline added by N (append without newline)
          s@\n@@
          # Restart loop x
          t x
      }
      
      

      结合cut我们做

      < huge.json cut -d '}' -f1- --output-delimeter="}"$'\n' |\
       json-newline-json.sed |\
       split ...
      

      注意

      如果您有相对明确(且不奇怪)的 JSON 输入,使用 sed 解决您的问题是强大、便携且快速的。然而,这很不稳定。缺点是输入或输出都没有经过验证。解析序列化格式最好更加小心。所以对于大多数意图和目的,我可能会坚持使用jq

      【讨论】:

        猜你喜欢
        • 2011-08-07
        • 1970-01-01
        • 2017-09-14
        • 2021-07-10
        • 1970-01-01
        • 2017-09-19
        • 2020-04-26
        • 1970-01-01
        • 1970-01-01
        相关资源
        最近更新 更多