ClickHouse Engine 机制
大约 13 分钟约 3797 字
ClickHouse Engine 机制
简介
ClickHouse 的表引擎(Table Engine)决定了数据的存储方式、查询执行路径和并发行为。MergeTree 家族是 ClickHouse 最核心的引擎系列,几乎覆盖所有 OLAP 场景。理解 MergeTree 及其派生引擎的合并机制、排序规则和去重策略,是设计高性能分析系统的前提。
表引擎是 ClickHouse 架构中最核心的概念之一。建表时必须指定 ENGINE,它决定了数据如何写入磁盘、如何读取、如何合并、是否支持副本、是否支持分片等关键行为。选错引擎不仅会导致查询性能低下,还可能使某些功能完全不可用。
MergeTree 家族全景
MergeTree(基础引擎)
├── ReplacingMergeTree — 去重(按版本保留最新行)
├── SummingMergeTree — 预聚合(数值列自动求和)
├── AggregatingMergeTree — 复杂聚合(配合物化视图)
├── CollapsingMergeTree — 撤销/恢复(通过 sign 标记)
├── VersionedCollapsingMergeTree — 带版本号的撤销/恢复
├── GraphiteMergeTree — Graphite 时序数据聚合
└── Replicated* — 副本系列(上述引擎的副本版本)
├── ReplicatedMergeTree
├── ReplicatedReplacingMergeTree
├── ReplicatedSummingMergeTree
├── ReplicatedAggregatingMergeTree
└── ReplicatedCollapsingMergeTree特点
实现
MergeTree 基础建表与分区
-- 基础 MergeTree 表:按日期分区,按 (site_id, event_time) 排序
CREATE TABLE events (
event_id UUID DEFAULT generateUUIDv4(),
site_id UInt32,
event_time DateTime,
event_type String,
user_id UInt64,
payload String
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_time)
ORDER BY (site_id, event_time)
TTL event_time + INTERVAL 90 DAY
SETTINGS index_granularity = 8192;
-- 查询时分区裁剪自动生效
SELECT count(), uniqExact(user_id)
FROM events
WHERE site_id = 42
AND event_time >= '2025-01-01'
AND event_time < '2025-02-01';
-- 查看分区信息
SELECT partition, name, rows, bytes_on_disk
FROM system.parts
WHERE table = 'events' AND active
ORDER BY partition;
-- 手动删除过期分区(比 DELETE 快得多)
ALTER TABLE events DROP PARTITION '202501';MergeTree 数据存储结构
MergeTree 数据目录结构:
/data/clickhouse/data/default/events/
├── 202501_1_3_1/ # 分区_最小part_最大part_合并层级
│ ├── event_time.bin # 列数据文件
│ ├── event_time.mrk2 # 列标记文件(稀疏索引)
│ ├── site_id.bin
│ ├── site_id.mrk2
│ ├── event_type.bin
│ ├── event_type.mrk2
│ ├── user_id.bin
│ ├── user_id.mrk2
│ ├── checksums.txt # 校验和
│ └── columns.txt # 列元信息
├── 202502_1_1_0/
│ └── ...
└── detached/ # 分离的 part(待处理)每个 part 包含所有列的数据文件(.bin)和对应的标记文件(.mrk2)。标记文件记录了每个 granule(默认 8192 行)在数据文件中的偏移位置,实现稀疏索引查找。
PARTITION BY 与 ORDER BY 的区别
-- PARTITION BY:控制数据的物理分区,影响分区裁剪和 TTL
-- ORDER BY:控制数据在分区内的排序,影响主键索引和查询效率
-- PRIMARY KEY:默认与 ORDER BY 相同,可以简化(仅用于索引跳表)
-- 示例:电商订单分析表
CREATE TABLE orders_analytics (
order_id UInt64,
user_id UInt64,
product_id UInt32,
category LowCardinality(String),
order_date Date,
amount Decimal64(2),
province LowCardinality(String)
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(order_date) -- 按月分区,便于清理旧数据
ORDER BY (category, order_date, user_id) -- 按类目+日期+用户排序
PRIMARY KEY (category, order_date) -- 主键可以比 ORDER BY 短
TTL order_date + INTERVAL 180 DAY; -- 6 个月自动过期
-- 查询时分区裁剪 + 主键索引跳表
SELECT category, sum(amount), uniqExact(user_id)
FROM orders_analytics
WHERE category = '电子产品'
AND order_date >= '2025-01-01'
AND order_date < '2025-02-01'
GROUP BY category;
-- ClickHouse 会:1) 跳过 2025 以外的分区 2) 在 202501 分区内用主键索引定位ReplacingMergeTree 数据更新
-- 使用 ReplacingMergeTree 实现"最后写入胜出"
CREATE TABLE user_profiles (
user_id UInt64,
updated_at DateTime DEFAULT now(),
name String,
email String,
sign UInt8 -- 版本号,越高越新
)
ENGINE = ReplacingMergeTree(sign)
ORDER BY (user_id)
PRIMARY KEY (user_id);
-- 插入重复数据(相同 user_id)
INSERT INTO user_profiles (user_id, name, email, sign) VALUES
(1, 'Alice', 'alice@old.com', 1);
INSERT INTO user_profiles (user_id, name, email, sign) VALUES
(1, 'Alice', 'alice@new.com', 2);
-- FINAL 关键字确保合并后再返回(有性能代价)
SELECT user_id, name, email
FROM user_profiles FINAL
WHERE user_id = 1;
-- 推荐:通过 argMax 手动取最新值,避免 FINAL 的全表扫描
SELECT user_id,
argMax(name, updated_at) AS name,
argMax(email, updated_at) AS email
FROM user_profiles
GROUP BY user_id;ReplacingMergeTree 的合并延迟问题
-- ReplacingMergeTree 的去重是异步的
-- 刚插入的数据可能还能查到旧版本
-- 解决方案 1:使用 OPTIMIZE 强制合并(生产环境慎用)
OPTIMIZE TABLE user_profiles FINAL;
-- 解决方案 2:使用 argMax 查询(推荐)
SELECT
user_id,
argMax(name, updated_at) AS name,
argMax(email, updated_at) AS email,
max(updated_at) AS last_updated
FROM user_profiles
GROUP BY user_id;
-- 解决方案 3:使用 version 列代替时间戳
CREATE TABLE user_profiles_v2 (
user_id UInt64,
version UInt64, -- 单调递增的版本号
name String,
email String
)
ENGINE = ReplacingMergeTree(version)
ORDER BY user_id;
-- 每次更新 version + 1
INSERT INTO user_profiles_v2 VALUES (1, 1, 'Alice', 'alice@old.com');
INSERT INTO user_profiles_v2 VALUES (1, 2, 'Alice', 'alice@new.com');
-- 合并时自动保留 version=2 的行SummingMergeTree 与 AggregatingMergeTree
-- SummingMergeTree:自动对数值列求和
CREATE TABLE orders_daily (
order_date Date,
product_id UInt32,
quantity UInt64,
revenue Float64
)
ENGINE = SummingMergeTree()
PARTITION BY toYYYYMM(order_date)
ORDER BY (order_date, product_id);
-- 批量写入当天数据,合并后自动汇总
INSERT INTO orders_daily VALUES ('2025-06-01', 101, 5, 250.0);
INSERT INTO orders_daily VALUES ('2025-06-01', 101, 3, 150.0);
-- 合并后:quantity=8, revenue=400.0
-- 注意:非 ORDER BY 列的非数值列取第一条
-- 如果 ORDER BY 包含 description 列,description 不会被合并
CREATE TABLE orders_daily_v2 (
order_date Date,
product_id UInt32,
quantity UInt64,
revenue Float64,
description String -- 非 ORDER BY 列
)
ENGINE = SummingMergeTree()
ORDER BY (order_date, product_id);
-- 合并后 description 取第一条的值,可能不是预期的结果
-- AggregatingMergeTree:物化视图增量聚合
CREATE TABLE visits_agg
ENGINE = AggregatingMergeTree()
ORDER BY (page_id, visit_date)
PARTITION BY toYYYYMM(visit_date)
AS SELECT
page_id,
visit_date,
uniqState(user_id) AS uniq_users,
sumState(duration) AS total_duration
FROM page_visits
GROUP BY page_id, visit_date;
-- 查询时使用 Merge 后缀还原聚合结果
SELECT page_id,
uniqMerge(uniq_users) AS users,
sumMerge(total_duration) AS total_dur
FROM visits_agg
GROUP BY page_id;CollapsingMergeTree 行级撤销
-- CollapsingMergeTree:通过 sign 列实现行的插入和撤销
-- sign=1 表示插入,sign=-1 表示删除
CREATE TABLE user_balance_log (
user_id UInt64,
balance Int64,
sign Int8, -- 1=插入, -1=撤销
updated_at DateTime DEFAULT now()
)
ENGINE = CollapsingMergeTree(sign)
ORDER BY user_id;
-- 插入余额记录
INSERT INTO user_balance_log VALUES (1, 1000, 1, now());
-- 余额变更(撤销旧记录 + 插入新记录)
INSERT INTO user_balance_log VALUES (1, 1000, -1, now()); -- 撤销
INSERT INTO user_balance_log VALUES (1, 1500, 1, now()); -- 新余额
-- 查询(合并后 sign=1 和 sign=-1 抵消)
SELECT user_id, sum(balance * sign) AS current_balance
FROM user_balance_log
GROUP BY user_id;
-- VersionedCollapsingMergeTree:解决并发写入的版本问题
CREATE TABLE user_balance_log_v2 (
user_id UInt64,
balance Int64,
sign Int8,
version UInt64, -- 并发控制版本号
updated_at DateTime DEFAULT now()
)
ENGINE = VersionedCollapsingMergeTree(sign, version)
ORDER BY user_id;TTL 与数据生命周期管理
-- TTL 不仅可以控制数据过期,还能实现数据自动移动(冷热分层)
CREATE TABLE events_with_ttl (
event_id UUID DEFAULT generateUUIDv4(),
event_time DateTime,
event_type String,
payload String,
-- 数据在 SSD 上存储 7 天后移动到 HDD 卷
-- 在 HDD 上再存储 83 天后自动删除
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_time)
ORDER BY (event_type, event_time)
TTL event_time + INTERVAL 7 DAY TO VOLUME 'cold',
event_time + INTERVAL 90 DAY
SETTINGS index_granularity = 8192;
-- 多卷存储配置(config.xml)
-- <storage_configuration>
-- <volumes>
-- <hot>
-- <disk>ssd_disk</disk>
-- </hot>
-- <cold>
-- <disk>hdd_disk</disk>
-- </cold>
-- </volumes>
-- <policies>
-- <default>
-- <volumes>
-- <hot>...</hot>
-- <cold>...</cold>
-- </volumes>
-- </default>
-- </policies>
-- </storage_configuration>
-- 列级 TTL:不同列可以有不同的过期时间
CREATE TABLE user_sessions (
user_id UInt64,
session_id String,
login_time DateTime,
ip_address String TTL login_time + INTERVAL 30 DAY, -- IP 30 天后清除
user_agent String TTL login_time + INTERVAL 7 DAY, -- UA 7 天后清除
page_views UInt64
)
ENGINE = MergeTree()
ORDER BY (user_id, login_time)
TTL login_time + INTERVAL 180 DAY; -- 整行 180 天后删除稀疏索引与跳数索引
-- ClickHouse 的主键索引是稀疏索引(每 8192 行一个索引条目)
-- 对于不在主键中的列,可以使用跳数索引加速查询
CREATE TABLE access_logs (
request_id UUID DEFAULT generateUUIDv4(),
timestamp DateTime,
path String,
method LowCardinality(String),
status_code UInt16,
response_time_ms UInt32,
user_agent String,
client_ip String
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(timestamp)
ORDER BY (timestamp, path)
-- 跳数索引:加速非主键列的查询
-- minmax 索引:适合范围查询
INDEX idx_status status_code TYPE minmax GRANULARITY 4
-- set 索引:适合等值查询(存储每个 granule 的去重值集合)
INDEX idx_method method TYPE set(0) GRANULARITY 4
-- bloom_filter 索引:适合高基数的等值或 LIKE 查询
INDEX idx_ua user_agent TYPE bloom_filter(0.01) GRANULARITY 1
-- tokenbf_v1 索引:适合空格分隔的文本搜索
INDEX idx_ip client_ip TYPE tokenbf_v1(512, 3, 0) GRANULARITY 1;
-- 跳数索引不是精确索引,可能会产生假阳性(多读一些 granule)
-- 但不会产生假阴性(不会漏读)
-- 选择 GRANULARITY 值:值越大索引越小但过滤能力越弱
-- 查看索引使用情况
SELECT table, name, type, expr, granularity
FROM system.data_skipping_indices
WHERE table = 'access_logs';
-- 检查索引的命中效果
EXPLAIN indexes = 1
SELECT count() FROM access_logs
WHERE status_code = 404;MergeTree 后台合并机制
-- ClickHouse 的合并是后台异步执行的
-- 理解合并机制对性能调优至关重要
-- 查看当前 part 信息
SELECT
table,
partition,
count() AS part_count,
sum(rows) AS total_rows,
formatReadableSize(sum(bytes_on_disk)) AS total_size,
min(modification_time) AS oldest_part,
max(modification_time) AS newest_part
FROM system.parts
WHERE database = 'default' AND active
GROUP BY table, partition
ORDER BY table, partition;
-- 合并相关配置
-- 在建表时指定
CREATE TABLE events_tuned (
event_time DateTime,
event_type String,
payload String
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_time)
ORDER BY (event_time)
SETTINGS
index_granularity = 8192, -- 稀疏索引粒度(默认 8192)
min_bytes_for_compact_part = '10M', -- 超过此大小使用 wide format
min_rows_for_compact_part = 100000, -- 超过此行数使用 wide format
min_bytes_for_wide_part = '100M', -- 超过此大小使用 compact format
min_merge_bytes_to_use_direct_io = '200M', -- 超过此大小使用直接 IO
merge_with_ttl_timeout = 14400, -- TTL 合并间隔(秒)
max_parts_in_total = 100000; -- 最大 part 数量
-- 手动触发合并(调试用,生产环境慎用)
OPTIMIZE TABLE events FINAL; -- 强制合并所有 part
OPTIMIZE TABLE events PARTITION '202506'; -- 只合并指定分区
-- 监控合并任务
SELECT
table,
is_mutation,
is_currently_executing,
parts_to_merge,
result_part_name,
progress
FROM system.merges;物化视图实战
-- 场景:原始访问日志表 → 每小时 UV/PV 聚合表
-- 原始表
CREATE TABLE page_visits (
page_id UInt32,
user_id UInt64,
visit_date DateTime,
duration UInt32
)
ENGINE = MergeTree()
ORDER BY (page_id, visit_date);
-- 物化视图(自动增量聚合)
CREATE MATERIALIZED VIEW visits_hourly_mv
TO visits_hourly
AS SELECT
page_id,
toStartOfHour(visit_date) AS hour,
uniqState(user_id) AS uniq_users,
countState() AS page_views,
sumState(duration) AS total_duration
FROM page_visits
GROUP BY page_id, toStartOfHour(visit_date);
-- 目标聚合表
CREATE TABLE visits_hourly (
page_id UInt32,
hour DateTime,
uniq_users AggregateFunction(uniq, UInt64),
page_views AggregateFunction(count),
total_duration AggregateFunction(sum, UInt32)
)
ENGINE = AggregatingMergeTree()
ORDER BY (page_id, hour);
-- 查询聚合结果
SELECT
page_id,
hour,
uniqMerge(uniq_users) AS uv,
countMerge(page_views) AS pv,
sumMerge(total_duration) AS avg_duration
FROM visits_hourly
WHERE hour >= now() - INTERVAL 7 DAY
GROUP BY page_id, hour
ORDER BY hour;优点
引擎选型决策树
根据业务场景选择合适的 MergeTree 引擎:
是否需要数据更新(UPDATE 语义)?
├── 是 → 是否按版本号更新?
│ ├── 是 → ReplacingMergeTree(version)
│ └── 否 → 需要撤销旧数据吗?
│ ├── 是 → CollapsingMergeTree(sign)
│ └── 否 → 使用 ALTER UPDATE(Mutation,有性能代价)
├── 否 → 是否需要预聚合指标?
│ ├── 是 → 聚合逻辑是否为 SUM?
│ │ ├── 是 → SummingMergeTree
│ │ └── 否 → AggregatingMergeTree
│ └── 否 → 是否需要高可用?
│ ├── 是 → ReplicatedMergeTree
│ └── 否 → MergeTree(基础引擎)Mutation(ALTER UPDATE/DELETE)机制
-- ClickHouse 的 UPDATE/DELETE 是异步 Mutation,不是即时生效
-- 适合低频的批量操作,不适合高频单行更新
-- 更新操作(异步 Mutation)
ALTER TABLE events UPDATE payload = 'updated' WHERE event_type = 'click';
-- 删除操作(异步 Mutation)
ALTER TABLE events DELETE WHERE event_time < '2024-01-01';
-- 查看 Mutation 进度
SELECT
table,
mutation_id,
command,
is_done,
parts_to_do,
latest_fail_reason
FROM system.mutations
WHERE table = 'events'
ORDER BY create_time DESC;
-- Mutation 的代价:
-- 1. 重写受影响的所有 part(不是原地更新)
-- 2. 大范围更新会产生大量 IO
-- 3. Mutation 队列串行执行,多个 Mutation 会排队
-- 4. 执行期间不影响查询(新 part 准备好后原子替换)
-- 优化建议:
-- 1. 尽量按分区范围操作,减少重写量
-- 2. 使用 INSERT + CollapsingMergeTree 代替频繁 UPDATE
-- 3. 使用 ReplacingMergeTree 实现幂等写入
-- 4. 评估是否可以通过 DROP PARTITION + 重新导入代替 UPDATE缺点
总结
ClickHouse 的 MergeTree 引擎族是构建 OLAP 系统的基石。ReplacingMergeTree 解决数据更新、SummingMergeTree 实现指标预聚合、AggregatingMergeTree 支持复杂聚合物化。选型时需要根据业务场景确定合适的引擎,并通过 argMax、物化视图等手段规避 FINAL 带来的性能问题。
关键知识点
- MergeTree ORDER BY 决定数据排序和主键索引,影响查询裁剪效率
- ReplacingMergeTree 的合并是异步的,查询时需要 FINAL 或手动 argMax
- SummingMergeTree 只对数值类型列自动求和,非数值列取第一条
- AggregatingMergeTree 必须配合 *State/*Merge 函数使用
- CollapsingMergeTree 通过 sign 列实现行级撤销,适合流水账场景
- 物化视图是实现增量聚合的核心手段
项目落地视角
- 建表前先用 EXPLAIN 分析查询模式,确定 ORDER BY 和分区键
- 写入时按主键排序批量写入,避免小批量频繁 INSERT
- 生产环境必须配置 TTL 和自动分区删除策略,防止磁盘爆满
- 监控 system.parts 中的 parts 数量,超过 300 触发告警
- 物化视图是高频聚合查询的最佳方案,避免每次查询都扫原始表
常见误区
- 以为 ReplacingMergeTree 插入后立即去重,实际需要等待后台合并
- 对大表滥用 FINAL,导致查询超时和内存溢出
- ORDER BY 键选择过多列,导致索引文件膨胀、写入变慢
- 在 ClickHouse 上做频繁的 UPDATE/DELETE 操作
- SummingMergeTree 的非数值列合并行为与预期不符
- 忽略 PARTITION BY 和 ORDER BY 的区别,导致分区裁剪失效
进阶路线
- 深入学习 ReplicatedMergeTree 实现高可用集群
- 研究分布式表 (Distributed) 的查询路由与聚合逻辑
- 掌握 ClickHouse 物化视图的链式使用场景
- 学习 ClickHouse Keeper 替代 ZooKeeper 的部署方案
- 了解 ClickHouse 的 Mutation(ALTER UPDATE/DELETE)机制和限制
适用场景
- 日志分析、用户行为追踪(MergeTree + 按日分区)
- 用户画像更新(ReplacingMergeTree + 版本号)
- 实时指标看板(AggregatingMergeTree + 物化视图)
- 订单汇总统计(SummingMergeTree 按天聚合)
- 账户余额流水(CollapsingMergeTree + sign 列)
落地建议
- 分区键优先选择日期粒度(月/日),便于过期数据清理
- ORDER BY 键应覆盖高频查询的 GROUP BY 和 WHERE 列
- 使用 INSERT SELECT 批量导入代替逐行写入
- 生产表必须设置 index_granularity 和 min_bytes_for_compact_part
- 高频聚合查询使用物化视图,避免直接查原始表
排错清单
- 查询慢:检查 system.query_log,确认是否触发全表扫描或 FINAL
- 磁盘增长快:查看 system.parts 是否有大量未合并的 part
- 数据不一致:确认 ReplacingMergeTree 的版本号或 sign 列是否正确写入
- 内存不足:检查 ORDER BY 键的基数,高基数排序消耗大量内存
- 物化视图不更新:检查 MV 的 SELECT 是否与源表结构匹配
复盘问题
- 当前表的 ORDER BY 键是否真正匹配了 Top 10 查询的过滤模式?
- 是否存在可以用 SummingMergeTree 替代的汇总表?
- FINAL 的使用频率是否过高?能否改为 argMax 方案?
- TTL 和分区清理策略是否覆盖了所有大表?
- 物化视图是否覆盖了所有高频聚合查询?
