Schema 设计是 ClickHouse 性能的起点。ORDER BY 选错,后面所有优化都是在补救。
Primary Key 在 ClickHouse 中意味着什么
如果你从 MySQL/PostgreSQL 过来,需要先忘掉一个概念:ClickHouse 的 Primary Key 不保证唯一性,也不会拒绝重复数据。
它的本质是一个稀疏索引(Sparse Index) 。ClickHouse 将数据按 ORDER BY 排序后,每隔 index_granularity(默认 8192)行记录一个索引标记。查询时通过二分查找定位到可能包含目标数据的 granule,然后只扫描这些 granule。
┌─────────────────────────────────────────────┐
│ Granule 0 │ Granule 1 │ Granule 2 │ ← 每个 8192 行
├──────────────┼──────────────┼───────────────┤
│ mark[0] │ mark[1] │ mark[2] │ ← 稀疏索引标记
└─────────────────────────────────────────────┘
关键推论:
| 特性 | MySQL InnoDB | ClickHouse MergeTree |
|---|---|---|
| 索引类型 | B+Tree(稠密) | 稀疏索引 |
| 唯一性约束 | 支持 | 不支持 |
| 索引粒度 | 每行一个指针 | 每 8192 行一个标记 |
| 索引大小 | 大(与数据量线性) | 极小(可常驻内存) |
这意味着:Primary Key 的选择不是为了去重,而是为了让查询跳过尽可能多的 granule。
ORDER BY 决定了查询成本的 80%
ORDER BY 定义了数据在磁盘上的物理排列顺序。如果不显式指定 PRIMARY KEY,它同时也是主键。
核心原则:把查询中最常出现在 WHERE 条件里的列,按基数从低到高排列。
sql
-- ❌ 错误:高基数字段放前面
ORDER BY (trace_id, service_name, timestamp)
-- ✅ 正确:低基数 → 高基数
ORDER BY (service_name, status_code, timestamp)
为什么低基数优先?因为稀疏索引是前缀匹配的。假设 ORDER BY (a, b, c):
WHERE a = 'x'→ 能用索引 ✅WHERE a = 'x' AND b = 'y'→ 能用索引 ✅WHERE b = 'y'→ 不能用索引 ❌(跳过了第一列)
低基数字段放前面,相同值连续存储的区间更大,一个索引标记能覆盖更多行,跳过的 granule 也更多。
实际对比
假设一张日志表有 1 亿行,service_name 有 50 个值,trace_id 有 5000 万个值:
| ORDER BY | 查询 WHERE service_name = 'api-gateway' 扫描量 |
|---|---|
(trace_id, service_name) |
~1 亿行(几乎全扫) |
(service_name, trace_id) |
~200 万行(1/50) |
50 倍的差距,仅仅因为列的顺序不同。
Partition 的正确使用方式
Partition 是数据管理单元,不是查询优化手段。
sql
PARTITION BY toYYYYMM(timestamp) -- ✅ 按月分区,便于 TTL 和删除
PARTITION BY toYYYYMMDD(timestamp) -- ⚠️ 按天分区,小表会产生过多 part
PARTITION BY cityHash64(user_id) % 100 -- ❌ 反模式
Partition 的正确用途:
| 用途 | 说明 |
|---|---|
| 数据生命周期管理 | TTL timestamp + INTERVAL 90 DAY DELETE 按分区整体删除 |
| 批量数据替换 | ALTER TABLE REPLACE PARTITION 原子替换整个分区 |
| 冷热分层 | 将旧分区移动到 S3/HDD |
常见误区:分区越细查询越快。 事实恰好相反------分区过多会导致:
- 每个分区产生独立的 part 文件,merge 压力增大
- 查询需要打开更多文件描述符
- 元数据膨胀,ZooKeeper 压力增大(集群模式)
经验法则:单个分区的数据量在 100 万 ~ 1 亿行之间最佳,分区总数不超过 1000。
高基数字段的放置策略
高基数字段(如 trace_id、user_id、request_id)不适合放在 ORDER BY 的前面,但业务上又经常需要按这些字段查询。解决方案:
方案一:放在 ORDER BY 末尾
sql
ORDER BY (service_name, status_code, trace_id)
当前缀列都命中时,trace_id 的过滤仍然有效。
方案二:使用跳数索引(Skip Index)
sql
ALTER TABLE traces ADD INDEX idx_trace_id trace_id TYPE bloom_filter(0.01) GRANULARITY 4;
跳数索引不改变数据排列,而是在 granule 级别记录额外的元信息(如布隆过滤器),帮助跳过不包含目标值的 granule。
| 索引类型 | 适用场景 | 误判率 |
|---|---|---|
bloom_filter |
高基数等值查询(trace_id, user_id) | 可配置 |
set(N) |
低基数等值查询(N 为集合大小上限) | 无 |
minmax |
范围查询(timestamp, amount) | 无 |
LowCardinality 的内部原理与适用边界
LowCardinality(String) 是 ClickHouse 的字典编码优化。它在列内部维护一个字典,将重复字符串替换为整数 ID。
原始数据: ["api-gw", "api-gw", "user-svc", "api-gw", "user-svc"]
字典: {0: "api-gw", 1: "user-svc"}
编码后: [0, 0, 1, 0, 1] ← 整数比较,SIMD 友好
性能收益:
- 压缩率提升 2-5 倍(整数比字符串小得多)
- 过滤速度提升(整数比较 vs 字符串比较)
- GROUP BY 加速(字典天然去重)
适用边界: 当不同值的数量(cardinality)超过 ~10,000 时,字典本身变大,收益递减甚至为负。
sql
-- ✅ 适合:service_name(几十个值)、status_code、region、env
LowCardinality(String)
-- ❌ 不适合:user_id(百万级)、trace_id、email
String
可以通过系统表验证效果:
sql
SELECT
column,
formatReadableSize(data_compressed_bytes) AS compressed,
formatReadableSize(data_uncompressed_bytes) AS uncompressed
FROM system.columns
WHERE table = 'traces' AND column = 'service_name';
生产级 Trace 表的完整设计示例
综合以上原则,以下是一个 APM Trace 表的生产级设计:
sql
CREATE TABLE traces
(
-- 时间列:分区键 + ORDER BY 组成部分
timestamp DateTime64(3) CODEC(DoubleDelta, LZ4),
-- 低基数维度列:ORDER BY 前缀
service_name LowCardinality(String),
env LowCardinality(String), -- prod / staging / dev
status_code UInt16 CODEC(T64, LZ4),
-- 高基数标识列:ORDER BY 末尾 + 跳数索引
trace_id String CODEC(ZSTD(1)),
span_id String CODEC(ZSTD(1)),
parent_span_id String CODEC(ZSTD(1)),
-- 度量列
duration_us UInt64 CODEC(T64, LZ4),
-- 半结构化数据
attributes Map(String, String) CODEC(ZSTD(1)),
INDEX idx_trace_id trace_id TYPE bloom_filter(0.001) GRANULARITY 4
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(timestamp)
ORDER BY (service_name, env, status_code, timestamp, trace_id)
TTL timestamp + INTERVAL 30 DAY
SETTINGS index_granularity = 8192;
设计决策总结:
| 决策 | 理由 |
|---|---|
ORDER BY 以 service_name 开头 |
最常见的过滤条件,基数低 |
timestamp 在 ORDER BY 中间 |
支持时间范围裁剪 |
trace_id 在 ORDER BY 末尾 |
高基数,放前面会破坏索引效率 |
bloom_filter 索引 |
支持 WHERE trace_id = 'xxx' 的点查 |
PARTITION BY toYYYYMM |
按月管理数据生命周期,分区数可控 |
LowCardinality 用于 service/env |
基数 < 100,字典编码收益最大 |
| TTL 30 天 | 自动清理过期数据 |
下一篇我们将深入压缩编码(Codec),理解为什么 ClickHouse 越压缩反而越快。