强曰为道
与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

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设置数据过期策略

扩展阅读

  1. MergeTree 分区
  2. 排序键与主键
  3. 分区操作
  4. 数据跳过索引
  5. ClickHouse 排序键最佳实践