前言

ClickHouse 适合在海量明细上快速做过滤、聚合和报表分析,例如订单经营看板、行为日志、监控明细和风控分析。它并不是“查询很快的 MySQL”:其高性能建立在列式扫描、批量写入、预聚合和允许一定延迟可见的基础上。

本文从数据如何落盘、查询如何跳过无关数据讲起,解释 MergeTree 家族、分区、排序键和索引的职责,并给出集群建模、写入治理、查询调优和故障处理方法。核心目标是建立正确的 OLAP 边界:ClickHouse 负责分析型派生数据,交易事实仍由事务型主库承担。

ClickHouse 解决什么问题

先区分 OLTP 和 OLAP:

维度 OLTP,例如 MySQL/PostgreSQL OLAP,例如 ClickHouse
主要目标 单笔交易的正确写入和点查 大量明细的筛选、聚合、报表
数据修改 高频小事务更新、删除 批量追加,尽量少做逐行更新
查询形态 主键点查、少量行 Join 扫描大量行后 GROUP BY、时间范围聚合
存储组织 通常按行存储 按列存储、按列压缩
一致性 强事务、约束、锁 分析数据通常允许秒级到分钟级同步延迟

例如运营需要统计“过去 30 天各区域、渠道、品类的支付金额与退款率”,一次查询可能扫描数亿订单明细,但只读取 pay_timeregionchannelamountrefund_flag 五列。列式存储避免读取订单地址、备注、扩展 JSON 等无关列,配合压缩和向量化执行,才能让这类分析在可接受时间内完成。

flowchart LR
    A[(MySQL 订单主库)] --> B[Binlog CDC / Outbox]
    B --> C[消息队列或同步任务]
    C --> D[(ClickHouse 明细与汇总表)]
    E[运营报表] --> D
    F[BI / 风控分析] --> D
    G[订单创建与支付] --> A

主库是权威事实源,ClickHouse 是可重放、可校验、可重建的分析投影。不要把扣库存、账户余额或订单状态机的唯一事实只放到 ClickHouse 中。

列式存储为什么快

行式存储把一行字段放在一起,读取一条完整订单很方便;列式存储把同一列连续存放,适合只读取少数列并对整批数据做计算。连续的同类型值具有更高压缩率,也更容易被 CPU 批量处理。

以订单事实表为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
CREATE TABLE order_event
(
event_time DateTime,
order_id UInt64,
tenant_id UInt32,
region LowCardinality(String),
channel LowCardinality(String),
amount Decimal(18, 2),
status Enum8('created' = 1, 'paid' = 2, 'refunded' = 3),
ext_json String
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_time)
ORDER BY (tenant_id, event_time, order_id);

执行下面的聚合时,通常不需要读取 order_idext_json

1
2
3
4
5
6
7
SELECT region, sum(amount) AS paid_amount
FROM order_event
WHERE tenant_id = 1001
AND event_time >= '2025-09-01'
AND event_time < '2025-10-01'
AND status = 'paid'
GROUP BY region;

高性能来自组合,而不只是“列存”:只读必要列、压缩后减少磁盘 I/O、向量化计算、按排序键裁剪数据块,以及分布式并行扫描。若查询每次都 SELECT *、过滤条件无法命中排序键,或者在超高基数字段上做无约束聚合,列存也会被拖慢。

ClickHouse 的真实存储结构:分区、Part、列文件、Granule 与 Mark

MergeTree 表为例,ClickHouse 不是把所有数据写进一个连续的“表文件”。一次或一批 INSERT 会生成一个不可变的 data part;后台再将多个小 part 合并成更大的 part。一个按月分区的订单表,磁盘逻辑可以理解为:

1
2
3
4
5
6
7
8
9
10
11
12
13
order_event
├── 202509/ # 2025-09 分区
│ ├── 202509_1_20_3/ # 一个已合并的数据 part
│ │ ├── event_time.bin # event_time 列压缩数据
│ │ ├── event_time.mrk* # event_time 列的 mark 偏移
│ │ ├── tenant_id.bin
│ │ ├── tenant_id.mrk*
│ │ ├── amount.bin
│ │ ├── amount.mrk*
│ │ ├── primary.idx # 稀疏主键索引
│ │ └── checksums.txt / metadata # 校验与 part 元信息
│ └── 202509_21_21_0/ # 新写入、尚未合并的 part
└── 202510/

