4. 数据建模
行情平台的数据建模不是把字段堆进一张表,而是围绕“写入吞吐、查询延迟、存储成本、数据保留”四个目标做结构性取舍。ClickHouse 的 MergeTree 家族把数据组织成“分区—有序片段—主键索引”三层,理解这三层如何协同,才能设计出既扛得住高频写入、又能支撑实时查询的行情表。
一、核心原理:分区、排序键与主键的分工
在 MergeTree 中,分区键(PARTITION BY)决定数据在磁盘上的物理目录划分,同一分区的数据可以被独立合并、删除或移动;排序键(ORDER BY)决定每个数据片段内部的行顺序,是稀疏主键索引和跳数索引的基础;主键(PRIMARY KEY)默认与排序键一致,也可以显式指定为排序键的前缀,用于生成更紧凑的索引粒度。三者关系可以概括为:分区负责“横向切块”,排序负责“纵向有序”,主键负责“快速定位”。
行情数据天然具有时间维度,因此分区键通常选择日期或日期+市场标识。排序键则要匹配最高频的查询模式,例如“按标的查最新行情”或“按时间范围回放逐笔”。如果排序键选择不当,即使分区合理,查询仍会扫描大量数据块。
二、方法流程:从查询模式反推表结构
设计行情表时,建议按以下顺序推进,避免先写 DDL 再补索引:
- 列出核心查询:最新行情快照、按标的+时间范围回放、按市场聚合统计、异常波动扫描。
- 确定分区粒度:日线级行情按天分区;逐笔或盘口快照若单日数据量极大,可按天+市场或按小时分区。
- 选择排序键:以“等值过滤列在前、范围过滤列在后”为原则,例如
(symbol, ts)或(market, symbol, ts)。 - 显式声明主键:若排序键包含低基数列,可将主键收窄为
(symbol, ts)的前缀,降低索引体积。 - 配置生命周期:通过 TTL 自动删除过期分区,或使用
ALTER TABLE ... DROP PARTITION做冷热分层。
下面是一张典型的逐笔行情表结构示例:
CREATE TABLE market.tick
(
market LowCardinality(String),
symbol LowCardinality(String),
ts DateTime64(3, 'Asia/Shanghai'),
price Decimal64(4),
volume UInt64,
side Enum8('buy' = 1, 'sell' = 2),
seq UInt64
)
ENGINE = MergeTree
PARTITION BY toDate(ts)
ORDER BY (market, symbol, ts)
PRIMARY KEY (symbol, ts)
TTL ts + INTERVAL 30 DAY DELETE
SETTINGS index_granularity = 8192;
toStartOfHour(ts) 或 (toDate(ts), market),避免单分区过大导致合并压力集中。三、案例场景:不同行情表的建模差异
行情平台通常包含多种表,它们的建模策略并不相同。下表对比了常见表类型的关键选择:
| 表类型 | 分区键 | 排序键 | 主键 | 生命周期 |
|---|---|---|---|---|
| 逐笔成交 | toDate(ts) | (market, symbol, ts) | (symbol, ts) | TTL 30 天删除 |
| 盘口快照 | (toDate(ts), market) | (symbol, ts) | (symbol, ts) | TTL 7 天删除 |
| 日线 K 线 | toYYYYMM(trade_date) | (symbol, trade_date) | (symbol, trade_date) | 保留 5 年 |
| 实时最新价 | 不分区或按市场 | (symbol) | (symbol) | ReplacingMergeTree 去重 |
以逐笔成交为例,查询“某标的某日全部成交”时,分区裁剪先定位到当天分区,排序键再定位到该标的的时间区间,主键索引进一步跳过无关 granule。三层协同后,扫描量可以从全表降到极小范围。
四、误区与复盘:常见建模陷阱
误区一:分区键基数过高。例如按 symbol 分区,会产生成千上万个分区,每个分区数据量很小,合并效率急剧下降,元数据压力巨大。分区键应保持低基数,通常控制在几十到几千个分区。
误区二:排序键顺序与查询不匹配。若高频查询是“按时间范围扫描全市场”,而排序键是 (symbol, ts),则时间范围无法有效裁剪。此时应考虑 (ts, symbol) 或增加投影。
误区三:主键与排序键随意分离。主键必须是排序键的前缀,否则无法生成有效索引。显式声明主键时,务必确认其列顺序与排序键一致。
误区四:TTL 只写在表级。若不同市场保留周期不同,可在列级或使用 TTL ts + INTERVAL 7 DAY DELETE WHERE market = 'crypto' 做差异化清理。
复盘时建议用 EXPLAIN indexes = 1 查看查询实际裁剪的分区与 granule 数量,再结合 system.parts 观察分区大小分布。如果发现单分区行数超过数亿,或分区数量超过数千,就需要重新审视分区键与排序键的设计。数据建模不是一次性的,随着查询模式和数据量变化,应定期评估并调整。