4. 行情建模原则
行情数据建模不是“把字段塞进一张表”这么简单。它同时受三股力量拉扯:写入端每秒可能几十万行,查询端又要求毫秒级聚合;业务上既要按标的看最新盘口,又要按时间窗口做K线、VWAP、波动率。建模的目标,是在ClickHouse的列式存储与MergeTree引擎约束下,找到写入吞吐、查询延迟、存储成本三者的平衡点。
一、核心原理:宽表与窄表的取舍
宽表把一次行情快照的所有字段放在同一行,例如 symbol, ts, bid1, ask1, bid1_vol, ask1_vol, last, volume, turnover。窄表则把每个指标拆成独立行,用 metric_name, metric_value 表达。两者在ClickHouse中的表现差异极大。
- 宽表优势:一次写入即完整快照,查询最新盘口无需聚合,列裁剪效率高,适合
ReplacingMergeTree或ReplacingMergeTree去重。 - 宽表代价:字段多、稀疏字段多时压缩率下降;新增指标需改表结构。
- 窄表优势:扩展灵活,新增指标无需DDL;适合指标种类多、写入频率不一致的场景。
- 窄表代价:查询需按
metric_name过滤并做行转列,聚合开销大,高频写入时行数膨胀严重。
二、维度建模与时间序列建模的融合
维度建模强调事实表与维度表分离。行情中的事实是“某时刻某标的的行情快照”,维度包括标的、交易所、品种、交易时段。ClickHouse没有传统外键,但可以用低基数维度内联的方式:把 exchange、product 等枚举字段直接放入事实表,用 LowCardinality(String) 编码,既保留维度语义,又避免Join开销。
时间序列建模的关键是时间分区与排序键。行情表通常按天分区,排序键为 (symbol, ts) 或 (exchange, symbol, ts)。这样按标的查最新值、按时间范围扫描都能命中主键索引。对于高频写入,分区粒度不宜过细,否则小分区过多会拖慢合并。
CREATE TABLE market.quote_snapshot
(
exchange LowCardinality(String),
symbol LowCardinality(String),
ts DateTime64(3, 'Asia/Shanghai'),
bid1 Decimal(18, 4),
ask1 Decimal(18, 4),
bid1_vol UInt64,
ask1_vol UInt64,
last Decimal(18, 4),
volume UInt64,
turnover Decimal(24, 4)
)
ENGINE = ReplacingMergeTree
PARTITION BY toDate(ts)
ORDER BY (exchange, symbol, ts)
TTL ts + INTERVAL 30 DAY;
三、高频写入与查询平衡的工程手段
写入侧要控制单批次行数与分区数。ClickHouse最佳实践是每批写入10万~100万行,批次过小会产生大量小part,触发频繁合并。查询侧要利用物化视图预聚合,把K线、VWAP等重查询从原始表剥离。
| 维度 | 宽表方案 | 窄表方案 | 推荐场景 |
|---|---|---|---|
| 写入吞吐 | 高,单行完整 | 低,行数膨胀 | 盘口/逐笔用宽表 |
| 查询最新值 | 直接取行,快 | 需过滤聚合,慢 | 宽表 |
| 指标扩展 | 需DDL | 灵活 | 窄表 |
| 压缩率 | 列式压缩好 | 重复维度多,压缩一般 | 宽表 |
| 典型引擎 | ReplacingMergeTree | MergeTree + 物化视图 | 按数据形态选择 |
GROUP BY symbol, ts 行转列。这在每秒百万行写入下会导致查询延迟飙升,且无法利用主键索引。盘口必须宽表化。四、案例复盘:从窄表迁移到宽表+物化视图
某行情平台最初用窄表 quote_metric(ts, symbol, metric, value) 存储所有指标,写入峰值80万行/秒,查询最新盘口需 WHERE metric IN (...) 再聚合,P99延迟超过3秒。迁移方案如下:
- 盘口与逐笔改为宽表,按
(exchange, symbol, ts)排序,写入批次控制在50万行。 - K线、VWAP、成交量等聚合指标用物化视图从宽表实时计算,写入
AggregatingMergeTree目标表。 - 查询最新盘口直接
SELECT ... ORDER BY ts DESC LIMIT 1,命中主键。 - 历史指标查询走物化视图结果表,避免扫描原始明细。
CREATE MATERIALIZED VIEW market.mv_kline_1m
ENGINE = AggregatingMergeTree
PARTITION BY toDate(ts)
ORDER BY (symbol, ts)
AS SELECT
symbol,
toStartOfMinute(ts) AS ts,
argMinState(last, ts) AS open,
maxState(last) AS high,
minState(last) AS low,
argMaxState(last, ts) AS close,
sumState(volume) AS volume
FROM market.quote_snapshot
GROUP BY symbol, ts;
迁移后写入吞吐提升约40%,最新盘口查询P99降至20毫秒以内,K线查询从秒级降到百毫秒级。核心经验是:原始明细用宽表保真,聚合结果用物化视图预计算,维度用LowCardinality内联,时间用分区+排序键裁剪。建模不是一次性的,需随写入量和查询模式持续调优。