4、列式存储原理:Parquet与ORC格式、列式压缩算法、分区策略

做高频做市的朋友,咱们今天聊点实在的。

行情数据存下来之后,怎么读得快?怎么存得省?

我早年刚接触这个领域时,踩过一个坑——用CSV存Tick数据。一天下来,一个合约的深度快照文件就几百兆。查询某只股票某秒的买卖价差,要扫全文件,慢得让人崩溃。后来我换成了Parquet,查询速度提升了两个数量级。嗯,这就是列式存储的威力。

4.1 行式存储 vs 列式存储:本质区别

传统数据库用行式存储,比如MySQL。每一行是一条完整记录。查询某列时,也得把整行读出来。你想想看,如果我只想看“最新价”这一列,却要把“买卖十档量价”全读出来,这得多浪费IO?

列式存储正好反过来。它把同一列的数据连续存放在一起。查询时只读需要的列,IO量大幅减少。

对比维度 行式存储 列式存储
数据布局 按行连续存储 按列连续存储
查询场景 适合OLTP,频繁增删改 适合OLAP,批量分析
压缩率 较低,列内数据类型混杂 极高,同列数据类型一致
典型格式 CSV, Avro Parquet, ORC

说白了,高频做市的数据分析,就是典型的OLAP场景。我们很少修改单条记录,更多是批量查询某段时间内某只股票的行情。列式存储天生适合这种模式。

核心结论:高频做市数据存储,首选列式格式。IO效率是第一优先级。

4.2 Parquet与ORC:两大主流格式

目前业界用得最多的列式格式,就是Parquet和ORC。我两个都用过,各有千秋。

4.2.1 Parquet

Parquet是Twitter和Cloudera联合开发的。它的核心设计是“嵌套式数据模型”。什么意思?就是它能很好地处理复杂结构,比如深度快照里的买卖十档数据。

我个人习惯用Parquet存Tick级行情。原因很简单:Parquet对嵌套结构的支持非常成熟。你想想看,一条深度快照记录里,有bid_prices数组、bid_sizes数组、ask_prices数组……用Parquet存,天然合适。

// 伪代码:定义Parquet schema
message DepthSnapshot {
  required int64 timestamp;
  required string symbol;
  repeated double bid_prices;   // 嵌套数组
  repeated int64 bid_sizes;
  repeated double ask_prices;
  repeated int64 ask_sizes;
}

Parquet的另一个优势是跨平台。Spark、Flink、Presto、Impala……几乎所有大数据引擎都原生支持。你不用担心格式兼容问题。

4.2.2 ORC

ORC是Hive社区搞出来的。它的强项是“行组索引”。每个行组(Row Group)都记录了该组内各列的最大值、最小值、空值分布等信息。查询时,可以快速跳过不相关的行组。

我记得有一次做回测,需要查询某只股票在某个价格区间内的所有成交记录。用ORC格式,查询引擎直接跳过了80%的行组,只扫描了20%的数据。速度非常快。

特性 Parquet ORC
嵌套数据支持 优秀 良好
行组索引 有(但较弱) 强(Min/Max索引)
压缩率 更高(尤其对数值列)
生态兼容性 极广 Hive生态为主

我的建议:如果团队技术栈偏Spark/Flink,选Parquet。如果偏Hive/Tez,选ORC。高频做市场景,我推荐Parquet,因为嵌套数据支持更好。

4.3 列式压缩算法:RLE与Delta Encoding

列式存储之所以压缩率高,除了数据布局外,还得益于专门的压缩算法。这里重点讲两个:RLE和Delta Encoding。

4.3.1 RLE(Run-Length Encoding)

RLE的原理很简单:把连续重复的值,存成“值+重复次数”。

举个例子,某只股票的买卖方向列,大部分时间都是“BUY, BUY, BUY, SELL, SELL, BUY, BUY”。用RLE压缩后,变成:

原始: [BUY, BUY, BUY, SELL, SELL, BUY, BUY]
RLE:  [(BUY, 3), (SELL, 2), (BUY, 2)]

压缩率非常可观。我在项目中遇到过,某只冷门股票的行情数据,RLE压缩后体积只有原来的15%。

