交易技术

时序数据库写入瓶颈:高频交易系统Tick数据存储的架构抉择

ClickHouse单节点写入能力通常可达到每秒50MB至200MB。按每条Tick数据100字节计算,对应约每秒50万至200万条记录。…

时序数据库写入瓶颈:高频交易系统Tick数据存储的架构抉择

这个数字只在写入批次足够大、磁盘和网络条件稳定、表结构合理时成立。

如果把每条Tick直接作为一次小批量写入请求,结果会完全不同。MergeTree引擎会持续生成数据分片。后台合并线程无法及时清理。分片数量上涨后,系统可能触发“too many parts”。单次写入延迟从毫秒级上升到数万毫秒。吞吐量随之下降。

问题不在数据库名称。

问题在写入模型。

高频交易系统的Tick数据存储,本质上是一个持续到达的数据流。数据具有高频写入、固定查询模式、时间顺序明显、字段结构相对稳定等特征。传统关系型数据库的B树索引并不适合直接承受这种负载。列式时序数据库也不是天然适合单条实时写入。

存储层必须围绕写入路径设计。

高频Tick数据写入的底层挑战

Tick数据不是普通业务数据

订单、账户、权限和配置数据,通常采用事务型关系数据库。

Tick数据不同。

一条Tick记录可能包含以下字段:

  • 合约代码。
  • 交易所代码。
  • 交易日。
  • 事件时间戳。
  • 最新价。
  • 最新成交量。
  • 累计成交量。
  • 买一至买五价格。
  • 卖一至卖五价格。
  • 买一至买五数量。
  • 卖一至卖五数量。
  • 成交方向或交易状态。
  • 行情源标识。
  • 接收时间戳。
  • 网卡硬件时间戳。

这些字段会在交易时段内持续写入。数据规模由合约数量、行情频率、字段数量和交易所消息格式共同决定。

写入路径通常可以抽象为:

1. 行情接口接收原始消息。

2. 解码器转换字段。

3. 时间戳被赋值。

4. 数据进入内存队列。

5. 批处理器聚合记录。

6. 数据库执行批量写入。

7. 查询服务读取历史数据。

8. 归档系统输出文件或副本。

任何一步出现阻塞,都会影响后续步骤。

直接写数据库,等于把行情接收线程和磁盘写入速度绑定。磁盘抖动时,接收线程也会被拖慢。网络突发时,内存队列会快速增长。最终表现为延迟扩大、丢包风险上升,甚至连接断开。

这不是SQL语句优化可以独立解决的问题。

传统关系型数据库的索引成本

MySQL、SQLite等关系型数据库通常采用B树或相关索引结构。

B树适合按主键查询、范围查询和事务更新。它不适合持续接收高频随机写入,尤其是在索引字段较多时。

每次写入可能触发以下操作:

  • 数据页定位。
  • 索引页更新。
  • 页面分裂。
  • 日志追加。
  • 脏页刷新。
  • 事务状态维护。
  • 锁或版本控制。
  • 缓存淘汰。

如果写入顺序与聚簇索引顺序不一致,磁盘访问会进一步离散化。高频随机写入带来的磁盘寻道和索引维护成本,会直接压低吞吐量。

SQLite的问题更加明确。它适合嵌入式场景和单机轻量级数据管理。高频并发写入时,写锁、事务提交和文件同步会成为硬限制。

这类数据库可以保存清洗后的分钟线、日线或低频回测结果。

不适合原样承接每一条实时Tick。

时序数据库的优势也有边界

时序数据库通常针对时间字段、顺序写入、时间窗口查询和批量压缩进行优化。

优势包括:

  • 按时间组织数据。
  • 连续写入路径更短。
  • 对时间窗口查询更友好。
  • 列式存储可以减少无关字段读取。
  • 压缩算法适合重复性较高的行情数据。
  • 聚合函数可以直接作用于时间区间。

但“时序数据库”不是一个统一的性能承诺。

不同系统的写入引擎不同。

有的系统依赖内存缓冲和预写日志。有的系统使用日志结构合并树。有的系统采用列式分片和后台合并。有的系统通过固定大小文件和内存映射文件组织数据。

