bigywater.tech
数据编码与压缩 · 7

只读一个字段要解多少:各格式的解码成本

很多场景只要一条记录或一个字段,却要把整批数据都解开。这篇量一量各格式点查、扫一列的代价,JSON 字节级扫描的陷阱,以及压缩分块大小怎么在压缩比和随机访问之间取舍。

0.003 对 42.8 ms
取第 k 条的 title:JSON Lines 带偏移索引 与 整批 json.loads
3.9 对 42.8 ms
对 2 万条求 price 的和:Protobuf 只声明 price 的 schema 与 json.loads 后求和
1.25× 到 5.42×
2 万条 JSON Lines 每块 1 条到整个文件一块的 zstd-3 压缩比(点查 0.0044 ms 到 6.9427 ms)

点查

实测 · 本机执行输出
  JSON 数组(整批 json.loads)             每次查询   42.8016 ms(每次要处理约   7045.1 KiB 的输入)
  JSON 数组(整批 orjson.loads)           每次查询   21.6310 ms(每次要处理约   7045.1 KiB 的输入)
  JSON Lines + 行偏移索引                 每次查询    0.0030 ms(每次要处理约      0.4 KiB 的输入)
  MessagePack 数组(整批 unpackb)         每次查询   31.1409 ms(每次要处理约   6208.3 KiB 的输入)
  MessagePack 逐条 + 偏移索引              每次查询    0.0017 ms(每次要处理约      0.3 KiB 的输入)
  Protobuf Batch(整批解析)               每次查询    3.7981 ms(每次要处理约   4799.2 KiB 的输入)
  Protobuf 逐条 + 偏移索引                 每次查询    0.0011 ms(每次要处理约      0.2 KiB 的输入)

如果每次都要把整个数组解开才能取一条,哪种格式都是几毫秒到几十毫秒;如果每条记录单独存放并记下偏移,取一条只处理几百字节,0.001–0.003 ms。差距来自存放方式,不是格式。

扫一列

实测 · 本机执行输出
  json.loads 后求和                           中位    42.8 ms(42.2–43.7),求和 1105543.79 ✓
  orjson.loads 后求和                         中位    21.0 ms(20.5–21.5),求和 1105543.79 ✓
  msgpack.unpackb 后求和                      中位    32.1 ms(31.2–37.5),求和 1105543.79 ✓
  Protobuf 完整 schema(11 个字段)               中位     6.3 ms(6.3–6.3),求和 1105543.79 ✓
  Protobuf 只声明 price 一个字段的 schema          中位     3.9 ms(3.9–4.0),求和 1105543.79 ✓
  JSON Lines 上用正则直接扫 "price":              中位     6.9 ms(6.8–6.9),求和 1105543.79 ✓
  正确的和是 1105543.79

Protobuf 访问单个字段不用把整条记录转成 Python 对象,求 price 的和 3.9–6.3 ms,JSON 要 21.0–42.8 ms。正则直接在文本上扫是 6.9 ms,但它依赖固定的写法。

字节级扫描的前提

实测 · 本机执行输出
  同样的数据用 json.dumps 默认写法(冒号后面有空格):正则 "price":([0-9.]+) 扫出 0 个,和 0.00(真实和 1105543.79,共 20000 条)
  每 100 条里有 1 条的 seller 对象里也有一个 price 键(值 1.0,共 200 条):正则的和 1105743.79,顶层 price 的真实和 1105543.79,多算 200.00(正好是嵌套的 price 之和)
  title 里含有 "price":999 这样的文本时,json.dumps 会把引号转义成 \",所以正则 没有被骗:找到 1 个 price(正确是 1 个)

正则 "price": 只对紧凑写法(冒号后没空格)有效,换成 json.dumps 默认写法一个都扫不到;对象里嵌套了同名的键就会多算。字符串里的引号会被转义,所以字符串内容本身骗不了它。

投影 schema

实测 · 本机执行输出
  schema 里声明  1 个字段:中位    4.0 ms(4.0–4.1)
  schema 里声明  3 个字段:中位    4.3 ms(4.3–4.4)
  schema 里声明  6 个字段:中位    4.6 ms(4.6–4.9)
  schema 里声明 11 个字段:中位    6.4 ms(6.3–6.4)
  投影 schema 解析后再序列化:4914350 字节(原 4914350 字节)——未声明的字段作为未知字段全部保留

Protobuf 解析时可以用只声明部分字段的 schema,未声明的字段作为未知字段跳过(并保留,重新序列化不丢)。声明越少越快,但解析本身的开销已经很小,差距有限。

压缩分块

实测 · 本机执行输出
      每块条数       压缩后总字节     压缩比       点查中位耗时
         1      5771649   1.25×     0.0044 ms
        10      2472557   2.92×     0.0084 ms
       100      1746783   4.13×     0.0425 ms
      1000      1421724   5.07×     0.3583 ms
     20000      1331194   5.42×     6.9427 ms

压缩后想随机访问就得分块压缩:每块 1 条压缩比只有 1.25×,每块 1000 条 5.07×,整个文件一块 5.42×,点查从 0.0044 ms 升到 6.9427 ms。第 17 篇的 zstd 字典是解决“块小压缩比低”的另一种办法。

分步演示

部分解码
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Python 3.14.8(系统 Python,自带 compression.zstd)/ zstandard 0.25.0 / lz4 4.4.5 / zstd、lz4、brotli、protoc 命令行(Homebrew);msgpack、protobuf、snappy 等第三方库临时装在临时目录里,用完删除;所有数据都是本机脚本生成的合成数据(固定随机种子),没有使用任何真实网站或用户的数据;计时用 time.perf_counter,取多次中位数,数字每次运行会有小幅抖动。protobuf 7.36.2、msgpack、orjson;.proto 在临时目录,用完删除;第一节点查用 300 次(整批类用 5 次)随机序号的中位数,第二、四节 7 次中位数,第五节 200 次随机序号的中位数;第三节是确定性结果
    • 没有验证:真正的列式格式(第 8 篇会比较行式和列式);JSON 的流式/惰性解析器(simdjson 的 On Demand);msgpack 的懒解码;Protobuf 的 Python 实现是否惰性(只看到耗时现象);更大的数据量(百万条)下偏移索引本身的内存;seekable zstd 等标准化的可随机访问压缩格式
    京ICP备2026067502号-1京公网安备11010502063487号