第四章:Tick级数据存储——高性能列式存储、内存映射与压缩策略
做高频交易的朋友都知道,Tick数据有多“吃”存储。一秒钟几千笔行情,一天下来就是几千万条记录。我见过不少团队,一开始用MySQL存Tick数据,结果一个月后数据库直接崩了。嗯,这其实不是数据库的错,是选型就错了。
今天我们就来聊聊,Tick级数据到底该怎么存。我个人习惯用三件套:Parquet列式存储 + 内存映射文件 + 智能压缩。这三样东西配合好了,查询速度能提升两个数量级。
4.1 为什么传统行式存储不适合Tick数据?
你想想看,Tick数据的特点是啥?
- 字段多:时间戳、价格、成交量、买卖盘口、标识位……少说十几个字段
- 写入频繁:每秒几千次追加
- 查询模式固定:大部分时候只查某几个字段,比如“某时间段的成交价”
行式存储(比如CSV、MySQL)会把一整行数据连续写在一起。当你只想查“价格”这一列时,它却要把整行都读出来。说白了,就是IO浪费严重。
4.2 Parquet列式存储实战
Parquet是我最推荐的列式存储格式。它有几个关键设计:
- 行组(Row Group):数据按行水平切分成多个组,每个组内部再按列存储
- 列块(Column Chunk):每个行组里,每列的数据连续存放
- 页(Page):列块内部再切分成页,这是压缩和编码的最小单元
我曾经在一个项目中,把100GB的Tick CSV数据转成Parquet,结果只用了12GB。查询速度从原来的30秒降到了0.8秒。为什么?因为Parquet自带的统计信息(min/max/null count)能直接跳过不相关的数据块。
来看一个实际写入示例:
import pyarrow as pa
import pyarrow.parquet as pq
import pandas as pd
# 模拟Tick数据
tick_data = {
'timestamp': [1625000000000 + i for i in range(1000000)],
'price': [100.0 + i * 0.01 for i in range(1000000)],
'volume': [100 + i % 50 for i in range(1000000)],
'side': ['buy' if i % 2 == 0 else 'sell' for i in range(1000000)]
}
df = pd.DataFrame(tick_data)
table = pa.Table.from_pandas(df)
# 写入Parquet,启用压缩和字典编码
pq.write_table(
table,
'tick_data.parquet',
compression='zstd', # 我用ZSTD,压缩比和速度平衡最好
use_dictionary=True, # 对重复值多的列启用字典编码
row_group_size=100000 # 每10万条一个行组
)
4.3 内存映射文件——零拷贝读取
Parquet文件存好了,怎么读最快?我的答案是:内存映射(Memory-Mapped File)。
传统读取方式是把文件内容从磁盘拷贝到用户态内存,再解析。内存映射则直接把文件映射到进程的虚拟地址空间,操作系统按需加载页面。说白了,就是省掉了一次拷贝。
在Python里,用numpy.memmap或者mmap模块就能实现:
import mmap
import numpy as np
# 假设我们已经把Tick数据按列存储为二进制文件
# 文件结构:前8字节是记录数,后面是连续的float64价格数据
with open('price_column.bin', 'r+b') as f:
# 内存映射整个文件
mmapped_file = mmap.mmap(f.fileno(), 0)
# 解析记录数
num_records = np.frombuffer(mmapped_file[:8], dtype=np.int64)[0]
# 直接映射价格数据,无需拷贝
prices = np.frombuffer(
mmapped_file[8:8 + num_records * 8],
dtype=np.float64
)
# 现在prices就是一个numpy数组,可以直接计算
mean_price = prices.mean()
print(f"平均价格: {mean_price:.4f}")
mmapped_file.close()
4.4 数据压缩策略——选对算法很重要
压缩不是越狠越好。高频交易系统里,我们追求的是压缩比与解压速度的平衡。我整理了一个对比表:
| 压缩算法 | 压缩比 | 压缩速度 | 解压速度 | 推荐场景 |
|---|---|---|---|---|
| Snappy | 2x~3x | 极快 | 极快 | 实时查询、热数据 |
| ZSTD | 3x~5x | 快 | 快 | 通用场景,我最常用 |
| Gzip | 4x~6x | 慢 | 中等 | 归档、冷数据 |
| LZ4 | 2x | 极快 | 极快 | 需要极致延迟的场景 |
我个人习惯:热数据用Snappy或LZ4,因为查询延迟最重要;冷数据用ZSTD,压缩比高,存储成本低。归档数据才用Gzip。
还有一个技巧:差分编码。Tick数据的价格和时间戳往往是连续变化的,存差值比存原始值省很多空间。Parquet内部就支持差分编码,你只需要在写入时指定:
import pyarrow as pa
# 对时间戳列启用差分编码
timestamp_field = pa.field(
'timestamp',
pa.int64(),
metadata={'encoding': 'DELTA_BINARY_PACKED'}
)
# 对价格列启用差分编码
price_field = pa.field(
'price',
pa.float64(),
metadata={'encoding': 'DELTA_BINARY_PACKED'}
)
4.5 整体架构图
下面这张图展示了Tick数据从采集到存储的完整流程:
4.6 避坑指南
最后分享几个我踩过的坑:
- 不要用默认压缩级别:ZSTD默认级别是3,但Tick数据我建议用级别1或2。压缩比差不了多少,但速度能快30%。
- 小心小文件问题:Parquet文件太小(比如小于64MB)时,元数据开销占比大。我一般把1小时的数据合并成一个文件。
- 内存映射别用在32位系统:虚拟地址空间只有4GB,映射大文件会失败。现在都用64位系统了,但还是要确认一下。
- 压缩和编码的顺序:先做差分编码/字典编码,再做压缩。这样压缩率更高。Parquet默认就是这个顺序,但如果你自己实现存储,要注意。
好了,Tick级数据存储的核心就这些。记住三个关键词:列式、映射、压缩。选对工具,你的Tick数据查询速度能提升一个数量级。