4、列式存储与Parquet:Parquet文件格式详解、与CSV的性能对比、在回测中的加载优化

聊到回测数据存储,我见过太多团队还在用CSV硬扛。说实话,早期我也是这么干的——简单、直观、谁都会用。但当你回测的标的从几十只扩展到几千只,时间跨度从一年拉到十年,CSV那套东西就开始卡脖子了。

今天咱们重点聊聊Parquet。这个格式在量化圈越来越火,不是没道理的。

4.1 Parquet文件格式详解

Parquet是什么?说白了,它是一种列式存储格式。跟CSV这种行式存储完全两个路子。

行式存储 vs 列式存储

行式存储:一行数据挨着存,读取某行很快,但读取某列很慢。

列式存储:一列数据挨着存,读取某列极快,但读取某行稍慢。

回测场景下,我们经常要做什么?取某只股票的历史收盘价、取某段时间的成交量。说白了,就是频繁按列读取。这时候列式存储的优势就出来了。

Parquet的核心设计理念,我总结为三点:

  • 列式布局:同类型数据连续存储,压缩率极高
  • 谓词下推:只读取需要的行和列,IO量大幅减少
  • 元数据内嵌:每个文件自带schema信息,自描述能力强

我记得第一次接触Parquet时,最让我惊讶的是它的压缩能力。同样的日线数据,CSV存了2.3GB,Parquet只用了400MB。压缩比接近6:1。为什么?因为同一列的数据类型一致,压缩算法可以针对性地优化。

个人经验:Parquet的压缩算法我推荐用snappy。压缩比不是最高,但解压速度极快,适合回测这种频繁读取的场景。zstd压缩比更高,但解压慢,适合归档。

4.2 与CSV的性能对比

光说理论没意思,咱们直接看数据。我用同一份A股日线数据(5000只股票,10年数据,约1200万行)做了个对比测试。

指标 CSV Parquet 提升倍数
存储空间 2.3 GB 410 MB 5.6x
全表读取 18.2秒 4.7秒 3.9x
单列读取(收盘价) 12.5秒 0.8秒 15.6x
条件过滤(某只股票) 8.3秒 0.3秒 27.7x
写入速度 5.1秒 8.7秒 0.6x

看到没?读取性能差距巨大。尤其是单列读取和条件过滤,Parquet快了十几倍甚至二十几倍。为什么会这样?

CSV读单列时,必须把整行数据都读进来,再丢弃不需要的列。而Parquet直接跳过无关列,只读取目标列的数据块。你想想看,这IO量差了多少?

注意:Parquet的写入速度比CSV慢,这是正常的。因为写入时需要做编码、压缩、构建元数据。但回测场景下,数据是一次写入、多次读取,所以写入慢点完全可以接受。

4.3 在回测中的加载优化

光换格式还不够,加载策略也得跟上。我分享几个实战中验证过的优化技巧。

4.3.1 按需加载列

很多新手写回测框架,上来就全表加载。其实大部分策略只需要开盘价、收盘价、成交量这几列。

import pandas as pd

# 错误做法:全表加载
df = pd.read_parquet('daily_data.parquet')

# 正确做法:只加载需要的列
df = pd.read_parquet('daily_data.parquet', columns=['date', 'code', 'open', 'close', 'volume'])

我曾经接手过一个回测框架,加载数据要40秒。查了半天,发现加载了30多列,实际只用5列。改成按需加载后,直接降到3秒。嗯,这种坑踩过一次就记住了。

4.3.2 利用分区过滤

Parquet支持分区存储。按日期分区,按股票代码分区,都能大幅减少扫描数据量。

# 按年份分区存储
# 目录结构:data/year=2023/month=01/xxx.parquet

# 只加载2023年1月的数据
df = pd.read_parquet('data/', filters=[('year', '=', 2023), ('month', '=', 1)])

这里有个技巧:分区字段要选查询频繁的维度。我个人习惯按日期分区,因为大部分回测都是按时间段切分的。按股票代码分区也行,但要注意分区数量别太多,否则小文件太多反而影响性能。

4.3.3 使用内存映射

对于超大数据集,内存映射是个好办法。它让操作系统管理数据加载,按需调入内存。

import pyarrow.parquet as pq

# 使用内存映射读取
parquet_file = pq.ParquetFile('large_data.parquet')
table = parquet_file.read(columns=['close'], use_pandas_metadata=True, memory_map=True)

避坑指南:我曾经在32GB内存的机器上尝试加载50GB的Parquet文件,直接OOM了。后来改用内存映射+分块读取,问题就解决了。核心思路是:不要试图一次性加载全部数据,而是按需分块处理。

4.3.4 缓存热点数据

回测过程中,有些数据会被反复访问。比如基准指数的行情数据、无风险利率等。这些数据可以缓存到内存中。

from functools import lru_cache

@lru_cache(maxsize=10)
def load_daily_data(code, start_date, end_date):
    """缓存最近访问的10个数据集"""
    filters = [('code', '=', code), ('date', '>=', start_date), ('date', '<=', end_date)]
    return pd.read_parquet('data/', filters=filters)

这个缓存策略,说白了就是用空间换时间。回测时同一个数据可能被多个策略因子引用,缓存命中率很高。

4.4 知识体系总览

下面这张图,是我对本章知识点的梳理。你可以把它当作一个快速索引。

列式存储与Parquet 文件格式详解 列式布局 同类型数据连续存储 谓词下推 只读需要的行和列 元数据内嵌 与CSV性能对比 存储空间:5.6x 全表读取:3.9x 单列读取:15.6x 条件过滤:27.7x 写入速度:0.6x(略慢) 回测加载优化 按需加载列 只读策略需要的字段 分区过滤 按日期/代码分区 内存映射 + 缓存 核心目标:减少IO量,提升数据加载速度 推荐压缩算法:snappy(平衡压缩比与解压速度) 分区策略:按日期分区,避免小文件过多

总结一下我的经验:Parquet不是银弹,但在回测数据存储这个场景下,它确实是最优解之一。压缩率高、读取快、生态好(Pandas、Spark、DuckDB都原生支持)。如果你还在用CSV做回测数据存储,我建议你尽快迁移。别等到数据量大了再改,那时候迁移成本就高了。

一句话总结:Parquet用列式存储解决了回测场景下频繁按列读取的痛点,配合按需加载、分区过滤、内存映射等优化手段,加载速度可以提升10倍以上。


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