如果应用层仍然每次只提交一条记录,数据库内部仍会面对大量小任务。存储引擎只能降低单次操作成本,不能消除操作次数本身。

高频Tick写入的第一原则不是“换一个更快的数据库”,而是减少数据库看到的写入次数。

写入吞吐和查询延迟不是同一指标

系统评估经常混淆三个指标:

1. 每秒写入条数。

2. 单批写入延迟。

3. 查询响应延迟。

三者不能互相替代。

单节点每秒写入50万条,并不意味着每条数据都能在微秒级完成持久化。批量写入通常会降低单位记录的写入成本,但会增加单条数据从接收到落盘的等待时间。

如果批次设置为10万行,批处理等待可能较短,也可能受行情速率影响。行情稀疏时,固定行数批次会产生额外等待。行情密集时,批次快速填满,延迟主要由序列化、网络和磁盘决定。

因此,批处理器通常需要两个触发条件:

  • 达到最大行数。
  • 达到最大等待时间。

任一条件满足,立即提交。

先定义数据可靠性,再谈吞吐量

Tick数据存储有三种常见目标:

  • 实时查询。
  • 事后回测。
  • 监管或审计留存。

三种目标的可靠性要求不同。

实时监控可以接受短暂延迟。回测需要完整数据和稳定排序。审计留存还需要明确的原始数据副本、接收时间和写入状态。

不能用一个数据库表同时承担所有职责。

较稳定的架构通常保留两份数据:

  • 原始消息流。用于故障重放。
  • 规范化时序表。用于查询和回测。

原始消息可以写入顺序文件、消息队列或对象存储。规范化数据进入时序数据库。数据库写入失败时,消费位点不能直接丢失。恢复后从原始流重放。

这比单纯增加数据库线程更有效。

ClickHouse的性能陷阱:小批量写入会生成过多分片

MergeTree不是单条写入引擎

ClickHouse的MergeTree系列引擎以数据分片为基本组织单位。

一次插入通常会生成一个新的数据分片。分片随后由后台线程合并。分片不是越多越好。小分片数量快速增长时,系统需要同时维护更多元数据、索引和合并任务。

当插入请求过于频繁,写入系统会出现两个问题:

第一,前台插入不断创建新分片。

第二,后台合并速度追不上分片生成速度。

如果分片积累到一定程度,ClickHouse可能返回“too many parts”。此时继续增加写入请求,只会进一步恶化状态。

常见错误路径如下:

  • 应用每次收到几百条Tick就发起一次插入。
  • 每秒产生大量小分片。
  • 后台合并线程持续运行。
  • 磁盘读写被合并任务占用。
  • 新写入等待时间增加。
  • 应用侧队列继续增长。
  • 最终触发分片数量限制。

这里没有神秘因素。是生产速率大于合并能力。

小批量写入的代价不止一次网络请求

一次写入请求包含多层开销:

  • 网络连接或连接池调度。
  • 数据序列化。
  • 协议解析。
  • 分区定位。
  • 数据压缩。
  • 临时文件生成。
  • 元数据更新。
  • 索引构建。
  • 日志写入。
  • 后台合并。

如果一批数据只有几十行或几百行,这些固定开销会被重复支付。

批次增大后,固定开销被更多记录分摊。列式压缩也更有效。相同字段的相邻数据越多,编码和压缩越容易发挥作用。

但批次也不是无限增大。

批次过大,会带来以下问题:

  • 内存占用上升。
  • 单次写入耗时变长。
  • 写入失败后的重试成本增加。
  • 数据延迟扩大。
  • 进程崩溃时,未提交数据窗口更长。

因此,实际设计应使用行数、时间和内存三重上限。

推荐的批次范围

在已有资料和常见工程实践中,ClickHouse批量写入通常采用10万至100万行的单批规模。另一种约束是每秒不超过一次写入请求。

这不是固定参数。

实际值依赖以下条件:

  • 单条记录大小。
  • 分区键设计。

-排序键长度。

  • 磁盘类型。
  • 压缩算法。
  • 网络带宽。
  • 并发查询数量。
  • 后台合并线程数。
  • 是否启用异步插入。
  • 单节点还是集群部署。