实际文件会因宽 part、紧凑 part、版本和存储策略而变化,不能依赖文件名编写业务程序;但上述层级说明了核心事实:分区下面有多个 part,part 内每列独立压缩保存,索引和 mark 负责告诉查询从哪一段列文件开始读取。

层级 含义 对性能的影响
Partition PARTITION BY 划分的逻辑片段 用于 TTL、删除分区、归档和粗粒度裁剪
Part 一次写入或后台合并形成的不可变数据文件集合 part 太多会增加查询和 merge 开销
Column file 一个列对应一组压缩数据与 mark 文件 查询只读实际需要的列
Granule part 内连续的一小段行,默认最大行数通常为 8192,且可按数据大小自适应 是主键索引和跳过索引裁剪的最小读取单位
Mark 一个 granule 在各列压缩流中的位置 让引擎能够跳到目标 granule,而不从列文件开头顺序扫描

这里的 granule 不是“索引命中一行”。即使条件精确命中某个 order_id,ClickHouse 也通常读取所在 granule 的一批行,再在内存中过滤。这正是它适合批量扫描和聚合、不擅长大量随机单行读写的原因。

与 MySQL B+ 树、PostgreSQL 堆表的差异

系统 主要数据组织 索引定位方式 更擅长的负载
MySQL InnoDB 聚簇索引叶子节点保存整行,二级索引保存二级键和主键 B+ 树逐层定位到具体记录或主键,再回表 高并发单行事务、主键点查、范围更新
PostgreSQL 表数据主要放在 heap page;B-tree 等索引保存键到 heap tuple 的引用 先走索引,再访问 heap tuple 并结合 MVCC 可见性判断 事务型读写、复杂 SQL、行级更新
ClickHouse MergeTree 一个 part 内按 ORDER BY 排序;同一列连续压缩存储 稀疏主键索引先裁剪 granule,再按 mark 读取列块 海量范围过滤、批量聚合、报表和分析

因此“ClickHouse 有主键”不等于“与 MySQL 主键相同”。ClickHouse 的主键通常由 ORDER BY 推导而来,用于按数据块裁剪;它默认不保证唯一,不提供逐行事务锁,也不会像 B+ 树那样把一次等值查询直接定位到唯一记录。MySQL/PostgreSQL 的索引首先服务于记录定位和并发事务,ClickHouse 的索引首先服务于减少列式扫描量。

一条查询如何使用这些结构

继续以 ORDER BY (tenant_id, event_time, order_id) 的表为例:

1
2
3
4
5
6
SELECT sum(amount)
FROM order_event
WHERE tenant_id = 1001
AND event_time >= '2025-09-01'
AND event_time < '2025-10-01'
AND status = 'paid';

执行过程可以概括为:

  1. 分区裁剪:根据时间条件排除 202508202510 等分区,只保留可能包含 9 月数据的 part。
  2. 主键稀疏索引裁剪:各 part 的 primary.idx 保存每个 granule 在 (tenant_id, event_time, order_id) 上的边界值。引擎据此排除 tenant 不为 1001 或时间不在范围内的 granule。
  3. 通过 mark 定位列偏移:对留下的 granule,读取 statusamount 的 mark,跳到相应压缩块,而不扫描整列文件。
  4. 解压、向量化过滤与聚合:批量解压 status,过滤出 paid;再读取并聚合命中行对应的 amount。如果查询不返回其他列,它们不会被读取。

若 SQL 改成 WHERE toDate(event_time) = '2025-09-01',或者只按 order_id 查而没有 tenant_id、时间条件,优化器可能无法充分使用排序键前缀,扫描 granule 会明显增多。应使用 EXPLAIN indexes = 1 查看分区和主键索引到底裁剪了多少 part、granule,而不是凭“建了索引”判断性能。

