ClickHouse 教程 / 分区与排序键设计
分区与排序键设计
分区键和排序键是 MergeTree 引擎中影响查询性能最关键的两个设计要素。
1. 分区键(PARTITION BY)设计
1.1 分区原理
表数据
├── 202601 分区 (January 2026)
│ ├── part_1_1_0
│ ├── part_1_2_0 (merged)
│ └── ...
├── 202602 分区 (February 2026)
│ ├── part_1_1_0
│ └── ...
└── 202603 分区 (March 2026)
└── ...
每个分区是独立的目录,拥有自己的数据部分(parts)。分区裁剪(Partition Pruning)可以让查询只扫描相关分区。
1.2 常见分区策略
按月分区(推荐,最常用)
CREATE TABLE events_monthly
(
event_date Date,
user_id UInt64,
event_type String,
data String
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_date)
ORDER BY (event_date, user_id);
-- 分区键值格式: 202601, 202602, ...
适用场景:日志、事件、订单等有明确时间字段的表,月数据量 100 万 ~ 10 亿行。
按天分区
CREATE TABLE logs_daily
(
log_date Date,
log_time DateTime,
level LowCardinality(String),
message String
)
ENGINE = MergeTree()
PARTITION BY log_date
ORDER BY (log_date, log_time);
-- 分区键值格式: 2026-05-01, 2026-05-02, ...
适用场景:日写入量过亿的日志表。注意分区数不要过多。
按类型 + 时间复合分区
CREATE TABLE metrics_by_type
(
metric_date Date,
metric_type LowCardinality(String),
metric_name String,
value Float64
)
ENGINE = MergeTree()
PARTITION BY (metric_type, toYYYYMM(metric_date))
ORDER BY (metric_date, metric_name);
适用场景:多种业务类型混合存储,需要按类型隔离管理。
1.3 分区裁剪(Partition Pruning)
-- ✅ 命中分区裁剪(使用了分区键)
SELECT count() FROM events_monthly
WHERE event_date >= '2026-05-01' AND event_date < '2026-06-01';
-- 执行计划: Selected 1/12 partitions
-- ✅ 使用 toYYYYMM() 函数也能触发裁剪
SELECT count() FROM events_monthly
WHERE toYYYYMM(event_date) = 202605;
-- ❌ 无法裁剪(条件不涉及分区键)
SELECT count() FROM events_monthly WHERE user_id = 123;
-- 查看分区信息
SELECT
partition,
count() AS parts_count,
sum(rows) AS total_rows,
formatReadableSize(sum(bytes_on_disk)) AS total_size
FROM system.parts
WHERE table = 'events_monthly' AND active = 1
GROUP BY partition
ORDER BY partition;
1.4 分区过多的危害
-- ❌ 按小时分区:一年产生 8760 个分区
-- 每个分区可能有多个 parts,系统维护成本极高
PARTITION BY toStartOfHour(event_time)
-- ❌ 按 user_id 分区:可能产生上亿个分区
PARTITION BY user_id
-- ✅ 经验法则
-- 单个分区数据量建议: 100MB ~ 10GB
-- 分区总数建议: < 1000
-- 每个分区的 parts 数: < 300
⚠️ 注意:分区过多会导致 ZooKeeper 压力增大、启动时间延长、合并调度变慢。如果每天数据量小于 100MB,使用按月分区即可。
2. 排序键(ORDER BY)设计
2.1 排序键原理
排序键决定了数据在磁盘上的物理排列顺序。由于 MergeTree 使用稀疏索引,数据按排序键有序存储,查询时可以快速跳过不相关的数据块。
排序键: (event_date, user_id, event_type)
磁盘上的数据排列:
event_date | user_id | event_type | ...
-----------|---------|------------|----
2026-05-01 | 1 | click | ... ←──
2026-05-01 | 1 | purchase | ... ←── 相同 event_date
2026-05-01 | 2 | click | ... ←── 相同 event_date
2026-05-02 | 1 | click | ... ←── 新的 event_date
2026-05-02 | 3 | view | ...
...
2.2 排序键列的选择原则
| 优先级 | 选择原则 | 示例 |
|---|---|---|
| 第一列 | 最常用于等值或范围查询的列 | event_date |
| 第二列 | 高频 GROUP BY 或过滤列 | user_id |
| 第三列 | 中等频率的过滤列 | event_type |
| 后续列 | 低频过滤或区分列 | url, ip |
-- 场景 1: 分析系统,主要按时间和用户查询
CREATE TABLE analytics
(
event_date Date,
user_id UInt64,
event_type LowCardinality(String),
url String,
duration UInt32
)
ENGINE = MergeTree()
ORDER BY (event_date, user_id, event_type);
-- 场景 2: 用户画像系统,主要按用户查询
CREATE TABLE user_profiles
(
user_id UInt64,
profile_date Date,
metric_name LowCardinality(String),
metric_value Float64
)
ENGINE = MergeTree()
ORDER BY (user_id, profile_date, metric_name);
-- 场景 3: 日志系统,主要按时间和级别查询
CREATE TABLE logs
(
log_time DateTime,
level LowCardinality(String),
service LowCardinality(String),
trace_id String,
message String
)
ENGINE = MergeTree()
ORDER BY (log_time, level, service);
2.3 排序键对查询性能的影响
-- 针对 ORDER BY (event_date, user_id, event_type) 设计
-- ✅ 前缀匹配:性能最好(可以利用稀疏索引快速定位)
SELECT * FROM analytics WHERE event_date = '2026-05-11';
SELECT * FROM analytics WHERE event_date = '2026-05-11' AND user_id = 123;
SELECT * FROM analytics WHERE event_date = '2026-05-11' AND user_id = 123 AND event_type = 'click';
-- ⚠️ 跳过前缀:需要扫描更多数据
SELECT * FROM analytics WHERE user_id = 123;
SELECT * FROM analytics WHERE event_type = 'click';
-- 💡 通过跳数索引优化跳过前缀的查询
ALTER TABLE analytics ADD INDEX idx_user_id user_id TYPE bloom_filter GRANULARITY 4;
3. 分区键与排序键的配合
3.1 设计组合
-- 最佳实践:分区键是排序键的前缀
CREATE TABLE best_practice
(
event_date Date,
user_id UInt64,
event_type LowCardinality(String),
data String
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_date) -- 分区键: event_date 的月份
ORDER BY (event_date, user_id); -- 排序键: event_date 在最前面
-- 这样设计的好处:
-- 1. 按月分区 → 分区裁剪
-- 2. 排序键以 event_date 开头 → 每个分区内数据按日期有序
-- 3. 按日期范围查询时,先裁剪分区,再利用排序键快速定位
3.2 分区键 ≠ 排序键前缀的场景
-- 按类型分区,按时间排序
CREATE TABLE mixed_partition
(
event_date Date,
event_type LowCardinality(String),
user_id UInt64,
data String
)
ENGINE = MergeTree()
PARTITION BY event_type -- 按类型分区
ORDER BY (event_date, user_id); -- 按时间+用户排序
-- 查询: 先裁剪到某个类型的分区,然后在分区内按日期范围过滤
SELECT * FROM mixed_partition
WHERE event_type = 'purchase'
AND event_date >= '2026-05-01'
AND event_date < '2026-06-01';
4. 数据跳过索引(Granularity)
4.1 索引粒度
-- 默认索引粒度: 每 8192 行一个索引标记
-- 查询时,ClickHouse 先通过索引标记定位到可能包含数据的 granule
-- 然后只读取这些 granule 的数据
-- 查看 granule 信息
SELECT
partition,
name AS part_name,
rows,
marks, -- 索引标记数
formatReadableSize(primary_key_bytes_in_memory) AS pk_memory
FROM system.parts
WHERE table = 'best_practice' AND active = 1;
-- 查看索引使用情况
SELECT
query,
read_rows,
read_bytes,
result_rows,
query_duration_ms
FROM system.query_log
WHERE type = 'QueryFinish'
AND query LIKE '%best_practice%'
ORDER BY event_time DESC
LIMIT 10;
4.2 自适应索引粒度
-- 启用自适应索引粒度(默认已启用)
CREATE TABLE adaptive_granularity
(
event_date Date,
user_id UInt64,
data String -- 大文本列
)
ENGINE = MergeTree()
ORDER BY (event_date, user_id)
SETTINGS
index_granularity_bytes = 10485760, -- 目标 granule 大小: 10MB
min_bytes_for_wide_part = 10485760;
-- 当单行数据较大时,granule 行数会自动减少
-- 当单行数据较小时,granule 行数接近默认的 8192
5. 分区操作命令
5.1 分区管理
-- 查看所有分区
SELECT
partition,
count() AS parts_count,
sum(rows) AS rows,
formatReadableSize(sum(bytes_on_disk)) AS size,
min(min_date) AS min_date,
max(max_date) AS max_date
FROM system.parts
WHERE table = 'events_monthly' AND active = 1
GROUP BY partition
ORDER BY partition;
-- DETACH 分区(将分区从表中移除,数据文件仍在磁盘上)
ALTER TABLE events_monthly DETACH PARTITION '202604';
-- ATTACH 分区(重新附加)
ALTER TABLE events_monthly ATTACH PARTITION '202604';
-- ATTACH PART(附加单个数据部分)
ALTER TABLE events_monthly ATTACH PART 'all_1_1_0';
-- DROP 分区(永久删除!)
ALTER TABLE events_monthly DROP PARTITION '202601';
-- 从另一张表复制分区
ALTER TABLE events_monthly ATTACH PARTITION '202603' FROM events_backup;
-- FREEZE 分区(创建快照备份)
ALTER TABLE events_monthly FREEZE PARTITION '202605';
-- MOVE 分区到另一个表
ALTER TABLE events_monthly MOVE PARTITION '202604' TO TABLE events_archive;
5.2 FETCH 与 MOVE
-- FETCH PARTITION: 从远程副本下载分区(需要 ReplicatedMergeTree)
-- ALTER TABLE events_monthly FETCH PARTITION '202605' FROM '/clickhouse/tables/events';
-- MOVE PARTITION: 在同一服务器的不同表之间移动分区
ALTER TABLE events_monthly MOVE PARTITION '202604' TO TABLE events_monthly_archive;
⚠️ 注意:DROP PARTITION 是不可逆操作,执行前请确认已经备份。DETACH PARTITION 是更安全的选择。
6. 分区最佳实践
| 场景 | 推荐分区策略 | 说明 |
|---|---|---|
| 通用日志(日写入 < 1 亿行) | 按月分区 | 简单、分区数少 |
| 大规模日志(日写入 > 1 亿行) | 按天分区 | 避免单分区过大 |
| 多租户系统 | 按租户 + 时间 | 方便按租户管理数据 |
| 多业务混合 | 按业务类型 + 时间 | 数据隔离 |
| 维度表(无时间字段) | 不分区 | 数据量小,无需分区 |
-- 分区数量检查
SELECT
database,
table,
count(DISTINCT partition) AS partition_count
FROM system.parts
WHERE active = 1
GROUP BY database, table
HAVING partition_count > 500
ORDER BY partition_count DESC;
7. 实际案例
7.1 时序数据设计
-- 监控指标数据:日写入 10 亿行
CREATE TABLE monitoring.metrics
(
timestamp DateTime64(3),
host LowCardinality(String),
metric_name LowCardinality(String),
labels Map(String, String),
value Float64
)
ENGINE = MergeTree()
PARTITION BY toYYYYMMDD(timestamp) -- 按天分区(数据量大)
ORDER BY (host, metric_name, timestamp) -- 先按主机和指标名,再按时间
TTL timestamp + INTERVAL 90 DAY DELETE
SETTINGS index_granularity = 8192;
-- 查询: 获取某台主机最近 1 小时的 CPU 使用率
SELECT
timestamp,
value
FROM monitoring.metrics
WHERE host = 'web-server-01'
AND metric_name = 'cpu_usage'
AND timestamp >= now() - INTERVAL 1 HOUR
ORDER BY timestamp;
-- 利用: 分区裁剪 + 排序键前三列快速定位
7.2 用户行为日志设计
-- 用户行为埋点数据
CREATE TABLE user_events
(
event_date Date,
event_time DateTime,
user_id UInt64,
session_id String,
event_type LowCardinality(String),
page_url String,
referrer String,
user_agent String,
ip IPv4,
extra Map(String, String)
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_date)
ORDER BY (event_date, user_id, event_time)
TTL event_date + INTERVAL 12 MONTH DELETE;
-- 典型查询 1: 某用户某天的行为轨迹
SELECT
event_time,
event_type,
page_url
FROM user_events
WHERE event_date = '2026-05-11'
AND user_id = 12345
ORDER BY event_time;
-- 典型查询 2: 某天的页面访问 Top10
SELECT
page_url,
count() AS pv,
uniq(user_id) AS uv
FROM user_events
WHERE event_date = '2026-05-11'
GROUP BY page_url
ORDER BY pv DESC
LIMIT 10;
7.3 电商订单设计
CREATE TABLE ecom.orders
(
order_date Date,
order_id UInt64,
user_id UInt64,
shop_id UInt64,
product_id UInt64,
product_name String,
category_id LowCardinality(UInt32),
quantity UInt32,
amount Decimal128(2),
status Enum8('pending'=1, 'paid'=2, 'shipped'=3, 'delivered'=4, 'cancelled'=5),
created_at DateTime,
updated_at DateTime
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(order_date)
ORDER BY (order_date, shop_id, user_id, order_id)
PRIMARY KEY (order_date, shop_id); -- 主键只需前两列,减少索引内存
-- 查询 1: 某店铺某月销售统计
SELECT
shop_id,
count() AS orders,
sum(amount) AS revenue,
uniq(user_id) AS customers,
sum(amount) / uniq(user_id) AS arpu
FROM ecom.orders
WHERE order_date >= '2026-05-01' AND order_date < '2026-06-01'
AND shop_id = 1001
AND status NOT IN ('cancelled')
GROUP BY shop_id;
-- 查询 2: 某用户的历史订单
SELECT
order_date,
order_id,
product_name,
amount,
status
FROM ecom.orders
WHERE user_id = 12345
ORDER BY order_date DESC
LIMIT 20;
8. 设计检查清单
| 检查项 | 建议 |
|---|---|
| ✅ 分区键选择 | 使用时间列,月/天级别 |
| ✅ 排序键前缀 | 放置最常过滤的列 |
| ✅ 分区数量 | 控制在 1000 以内 |
| ✅ 单分区大小 | 100MB ~ 10GB |
| ✅ 排序键列数 | 不超过 5 列 |
| ✅ PRIMARY KEY | 可以比 ORDER BY 短,减少索引内存 |
| ✅ TTL | 设置数据过期策略 |