如果每条Tick包含五档盘口,100万行可能已经占用较大内存。如果只有最新价、成交量和时间戳,100万行的内存压力相对较低。

不能直接照搬批次大小。

需要从压测开始。

表结构决定后续代价

ClickHouse表结构至少需要明确三个字段:

  • 分区键。
  • 排序键。
  • 查询时间列。

行情数据通常按交易日或月份划分分区。分区过细会增加管理对象数量。分区过粗会增加单分区合并和清理成本。

排序键应覆盖高频查询条件。

如果查询模式是“某个合约在某段时间内的Tick”,排序键通常需要让合约和时间具备较好的局部性。将大量低选择性的字段放入排序键,会增加索引体积和写入成本。

主键、分区键、排序键不是同一个概念。

混用它们,会导致错误优化。

一个常见设计思路是:

  • 交易日用于分区。
  • 交易所和合约用于排序前缀。
  • 事件时间用于排序尾部。
  • 接收时间单独保存。
  • 原始序号用于同一时间戳内的稳定排序。

事件时间和接收时间必须区分。

事件时间来自交易所或上游行情源。接收时间来自本地程序。两者的差值可以用于分析网络和处理延迟。只保留一个时间戳,会丢失系统诊断能力。

异步插入解决的是请求聚合

开启异步插入后,数据库不会立即把每一个小请求都转成独立的底层写入。系统会先聚合数据,再形成较大的写入批次。

常见参数是:

  • async_insert=1
  • 配合等待异步插入结果的参数。
  • 设置最大数据量。
  • 设置最大等待时间。
  • 设置失败重试策略。

异步插入不是无限缓冲。

如果发送速度持续超过数据库处理能力,异步队列仍会增长。等待时间增加后,系统依旧会出现延迟和内存压力。

异步插入只解决“请求太碎”。不能解决以下问题:

  • 分区设计错误。
  • 排序键过长。
  • 磁盘吞吐不足。
  • 后台合并线程不足。
  • 查询任务长期占用资源。
  • 数据重复写入。
  • 上游产生速率长期超过系统容量。

参数应结合监控调整,而不是直接复制配置文件。

双缓冲队列:把接收路径和持久化路径拆开

为什么需要双缓冲

单队列模型通常存在一个问题。

生产线程写入队列。消费线程从同一个队列取数据并构建批次。批次写入期间,队列仍然持续变化。锁竞争、内存复制和批次边界处理会影响吞吐。

双缓冲模型将内存分成两个区域:

  • 当前写入缓冲区。
  • 当前提交缓冲区。

生产线程只写入当前写入缓冲区。达到行数或时间阈值后,系统原子切换两个缓冲区。旧缓冲区进入提交状态。生产线程继续写入新的缓冲区。

流程可以拆成五步:

1. 行情线程写入缓冲区甲。

2. 缓冲区甲达到阈值。

3. 指针切换到缓冲区乙。

4. 后台线程提交缓冲区甲。

5. 缓冲区甲完成后清空并重新加入循环。

切换动作必须短。

不能在行情接收线程中执行压缩、网络写入和磁盘同步。接收线程只负责解析和入队。数据库提交由独立线程执行。

双缓冲的边界条件

双缓冲不是简单地准备两个数组。

至少需要处理以下状态:

  • 数据库写入成功。
  • 数据库写入失败。
  • 批次正在重试。
  • 新批次持续到达。
  • 程序收到退出信号。
  • 网络连接中断。
  • 缓冲区达到内存上限。
  • 同一批次被重复提交。
  • 重启后恢复未确认数据。

每个批次需要一个唯一标识。可以由交易日、数据源、首尾序号和批次序号构成。数据库侧需要根据业务需求处理幂等。

如果数据库写入成功,但客户端在收到确认前断开,客户端无法判断结果。直接重试可能造成重复数据。解决方式不是依赖猜测,而是建立批次状态表、唯一序号或可重放的数据日志。

Redis适合做缓冲,但不是最终事实源

研究资料中提到,可以使用内存或Redis积累数据,再按批次写入ClickHouse。

