做量化这几年,我发现自己绝大部分时间不是在写策略,而是在跟数据较劲。行情要落库、因子要清洗、回测要取数,每一环都压着延迟和吞吐两条线。早期用 MySQL 硬扛,数据量到几千万行就开始出各种幺蛾子,慢查询把回测流程拖到让人怀疑人生。后来把 Apache Doris 4.x 引入到整套量化交易的数据链路里,行情落库、K线预聚合、回测取数这几件事才总算理顺。这篇就把完整应用过程整理出来,包括表模型怎么选、实时链路怎么搭、SQL 怎么优化,以及上线之后踩过的那些坑。如果你正想给自己的量化策略搭一套数据平台,或者已经在用 Doris 但没把它在交易场景里用透,这篇应该能帮你节省不少试错时间。
1. 为什么量化交易场景里最终选了 Doris
1.1 量化数据系统要解决的三个核心问题
做量化的人应该都有同感:策略本身的逻辑可能并不复杂,复杂的是你永远需要回答三个问题——当前市场发生了什么、历史某个时段发生过什么、如果按某套规则操作会得到什么结果。对应到数据层面,就是行情存储、因子计算、回测取数三件事。
行情存储要求高吞吐写入和低成本保存。A股全市场五千多只股票,按 Tick 级别记录,一天大概两到三亿条成交明细,单日原始数据量在 10GB 到 20GB 之间。这个量级 MySQL 已经很难扛住,即使分库分表也会把查询逻辑搞得非常痛苦。
因子计算要求快速扫描大量历史数据。比如计算某个动量因子,需要扫描过去三年所有股票的日线或分钟线做 rolling 操作,单表扫描量轻松超过百亿行,这时候对列式存储和向量化执行的依赖就非常明显。
回测取数要求灵活、快速、可复现。你可以随时取出任意股票、任意时间段、任意频率的行情数据,还要保证数据没有缺口、没有错位。这背后不只是存储引擎的问题,还有时间对齐、复权处理、数据质量校验等一系列工程问题。
我之前尝试过用 PostgreSQL 存 Tick 数据,查询倒是灵活,但写入吞吐上不去,一张表过亿行之后索引膨胀得厉害,回测时一个区间扫描经常要等几十秒。后来也试过 ClickHouse,查询性能确实强,但数据更新和事务支持太弱,订单流这种需要频繁 upsert 的场景用起来很别扭。Doris 4.x 算是把这几块平衡得最好的一个:列式存储支撑高吞吐扫描,Unique 模型能处理低频 upsert,再加上完整 SQL 兼容和物化视图,几乎不用改造业务逻辑就能直接接入。
1.2 Doris 4.x 的版本特性里,哪些是量化真正用得上的
Doris 4.x 延续了 2.x 以来对 Lakehouse 架构的演进方向,但对于量化交易这种偏传统 OLAP 的场景,我实际中用得最多、收益最明显的反而是一些基础能力的强化。
第一个是异步物化视图。以前要做分钟级 K 线预聚合,要么在写入端用 Flink 做一层加工,要么在查询端硬算,方案都不够优雅。Doris 的异步物化视图支持透明的查询改写,底层明细表更新后物化视图自动刷新,查询端完全无感。量化场景里面很多因子依赖固定频率的聚合结果,这套机制帮我省掉了一个流计算集群的维护成本。
第二个是多表物化视图和直查外表的能力。实盘里经常需要把行情和订单数据做关联分析,比如看某只股票的大单成交占比。Doris 4.x 对多表 Join 后创建物化视图的支持更成熟,配合外表查询可以直连 Hive 或者 S3 上的冷数据,不用把所有历史数据都搬到本地。
第三个是写入链路的稳定性。Routine Load 组件在消费 Kafka 行情流时,支持断点续传和延迟监控,对实时性要求高的场景非常关键。行情数据一旦因为 Kafka 堆积出现断流,整套实盘信号都会受影响,好在 4.x 版本里 Routine Load 的可观测性比早期强了不少,出问题能及时暴露。
还有一个容易被忽略的点是资源隔离。Doris 4.x 支持 Workload Group,可以把回测的大查询和实盘的轻量查询放在不同的资源组里,避免两个场景互相抢占资源导致查询延迟抖动。实盘查询通常要求在几十毫秒内返回,而回测查询动不动就要跑几秒钟,没有隔离的话,两边都会很难受。
2. 表模型选型与建表实操
Doris 的表模型直接决定了数据怎么存储、怎么更新、怎么聚合,这一步选错了,后面所有的优化都是在打补丁。量化场景里最常见的是三类数据:Tick 行情明细、K线聚合结果、订单与持仓状态。我分别用了 Duplicate、Aggregate 和 Unique 三种模型来对应。
2.1 Tick 行情明细表:Duplicate 模型怎么建
Tick 数据是最原始的行情记录,一条数据代表一次成交,理论上不应该做任何合并,所以建表用的是 Duplicate 模型。关键设计在于排序键的选择——Doris 的数据按照排序键组织,排序键用得对不对,直接决定查询性能。
CREATE TABLE stock_tick ( symbol VARCHAR(10) COMMENT '证券代码', trade_time DATETIME(6) COMMENT '成交时间', price DECIMAL(10,2) COMMENT '成交价格', volume BIGINT COMMENT '成交量(股)', turnover DECIMAL(16,2) COMMENT '成交金额', direction TINYINT COMMENT '主动方向 1买 -1卖', trade_index BIGINT COMMENT '原始序号' ) DUPLICATE KEY(symbol, trade_time) PARTITION BY RANGE(trade_time) () DISTRIBUTED BY HASH(symbol) BUCKETS 48 PROPERTIES ( "dynamic_partition.enable" = "true", "dynamic_partition.time_unit" = "DAY", "dynamic_partition.start" = "-90", "dynamic_partition.end" = "1", "replication_num" = "3" );注意几点。排序键没有把 trade_time 放在第一位,而是把 symbol 放在前面,这是因为日常查询几乎都是“先指定股票,再取时间范围”,symbol 在前才能走前缀索引做快速裁剪。如果把时间放前面,全市场扫描一个时间切片可能也能用上分区裁剪,但当你想单独拉某只股票的历史行情时,就会变成全分区扫描,效率差很多。
时间字段用 DATETIME(6),保留微秒精度。有段时间我发现盘中数据经常出现同一毫秒内多条成交,排序不稳定,后来加上 trade_index 这个原始序号用于辅助排序,问题才解决。
分区和分桶的规划我单独放在 2.4 节里展开,这里先说明一点:动态分区设置为保留 90 天,既覆盖了大多数中低频策略的回测窗口,又不至于让本地存储无限膨胀。更早的历史数据我会定期导出到 S3 冷存储,Doris 通过外表再访问,成本能控制住。
2.2 分钟级K线:Aggregate 模型和物化视图怎么选
分钟级 K 线是量化策略最常用的基础数据。从 Tick 明细聚合出每分钟的 OHLCV,第一种方案是直接用 Aggregate 模型建表,写入时由 Doris 自动完成聚合。
CREATE TABLE stock_kline_1m ( symbol VARCHAR(10), ts DATETIME, open DECIMAL(12,2) REPLACE_IF_NOT_NULL, high DECIMAL(12,2) MAX, low DECIMAL(12,2) MIN, close DECIMAL(12,2) REPLACE, volume BIGINT SUM, amount DECIMAL(16,2) SUM ) AGGREGATE KEY(symbol, ts) PARTITION BY RANGE(ts) () DISTRIBUTED BY HASH(symbol) BUCKETS 32 PROPERTIES ( "dynamic_partition.enable" = "true", "dynamic_partition.time_unit" = "DAY", "dynamic_partition.start" = "-730", "dynamic_partition.end" = "1", "replication_num" = "3" );这个设计的关键在于各个字段用了不同的聚合函数。最高价用 MAX,最低价用 MIN,收盘价用 REPLACE(同一分钟内最后一条覆盖前一条),成交量用 SUM。写入端只要不断把 Tick 数据喂进来,Doris 就会在后台完成聚合,查询时直接读结果表,速度非常快。
但聚合模型的局限在于,一旦你后续需要基于分钟线再算更高频的指标,或者某些字段需要更复杂的聚合逻辑(比如 VWAP 的价量加权),预聚合的维度就对不上了。所以实际项目中,我更推荐把 Tick 明细完整保留在 Duplicate 表里,然后通过物化视图来做分钟级聚合。这样既能享受预聚合的查询速度,又保留了明细数据做纠错和重算的可能性。后面的查询优化章节里,我会展开讲物化视图的具体配置。
2.3 实盘订单与持仓:Unique 模型如何保证幂等更新
订单和持仓数据和行情不同,它天然是“状态型”数据:一条订单从已提交到已成交、已撤单,状态在不断变化,数据库里只需要保存最新一条。Doris 的 Unique 模型就是为这种场景设计的。
CREATE TABLE order_flow ( order_id BIGINT COMMENT '订单ID', account_id INT COMMENT '账号ID', symbol VARCHAR(10) COMMENT '证券代码', side TINYINT COMMENT '1买 2卖', order_status VARCHAR(20) COMMENT '状态', order_price DECIMAL(12,2) COMMENT '委托价格', filled_price DECIMAL(12,2) COMMENT '成交均价', filled_qty INT COMMENT '累计成交量', create_time DATETIME(6) COMMENT '创建时间', update_time DATETIME(6) COMMENT '最后变更时间' ) UNIQUE KEY(order_id) DISTRIBUTED BY HASH(order_id) BUCKETS 16 PROPERTIES ( "replication_num" = "3", "enable_unique_key_merge_on_write" = "true" );这里需要重点说明的是enable_unique_key_merge_on_write参数。默认情况下 Unique 模型的更新走 Merge-On-Read 路线,查询时需要合并历史版本,数据量大了会慢。开启 Merge-On-Write 后,写入时直接完成标记删除,查询链路更短,延迟更稳。实盘场景里订单状态更新很频繁,查询要求实时看到最新状态,这个参数强烈建议打开。
持仓快照的原理类似,不过因为要按账号+股票维度更新,所以 Unique Key 会设计成 (account_id, symbol)。每天收盘后我会跑一个批量任务,把当日所有持仓变化统一写入 DORIS,再用一个定时查询和券商对账单做比对,确保持仓数据不出差错。这套机制上线后,我再也没有因为持仓数据不一致而半夜爬起来手工修数据。
2.4 分区、分桶、排序键:一张表最关键的物理设计
建表时很多人只关注字段和模型,忽略了物理设计。其实分区、分桶和排序键对 Doris 的查询性能影响是数量级的。
分区在上面的 DDL 里已经用了 RANGE 分区按天切分。分区的作用有两个:一个是查询裁剪,只扫描命中的分区;另一个是数据生命周期管理,动态分区加删除过期分区可以省掉大量运维工作。行情表设置动态分区保留 90 天,K线表保留 730 天,这是我在存储成本和回测需求之间反复试出来的组合。
分桶数量直接决定并行度。Doris 的数据按照分桶键哈希分散到不同的 Bucket,每个 Bucket 由独立的线程处理。分桶太少,并行度不够;分桶太多,生成大量小文件,写入和查询都要被拖累。经验法则是单个 Bucket 的数据量控制在 1GB 到 3GB 之间。以 Tick 表为例,单日全市场约 10GB 数据,48 个分桶每个约 200MB,配合多 BE 节点并行扫描,查询速度非常理想。
排序键的定义更是影响深远。Doris 的排序键默认就是表的前几列,并且参与构建前缀索引。前缀索引的匹配规则是“最左匹配”,所以你要把自己最常用的等值条件和范围条件按顺序排在最前面。在我的表里,主查询模式固定是“股票 + 时间范围”,所以排序键就是 (symbol, trade_time)。如果你还有一种常见的查询是“全市场某时间点快照”,那就要评估一下是否需要额外建一张按时间分桶的表,不要试图用一张表覆盖所有场景。
3. 实时行情链路:Kafka 到 Doris 的完整接入
3.1 两条写入路径:Stream Load 和 Routine Load 各自负责什么
Doris 支持很多种数据导入方式,但实际生产中我只用两种:Stream Load 和 Routine Load。选择标准很简单——批量数据走 Stream Load,实时流走 Routine Load。
Stream Load 是同步导入接口,客户端把数据文件通过 HTTP 推给 Doris,Doris 返回导入结果。我用它来做每日盘后数据批处理:交易所收盘后拿到全天的 Tick 文件,经过清洗后用 Stream Load 批量写入,吞吐量很高,单批次导入几 GB 数据也就几十秒的事。
Routine Load 是常驻任务,持续消费 Kafka 消息并写入 Doris。盘中实时行情通过行情网关推到 Kafka,Routine Load 负责拉取消费写入,延迟控制在秒级。它的好处是 Doris 自己维护消费位点,任务异常重启后能从上次位置继续消费,不用额外开发一套 Kafka 消费者管理逻辑。
两条链路各有各的应用场景,千万不要混用。早期为了图省事,我把盘中实时数据也用 Stream Load 批量推送,每 10 秒推一次。结果每次导入都有额外开销,集群导入任务排队严重,还出现过两批数据重叠覆盖的问题。后来全部切到 Routine Load,消费位点和导入事务都由 Doris 统一管理,轻松多了。
3.2 Routine Load 消费 Tick 主题的配置实例
下面是一个典型的 Routine Load 任务配置,直接消费 Kafka 里实时行情 Topic,写入前面建立好的 stock_tick 表。
CREATE ROUTINE LOAD quant_db.tick_load ON stock_tick COLUMNS(symbol, trade_time, price, volume, turnover, direction, trade_index), COLUMNS_TERMINATED_BY(',') PROPERTIES ( "desired_concurrent_number" = "3", "max_batch_interval" = "10", "max_batch_rows" = "500000", "strict_mode" = "true", "format" = "csv" ) FROM KAFKA ( "kafka_broker_list" = "192.168.1.10:9092,192.168.1.11:9092", "kafka_topic" = "stock-tick", "kafka_partitions" = "0,1,2,3,4,5", "kafka_offsets" = "OFFSET_BEGINNING" );关键参数里,desired_concurrent_number控制消费并行度,它决定了同时开几个任务线程去拉 Kafka 数据,我设置为 Kafka 分区数的三分之一。max_batch_interval是最大攒批时间,10 秒攒一批,兼顾实时性和导入效率。strict_mode开启后,字段类型转换失败的脏数据会被过滤掉,避免一条异常记录拖垮整个导入任务。Doris 会单独记录过滤掉的行数,方便后续做数据质量巡检。
别小看这个任务,运维上我最常遇到的就是 Routine Load 停滞。通常原因有三个:Kafka 分区扩容后任务没有自动感知、消费位点过期被 Kafka 清理、目标表 schema 变更导致写入失败。Doris 提供了一些监控指标,最有用的是Lagged,它反映当前未消费的消息积压量。我会用 Prometheus 采集这些指标,设置告警规则,只要Lagged超过一定阈值就触发通知。
3.3 落库后的对账与补数,容灾不能只靠 Doris 自身
实时链路跑起来只是第一步,数据完整性和一致性才是实盘系统的生命线。我平时会做两类对账:数值对账和数量对账。
数值对账是抽样比对。每天收盘后,用行情源提供的收盘文件作为基准,和 Doris 里的数据做全量比对。比对维度包括总成交笔数、总成交量、总成交金额,以及随机抽取几十只股票的逐笔数据。这个任务用 Doris 的 SQL 就能完成,写一个查询按symbol + trade_date汇总,和外部的原始数据比对,差异超过阈值就告警。
数量对账主要针对 Routine Load 的消费位点和实际写入行数。Doris 的监控面板里能看到每个导入任务的导入行数,Kafka 侧也能查到某个 Topic 的总消息量。两边数字对不上,就说明有消息被过滤或者任务有积压。如果发现缺数据,我会写一个补数脚本,从 Kafka 指定 offset 重新导入。补数时注意先清理掉重复数据,Doris 虽然靠排序键能去重,但 Tick 是按次成交记录,同一批数据导入两次就会产生重复行,这点大家一定要警惕。
4. 量化分析查询的 SQL 优化实战
4.1 因子计算中最高频的 SQL 模式拆解
让我先列出量化查询里出现频率最高的几类 SQL,再做优化分析。
第一类是截面快照查询。取某个时间点全市场所有股票的最新价、涨跌幅、成交量,用于计算选股因子。这类查询的特点是等值时间条件 + 全市场扫描。
SELECT symbol, price, volume FROM stock_kline_1m WHERE ts = '2025-05-20 14:00:00' AND symbol IN ('000001', '600000', ...)第二类是时序窗口查询。取某只股票一段时间的分钟线或 Tick,用于技术指标计算。
SELECT ts, close, volume FROM stock_kline_1m WHERE symbol = '600519' AND ts >= '2025-04-01 00:00:00' AND ts < '2025-05-01 00:00:00' ORDER BY ts;第三类是横截面聚合查询。按股票维度 group by,计算过去 N 天的均值、标准差、动量等因子。
SELECT symbol, AVG(close) AS avg_close, STDDEV(close) AS std_close, (MAX(high) - MIN(low)) / MIN(low) AS range_pct FROM stock_kline_1m WHERE ts >= '2025-03-01' GROUP BY symbol;这三种 SQL 的优化方向不同:第一类靠分桶裁剪和并行扫描,第二类靠前缀索引和分区裁剪,第三类靠物化视图预聚合。后面我会针对每一种讲具体优化手段。
4.2 从秒级到百毫秒级:前缀索引和分区裁剪的配合
在讲优化之前,可以先看一个我优化示例的完整流程。之前有一段时间,我们的回测系统总是卡在“取某股票一个月的分钟线”这个步骤上,耗时将近两秒。用了 Doris 的 Profile 功能查看执行计划,发现它扫描了整整一天分区的全部分桶数据,因为排序键里 symbol 虽然在前,但查询并没有让预处理器充分利用分区裁剪。
问题出在表的分区键上。我之前把分区键设成了trade_time,但每天的分区里包含了全市场的股票,这意味着即使只查一只股票,也要扫描当天全量数据的所有分桶。解决方案是通过二级分区或者在建表时把分区粒度拆得更细。Doris 4.x 支持分区分桶的灵活设定,最适合这个场景的方案是在时间分区内,再按日期做二级分区,或者直接把分桶键和排序键都设计在 symbol 上,这样查询单只股票时天然就只扫描 symbol 对应的一小部分数据。
调整后,查询时间从 1.8 秒降到了 120 毫秒左右,提升非常明显。还有一个细节是不要写SELECT *,Doris 是列式存储,只取需要的列能减少 IO 和内存占用。尤其 Tick 表字段多,只查价格和量能少读大半数据。
4.3 物化视图在 K 线场景的落地方式
物化视图是我在整体架构里收益最大的一个功能。Tick 明细表保留了最细粒度的数据,但大量分析只需要分钟级甚至日级的数据。如果每次分析都从 Tick 实时聚合,既浪费资源,又容易因为聚合逻辑不一致导致结果有偏差。
我建了一个分钟的异步物化视图,把 Tick 数据自动聚合成分钟级 K 线:
CREATE MATERIALIZED VIEW mv_kline_1m BUILD DEFERRED REFRESH AUTO ON MANUAL DISTRIBUTED BY HASH(symbol) BUCKETS 32 PROPERTIES ( "replication_num" = "1", "grace_period" = "10" ) AS SELECT symbol, DATE_FORMAT(trade_time, '%Y-%m-%d %H:%i') AS ts, MAX(price) AS high, MIN(price) AS low, LAST_VALUE(price) AS close, SUM(volume) AS volume, SUM(turnover) AS amount FROM stock_tick GROUP BY symbol, DATE_FORMAT(trade_time, '%Y-%m-%d %H:%i');物化视图建好之后,一个关键步骤是验证查询改写是否生效。可以用EXPLAIN查看执行计划,如果查询可以命中物化视图,计划里会直接扫描 mv_kline_1m 而不是原始明细表。如果没有命中,说明查询条件和物化视图的粒度不匹配,需要调整 SQL 写法。
我实际测试过的效果:从近 30 天 Tick 明细(约 70 亿行)聚合出日线收益波动率,原来实时聚合耗时 5 秒以上,使用物化视图后查询时间基本在 200 毫秒以内。回测系统的整体运行时间也因此缩短了近一半。
4.4 查询超时的排查案例
这里分享一个前阵子排查的查询超时问题,过程比较典型。某个因子计算任务每天在固定时段跑,突然某天执行时间从 3 分钟膨胀到 20 分钟,最后直接触发超时。
第一步,看 Profile。Doris 的 Query Profile 记录了每个算子的耗时,查看后发现问题集中在AGGREGATION_NODE上,单节点内存消耗特别大。第二步,检查数据分布。发现某个热门股票的数据量是其他股票的上百倍,而分桶是按 symbol 哈希的,导致这个热门 symbol 所在的分桶数据量严重超载,单个 BE 节点承担了绝大部分计算。
解决办法是调整分桶策略。把分桶键从 symbol 换成 symbol + 一个恒定数值的取模运算,或者增加分桶数量,让热点数据的压力分散到更多分桶。后来我把这张表的分桶数从 32 调到 128,重新导入数据后,同样的查询回到 2 分钟以内。这类问题不会经常出现,但一旦出现基本都和热点倾斜有关。
5. 上线后的性能调优与运维经验
5.1 常用 BE 参数和内存配置
Doris 集群部署并不复杂,但性能调优需要结合量化数据的特点。这里重点说 BE(Backend)节点的几个核心参数。
首先是内存。BE 的默认内存上限建议设置为物理内存的 50% 到 70%。量化场景里有大量聚合查询,Buffer Pool 给足一点能减少数据落盘。我一般把buffer_pool_size控制在总内存的 25% 左右,max_tablet_version_num保持默认,同时把compaction_policy调整为按时间触发,避免大批量导入后压缩风暴影响查询。
其次线程模型。num_threads_per_core默认是 1,在并发查询比较高的时候可以调到 2 或 3,让每个 CPU 核心处理更多执行线程。但要小心,线程数太高会带来上下文切换开销,反而是负优化。我用 16 核 64GB 的 BE 节点,实测num_threads_per_core = 2时整体吞吐最优。
最后是导入相关参数。Stream Load 大批量写入时,streaming_load_max_mb和streaming_load_max_parallelism需要调大,否则大文件会被拆成很多小片段,导入效率反而降低。我自己会把streaming_load_max_parallelism调到 8,配合 48 个分桶并行写入,导入吞吐能到每秒 80MB 以上。
5.2 数据倾斜的识别和分桶调整
数据倾斜是量化场景里躲不开的坑。股票热度差异巨大,茅台一天成交几万笔,而很多小盘的冷门股可能一天只有几十笔。如果分桶键和服务对象没有选好,热点数据就会让某个 BE 节点忙死,其他节点却在空转。
判断倾斜的方法很简单。在 FE 的 Web UI 或者通过 SQL 查询每个分桶的行数分布,如果某个分桶的 Tablet 数据量是平均值的五倍以上,基本可以断定存在倾斜问题。
处理方法有两类。一类是修改分桶策略,把分桶键设计成symbol加上一个随机数后缀,让数据均匀散开到所有分桶。但这样会让按 symbol 的查询无法精确剪枝,需要权衡。另一类是拆分热点数据,把交易量最大的几十只股票单独建表,普通表放其他股票,两表用 UNION ALL 或者视图做逻辑合并。我采用了第二种方案,收益非常明显,热点查询和冷数据查询互不干扰,整体集群的 CPU 使用率也均衡了很多。
5.3 扩容和数据迁移的注意点
量化策略跑起来后数据量只会越来越大,扩容是必然会遇到的事情。Doris 扩容本身很平滑,新节点加进来后,FE 会自动把 Tablet 均衡到新节点上,期间不影响查询和写入。但有几个细节要特别注意。
第一,扩容前先检查磁盘空间和网络带宽。Tablet 迁移会产生大量网络流量,如果业务正处于盘中交易时段,迁移可能影响查询延迟。我一般选择在收盘后或者周末操作,并且在夜间窗口执行。
第二,BE 节点数量增加后,分桶数不一定要同步改。分桶数只在建表和之后再执行特定操作时才会调整,扩容新节点虽然能分担负载,但不会自动把已有分桶切得更细。如果你的查询因为分桶粒度不够一直上不去,可能需要考虑重建一张分桶数更多的表。这个操作只能通过新建表 + 数据迁移实现,不要心存侥幸直接在老表上动态改。
第三,所有运维操作前先备份元数据。FE 的元数据是整个集群的中枢,我用脚本定期备份 FE 的 image 和 journal,一旦出现异常能快速恢复。这里吃过一次亏,一次误操作导致表结构丢失,幸好有元数据备份,不然所有建表 SQL 要重新执行一遍,想想都冒冷汗。
6. 踩坑记录和一直在用的操作习惯
6.1 时间字段别存字符串,时区提前定死
这是建表设计的时候最容易被忽略但后期最痛苦的问题。Tick 数据的 trade_time,如果从上游接口拿过来是字符串,直接照着原样存进去,查询的时候倒也能工作。但字符串字段无法走 Doris 的 DATETIME 索引和分区裁剪,全表扫描就成了常态,性能完全没法看。
所以我从一开始就要求所有接入的数据在清洗阶段统一转成 DATETIME(6),分区键也用日期类型。宁可清洗多花 10 分钟,也不要让存储层为了这个错误买单。
时区问题更加隐蔽。A股数据全部是北京时间,但如果你同时接了期货夜盘,或者未来打算做跨境品种,时区混用就会导致 K 线错位。我的建议是内部存储一律统一到 Asia/Shanghai,外部数据在入口处做时区转换。不要在查询阶段做转换,否则没法用分区裁剪,而且每个查询都要重复算一遍,成本极高。
6.2 DECIMAL 精度问题在回测中会被放大
股票价格用 DECIMAL(10,2) 就够了,但成交额、市值这类大数要用 DECIMAL(16,2) 或更宽精度。有一个真实教训:有段时间我发现某些因子值在回测结果里总是出现微小偏差,排查了很久,最后定位到问题是把某只高价股的成交价格用 FLOAT 存储,导致涨停价计算时出现浮点误差,策略里一个大的盈亏因子反复触发假信号。
DOUBLE 和 FLOAT 在单条数据上差异可能只有分毫,但因子计算会做大量乘法和累加,误差被累积放大,最终影响信号判定。所以价格、金额、持仓成本这类字段,一律用 DECIMAL,不要因为偷懒用 DOUBLE。收益率的计算可以在查询时用 CAST 转成 DOUBLE,但存储层必须保持精确十进制。
6.3 数据对账脚本:量化团队的必备品
实盘系统的数据一致性不能靠运气,要靠制度。我要求团队每周至少执行一次数据对账,把所有关键表的关键指标和交易所官方数据源做交叉验证。对账脚本不难写,但覆盖面一定要够:
- 总量核对:日成交笔数、总量、总金额
- 抽样核对:随机抽 20 只股票,逐笔比对价格和成交量
- 状态核对:订单流表的终态比例是否合理(成交、撤单、废单)
- 时间核对:最新分区的时间是否更新到当前交易日
对账不通过时,第一优先级是找到数据缺口和原因,第二优先级才是修复。早期我们为了赶进度,对账只做总量核对,结果一次 Kafka 分区扩容导致某只股票的数据漏了整整一周,总金额因为量级大根本看不出差异,最后是策略工程师复盘收益曲线异常才发现的。从那以后,抽样核对和状态核对两个环节再也没省过。
6.4 从这套架构里还能延伸出什么
这套 Doris 架构跑通之后,我明显感受到量化研发的反馈周期变短了。以前新因子从验证到实盘,数据准备就要花掉一半时间,现在数据链路稳定,策略团队能快速拿到高质量数据,把精力主要集中在策略本身的迭代上。
未来有几个方向我会继续探索。一个是把更多维度数据放到 Doris 里,比如龙虎榜、资金流向、融资融券余额这些数据,虽然量不大,但对因子丰富度很有帮助。另一个是加强实时因子计算,现在分钟级因子已经稳定运行,后续想在 Doris 上做更高频的 Tick 级因子计算,挑战不小但值得试。
说到底,数据架构是为策略服务的,不要为了追求架构的复杂度而过度设计。Apache Doris 4.x 在这一整套流程里最独特的价值就是从明细存储、预聚合到实时查询全链路打通,让我用一套系统解决了过去需要 MySQL、ClickHouse、Redis 多个组件配合才能解决的问题。如果你的量化系统正在被数据和查询性能拖后腿,这套方案值得你仔细研究。