三类索引各自解决什么问题

ClickHouse 中常被都叫作“索引”的机制,实际层级不同:

类型 保存内容 能否定位单行 典型用途
Partition pruning 分区表达式的范围 不能 快速排除无关月份、日期等分区
Primary key sparse index 每个 granule 的排序键边界 不能,只缩小 granule 范围 高频条件与 ORDER BY 前缀一致的范围查询
Data skipping index 每个 granule 的 min/max、集合摘要或 Bloom Filter 不能,只判断是否可能命中 排序键之外的高价值过滤列

跳过索引的本质是“可以证明某个 granule 一定不命中,因此不读它”,不是“可以证明一行一定命中”。例如 minmax 适用于按时间或有序数值过滤;set 适用于每个 granule 内不同值数量较少的枚举列;Bloom Filter 可以降低字符串、数组包含类条件的无效扫描,但存在假阳性,仍需回表读取后验证。

1
2
3
4
5
ALTER TABLE order_event
ADD INDEX idx_status status TYPE set(100) GRANULARITY 4;

ALTER TABLE order_event
ADD INDEX idx_error_message error_message TYPE bloom_filter(0.01) GRANULARITY 2;

上述 SQL 不是通用模板。status 若在每个 granule 中都同时存在 paidrefunded 等所有状态,set 无法跳过数据;字符串搜索若本身很少出现,也应先评估排序、物化列或专用检索引擎是否更合适。索引是否有效必须用真实查询、扫描行数和压测结果验证。

数据类型和编码选择

  • 时间使用 DateDateTimeDateTime64,不要把时间保存为字符串。
  • 金额使用 Decimal,避免 Float64 的精度误差。
  • 固定枚举值使用 Enum;低基数字符串如区域、渠道、状态可评估 LowCardinality(String)
  • 用户输入的扩展字段可以保留 JSON/String,但高频查询字段应在写入时提取为独立列或物化列。
  • 不要把 Nullable 当成默认选择。它会额外维护空值标记;能用业务默认值、枚举状态或明确的未知值表达时,通常更容易优化。

MergeTree、分区、排序键和索引

MergeTree 是最常用的表引擎。每次插入会产生数据 part,后台异步把多个小 part 合并为更大的 part;每个 part 内部按 ORDER BY 排序,并按固定粒度形成 mark。查询先通过分区和主键稀疏索引定位可能命中的 mark,再只读取对应列的数据块。

flowchart LR
    A[批量 INSERT] --> B[新数据 Part]
    B --> C[按 ORDER BY 排序]
    C --> D[列文件与 Mark]
    D --> E[后台 Merge]
    F[查询条件] --> G[分区裁剪]
    G --> H[稀疏主键索引裁剪 Mark]
    H --> I[读取命中列并向量化聚合]

四个概念不能混淆

概念 解决的问题 常见误解
PARTITION BY 生命周期、删除历史数据、减少跨时间范围扫描 不是越细越好,按用户或每天分区常产生大量 part
ORDER BY 数据在 part 内的物理排序,决定稀疏主键能否裁剪 它不是传统 B+ 树唯一索引
主键稀疏索引 基于排序键按粒度记录边界,跳过不可能命中的 granule 不能高效定位任意一行
data skipping index 根据列的 minmax、集合或 Bloom Filter 跳过数据块 只有数据分布和查询谓词匹配时有效

如何设计分区

分区首要服务于数据生命周期。例如行为日志保留 180 天,可以使用 PARTITION BY toYYYYMM(event_time),按月执行 TTL 清理;每日数据量特别大且清理、迁移需要更细粒度时才考虑按天分区。

避免把 tenant_iduser_idorder_id 这类高基数字段作为分区键。它们会制造海量小分区和 part,增加元数据、合并和查询计划开销。多租户隔离通常应放在排序键前缀,或者在确有物理隔离要求时采用独立库、独立表或集群策略。

如何设计 ORDER BY