Redis的优势是读写延迟低、结构简单、部署灵活。

但Redis不是天然的长期Tick存储。内存容量有限。持久化策略也会影响恢复点。若使用Redis作为缓冲层,必须明确:

  • 最大队列长度。
  • 数据过期策略。
  • 持久化方式。
  • 重启恢复方式。
  • 消费位点。
  • 重复消费处理。
  • 队列积压告警。
  • 数据溢出后的降级策略。

如果数据不能丢失,仍然需要原始日志或顺序文件作为事实源。

Redis负责削峰。

不负责替代完整归档。

生产速率必须小于长期处理能力

假设行情输入速率为λ,数据库长期处理速率为μ。

当λ持续大于μ时,队列长度必然增长。无论队列放在内存、Redis还是消息系统,结论都不会改变。

因此,容量规划至少需要计算:

  • 常态输入速率。
  • 峰值输入速率。
  • 峰值持续时间。
  • 单条数据平均字节数。
  • 单批数据行数。
  • 批处理平均耗时。
  • 重试比例。
  • 数据库查询占用。
  • 可用内存。
  • 可接受延迟。

不能只看平均写入速度。

交易系统更关心峰值窗口。

开盘、收盘、合约切换和异常行情阶段,输入速率可能显著高于日内平均值。研究资料没有给出不同数据库在极端行情下的绝对丢包率和延迟抖动上限。因此,不能直接给出统一结论。必须用目标行情源和目标硬件做压力测试。

建议监控的运行指标

写入瓶颈出现前,通常已经有指标变化。

可以持续记录:

  • 接收消息速率。
  • 解码失败数量。
  • 队列当前长度。
  • 队列最大长度。
  • 批次行数分布。
  • 批次等待时间。
  • 批次提交耗时。
  • 数据库确认延迟。
  • 写入失败次数。
  • 重试次数。
  • 重复批次数量。
  • 磁盘写入带宽。
  • 磁盘使用率。
  • 数据库分片数量。
  • 后台合并任务数量。
  • 查询并发数。
  • 事件时间与接收时间的差值。

其中,队列长度和分片数量最有诊断价值。

队列长度持续增长,说明端到端处理能力不足。分片数量持续增长,说明写入请求过碎,或者后台合并能力不足。

两个指标同时增长时,应先降低写入请求频率,再检查硬件和表结构。

缓冲区不是性能魔法。它只把短期突发从数据库前台转移到内存或队列。长期容量不足仍然会暴露。

DolphinDB与TDengine:两种不同的组织方式

DolphinDB:内存引擎、缓存和异步排序

DolphinDB采用列式内存引擎,并结合预写日志和写入缓存设计。

这类结构适合高吞吐数据接入和时间窗口查询。数据先进入内存路径,再由后续机制落盘。磁盘成为瓶颈时,发布节点可以通过队列积累数据。

DolphinDB的性能不能只看接口吞吐量。研究资料显示,在并发用户数增加时,其接口吞吐量可以呈线性增长,最大可达1.4GB/s。实际结果受到网络条件和压缩率影响。

这个数字不能直接等同于所有Tick写入场景的有效吞吐。

需要区分:

  • API接收吞吐。
  • 服务器内部写入吞吐。
  • 持久化吞吐。
  • 查询返回吞吐。
  • 压缩后的磁盘带宽。
  • 原始数据的网络带宽。

如果磁盘写入已经成为瓶颈,可以关注异步排序工作线程数等参数。DolphinDB中存在与异步排序相关的配置,例如TSDBAsyncSortingWorkerNum

参数增加后,排序任务并行度提高。但并行度不是越高越好。线程数量过多,会增加CPU争用、缓存失效和磁盘竞争。查询线程也会受到影响。

正确方法是固定数据集、固定硬件和固定写入模式,逐项改变参数。记录吞吐、延迟、队列长度和磁盘利用率。不要只看某一次峰值。

DolphinDB适合统一研究和生产链路

如果系统需要同时完成以下任务,DolphinDB的集成度会更有价值:

  • Tick数据接入。
  • 行情清洗。
  • 时间窗口聚合。
  • 因子计算。
  • 历史回测。
  • 结果查询。
  • 策略研究。