但RLE也有局限。如果数据变化频繁,比如价格列,几乎每个值都不同,RLE就没什么效果了。这时候需要别的算法。

4.3.2 Delta Encoding

Delta Encoding存的是相邻值的差值,而不是原始值。因为差值通常比原始值小得多,可以用更少的位数存储。

比如时间戳列:

原始: [1000000, 1000005, 1000012, 1000020]
Delta: [1000000, 5, 7, 8]  // 第一个存原始值,后面存差值

你看,差值只有个位数,用int32甚至int16就能存下。而原始时间戳是int64。这压缩效果立竿见影。

Parquet和ORC内部都混合使用了多种压缩算法。它们会根据列的数据特征,自动选择最合适的算法。比如价格列用Delta Encoding,买卖方向列用RLE,字符串列用字典编码。

注意:压缩算法不是万能的。如果数据本身随机性很强(比如完全随机的价格序列),压缩率可能很低。我曾经遇到过这种情况,最后不得不调整采样频率,减少冗余数据。

4.4 分区策略:时间分区与符号分区

格式选好了,压缩算法也懂了,但数据怎么组织?这就涉及到分区策略。

分区说白了,就是把数据按某个维度切分成多个小文件。查询时只扫描相关分区,而不是全表扫描。

4.4.1 时间分区

高频做市数据,时间是最天然的分区键。我通常按“日期+小时”分区。

数据目录结构示例:
/data/tick/
  date=2024-01-15/
    hour=09/
      part-00000.parquet
      part-00001.parquet
    hour=10/
      part-00000.parquet
    ...
  date=2024-01-16/
    hour=09/
      ...

为什么要按小时分区?因为高频做市的数据量太大了。一天的数据可能有几十GB。如果只按天分区,查询某半小时内的数据,还是要扫整个天的文件。按小时分区后,查询范围缩小到1/24。

我建议时间分区粒度不要太小。按分钟分区?文件数量太多,元数据开销反而会拖慢查询。按小时分区是个不错的平衡点。

4.4.2 符号分区

除了时间,符号(股票代码)也是高频分区键。查询时通常只关心某几只股票。

符号分区有两种做法:

  • 静态分区:每个符号一个目录。适合符号数量少(比如几十个)的场景。
  • 动态分区:按符号哈希值分区。适合符号数量多(比如几千个)的场景,避免目录过多。

我个人习惯用“时间+符号”组合分区。先按时间分区,再按符号分区。这样查询某只股票某段时间的数据,只需要扫描一个很小的文件子集。

数据目录结构:
/data/tick/
  date=2024-01-15/
    hour=09/
      symbol=000001/
        part-00000.parquet
      symbol=000002/
        part-00000.parquet
    hour=10/
      symbol=000001/
        part-00000.parquet

分区策略总结:

  • 时间分区:按小时分区,平衡文件数量和查询粒度
  • 符号分区:符号少用静态分区,符号多用哈希分区
  • 组合分区:先时间后符号,查询效率最高

4.5 知识体系总览

下面这张图,把本章的核心逻辑串起来了。从列式存储原理,到具体格式,再到压缩算法和分区策略,环环相扣。

高频做市数据存储与检索架构 列式存储原理 Parquet格式 ORC格式 列式压缩算法 RLE(游程编码) Delta Encoding 分区策略:时间分区 + 符号分区 核心原理 存储格式 压缩算法 分区策略

这张图展示了本章的知识体系。从列式存储原理出发,向下延伸出两大主流格式、两种核心压缩算法,以及分区策略。在实际项目中,这些技术是组合使用的。比如用Parquet格式,配合Delta Encoding压缩时间戳列,再按小时和符号分区。这样一套组合拳下来,存储效率能提升5-10倍。

避坑指南:我曾经在项目中,分区粒度过细(按分钟分区),结果文件数量太多,HDFS的NameNode压力巨大。后来调整为按小时分区,问题解决。分区粒度要权衡查询效率和元数据开销。

好了,列式存储这块就聊到这儿。记住一句话:高频做市的数据,用列式存,按时间和符号分区,压缩算法让引擎自己选。这套组合,我在多个生产环境中验证过,效果稳定。


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