排序键应从最常见、选择性较高且能限制扫描范围的过滤条件出发。对于“租户内按时间看报表”的表,(tenant_id, event_time, order_id) 通常比 (order_id) 更适合;对于全局按时间做监控统计,(metric, event_time, host) 可能更自然。

设计步骤:

  1. 收集真实 SQL,确定最常用的 WHERE、时间范围和聚合维度。
  2. 将高频且低到中等基数的业务隔离字段放在前面,例如租户、业务类型、区域。
  3. 将时间字段放在可帮助范围裁剪的位置。
  4. 用唯一 ID 放在后面维持同一对象的局部顺序,不要期待它替代事务型点查索引。
  5. EXPLAIN indexes = 1、实际扫描行数和 part 数验证,而不是只根据字段基数猜测。

排序键一旦确定,历史数据重排的代价很高。上线前应使用接近真实分布的数据压测主要报表,而不是只验证单条 SQL 可以执行。

跳过索引何时有用

常见类型包括:时间或数值范围用 minmax,小集合值可评估 set,字符串或数组包含查询可评估 bloom_filter。例如日志按时间排序,但经常按错误码过滤,可为 error_code 添加合适的跳过索引。

它不是“给每列加索引”。若同一错误码随机分散在所有 granule 中,索引无法跳过多少数据,只会增加写入、合并和存储成本。应先通过排序和分区解决大部分扫描范围,再针对明确的高价值谓词补充跳过索引。

表引擎与去重语义

引擎 典型用途 关键注意点
MergeTree 追加型明细、日志、事件 默认不去重、不做更新
ReplacingMergeTree CDC 快照或可能重复的版本记录 去重发生在后台 merge,查询最新视图需明确策略
SummingMergeTree 可直接相加的预汇总指标 不适合复杂状态、去重计数等语义
AggregatingMergeTree 保存聚合函数状态 查询时使用 ...Merge 函数合并状态
CollapsingMergeTree / VersionedCollapsingMergeTree 成对正负记录抵消 建模和查询复杂,优先评估 CDC 快照方案

ReplacingMergeTree 常被误认为“唯一键表”。它会在合并时保留同一排序键的一条版本,但合并是异步的,多个 part 在一段时间内可能仍有旧版本。FINAL 可以强制合并视图,但代价高,不适合高并发报表的默认写法。

生产上通常有三种方案:保留 append-only 事件并在查询中按版本聚合;通过物化视图维护最新状态表;在离线或低频任务中做最终去重。选择取决于数据时效、查询频率和可接受的一致性延迟。

写入、合并与物化视图

批量写入而不是逐行 INSERT

ClickHouse 更适合批量写入。逐条 INSERT 会生成大量小 part,导致后台合并追不上,进而增加查询需要访问的 part 数、磁盘放大和延迟。应用侧应按条数或时间窗口缓冲,例如每批数千到数万行,再根据行大小、写入并发和延迟目标压测确定。

CDC 写入需要处理至少一次投递:消费者应有可恢复位点、失败重试和死信处理;表模型应利用事件 ID、版本号或下游去重逻辑避免重复统计。不要只因为消息发送成功就假设 ClickHouse 已经可靠落盘并可查询。

合并压力如何判断

小 part 堆积、磁盘空间持续上涨、查询变慢时,要检查:写入批次是否过小、分区是否过细、分片是否倾斜、后台 merge 是否受 CPU/磁盘 I/O 限制,以及 TTL 删除和 mutation 是否同时大量执行。不能用频繁 OPTIMIZE TABLE ... FINAL 当作日常治理手段,它会强制重写数据并抢占资源。

system.partssystem.mergessystem.mutationssystem.query_log 是排查的重要系统表。应持续观察活跃 part 数、合并队列、读写字节、查询扫描行数、内存峰值和慢查询分位数。

物化视图与预聚合

物化视图适合把明细写入时同步转换为面向查询的汇总表,例如按“天、租户、渠道、商品类目”累计金额和订单数。它能减少报表扫描量,但代价是写入链路更复杂,以及维度变更、迟到数据和回补需要明确方案。