但统一平台也会形成资源共享问题。

研究人员执行大范围回测时,可能占用内存和计算资源。实时写入路径不能依赖查询任务“刚好不忙”。生产和研究最好分离资源,至少分离节点、线程池或服务等级。

交易系统的低延迟路径需要确定性。

查询型系统更关心平均吞吐和扫描速度。两者的优化目标不同。

TDengine:单表单设备与超级表

TDengine存储行情Tick时,常见设计是“单表单设备”。

每个合约对应一张子表。交易所代码作为标签。多个子表通过超级表统一管理。

这种设计适合合约数量明确、查询通常按合约和时间窗口进行的场景。

基本逻辑是:

  • 合约对应子表。
  • 交易所对应标签。
  • 时间戳作为时间列。
  • Tick字段作为普通列。
  • 超级表负责统一结构和聚合查询。

该结构减少了应用侧手工拼接大量异构表的复杂度。按照合约查询时,数据边界也更清晰。

但子表数量需要控制。

如果把每一条行情消息都设计为一张表,表数量会失控。如果合约生命周期短、动态生成频繁,也会增加元数据管理压力。

“单表单设备”中的设备应当是稳定的数据实体。通常是合约,而不是每个订单、每个连接或每次行情事件。

三种数据库的侧重点

方案写入组织适合的查询主要风险适用场景
ClickHouse列式分片,后台合并大范围扫描、聚合、离线分析小批量写入产生过多分片大规模历史行情、分析型查询
DolphinDB列式内存引擎、写入缓存、异步排序时间窗口、因子计算、研究分析查询与实时写入争用资源行情接入、研究和回测一体化
TDengine子表、超级表、时间序列写入按设备和时间窗口查询子表设计不当导致元数据膨胀合约数量明确的行情存储
MySQL或SQLite事务表、B树索引事务查询、配置和低频数据高频随机写入、索引维护账户、配置、低频结果保存
MarketStore列式存储、内存映射文件、固定大小时间窗口文件Tick与K线历史访问生态和扩展方式需要单独评估单独的行情归档与回测数据层

MarketStore的设计重点是列式存储、内存映射文件和基于时间窗口的固定大小文件。它通过固定文件组织规避部分传统数据库写入成本,并降低历史行情存储成本。

但数据库选择不能只看基准测试。

需要把测试分成四类:

1. 顺序持续写入。

2. 峰值批量写入。

3. 写入同时查询。

4. 故障恢复和数据重放。

只测第一类,结果没有完整意义。

原始数据和查询数据应当分层

对于高频交易系统,推荐把数据分成三层:

接入层

保存原始消息或标准化消息。

目标是低延迟接收和可重放。数据不一定适合直接查询。字段可以保留更多元信息,包括原始序号、接收时间和数据源标识。

热数据层

保存近期Tick数据。

目标是实时查询、策略监控和短周期回测。可以使用ClickHouse、DolphinDB或TDengine,具体取决于查询模式和运维条件。

冷数据层

保存完整历史数据。

目标是长期归档和批量回测。可以按交易日、合约和数据源进行压缩。冷数据不需要承担实时写入压力。

分层后,实时数据库不再承担永久归档任务。历史归档也不会影响接入线程。

时间戳精度:数据库之外的物理问题

NTP不是微秒级方案

高频交易系统中,时间戳不仅用于排序。

它还用于:

  • 计算行情接收延迟。
  • 比较不同服务器的处理时间。
  • 对齐交易所事件。
  • 复现策略决策过程。
  • 分析撮合和下单路径。
  • 评估网络抖动。
  • 检查数据乱序。

传统网络时间协议通常受到操作系统内核调度、网卡中断和网络路径影响。同步精度通常在毫秒至100微秒级别。深度优化后,也很难稳定突破100微秒。

这对普通业务足够。

对微秒级交易系统不够。

如果不同服务器的时钟误差大于目标策略延迟,所有延迟分析都会失真。数据库保存再多小数位,也不能修复错误的时间源。

PTP需要硬件配合

