3、列式存储与压缩:Parquet格式原理、行存vs列存、压缩效果实测
聊到行情数据的存储,我第一个想跟你聊的就是列式存储。
为什么?因为行情数据有个特点——字段多,但每次查询往往只取其中几个。比如查某只股票的历史收盘价,你只需要 ts_code、trade_date、close 三个字段。如果用行存,整行数据都得读出来,IO开销巨大。列存就不一样了,只读你需要的列,其他列碰都不碰。
3.1 行存 vs 列存:本质区别在哪?
先看一张对比图,我画了个简单的示意:
说白了,行存是把一条记录的所有字段挨个排好,存成一行。列存是把所有记录的同一个字段放在一起,存成一个列块。
你想想看,行情数据里 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 对时间类型支持很好,但别用字符串存日期。用
timestamp或date32,压缩效果更好。 - 我曾经踩过的坑:有一次我把所有股票的日线数据写到一个 Parquet 文件里,结果文件 20GB,查询时 Spark 扫描了整个文件。后来改成按股票代码分区存储,查询速度提升了 10 倍。
好了,列式存储和 Parquet 的核心原理就这些。记住一句话:行情数据用列存,压缩比高、查询快、成本低。下一节我们聊聊更进阶的压缩策略——差分编码和字典编码,这两个才是真正压箱底的东西。
公众号:蓝海资料掘金营,微信deep3321