1
2
3
4
5
6
7
8
9
10
11
CREATE MATERIALIZED VIEW mv_daily_channel
TO daily_channel_amount
AS SELECT
toDate(event_time) AS day,
tenant_id,
channel,
sumState(amount) AS amount_state,
countState() AS order_count_state
FROM order_event
WHERE status = 'paid'
GROUP BY day, tenant_id, channel;

汇总表使用 AggregatingMergeTree 时保存的是聚合状态,读取时要使用 sumMergecountMerge。物化视图只处理创建之后流入源表的数据,历史数据需单独回灌;回灌任务应可重试、可校验,并避免和实时数据重复计算。

分布式部署与数据建模

常见集群结构由分片和副本组成:一个 shard 保存全量数据的一个子集,replica 保存同一 shard 的副本以提高可用性。Distributed 表负责将查询或写入路由到多个本地表;协调节点聚合结果,但真正的数据仍在各分片的 ReplicatedMergeTree 本地表中。

flowchart TB
    Q[BI / 查询服务] --> D[Distributed 表]
    D --> S1A[Shard 1 Replica A]
    D --> S1B[Shard 1 Replica B]
    D --> S2A[Shard 2 Replica A]
    D --> S2B[Shard 2 Replica B]
    S1A --> Z[ClickHouse Keeper]
    S1B --> Z
    S2A --> Z
    S2B --> Z

分片键要让数据和计算尽量均匀。按 cityHash64(tenant_id) 分片适合租户内聚合和租户隔离查询;纯时间分片容易形成当前时间段热点;单一超级租户会造成分片倾斜,可能需要单独分片、限流或在模型上拆分。

副本解决的是可用性,不是自动解决逻辑重复和跨分片聚合成本。跨分片 JOIN、高基数 GROUP BY 和全局精确去重会放大网络、内存和协调节点压力,应尽量通过预聚合、字典、局部 Join 或查询约束降低代价。

查询调优与常见陷阱

优先缩小扫描范围

  1. 所有大表查询都应带明确时间范围,避免无界扫描。
  2. WHERE 中使用与排序键前缀匹配的条件,避免对过滤列套函数导致裁剪失效。
  3. 只选择需要的列,避免 SELECT * 和无必要的大字段。
  4. 先过滤、再 Join、再聚合;大表与大表 Join 前要明确其中一侧是否能收敛。
  5. 对固定口径的高频看板采用物化视图或预计算,而不是每次从明细重算。

JOIN 的边界

ClickHouse 可以做 Join,但不是替代事务库中任意复杂 Join 的理由。大表 Join 通常需要构建右表哈希结构,可能消耗大量内存。小维表可以用字典、预加载维表或合理的 Join;两个超大明细表应优先在数据模型或离线链路中预关联,或限制时间范围和数据规模。

维度变更也需明确语义:报表是按“当前维度”展示,还是按“事件发生时维度”展示。前者可在查询时关联当前维表,后者要在事件写入时冗余快照字段,两种结果不能混用。

去重与近似计算

精确 uniqExact、全局 ORDER BY、大范围 DISTINCT 会显著增加内存和网络开销。若业务是趋势看板、漏斗分析或容量统计,可评估 uniqCombined64uniqHLL12 等近似算法,并向产品和数据使用方明确误差边界。财务结算、对账和对外账单则不能为了性能随意使用近似值。

生命周期、权限与运维治理

TTL 与冷热分层

时间序列和日志数据应从建表时明确保留周期。例如明细保留 180 天、日汇总保留三年,过期数据通过 TTL 自动删除或迁移到冷存储。TTL 会触发后台数据重写,执行节奏必须结合磁盘和合并能力规划,不能在资源紧张时一次性对海量历史数据进行 mutation。

权限与资源隔离

生产环境不应让所有 BI 用户直接使用管理员账户扫描明细。至少需要区分写入账号、在线查询账号、离线任务账号和运维账号;通过用户 profile 或 quota 限制并发、内存、执行时间和读取量。对多租户场景,还要在网关、语义层或视图中强制租户条件,不能只依赖前端传参。

