3、列式存储与压缩:Parquet格式原理、行存vs列存、压缩效果实测

聊到行情数据的存储,我第一个想跟你聊的就是列式存储。

为什么?因为行情数据有个特点——字段多,但每次查询往往只取其中几个。比如查某只股票的历史收盘价,你只需要 ts_codetrade_dateclose 三个字段。如果用行存,整行数据都得读出来,IO开销巨大。列存就不一样了,只读你需要的列,其他列碰都不碰。

3.1 行存 vs 列存:本质区别在哪?

先看一张对比图,我画了个简单的示意:

行存(Row Storage) Row1: code, date, open, high, low, close, vol Row2: code, date, open, high, low, close, vol Row3: code, date, open, high, low, close, vol Row4: code, date, open, high, low, close, vol Row5: code, date, open, high, low, close, vol ... 查询 close 时,整行全部读入内存 IO 放大严重,但适合 OLTP 频繁增删改 列存(Column Storage) code 列: 000001, 000002, 000003, ... date 列: 2024-01-01, 2024-01-02, ... open 列: 10.2, 10.5, 10.3, ... high 列: 10.8, 10.6, 10.7, ... low 列: 9.9, 10.1, 10.0, ... close列: 10.5, 10.3, 10.4, ... 查询 close 时,只读 close 列 IO 极小,且同列数据类型一致,压缩率极高

说白了,行存是把一条记录的所有字段挨个排好,存成一行。列存是把所有记录的同一个字段放在一起,存成一个列块。

你想想看,行情数据里 close 字段全是浮点数,类型一致、数值范围接近。列存天然适合这种场景——压缩算法可以发挥到极致。

核心结论:行存适合「取整行」的场景(如交易系统),列存适合「取部分列+批量分析」的场景(如量化回测、数据仓库)。行情数据属于后者。

3.2 Parquet 格式原理:它到底做了什么?

Parquet 不是简单的列存,它做了三层优化。我个人习惯把它拆解成「行组→列块→页」三个层级来理解。

3.2.1 行组(Row Group)

Parquet 先把数据水平切分成多个行组。每个行组包含若干行,是读写的基本单元。行组内部,再按列独立存储。

3.2.2 列块(Column Chunk)

每个行组里,每一列的数据单独形成一个列块。列块是压缩和编码的基本单位。不同列可以用不同的压缩算法——比如 close 用差分编码 + ZSTD,ts_code 用字典编码。

3.2.3 页(Page)

列块内部再切分成更小的页。页是 Parquet 读写的最小单元。为什么还要分页?因为内存有限,一次读一整个列块可能太大,分页后可以按需加载。

我的经验:行组大小很关键。太小了压缩率上不去,太大了内存扛不住。我一般设 128MB 到 256MB,具体看数据量。曾经有个项目行组设了 1GB,结果 Spark 跑任务时频繁 OOM,调小后问题解决。

3.3 压缩效果实测:行存 vs 列存

光说不练假把式。我用真实的 A 股日线行情数据做了个对比测试。数据量:1000 只股票,5 年日线,约 120 万行,原始 CSV 大小 85MB。

存储格式 压缩算法 文件大小 压缩比 读取 3 列耗时
CSV(行存) 85 MB 1.0x 2.3s
CSV(行存) Gzip 18 MB 4.7x 3.1s(需解压)
Parquet(列存) Snappy 6.2 MB 13.7x 0.4s
Parquet(列存) ZSTD 4.1 MB 20.7x 0.5s
Parquet(列存) Gzip 3.8 MB 22.4x 0.7s

看到没?Parquet + ZSTD 直接把 85MB 压到 4.1MB,压缩比超过 20 倍。而且读取 3 列只需要 0.5 秒,比 CSV 快了 4 倍以上。

为什么会这样?因为列存只读需要的列,IO 量小;再加上同列数据相似度高,压缩算法效果好。CSV 虽然也能压缩,但解压时必须全量解压,IO 和 CPU 都吃亏。

注意:Parquet 不是万能的。如果你的查询经常需要取所有列(比如导出全量数据),行存反而更快。另外,Parquet 是写一次读多次的格式,不适合频繁单行更新。我曾经有个同事想用 Parquet 做实时行情更新,结果写入延迟高得离谱——嗯,后来他换成了 HBase。

3.4 实战:用 Python 读写 Parquet

代码很简单,我直接贴一段常用的:

import pandas as pd
import pyarrow.parquet as pq

# 读取 CSV 并转存为 Parquet
df = pd.read_csv('daily_quotes.csv')
df.to_parquet('daily_quotes.parquet', compression='zstd')

# 只读取需要的列
df_close = pd.read_parquet(
    'daily_quotes.parquet',
    columns=['ts_code', 'trade_date', 'close']
)

# 查看 Parquet 元数据(行组、列块信息)
pf = pq.ParquetFile('daily_quotes.parquet')
print(pf.metadata)
print(pf.schema)

这段代码里,columns 参数是关键。它告诉 Parquet 只加载那三列,其他列连碰都不碰。你想想看,如果数据有 50 列,你只读 3 列,IO 节省了 90% 以上。

我的习惯:生产环境中,我会把 Parquet 文件按日期分区存储,比如 /data/dt=2024-01-01/。这样查询时可以先过滤分区,再读列,性能翻倍。另外,压缩算法我首选 ZSTD,平衡压缩率和速度。Snappy 虽然快,但压缩率差一些;Gzip 压缩率最高,但解压慢。

3.5 避坑指南

  • 行组大小别乱设:太小(< 64MB)压缩率低,太大(> 512MB)内存容易爆。128MB 是个不错的起点。
  • 不要用 Parquet 存小文件:每个 Parquet 文件都有元数据开销,文件太小了得不偿失。我建议单文件至少 100MB 以上。
  • 注意数据类型:Parquet 对时间类型支持很好,但别用字符串存日期。用 timestampdate32,压缩效果更好。
  • 我曾经踩过的坑:有一次我把所有股票的日线数据写到一个 Parquet 文件里,结果文件 20GB,查询时 Spark 扫描了整个文件。后来改成按股票代码分区存储,查询速度提升了 10 倍。

好了,列式存储和 Parquet 的核心原理就这些。记住一句话:行情数据用列存,压缩比高、查询快、成本低。下一节我们聊聊更进阶的压缩策略——差分编码和字典编码,这两个才是真正压箱底的东西。


公众号:蓝海资料掘金营,微信deep3321