4. 列式存储格式(Parquet/ORC)的逐笔数据压缩实践

逐笔成交与逐笔委托数据是典型的“宽表 + 高重复”场景:单条记录往往包含证券代码、交易所、时间戳、逐笔序号、方向、价格、数量、成交编号、买卖订单号等十余个字段,而一天内同一只证券的代码、交易所、方向、价格档位会反复出现。行式存储按记录连续写入,压缩器看到的是一行行交错的不同类型数据,字典与游程编码难以发挥;列式存储把同一字段的同类值聚在一起,重复模式被显式暴露,压缩率与列裁剪能力因此显著提升。本节围绕 Parquet 与 ORC 两种主流格式,给出可落地的 schema 设计、编码与压缩算法组合,并说明如何对比压缩率与列裁剪查询性能。

一、schema 设计:先定类型,再定层级

逐笔数据的 schema 设计有两个关键决策。第一是时间字段的表示:建议用 INT64 存储“自当日零点起的毫秒数或微秒数”,而不是字符串时间戳,既省空间又利于范围过滤;若跨日,可另加 INT32 的交易日字段。第二是枚举字段的表示:方向(买/卖/未知)、订单类型、交易所等应使用小整数编码或直接存字符串并依赖字典编码,二者在 Parquet/ORC 中都能获得很好的压缩,但整数编码在列裁剪后做谓词下推更直接。

对于逐笔委托中“同一订单可能多次更新”的情况,应避免嵌套过深。Parquet 支持 optional group 与 repeated 字段,但嵌套层级每增加一层,定义级别(definition level)与重复级别(repetition level)就多一份存储开销。实践中推荐“扁平化优先”:把高频过滤字段(证券代码、交易日、时间、方向)放在顶层,把低频或整块读取的字段(如订单簿快照扩展)放入嵌套结构。ORC 则利用 struct 与 list 类型,同样建议控制嵌套深度在两层以内。

-- Parquet 风格 schema 片段(逐笔成交)
message tick_trade {
  required int32  trade_date;      -- 20240520
  required binary symbol (UTF8);   -- 600519
  required int64  ts_us;           -- 当日微秒
  required int64  seq;             -- 逐笔序号
  required int32  side;            -- 0买 1卖 2未知
  required int64  price;           -- 价格×10000 的整数
  required int64  qty;             -- 数量(股)
  optional int64  bid_order_id;
  optional int64  ask_order_id;
}

价格与数量用定点整数(价格乘 10000、数量按股)而非浮点,既避免浮点压缩效果差,也保证跨语言一致性。这是逐笔数据 schema 设计中最容易被忽视、却对压缩率影响很大的一点。

二、编码与压缩算法组合

Parquet 的列编码包括 PLAIN、RLE/bit-packed、DELTA_BINARY_PACKED、DELTA_BYTE_ARRAY、DICTIONARY 等;ORC 则提供 DIRECT、DICTIONARY、RLE、DELTA、BIT_FIELD 等编码,并自带轻量索引(stripe 级 min/max 统计)。针对逐笔字段的取值特征,可按下表组合:

字段取值特征Parquet 编码ORC 编码压缩算法
symbol低基数、强重复DICTIONARYDICTIONARYZSTD
side / 交易所极低基数RLE + DICTIONARYRLEZSTD
ts_us / seq单调递增DELTA_BINARY_PACKEDDELTAZSTD
price档位有限、局部重复DICTIONARY 或 DELTADICTIONARYZSTD
qty整数、分布偏斜DELTA_BINARY_PACKEDDELTAZSTD
order_id高基数、近似随机PLAIN 或 DELTADIRECTZSTD

压缩算法上,逐笔数据推荐 ZSTD(级别 3~6)而非 GZIP:在相近压缩率下 ZSTD 解压更快,适合查询频繁的列裁剪场景;若存储成本极度敏感且查询较少,可对冷数据用 ZSTD 高等级或 GZIP。Snappy 解压快但压缩率偏低,适合对延迟极敏感的热数据。一个常见误区是对所有列统一用同一种编码:order_id 这类高基数列用字典编码反而会因字典膨胀而变慢,应改用 PLAIN/DELTA。

