第四章:Tick级数据存储——高性能列式存储、内存映射与压缩策略

做高频交易的朋友都知道,Tick数据有多“吃”存储。一秒钟几千笔行情,一天下来就是几千万条记录。我见过不少团队,一开始用MySQL存Tick数据,结果一个月后数据库直接崩了。嗯,这其实不是数据库的错,是选型就错了。

今天我们就来聊聊,Tick级数据到底该怎么存。我个人习惯用三件套:Parquet列式存储 + 内存映射文件 + 智能压缩。这三样东西配合好了,查询速度能提升两个数量级。

4.1 为什么传统行式存储不适合Tick数据?

你想想看,Tick数据的特点是啥?

  • 字段多:时间戳、价格、成交量、买卖盘口、标识位……少说十几个字段
  • 写入频繁:每秒几千次追加
  • 查询模式固定:大部分时候只查某几个字段,比如“某时间段的成交价”

行式存储(比如CSV、MySQL)会把一整行数据连续写在一起。当你只想查“价格”这一列时,它却要把整行都读出来。说白了,就是IO浪费严重

核心结论: Tick数据天然适合列式存储。因为列式存储只读取你需要的列,IO量能减少80%以上。

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万条一个行组
)
我的经验: 行组大小建议设置在5万~20万之间。太小了压缩率上不去,太大了内存占用高。我一般用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数据从采集到存储的完整流程:

Tick数据存储架构 数据采集层 交易所行情 → 行情网关 → 内存队列(Ring Buffer) 存储引擎层 Parquet列式存储 内存映射文件 压缩策略引擎 存储介质层 SSD(热数据) ↔ HDD(冷数据) ↔ 对象存储(归档) 数据流向:采集 → 列式存储 → 内存映射读取 → 按需解压

4.6 避坑指南

最后分享几个我踩过的坑:

  • 不要用默认压缩级别:ZSTD默认级别是3,但Tick数据我建议用级别1或2。压缩比差不了多少,但速度能快30%。
  • 小心小文件问题:Parquet文件太小(比如小于64MB)时,元数据开销占比大。我一般把1小时的数据合并成一个文件。
  • 内存映射别用在32位系统:虚拟地址空间只有4GB,映射大文件会失败。现在都用64位系统了,但还是要确认一下。
  • 压缩和编码的顺序:先做差分编码/字典编码,再做压缩。这样压缩率更高。Parquet默认就是这个顺序,但如果你自己实现存储,要注意。
一个小技巧: 如果你用Parquet存储Tick数据,建议把时间戳作为分区键。按天分区,查询时直接跳过无关分区。我见过有人按小时分区,结果分区数太多,反而变慢了。按天分区是最稳妥的。

好了,Tick级数据存储的核心就这些。记住三个关键词:列式、映射、压缩。选对工具,你的Tick数据查询速度能提升一个数量级。

专注资料整理