备份与恢复

副本不是备份:错误的 DROP、错误回灌或逻辑污染会同步到副本。需要定期验证可恢复备份、对象存储生命周期、元数据和 Keeper 配置,并演练单分片不可用、误删分区和历史回灌。对于可由 Kafka、主库或数据湖重建的派生数据,应明确重建顺序、预计时长和业务降级策略。

高频面试题

ClickHouse 为什么快?

核心是列式存储减少无关列 I/O、同类型数据压缩率高、向量化执行提升批量计算效率,以及 MergeTree 按排序键和稀疏索引跳过无关 granule。实际效果还依赖查询是否有时间范围、是否命中排序键、part 数量和集群分片是否均衡;不能只回答“因为列存”。

ClickHouse 的主键为什么不是 MySQL 的 B+ 树主键?

MergeTree 主键基于 ORDER BY 的稀疏索引,记录的是数据块边界,用于跳过不可能命中的 granule,而非为每行建立指针。它没有唯一约束,也不适合把任意 ID 点查当成 OLTP 主键查询。因此业务唯一性仍应由主库、写入链路或明确的去重模型保障。

一条数据在 ClickHouse 中怎样存储和被索引?

数据先按 PARTITION BY 进入一个分区,再以每批写入或后台合并后的 data part 形式保存。part 内按 ORDER BY 排序,同一列分别压缩为列文件;每约 8192 行形成一个 granule,并保存 mark 偏移和排序键边界。查询先裁剪分区,再用 primary.idx 排除不可能命中的 granule,最后通过 mark 跳到所需列的压缩块读取和过滤。它定位的是一批可能命中的行,不是像 B+ 树一样直接定位一条记录。

分区键和排序键怎么选?

分区键服务于数据生命周期和粗粒度裁剪,通常使用月或天等低基数时间维度;排序键服务于 part 内数据排列和高频查询裁剪,应从真实 WHERE 条件设计,例如 (tenant_id, event_time, order_id)。不要使用高基数用户 ID、订单 ID 作为分区键,也不要把所有字段塞进排序键。

ReplacingMergeTree 能保证实时去重吗?

不能。去重发生在后台 part merge,旧版本在合并前可能仍可见;FINAL 虽能得到合并视图,却会增加查询成本。需要按业务选择版本聚合、物化视图维护最新状态、离线去重或 CDC 幂等策略,并明确查询对实时性的要求。

为什么 ClickHouse 不适合做订单主库?

订单创建、支付、退款和库存扣减需要强事务、唯一约束、行级并发控制和明确的更新语义。ClickHouse 针对批量追加和分析查询优化,逐行更新、删除和高频小事务成本高,且副本和 merge 机制无法替代交易一致性。应由 MySQL/PostgreSQL 保存权威订单,ClickHouse 承担报表和分析。

小 part 太多会造成什么问题,怎么处理?

大量小 part 会增加文件、元数据、查询打开 part 的成本,后台合并也可能持续积压,最终表现为写入变慢、查询抖动和磁盘放大。先检查逐行写入、过细分区、写入并发和分片倾斜;通过增大批次、优化分区、控制写入并发和保障 merge 资源治理。频繁执行 OPTIMIZE FINAL 只能临时缓解,不能替代根因修复。

如何处理分析数据与主库不一致?

使用 CDC 或 Outbox 保证主库提交后的变更可可靠投递,消费者保存位点并支持幂等重试,针对迟到、乱序和失败设置补偿与重放。建立主库与 ClickHouse 的行数、金额、最大事件时间和抽样明细校验;当分析链路延迟或失败时,在看板展示数据截止时间,而不是把过期数据伪装为实时数据。

总结

ClickHouse 的正确使用方式是围绕分析查询建模:以批量写入、合理分区、面向查询的排序键和可重建的派生数据换取高吞吐聚合能力。生产落地时,要把 OLTP 主库、CDC 同步、明细表、预聚合表、权限配额和备份重建视为一个整体,而不是只部署一张“查询很快”的表。