精密时间协议基于IEEE 1588版本2。

配合支持硬件时间戳的低延迟网卡,可以在物理层记录数据包到达时间。研究资料中提到,Solarflare等低延迟网卡可用于这类架构。

PTP的同步精度可以达到微秒,甚至纳秒级别。实际精度仍然依赖:

  • 网卡是否支持硬件时间戳。
  • 交换机是否支持边界时钟或透明时钟。
  • 主时钟质量。
  • 光纤或网线链路。
  • 操作系统配置。
  • 驱动实现。
  • 服务器主板时钟源。
  • 时间同步服务状态。

不能只安装一个时间同步软件,然后把结果标记为纳秒级。

硬件链路必须完整。

事件时间、接收时间和处理时间

一条Tick至少建议保留三个时间字段:

  • 事件时间:行情源生成事件的时间。
  • 接收时间:本机网卡或程序收到消息的时间。
  • 处理时间:系统完成解码和标准化的时间。

如果条件允许,还可以记录:

  • 入队时间。
  • 批次封装时间。
  • 数据库确认时间。
  • 策略读取时间。
  • 下单请求发送时间。

这些时间字段可以用于构造延迟链路:

  • 网络延迟近似等于接收时间减事件时间。
  • 解码延迟近似等于处理时间减接收时间。
  • 排队延迟近似等于入队时间减处理时间。
  • 数据库等待近似等于确认时间减批次封装时间。

这里的“近似”必须保留。

不同设备的时间戳来源可能不同。硬件时间戳与软件时间戳不能直接混算。时钟偏差、跨机器同步误差也需要单独建模。

数据排序不能只依赖时间戳

多个Tick可能具有相同时间戳。不同消息源也可能出现乱序。

因此,需要额外保存稳定排序字段:

  • 行情源序号。
  • 交易所消息序号。
  • 本地接收序号。
  • 连接编号。
  • 批次编号。

排序规则应当明确。例如:

1. 事件时间。

2. 行情源序号。

3. 本地接收序号。

不能把相同时间戳的数据交给数据库后再假定其顺序天然稳定。

回测系统尤其需要确定性。

相同输入必须产生相同排列。否则,成交价相同的情况下,盘口更新顺序可能改变策略结果。这里涉及的不是数据库美观,而是回测可复现性。

从指标开始,而不是从数据库品牌开始

先描述查询模式

数据库选择前,先写出真实查询。

例如:

  • 查询某合约某交易日的全部Tick。
  • 查询一组期货合约在五分钟窗口内的最新价。
  • 聚合每秒成交量。
  • 回放盘口五档变化。
  • 计算事件时间和接收时间差。
  • 查询全部合约的开盘前后数据。
  • 按交易所和合约筛选历史记录。

不同查询对排序键、分区键和列存储的要求不同。

如果查询主要按合约和时间范围进行,数据应围绕这两个维度组织。如果查询主要按交易所和交易日进行,表结构又会不同。

不要先选数据库,再强行改写查询。

再定义写入指标

建议至少定义以下指标:

  • 峰值每秒消息数。
  • 平均每秒消息数。
  • 峰值每秒字节数。
  • 单批行数。
  • 单批字节数。
  • 写入确认延迟。
  • 99分位写入延迟。
  • 99.9分位写入延迟。
  • 最大队列长度。
  • 数据库分片增长速度。
  • 重试后的重复率。
  • 进程重启后的恢复时间。

平均延迟不能覆盖尾部延迟。

高频系统经常不是被平均值击穿,而是被极端尾部击穿。单次后台合并、磁盘刷新或查询扫描,都可能制造长尾。

如果只记录平均写入速度,系统可能在监控面板上看起来正常,实际队列已经持续增长。

参数优化顺序

参数优化需要固定顺序。

第一步:优化请求粒度

减少写入请求次数。优先设置批次数量和时间窗口。ClickHouse可以从10万行至100万行范围开始压测,也可以设置每秒不超过一次写入请求作为初始边界。

第二步:优化缓冲方式

引入双缓冲或异步插入。将接收线程与数据库提交线程分离。检查缓冲区切换是否存在锁竞争。

