第 3 篇:ClickHouse 表结构设计的核心原则

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_iduser_idrequest_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 BYservice_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 越压缩反而越快。

相关推荐
FinTech老王2 小时前
时序数据库存储引擎解密:LSM-Tree vs B-Tree vs 倒排索引,谁最适合时序场景?
数据库·时序数据库·lsm-tree
阿坤带你走近大数据2 小时前
存储过程在 oracle数据库管理工具里定时自动化运行方案
数据库·oracle·自动化
切糕师学AI2 小时前
Elasticsearch Learning to Rank 完全指南
大数据·elasticsearch·机器学习·搜索引擎
熬夜的咕噜猫2 小时前
数据库常用SQL命令
数据库·oracle
Justice Young2 小时前
Flink第一章:Flink概述
大数据·flink
William Dawson2 小时前
【实战分享】DTU设备高并发数据接入全流程(Redis + RabbitMQ + 数据库)
数据库·redis·rabbitmq
wregjru2 小时前
【MySQL】5. 数据更新与查询详解
java·数据库·mysql
教育知暖意2 小时前
2026年PPT生成工具实测,每款都适配不同需求
大数据·人工智能
洛菡夕2 小时前
PG数据库日常应用
数据库