4、高性能数据存储与读取:HDF5与Parquet格式对比、使用PyArrow进行列式存储、内存映射(Memory Map)技术
做高频交易回测,最头疼的问题之一就是数据。
你想想看,Tick级别的数据,一天下来就是几个GB。一年呢?十年呢?我见过不少团队,策略逻辑写得漂亮,结果卡在数据读取上,回测跑一天都出不来结果。说白了,数据I/O才是真正的瓶颈。
这一章,我们就来聊聊怎么搞定这个瓶颈。我会从格式选型、列式存储、到内存映射,把我在实战中踩过的坑和积累的经验,一次性讲清楚。
4.1 HDF5 vs Parquet:选型之争
先问个问题:为什么不用CSV?
嗯,CSV是人类可读的,但机器读起来效率极低。尤其是高频数据,动辄上亿行,CSV的解析开销会让你崩溃。我早期做回测时,就吃过这个亏——一个简单的读取操作,等了半小时还没读完。
所以,我们需要二进制格式。目前主流的就是HDF5和Parquet。
4.1.1 HDF5:老牌劲旅
HDF5是Hierarchical Data Format的缩写,说白了就是一种支持层级结构的二进制文件格式。它最大的特点是:可以像文件系统一样组织数据。
我个人习惯用HDF5来存储「多品种、多时间周期」的数据。比如,一个HDF5文件里,可以这样组织:
/data/
/SH600519/
/tick/
/1min/
/5min/
/SH000001/
/tick/
/1min/
/5min/
这种结构,在回测时非常方便。你只需要指定路径,就能直接读取某个品种、某个周期的数据,不需要额外维护索引。
但是,HDF5有个问题:并发写入性能差。我在项目中遇到过,多个进程同时往一个HDF5文件里写数据,直接导致文件损坏。所以,HDF5更适合「一次写入,多次读取」的场景。
4.1.2 Parquet:后起之秀
Parquet是列式存储格式,天生为分析型负载设计。它的核心优势是:只读取你需要的列。
举个例子,你的数据有100列,但回测时只需要「时间、价格、成交量」这3列。用Parquet,它只会读取这3列的数据,I/O量直接减少97%。而HDF5是行式存储,必须把整行数据都读出来。
你想想看,对于高频交易这种「列多、行更多」的场景,Parquet的优势有多明显。
另外,Parquet的压缩率也更高。我实测过,同样的Tick数据,Parquet的存储体积比HDF5小30%-50%。
4.1.3 对比总结
| 特性 | HDF5 | Parquet |
|---|---|---|
| 存储方式 | 行式存储 | 列式存储 |
| 读取效率 | 全列读取快 | 部分列读取极快 |
| 写入性能 | 单线程写入快 | 批量写入快 |
| 并发支持 | 差(易损坏) | 好(支持多线程) |
| 压缩率 | 中等 | 高 |
| 适用场景 | 小规模、层级数据 | 大规模、分析型数据 |
我的建议:
- 如果数据量在10GB以内,且需要频繁按品种/周期读取,用HDF5。
- 如果数据量超过100GB,或者需要做复杂的列筛选,用Parquet。
- 如果介于两者之间,我建议优先选Parquet——它的生态更好,未来扩展性更强。
4.2 使用PyArrow进行列式存储
说到Parquet,就不得不提PyArrow。它是Apache Arrow的Python绑定,提供了高性能的列式数据操作能力。
为什么用PyArrow?因为Pandas的read_parquet底层其实也是调用的PyArrow。但直接使用PyArrow,你能获得更细粒度的控制,性能也更好。
4.2.1 写入Parquet文件
先看一个写入的例子。假设我们有一个DataFrame,包含Tick数据:
import pyarrow as pa
import pyarrow.parquet as pq
import pandas as pd
# 模拟Tick数据
df = pd.DataFrame({
'timestamp': pd.date_range('2024-01-01', periods=1000000, freq='1ms'),
'price': np.random.uniform(100, 200, 1000000),
'volume': np.random.randint(1, 100, 1000000),
'bid': np.random.uniform(99, 199, 1000000),
'ask': np.random.uniform(101, 201, 1000000)
})
# 转换为PyArrow Table
table = pa.Table.from_pandas(df)
# 写入Parquet,使用Snappy压缩
pq.write_table(table, 'tick_data.parquet', compression='snappy')
这里要注意:压缩方式的选择。Snappy压缩速度快,但压缩率一般;Gzip压缩率高,但速度慢。我个人习惯用Snappy,因为高频交易更看重读取速度。
4.2.2 读取指定列
这才是PyArrow的杀手锏。回测时,我们通常只需要部分列:
# 只读取timestamp和price两列
table = pq.read_table('tick_data.parquet', columns=['timestamp', 'price'])
# 直接转为Pandas DataFrame
df = table.to_pandas()
你想想看,如果数据有50列,你只读2列,I/O量减少了96%。这就是列式存储的魅力。
4.2.3 分区写入
对于超大规模数据,我建议按时间分区。比如按天分区:
# 按日期分区写入
pq.write_to_dataset(
table,
root_path='tick_data_partitioned',
partition_cols=['date']
)
这样,读取某一天的数据时,只需要加载对应的分区文件,速度极快。
避坑指南:
我曾经犯过一个错误:把所有的Tick数据写到一个Parquet文件里。结果文件超过50GB,读取时内存直接爆了。后来改用分区写入,问题迎刃而解。记住:单个Parquet文件不要超过10GB。
4.3 内存映射(Memory Map)技术
内存映射,说白了就是把磁盘文件直接映射到进程的虚拟地址空间。这样,读取文件就像读取内存一样,不需要显式的read/write操作。
为什么这对高频交易很重要?因为回测时,我们需要反复读取同一份数据。如果用传统方式,每次读取都要经过系统调用,开销很大。而内存映射,数据只需要加载一次,后续访问直接走内存。
4.3.1 在HDF5中使用内存映射
HDF5原生支持内存映射。只需要在打开文件时指定驱动:
import h5py
# 使用内存映射模式打开
with h5py.File('data.h5', 'r', driver='core') as f:
# 数据会被加载到内存
dataset = f['/data/SH600519/tick']
# 后续访问极快
print(dataset[:10])
这里有个细节:driver='core'会把整个文件加载到内存。如果文件太大,内存会爆。所以,我一般只在文件小于可用内存50%时使用。
4.3.2 在Parquet中使用内存映射
PyArrow也支持内存映射。通过memory_map=True参数:
import pyarrow.parquet as pq
# 使用内存映射读取
table = pq.read_table('tick_data.parquet', memory_map=True)
嗯,这里要注意:内存映射并不等于数据全部加载到内存。它只是建立了映射关系,实际数据是按需加载的。操作系统会管理哪些页面在内存中,哪些在磁盘上。
4.3.3 手动实现内存映射
如果你需要更精细的控制,可以用Python的mmap模块:
import mmap
import os
# 打开文件
with open('tick_data.bin', 'rb') as f:
# 创建内存映射
with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mm:
# 直接读取,就像操作字节数组
data = mm[:1024] # 读取前1024字节
# 随机访问
data = mm[4096:5120] # 读取第4096到5120字节
这种方式适合自定义二进制格式。我在做极低延迟回测时,就曾用mmap直接映射自定义的Tick数据文件,延迟降低了90%以上。
警告:
内存映射虽然快,但有个致命问题:如果文件在映射期间被修改,会导致程序崩溃。所以,内存映射只适合「只读」场景。如果你需要同时读写,请使用传统I/O方式。
4.4 知识体系总览
为了让你更直观地理解本章的知识结构,我画了一张图:
这张图把本章的核心内容串起来了。你可以看到,三个分支各有侧重,但最终都指向同一个目标:减少I/O,按需加载,善用缓存。
好了,这一章的内容就到这里。记住,数据存储不是小事,选对了格式和工具,你的回测效率能提升一个数量级。下一章,我们会聊聊更深入的话题。