2. Tick数据存储挑战:海量数据写入瓶颈、磁盘I/O与内存延迟对比、数据压缩的必要性

好,咱们接着聊Tick数据。上一章我讲了Tick数据长什么样,这一章咱们得直面一个现实问题——怎么把它存下来

你可能觉得,存数据嘛,写个文件不就完了?嗯,我刚开始做量化系统时也是这么想的。结果呢?一天下来,几千万条Tick数据,直接把磁盘写爆了。系统卡死,策略停摆,那叫一个惨。

今天我就把当年踩过的坑,一个一个给你拆开看。

2.1 海量数据写入:第一个拦路虎

先算一笔账。假设你只做A股,3000多只股票,每只股票每秒产生2-3笔Tick。算下来,一天就是:

  • 3000只 × 3笔/秒 × 4小时 × 3600秒 ≈ 1.3亿笔
  • 每笔Tick约100字节,一天就是13GB
  • 这还只是A股。加上期货、期权、外盘呢?

你想想看,每秒几万笔写入,传统的关系型数据库根本扛不住。我见过有人用MySQL存Tick,结果写入延迟从几毫秒飙升到几百毫秒,最后直接OOM。

核心矛盾:Tick数据是流式的,每秒都在产生。但磁盘写入是块式的,需要攒够一定量才高效。这个「流 vs 块」的矛盾,是所有Tick存储方案都要解决的。

我个人习惯的做法是:先写内存,再刷磁盘。具体来说:

  1. Tick数据先写入内存中的环形缓冲区(Ring Buffer)
  2. 缓冲区满了,或者到了固定时间间隔(比如1秒),批量写入磁盘
  3. 写入时用顺序写,别用随机写

这样能把写入吞吐量提升10倍以上。我在项目中遇到过最极端的情况——每秒10万笔Tick写入,用这个方案,磁盘利用率才60%。

2.2 磁盘I/O vs 内存延迟:差了几个数量级

为什么非要先写内存?咱们看一组数据:

存储介质 延迟 吞吐量(顺序读写) 吞吐量(随机读写)
L1缓存 ~1ns ~1TB/s ~1TB/s
内存(DDR4) ~100ns ~50GB/s ~50GB/s
NVMe SSD ~10μs ~3GB/s ~500MB/s
SATA SSD ~100μs ~500MB/s ~50MB/s
机械硬盘 ~10ms ~200MB/s ~1MB/s

看到了吗?内存比NVMe SSD快100倍,比机械硬盘快10万倍。说白了,内存才是Tick数据的「家」,磁盘只是「仓库」。

但这里有个坑——内存是有限的。你不可能把所有Tick数据都放内存里。所以我们需要一个分层存储策略

  • 热数据(最近1小时):放内存,毫秒级查询
  • 温数据(最近1天):放SSD,秒级查询
  • 冷数据(历史数据):放机械硬盘或对象存储,分钟级查询

我曾经犯过一个错误:把所有Tick数据都放内存里,结果内存爆了,系统直接OOM。后来我学乖了,用LRU缓存管理热数据,超过一定时间自动降级到磁盘。

小技巧:内存里的Tick数据,可以用列式存储。比如把价格、成交量、时间戳分别存成数组。这样查询时只加载需要的列,内存利用率能提高3-5倍。

2.3 数据压缩:不是可选项,是必选项

好,现在你知道了,Tick数据量巨大,内存又贵。怎么办?压缩

你可能觉得压缩会消耗CPU,得不偿失。嗯,我一开始也这么想。但后来发现,压缩带来的收益远大于成本

为什么?因为Tick数据有很强的局部性

  • 相邻Tick的价格变化很小(比如0.01元)
  • 成交量变化有规律(比如每笔交易量在100-1000股之间)
  • 时间戳是递增的(差值很小)

利用这些特性,我们可以做到10:1甚至20:1的压缩比。也就是说,13GB的Tick数据,压缩后只有1GB左右。

常用的压缩方法:

  1. 增量编码(Delta Encoding):只存相邻Tick的差值,而不是绝对值
  2. 变长编码(Variable-Length Encoding):小数值用更少的字节存储
  3. 字典编码(Dictionary Encoding):重复出现的值(比如股票代码)用整数索引代替

我给你们看一段伪代码,演示增量编码的思路:

// 原始数据:价格序列
double[] prices = {100.01, 100.02, 100.01, 100.03, 100.02};

// 增量编码:只存差值
double[] deltas = new double[prices.length];
deltas[0] = prices[0];  // 第一个存原始值
for (int i = 1; i < prices.length; i++) {
    deltas[i] = prices[i] - prices[i-1];
}
// 结果:{100.01, 0.01, -0.01, 0.02, -0.01}

// 解码时反向操作
double[] decoded = new double[deltas.length];
decoded[0] = deltas[0];
for (int i = 1; i < deltas.length; i++) {
    decoded[i] = decoded[i-1] + deltas[i];
}

你看,原始数据需要5个double(40字节),增量编码后,大部分值都很小,可以用short甚至byte存储。压缩效果立竿见影。

注意:压缩不是万能的。如果数据本身随机性很强(比如高频交易中的异常报价),压缩比会很低。我建议你在实际使用前,先对数据做采样测试,看看压缩效果再决定方案。

2.4 一张图看懂Tick数据存储架构

说了这么多,咱们用一张图把整个流程串起来:

Tick数据存储架构 交易所Tick数据流 内存环形缓冲区 (热数据:最近1小时) 数据压缩(增量编码+变长编码) 分层存储 SSD(温数据) 最近1天 内存(热数据) 最近1小时 机械硬盘(冷数据) 历史数据 数据流向:交易所 → 内存缓冲区 → 压缩 → 分层存储

这张图展示了我个人比较推崇的架构。核心思路就一句话:用内存扛写入,用压缩省空间,用分层管成本

2.5 避坑指南

最后,分享几个我踩过的坑,希望能帮你少走弯路:

  • 别用数据库存Tick:关系型数据库的ACID特性在Tick数据面前就是累赘。用文件系统或时序数据库(如InfluxDB、ClickHouse)更合适。
  • 注意内存碎片:频繁分配和释放Tick对象会导致内存碎片。我建议用对象池复用Tick对象。
  • 压缩和解压要平衡:压缩比越高,解压越慢。如果查询频率高,别用太复杂的压缩算法。LZ4或Snappy是不错的选择。
  • 别忘了校验:Tick数据在传输和存储过程中可能损坏。我习惯在每个数据块末尾加一个CRC32校验码。

一句话总结:Tick数据存储的核心挑战是「写入速度」和「存储成本」的平衡。用内存缓冲区扛写入,用压缩算法降成本,用分层存储管生命周期。这三板斧用好了,大部分Tick存储问题都能解决。

好,这一章就到这里。下一章咱们聊聊Tick数据的查询优化——数据存好了,怎么快速查出来?


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