第三步:优化表结构

重新评估分区键、排序键和字段类型。删除没有查询用途的索引。减少高基数字段参与排序。

第四步:优化压缩和序列化

比较原始行格式、列式格式和协议格式。记录压缩前后字节数。压缩不是免费操作。CPU不足时,压缩可能成为新的瓶颈。

第五步:优化后台合并

观察分片数量、合并队列、磁盘读写和线程池。不要在没有指标的情况下盲目增加合并线程。

第六步:优化硬件和网络

确认磁盘带宽、随机写入性能、网络带宽和网卡队列。需要微秒级时间戳时,再加入PTP和硬件时间戳测试。

第七步:增加容量

当输入速率长期超过单节点处理能力时,才考虑分片、分库或水平扩展。扩展不能替代前面的写入模型优化。

测试数据不能过于理想

压测数据需要接近真实Tick。

至少应包含:

  • 相同字段数量。
  • 相同数据类型。
  • 相近的合约数量。
  • 相同时间戳精度。
  • 相近的数据重复率。
  • 相同的批次大小。
  • 相同的查询并发。
  • 相同的磁盘和网络条件。

只用一列时间戳测试,不能代表五档盘口数据。只测试空闲数据库,也不能代表交易时段。

还需要加入故障测试:

  • 数据库短暂不可用。
  • 网络连接中断。
  • 单批写入失败。
  • 进程突然退出。
  • 磁盘空间不足。
  • 查询压力突然增加。
  • 队列达到内存上限。

故障恢复时间和丢失数据范围,需要单独记录。

一套可执行的架构拆解

可以把高频Tick存储系统拆成以下模块:

行情接入模块

负责网络连接、协议解析和硬件时间戳读取。

不执行复杂计算。不访问数据库。不等待慢查询。

标准化模块

负责字段转换、数据类型统一和异常字段处理。

例如:

  • 价格转换为定点数或统一精度。
  • 合约代码统一格式。
  • 交易所代码映射。
  • 空值处理。
  • 交易状态归一化。
  • 原始序号保留。

标准化必须可重复。

缓冲模块

负责队列、双缓冲、批次封装和内存上限。

触发条件至少包括:

  • 达到最大行数。
  • 达到最大等待时间。
  • 达到最大字节数。
  • 系统准备退出。
  • 队列进入高水位状态。

持久化模块

负责数据库连接、批量写入、异步确认和失败重试。

重试必须有次数上限和退避策略。无上限重试会把数据库故障放大为内存故障。

校验模块

负责记录数量、时间范围、序号连续性和校验值。

每个批次写入后,可以检查:

  • 发送行数与确认行数。
  • 首条和末条时间戳。
  • 首尾序号。
  • 批次标识。
  • 重复记录。
  • 缺失记录。

归档模块

负责将原始数据和规范化数据输出到冷存储。

归档过程不能阻塞实时写入。文件按交易日、交易所和合约划分。格式需要支持批量读取和压缩。

监控模块

负责记录延迟、吞吐、队列和错误。

监控数据本身也要避免高频同步写入。可以采用本地聚合后批量上报。

不同方案的取舍边界

使用ClickHouse

适合以下条件:

  • 历史数据量大。
  • 查询以扫描和聚合为主。
  • 需要较强的列式分析能力。
  • 可以接受批量写入。
  • 能够独立设计缓冲和重试机制。

不适合直接执行单条Tick高频写入。至少需要批处理、异步插入或外部队列。

使用DolphinDB

适合以下条件:

  • 研究、回测和数据接入需要较高集成度。
  • 查询涉及时间窗口和计算任务。
  • 团队接受其脚本和部署方式。
  • 需要内存引擎、缓存和异步处理能力。

需要单独规划实时写入与研究计算的资源边界。

使用TDengine

适合以下条件:

  • 合约是稳定的数据实体。
  • 查询主要按合约和时间窗口进行。
  • 能够管理子表和超级表。
  • 数据模型比较规则。

需要控制子表数量。不能将动态事件直接映射为永久表对象。

使用MySQL或SQLite