三、压缩率与列裁剪性能的对比方法

对比不能只看文件大小,还要看“查询实际读了多少字节”。列裁剪的收益来自两点:只读所需列,以及利用行组(Parquet row group,默认 128MB 可调)或 stripe(ORC 默认 64MB)的统计信息跳过不满足谓词的数据块。因此评估时应固定同一份逐笔样本(例如某日全市场逐笔成交约数千万行),分别写入 Parquet 与 ORC,记录文件总大小、各列压缩后大小,再执行同一组查询:

  • Q1:按 symbol + 时间范围取全部字段(宽查询,检验整体压缩率);
  • Q2:只取 symbol、ts_us、price、qty(列裁剪,检验投影下推);
  • Q3:按 side 过滤后聚合 qty(检验谓词下推与统计跳过)。

观察指标包括:扫描字节数(可通过查询引擎的统计或 parquet-tools/orc-tools 的元数据查看)、端到端耗时、解压 CPU 占用。合理的预期是:Q2 的扫描字节数应显著低于 Q1,且 Parquet 与 ORC 在低基数列上的压缩率接近,差异主要出现在高基数列与行组/stripe 大小设置上。行组过小会增加元数据开销,过大则降低跳过粒度,实践中可先用 64~128MB 行组做基线,再按查询模式微调。

逐笔数据:行式写入 vs 列式列组 + 编码压缩 行式记录(字段交错) 600519 | 买 | 1710.5 | 100 | 09:30:01.120 600519 | 卖 | 1710.6 | 200 | 09:30:01.135 000001 | 买 | 11.20 | 500 | 09:30:01.140 同类值被不同类型打断,字典/游程难生效 按列拆分 列组(每列独立编码 + 压缩) symbol 字典编码 ZSTD 压缩率高 ts_us / seq DELTA ZSTD 单调递增 order_id PLAIN/DELTA ZSTD 高基数 行组 / stripe 统计与列裁剪 Row Group 1 min/max: 09:30–10:00 Row Group 2 min/max: 10:00–11:00 Row Group 3 min/max: 13:00–14:00 查询 09:30–10:30 → 跳过 RG3 → 只读所需列 评估口径:文件总大小 + 各列压缩后大小 + 查询扫描字节数 + 端到端耗时 关键调参:行组/stripe 大小、字典开关、ZSTD 级别、嵌套深度

四、实践中的判断依据与常见问题

落地时建议先做一次“列级压缩画像”:把样本写入后,用工具查看每列的压缩后大小与编码方式,找出占比最高的列。逐笔数据中 order_id 与价格往往占据大头,若 order_id 压缩后仍接近原始大小,说明字典编码不适用,应切换为 DELTA 或 PLAIN;若价格列字典条目过多,可考虑按价格档位做整数映射后再编码。另一个常见问题是小文件过多:逐笔数据按日或按证券切分时容易产生大量小文件,导致行组统计失效、元数据开销上升,应按“单文件 128MB~1GB、行组 64~128MB”控制切分粒度。

判断压缩方案是否达标,不看单一压缩率数字,而看目标查询的扫描字节数是否随列裁剪与行组跳过而下降。若 Q2 的扫描字节数没有明显低于 Q1,说明投影下推未生效,应检查查询引擎是否真正按列读取,以及文件是否被过度切分。

最后需要强调,Parquet 与 ORC 的选择并非绝对:Parquet 生态更广、与 Spark/Presto/DuckDB 集成成熟,ORC 在 Hive 体系与 stripe 索引上有优势。对逐笔数据而言,两者在正确设置编码与压缩后压缩率差距通常在个位数百分比,真正的性能差异更多来自行组/stripe 大小、谓词下推实现与查询引擎的读取策略。因此实践顺序应是:先定 schema 与类型,再按列选编码,再选压缩算法与块大小,最后用固定查询集验证扫描字节数与耗时,形成可复现的对比基线。