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

场景一:爬虫结果落盘,JSON Lines 加 zstd 对比 sqlite BLOB

把抓下来的数据存起来,有 JSON Lines、zstd 文件、sqlite 文本、sqlite 压缩 BLOB 等做法。这篇在同一份 2 万条商品数据上比较体积、写入、点查、扫描、更新和删除,看看各自适合什么。

18.5% 对 87.7%
整个文件一个 zstd 帧与 sqlite 每条单独 zstd BLOB 占原始 JSON Lines 大小的比例
7657.5 对 5.5 微秒
按 id 取一条:整个文件一个 zstd 帧(每次解压整个文件)与 JSONL 加行偏移索引
1.1 对 58.5 ms
扫全表求价格和:sqlite 关系列 SQL 聚合与 JSONL 逐行 json.loads

体积

实测 · 本机执行输出
  方式                                                文件字节      写入耗时  相对 JSONL
  A  JSON Lines 文本                               7214162      76ms    100.0%
  B  JSON Lines 整个文件一个 zstd-3 帧                  1331194      87ms     18.5%
  C  JSON Lines 每 1000 条一个 zstd 帧(带偏移索引)         1421758      17ms     19.7%
  D  sqlite:id + JSON 文本                         7790592      49ms    108.0%
  E  sqlite:id + 每条单独 zstd-3 的 BLOB              6324224     115ms     87.7%
  F  sqlite:id + 每条 zstd-3 + 16 KiB 字典           2449408      59ms     34.0%
  G  sqlite:每个字段一列(关系表)                          4886528      47ms     67.7%
  H  sqlite:每 100 条一个 zstd-3 的 BLOB              1773568      25ms     24.6%

整个文件一个 zstd 帧最小(18.5%),但不能随机访问;每条单独压缩的 BLOB 只省 12%,加字典 34%;每 100 条一块 24.6%,每 1000 条一帧 19.7%。块越大压缩越好,点查越慢。

点查

实测 · 本机执行输出
  A  JSONL + 行偏移索引                                    5.5 微秒
  B  整个文件一个 zstd 帧(每次解压整个文件!)                      7657.5 微秒
  C  每 1000 条一个帧 + 偏移索引                             369.5 微秒
  D  sqlite JSON 文本                                   4.1 微秒
  E  sqlite 每条 zstd BLOB                              5.9 微秒
  F  sqlite 每条 zstd + 字典                              4.3 微秒
  G  sqlite 列(取一整行)                                   6.9 微秒
  H  sqlite 每 100 条一个 BLOB                           43.8 微秒

带偏移索引的 JSONL、sqlite 文本、每条压缩的 BLOB 都是几微秒;整个文件一个 zstd 帧每次点查要 7657.5 微秒;每 100 条一块 43.8 微秒,每 1000 条一块 369.5 微秒。

扫描

实测 · 本机执行输出
  A  JSONL 逐行 json.loads                           58.5 ms  结果 884659.70 ✓
  B  zstd 帧解压后逐行 json.loads                        61.2 ms  结果 884659.70 ✓
  D  sqlite JSON 文本,SQL 里 json_extract             14.2 ms  结果 884659.70 ✓
  E  sqlite zstd BLOB,逐行解压 + json.loads            89.6 ms  结果 884659.70 ✓
  G  sqlite 列,SQL 直接聚合                              1.1 ms  结果 884659.70 ✓
  正确结果 884659.70

要对字段做过滤聚合,关系列最快(1.1 ms);sqlite 的 json_extract 14.2 ms;逐行 json.loads 58.5 ms;压缩 BLOB 最慢(89.6 ms),因为数据库没法帮你过滤压缩过的字节。

原始页面

实测 · 本机执行输出
  逻辑大小 20859437 字节;1500 个文件的磁盘占用(按块计)24576000 字节(117.8%,每个文件向上取整到文件系统的块大小)
  sqlite 原样存放                         21520384 字节(103.2%)
  sqlite 每页 zstd-3                     7405568 字节(35.5%)
  sqlite 每页 zstd-3 + 64 KiB 字典         6164480 字节(29.6%)
  对照:全部页面拼成一个 zstd-3 帧 4151340 字节(19.9%);字典本身 65536 字节(要和数据一起保存)

1500 个页面当文件存,磁盘占用比逻辑大小多 17.8%(块对齐);放进 sqlite 原样是 103.2%;每页压缩 35.5%,加字典 29.6%,整体压缩 19.9%。

更新

实测 · 本机执行输出
  A  JSONL:重写整个文件(7.2 MB)       7 ms
  D  sqlite JSON 文本                  15 ms
  E  sqlite 每条 zstd BLOB             17 ms
  G  sqlite 列                         8 ms
  H  sqlite 每 100 条一个 BLOB(每条更新都要重压 100 条)          127 ms

每条单独存放的方式更新 1000 条只要 8–17 ms;攒批压缩的 BLOB 每次更新要解压并重压整块,127 ms。追加不改的数据适合攒批压缩,经常更新的不适合。

删除

实测 · 本机执行输出
  隔行删除(id 为偶数的行):写入后 7790592 字节(1902 页 × 4096);删除后文件 7790592 字节,空闲页 1 个(0.1%);VACUUM 后 3907584 字节(用时 21 ms)
  连续删除(前一半 id):写入后 7790592 字节(1902 页 × 4096);删除后文件 7790592 字节,空闲页 951 个(50.0%);VACUUM 后 3891200 字节(用时 24 ms)

sqlite 删除后文件不缩小:隔行删除几乎没有整页被释放,连续删除释放的页留在空闲列表。文件缩小要 VACUUM,会重写整个库。

分步演示

落盘方式
本步的对象

    本文用到的工具与边界

    • 本机 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,取多次中位数,数字每次运行会有小幅抖动。sqlite 3.53.4(WAL、synchronous=NORMAL);zstandard 0.25.0;数据集见第 1 篇;写入耗时是单次测量,点查是 2000 次随机的中位数,扫描 3 次中位数,更新各一次;“磁盘占用”用文件的 st_blocks × 512 计算;所有文件在临时目录里,实验后删除
    • 没有验证:更大数据量(百万条以上)的表现;sqlite 页大小、cache_size 等参数调整;Parquet、DuckDB 等列存方案;网络文件系统或 SSD 差异;并发读写下的表现;压缩 BLOB 上建立倒排索引的办法;文件系统自带的透明压缩(APFS、btrfs、ZFS)
    京ICP备2026067502号-1京公网安备11010502063487号