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

相关推荐
qq_413847405 小时前
如何脱机维护表空间数据文件_OFFLINE与ONLINE状态的切换场景
jvm·数据库·python
OpenClawCSDN5 小时前
2026年腾讯云如何安装和集成Hermes Agent/OpenClaw?深度剖析
数据库·阿里云·云计算·腾讯云·京东云
鸽芷咕5 小时前
Oracle迁移到KingbaseES实战:语法差异、函数映射与避坑指南
数据库·oracle
四维迁跃5 小时前
Python Selenium怎么定位元素_By.XPATH与By.CSS_SELECTOR操作DOM节点
jvm·数据库·python
千里念行客2405 小时前
扬电科技落子“草原云谷”:一场算电协同的西部突围
大数据·人工智能·科技·安全
灵途科技5 小时前
灵途科技加速推进具身智能产业协同,持续拓展空间感知技术应用边界
大数据·人工智能
qq_372154235 小时前
CSS如何改变单个网格项目的对齐方式
jvm·数据库·python
kexnjdcncnxjs5 小时前
CodeIgniter4安全加固指南:防御XSS与CSRF攻击
jvm·数据库·python
2401_871492855 小时前
Imagick PDF 处理失败的常见原因与解决方案
jvm·数据库·python
b***25115 小时前
动力电池气动点焊机:破解高能量密度电池制造的焊接难题
大数据·制造