适合以下数据:

  • 账户信息。
  • 策略配置。
  • 交易日历。
  • 合约元数据。
  • 低频K线。
  • 回测任务状态。
  • 写入批次状态。

不适合作为未经缓冲的百万级Tick并发写入主库。

参数优化建议与模型局限

建议从四个参数开始

第一,批次行数。

从10万行、30万行、100万行三个点做对比。观察写入耗时、内存和分片增长。

第二,最大等待时间。

避免低流量时批次长期滞留。不能只设置行数阈值。

第三,异步工作线程数。

适用于存在排序、合并或异步持久化的系统。每次只调整一个参数。记录CPU、磁盘和查询延迟。

第四,队列高水位。

达到高水位后,系统应触发告警或降级。不能无限扩大内存队列。

参数优化目标不是单项吞吐最高。

合理目标应同时满足:

  • 队列长期不增长。
  • 数据库分片数量稳定。
  • 写入延迟可控。
  • 重启后可以恢复。
  • 查询不会长期阻塞写入。
  • 数据重复和丢失可检测。
  • 资源利用率存在余量。

不要把基准测试当成生产承诺

单节点50MB/s至200MB/s是一个参考范围。实际吞吐受到硬件、数据结构、压缩率、批次大小和查询负载影响。

DolphinDB接口达到1.4GB/s,也不能直接推导出某个具体行情源能够获得相同结果。

PTP可以达到微秒甚至纳秒级同步能力,也不等于整条交易链路拥有纳秒级确定性。数据库、应用调度、网络队列和磁盘路径仍然存在延迟。

研究资料也没有给出各主流时序数据库在黑天鹅行情、开盘暴跌等极端场景下的单机绝对丢包率和延迟抖动上限。这个指标不能凭空补齐。只能通过目标环境压测获取。

最终判断标准

高频Tick数据存储的架构抉择,可以压缩为四个问题:

1. 数据是否允许丢失。

2. 数据是否需要实时查询。

3. 查询是否以合约和时间窗口为主。

4. 写入速率是否长期超过单节点能力。

如果数据不能丢失,先建设原始日志和重放机制。

如果数据需要实时查询,建设热数据层。

如果写入请求过碎,先做批量化和双缓冲。

如果ClickHouse出现“too many parts”,先降低小批量写入频率,再检查分区、排序和后台合并。

如果时间戳需要微秒级精度,先检查硬件时间戳和PTP链路,不要从数据库字段精度开始。

时序数据库不是写入瓶颈的全部答案。瓶颈通常由四个因素共同决定:请求粒度、数据组织、持久化路径和时间同步。

先拆开路径。

再测每一段。

最后调参数。

模型的局限性必须保留。任何数据库在未经过目标行情、目标硬件和目标查询负载测试前,都只能作为候选方案,不能作为性能结论。

Related reading: 高频交易系统内存池设计:如何从底层消除动态分配的延迟抖动 and LMAX Disruptor的诞生:低延迟交易系统如何打破传统队列瓶颈.

常见问题

为什么ClickHouse写入Tick数据时会报错“too many parts”?
这是因为写入请求过于频繁,导致MergeTree引擎生成了大量小分片,而后台合并线程无法及时清理这些分片,最终触发了系统的分片数量限制。
高频交易系统应该如何设置写入批次?
建议采用行数、时间和内存三重上限触发机制,通常单批规模在10万至100万行之间,并确保每秒写入请求不超过一次。
为什么不能直接用关系型数据库(如MySQL)存储高频Tick数据?
关系型数据库的B树索引结构不适合持续的高频随机写入,频繁的索引更新、页面分裂和事务维护会产生巨大的磁盘寻道成本,导致吞吐量大幅下降。
如何解决高频行情数据写入时的延迟抖动问题?
应引入双缓冲模型,将行情接收线程与数据库提交线程拆开,通过内存缓冲区聚合数据,避免磁盘抖动或网络突发直接拖慢行情接收速度。
在时序数据库中,如何设计表结构以优化查询性能?
应根据查询模式明确分区键和排序键,例如按交易日分区,按合约和时间戳排序,并区分事件时间与接收时间,以保留系统诊断能力。