3. 列式存储原理:行存 vs 列存、Parquet格式详解、ORC格式详解、列式压缩优势
聊到行情数据压缩,有个概念绕不开——列式存储。说实话,我早年刚接触大数据时,对行存列存的理解也就停留在「一个横着存,一个竖着存」的层面。直到有一次处理某交易所的逐笔成交数据,单日原始 CSV 文件 200GB,用行存压缩后还有 60GB,换成列存后直接干到 8GB。嗯,那次之后我才真正体会到什么叫「存储架构选型决定一切」。
行存 vs 列存:本质区别在哪?
先看一张我手绘的对比图,帮你快速建立直观印象。
说白了,行存把一条记录的所有字段挨个儿排在一起,像超市购物小票——你买的所有东西都打印在同一行。列存则反过来,把所有记录的同一个字段放在一起,像仓库里按品类堆货——所有可乐放一个货架,所有薯片放另一个货架。
为什么这个区别对行情数据这么重要?你想想看,行情分析场景下,我们经常要算「某只股票过去一小时的平均价」,或者「所有股票在某个时刻的总成交量」。这些操作本质上只涉及少数几个字段(比如最新价、成交量),而不是整行数据。用行存的话,哪怕你只查一列,也得把整行数据从磁盘读出来——IO 浪费非常严重。列存则只读你需要的列,IO 量直接降一个数量级。
核心结论:行情数据是典型的「写多读少、按列聚合」场景。我个人习惯,只要涉及历史行情分析,首选列式存储。行存更适合 OLTP 场景,比如交易系统的订单表——经常要增删改一整条记录。
Parquet 格式详解
Parquet 是 Hadoop 生态里最流行的列式存储格式之一。我最早接触它是在做某券商的 Level-2 行情归档项目,当时每天要处理 3 亿条逐笔成交,用 Parquet 后存储成本降了 70%。
Parquet 的核心设计理念是「按列分块 + 嵌套编码」。它的文件结构大致分三层:
- Row Group(行组):把数据水平切分成多个组,每个组包含若干行。这是并行读取的基本单元。
- Column Chunk(列块):每个行组内,每一列的数据单独存成一个连续块。这是列存的核心。
- Page(页):列块内部再切分成更小的页,每页独立编码和压缩。这是压缩和索引的粒度。
举个例子,假设你有 1000 万条行情记录,Parquet 可能把它分成 10 个 Row Group,每个 Group 100 万行。每个 Group 里,「最新价」这一列单独存成一个 Column Chunk,内部再按 65536 行一页切分。这样当你查询「最新价 > 10 元」时,只需要扫描「最新价」这一列的页,其他列完全不用碰。
避坑指南:我曾经遇到过一个坑——Row Group 大小设置不当。设得太小(比如 1000 行),元数据开销会很大,压缩率也差;设得太大(比如 1000 万行),内存压力大,并行度也上不去。我现在的经验是:Row Group 大小控制在 64MB~256MB 之间,或者按行数控制在 100 万~500 万行,具体看单行大小。
Parquet 支持的编码方式也很丰富:
| 编码方式 | 适用场景 | 压缩比(行情数据实测) |
|---|---|---|
| PLAIN(明文) | 不适合压缩的场景 | 1:1(无压缩) |
| RLE(游程编码) | 重复值多的列(如股票代码) | 5:1~20:1 |
| DELTA(增量编码) | 单调递增的列(如时间戳) | 3:1~8:1 |
| ZSTD(通用压缩) | 数值型列(如最新价、成交量) | 4:1~10:1 |
我个人最常用的组合是:股票代码用 RLE,时间戳用 DELTA,数值字段用 ZSTD。这样搭配下来,行情数据的整体压缩比通常在 8:1 到 15:1 之间。
ORC 格式详解
ORC(Optimized Row Columnar)是 Hive 生态的亲儿子,后来也被 Spark、Presto 广泛支持。和 Parquet 相比,ORC 有几个独特的设计:
- 内置索引:ORC 在每个 Stripe(相当于 Parquet 的 Row Group)末尾存储了该 Stripe 内每列的最大值、最小值、空值数等统计信息。查询时可以直接跳过不满足条件的 Stripe。
- 更细粒度的压缩:ORC 支持在列级别指定不同的压缩算法,甚至可以在同一个文件里混合使用。
- ACID 支持:ORC 原生支持事务和增量更新,这在某些需要实时写入的场景下很有用。
我记得有一次做回测系统,需要每天增量更新历史行情数据。用 Parquet 的话,每次更新都得重写整个文件,非常痛苦。换成 ORC 后,利用它的 ACID 特性,可以直接对文件做增量合并,效率高了很多。
注意:ORC 的 ACID 支持需要 Hive 的事务管理器配合,不是开箱即用的。如果你只是做纯读写的批处理场景,Parquet 和 ORC 差别不大。但如果你需要「读时合并」或「小文件合并」,ORC 会更顺手。
ORC 的文件结构也很有意思:
ORC 文件结构
├── PostScript(文件尾,记录元数据位置)
├── Footer(文件级元数据:列名、类型、行数等)
├── Stripe 0
│ ├── Index Data(每列的 min/max/null 统计)
│ ├── Row Data(实际列数据,按列存储)
│ └── Stripe Footer(Stripe 级元数据)
├── Stripe 1
│ └── ...
└── Stripe N
└── ...
这个结构的好处是:读取时先读 PostScript 和 Footer,拿到全局信息;然后根据查询条件,只读取满足条件的 Stripe 和列。索引数据的存在让「谓词下推」变得非常高效。
列式压缩优势
为什么列式存储的压缩效果比行式好那么多?说白了,原因就两个:
- 数据局部性更好:同一列的数据类型相同,数值范围也相近。比如「最新价」这一列,所有值都是浮点数,而且大部分在 1~100 之间。这种高度相似的数据,压缩算法很容易找到规律。
- 可以针对列选算法:行存只能用一种压缩算法对付所有字段,但列存可以给每列选最合适的算法。比如「股票代码」用字典编码,「时间戳」用增量编码,「成交量」用 ZSTD——各取所长。
我拿一个真实案例来说明。某次处理沪深两市的全量 Tick 数据,原始 CSV 约 500GB:
| 存储方式 | 压缩后大小 | 压缩比 | 查询耗时(按股票代码过滤) |
|---|---|---|---|
| CSV(无压缩) | 500 GB | 1:1 | N/A(无法直接查询) |
| 行存(Snappy) | 180 GB | 2.8:1 | 45 秒 |
| Parquet(ZSTD) | 42 GB | 11.9:1 | 3.2 秒 |
| ORC(ZSTD + 索引) | 38 GB | 13.2:1 | 1.8 秒 |
看到没?列式存储不仅省了 10 倍以上的存储空间,查询速度还快了 10 倍以上。这就是「压缩即计算」的典型体现——数据越小,IO 越少,查询越快。
我的建议:如果你正在搭建行情数据仓库,Parquet 和 ORC 二选一的话,我倾向于 Parquet,因为它的生态更广,Spark、Presto、DuckDB 都原生支持。但如果你重度使用 Hive,或者需要 ACID 更新,ORC 会是更好的选择。无论选哪个,记住一点:列式存储 + 合适的编码算法,是行情数据压缩的黄金组合。
嗯,关于列式存储的原理和主流格式,就聊到这里。下一节我们会深入具体的压缩算法实现,看看 RLE、DELTA、ZSTD 这些算法在行情数据上到底是怎么工作的。