3. 列式存储原理:行存 vs 列存、Parquet格式详解、ORC格式详解、列式压缩优势

聊到行情数据压缩,有个概念绕不开——列式存储。说实话,我早年刚接触大数据时,对行存列存的理解也就停留在「一个横着存,一个竖着存」的层面。直到有一次处理某交易所的逐笔成交数据,单日原始 CSV 文件 200GB,用行存压缩后还有 60GB,换成列存后直接干到 8GB。嗯,那次之后我才真正体会到什么叫「存储架构选型决定一切」。

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

先看一张我手绘的对比图,帮你快速建立直观印象。

行存(Row-Oriented) 时间戳 代码 最新价 成交量 09:30:01 000001 3.25 12000 09:30:02 000002 8.91 8500 09:30:03 000003 15.60 3200 ← 一行是一条完整记录 → 适合:频繁增删改、按行查询 不适合:只查某几列、大量聚合 列存(Column-Oriented) 时间戳 09:30:01 09:30:02 09:30:03 最新价 3.25 8.91 15.60 成交量 12000 8500 3200 ← 一列是一个连续块 → 适合:只读某几列、批量聚合 不适合:频繁全行更新

说白了,行存把一条记录的所有字段挨个儿排在一起,像超市购物小票——你买的所有东西都打印在同一行。列存则反过来,把所有记录的同一个字段放在一起,像仓库里按品类堆货——所有可乐放一个货架,所有薯片放另一个货架。

为什么这个区别对行情数据这么重要?你想想看,行情分析场景下,我们经常要算「某只股票过去一小时的平均价」,或者「所有股票在某个时刻的总成交量」。这些操作本质上只涉及少数几个字段(比如最新价、成交量),而不是整行数据。用行存的话,哪怕你只查一列,也得把整行数据从磁盘读出来——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. 数据局部性更好:同一列的数据类型相同,数值范围也相近。比如「最新价」这一列,所有值都是浮点数,而且大部分在 1~100 之间。这种高度相似的数据,压缩算法很容易找到规律。
  2. 可以针对列选算法:行存只能用一种压缩算法对付所有字段,但列存可以给每列选最合适的算法。比如「股票代码」用字典编码,「时间戳」用增量编码,「成交量」用 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 这些算法在行情数据上到底是怎么工作的。


专注资料整理