ClickHouse:列式存储、MergeTree、索引与 OLAP 生产实践
前言
ClickHouse 适合在海量明细上快速做过滤、聚合和报表分析,例如订单经营看板、行为日志、监控明细和风控分析。它并不是“查询很快的 MySQL”:其高性能建立在列式扫描、批量写入、预聚合和允许一定延迟可见的基础上。
本文从数据如何落盘、查询如何跳过无关数据讲起,解释 MergeTree 家族、分区、排序键和索引的职责,并给出集群建模、写入治理、查询调优和故障处理方法。核心目标是建立正确的 OLAP 边界:ClickHouse 负责分析型派生数据,交易事实仍由事务型主库承担。
ClickHouse 解决什么问题
先区分 OLTP 和 OLAP:
| 维度 | OLTP,例如 MySQL/PostgreSQL | OLAP,例如 ClickHouse |
|---|---|---|
| 主要目标 | 单笔交易的正确写入和点查 | 大量明细的筛选、聚合、报表 |
| 数据修改 | 高频小事务更新、删除 | 批量追加,尽量少做逐行更新 |
| 查询形态 | 主键点查、少量行 Join | 扫描大量行后 GROUP BY、时间范围聚合 |
| 存储组织 | 通常按行存储 | 按列存储、按列压缩 |
| 一致性 | 强事务、约束、锁 | 分析数据通常允许秒级到分钟级同步延迟 |
例如运营需要统计“过去 30 天各区域、渠道、品类的支付金额与退款率”,一次查询可能扫描数亿订单明细,但只读取 pay_time、region、channel、amount、refund_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 | CREATE TABLE order_event |
执行下面的聚合时,通常不需要读取 order_id 和 ext_json:
1 | SELECT region, sum(amount) AS paid_amount |
高性能来自组合,而不只是“列存”:只读必要列、压缩后减少磁盘 I/O、向量化计算、按排序键裁剪数据块,以及分布式并行扫描。若查询每次都 SELECT *、过滤条件无法命中排序键,或者在超高基数字段上做无约束聚合,列存也会被拖慢。
ClickHouse 的真实存储结构:分区、Part、列文件、Granule 与 Mark
以 MergeTree 表为例,ClickHouse 不是把所有数据写进一个连续的“表文件”。一次或一批 INSERT 会生成一个不可变的 data part;后台再将多个小 part 合并成更大的 part。一个按月分区的订单表,磁盘逻辑可以理解为:
1 | order_event |
实际文件会因宽 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 | SELECT sum(amount) |
执行过程可以概括为:
- 分区裁剪:根据时间条件排除
202508、202510等分区,只保留可能包含 9 月数据的 part。 - 主键稀疏索引裁剪:各 part 的
primary.idx保存每个 granule 在(tenant_id, event_time, order_id)上的边界值。引擎据此排除 tenant 不为1001或时间不在范围内的 granule。 - 通过 mark 定位列偏移:对留下的 granule,读取
status和amount的 mark,跳到相应压缩块,而不扫描整列文件。 - 解压、向量化过滤与聚合:批量解压
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 | ALTER TABLE order_event |
上述 SQL 不是通用模板。status 若在每个 granule 中都同时存在 paid、refunded 等所有状态,set 无法跳过数据;字符串搜索若本身很少出现,也应先评估排序、物化列或专用检索引擎是否更合适。索引是否有效必须用真实查询、扫描行数和压测结果验证。
数据类型和编码选择
- 时间使用
Date、DateTime或DateTime64,不要把时间保存为字符串。 - 金额使用
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_id、user_id、order_id 这类高基数字段作为分区键。它们会制造海量小分区和 part,增加元数据、合并和查询计划开销。多租户隔离通常应放在排序键前缀,或者在确有物理隔离要求时采用独立库、独立表或集群策略。
如何设计 ORDER BY
排序键应从最常见、选择性较高且能限制扫描范围的过滤条件出发。对于“租户内按时间看报表”的表,(tenant_id, event_time, order_id) 通常比 (order_id) 更适合;对于全局按时间做监控统计,(metric, event_time, host) 可能更自然。
设计步骤:
- 收集真实 SQL,确定最常用的
WHERE、时间范围和聚合维度。 - 将高频且低到中等基数的业务隔离字段放在前面,例如租户、业务类型、区域。
- 将时间字段放在可帮助范围裁剪的位置。
- 用唯一 ID 放在后面维持同一对象的局部顺序,不要期待它替代事务型点查索引。
- 用
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.parts、system.merges、system.mutations、system.query_log 是排查的重要系统表。应持续观察活跃 part 数、合并队列、读写字节、查询扫描行数、内存峰值和慢查询分位数。
物化视图与预聚合
物化视图适合把明细写入时同步转换为面向查询的汇总表,例如按“天、租户、渠道、商品类目”累计金额和订单数。它能减少报表扫描量,但代价是写入链路更复杂,以及维度变更、迟到数据和回补需要明确方案。
1 | CREATE MATERIALIZED VIEW mv_daily_channel |
汇总表使用 AggregatingMergeTree 时保存的是聚合状态,读取时要使用 sumMerge、countMerge。物化视图只处理创建之后流入源表的数据,历史数据需单独回灌;回灌任务应可重试、可校验,并避免和实时数据重复计算。
分布式部署与数据建模
常见集群结构由分片和副本组成:一个 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 或查询约束降低代价。
查询调优与常见陷阱
优先缩小扫描范围
- 所有大表查询都应带明确时间范围,避免无界扫描。
- 在
WHERE中使用与排序键前缀匹配的条件,避免对过滤列套函数导致裁剪失效。 - 只选择需要的列,避免
SELECT *和无必要的大字段。 - 先过滤、再 Join、再聚合;大表与大表 Join 前要明确其中一侧是否能收敛。
- 对固定口径的高频看板采用物化视图或预计算,而不是每次从明细重算。
JOIN 的边界
ClickHouse 可以做 Join,但不是替代事务库中任意复杂 Join 的理由。大表 Join 通常需要构建右表哈希结构,可能消耗大量内存。小维表可以用字典、预加载维表或合理的 Join;两个超大明细表应优先在数据模型或离线链路中预关联,或限制时间范围和数据规模。
维度变更也需明确语义:报表是按“当前维度”展示,还是按“事件发生时维度”展示。前者可在查询时关联当前维表,后者要在事件写入时冗余快照字段,两种结果不能混用。
去重与近似计算
精确 uniqExact、全局 ORDER BY、大范围 DISTINCT 会显著增加内存和网络开销。若业务是趋势看板、漏斗分析或容量统计,可评估 uniqCombined64、uniqHLL12 等近似算法,并向产品和数据使用方明确误差边界。财务结算、对账和对外账单则不能为了性能随意使用近似值。
生命周期、权限与运维治理
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 同步、明细表、预聚合表、权限配额和备份重建视为一个整体,而不是只部署一张“查询很快”的表。


