ClickHouse系列 第1篇:为什么 ClickHouse 具备高性能分析能力
定位认知地基。理解 ClickHouse 的速度不是调参调出来的而是从存储格式、执行模型到硬件利用每一层都在为分析查询服务。一、ClickHouse 要解决的核心问题是什么在讨论快之前先明确 ClickHouse 的战场——OLAP联机分析处理。OLAP 查询的典型特征特征说明读多写少数据批量灌入查询远多于写入宽表少列表可能有数百列但单次查询只涉及 3-5 列聚合为主COUNT、SUM、AVG、PERCENTILE是主角扫描量大动辄扫描数亿行但结果集很小无事务要求不需要行级锁、MVCC、回滚传统 OLTP 数据库MySQL、PostgreSQL为行级事务优化——每一行的所有列紧密存放在一起方便单行读写。但当你执行SELECT avg(amount) FROM orders WHERE date 2024-01-01时MySQL 不得不把每一行的所有列都从磁盘读出来即使你只需要amount和date两列。ClickHouse 的设计哲学很简单把 OLAP 查询路径上的每一个环节都做到极致。二、列式存储 向量化执行的真实收益2.1 列式存储只读你需要的列行式存储MySQL磁盘布局 ┌──────────────────────────────────────────────┐ │ row1: id1, nameAlice, age30, amount100 │ │ row2: id2, nameBob, age25, amount200 │ │ row3: id3, nameCarol, age35, amount150 │ └──────────────────────────────────────────────┘ → 查询 SUM(amount) 需要读取所有列的数据 列式存储ClickHouse磁盘布局 ┌─────────────────────┐ │ id: [1, 2, 3] │ ← 一个文件 │ name: [A, B, C] │ ← 一个文件 │ age: [30, 25, 35] │ ← 一个文件 │ amount: [100,200,150]│ ← 一个文件 └─────────────────────┘ → 查询 SUM(amount) 只需要读 amount 这一列假设一张表有 200 列查询只涉及 3 列列式存储的 I/O 量直接降为行式的3/200 1.5%。这不是优化这是数量级的差异。2.2 压缩率同类型数据天然高压缩列式存储的另一个隐藏收益是压缩率极高。同一列的数据类型相同、值域相近压缩算法LZ4、ZSTD可以获得 5-20 倍的压缩比。-- 查看表的压缩情况SELECTcolumn,formatReadableSize(data_compressed_bytes)AScompressed,formatReadableSize(data_uncompressed_bytes)ASuncompressed,round(data_uncompressed_bytes/data_compressed_bytes,2)ASratioFROMsystem.columnsWHEREtablemy_tableORDERBYdata_uncompressed_bytesDESC;实际生产中一张包含 100 亿行日志的表原始大小 2TB在 ClickHouse 中压缩后通常只占 150-300GB。2.3 向量化执行批量处理而非逐行处理传统数据库的执行模型是火山模型Volcano Model——每个算子一次处理一行通过next()调用逐行传递。这意味着每处理一行都有一次虚函数调用的开销。ClickHouse 采用向量化执行引擎每次处理一批数据默认 8192 行用紧凑的列式数组在算子间传递。火山模型逐行 Filter.next() → 返回 1 行 → Aggregate.next() → 返回 1 行 ↑ 每行一次函数调用CPU 分支预测频繁失败 向量化模型批量 Filter.process(block[8192行]) → 返回 block → Aggregate.process(block) ↑ 一次调用处理 8192 行CPU 流水线充分利用向量化的收益不仅是减少函数调用次数更关键的是让 CPU 能够使用SIMD 指令进行并行计算。三、列式存储如何对齐 ORDER BY 排序列式存储把每列拆开存放但排序需要行与行之间的对应关系——ClickHouse 通过行号Row Number对齐来解决这个问题。3.1 核心机制所有列文件共享同一个行号顺序写入时ClickHouse 先按ORDER BY对整行排序然后把排好序的结果按列拆开写入各自的.bin文件。每个列文件中第 N 个值一定对应同一行。假设 ORDER BY (date, user_id)写入 3 行数据 排序前 row0: date2024-01-03, user_id100, amount50 row1: date2024-01-01, user_id200, amount30 row2: date2024-01-01, user_id100, amount80 按 (date, user_id) 排序后 row0: date2024-01-01, user_id100, amount80 ← 行号 0 row1: date2024-01-01, user_id200, amount30 ← 行号 1 row2: date2024-01-03, user_id100, amount50 ← 行号 2 拆成列文件存储 date.bin: [2024-01-01, 2024-01-01, 2024-01-03] ← 行号 0,1,2 user_id.bin: [100, 200, 100 ] ← 行号 0,1,2 amount.bin: [80, 30, 50 ] ← 行号 0,1,2 ↑ 同一个行号位置跨列文件一定是同一行的数据3.2 物理对齐的保证Granule 与 Mark 文件ClickHouse 把数据按固定行数默认 8192 行切成Granule每个 Granule 是读取的最小单位。所有列通过.mrk2Mark 文件记录每个 Granule 在对应.bin文件中的偏移量行号一一对应。一个 Part 的物理结构 date.bin: [granule0: 8192行] [granule1: 8192行] [granule2: 8192行] ... user_id.bin: [granule0: 8192行] [granule1: 8192行] [granule2: 8192行] ... amount.bin: [granule0: 8192行] [granule1: 8192行] [granule2: 8192行] ... ↕ 对齐 ↕ 对齐 ↕ 对齐 primary.idx: [mark0 → granule0] [mark1 → granule1] [mark2 → granule2] date.mrk2: [mark0: offset0] [mark1: offsetxxx] [mark2: offsetyyy] user_id.mrk2: [mark0: offset0] [mark1: offsetxxx] [mark2: offsetyyy] amount.mrk2: [mark0: offset0] [mark1: offsetxxx] [mark2: offsetyyy]Mark 3 在date.bin里指向的那 8192 行和在amount.bin里指向的那 8192 行一定是同一批行。3.3 查询时的还原过程SELECTuser_id,amountFROMtWHEREdate2024-01-01;执行流程通过primary.idx定位date2024-01-01在 Granule 0 中通过date.mrk2找到date.bin中 Granule 0 的偏移读出来做过滤得到行号 0 和 1 匹配通过user_id.mrk2和amount.mrk2找到对应列文件中 Granule 0 的偏移读取行号 0 和 1 的值拼装返回(100, 80), (200, 30)一句话总结ClickHouse 是先排序、再拆列所有列文件通过共享的行号顺序和 Mark 文件保持对齐。排序发生在写入时以及后台 Merge 时查询时不需要重新排序直接按 Granule 粒度跨列文件读取即可还原出完整的行。四、为什么 ClickHouse 不怕全表扫描很多从 MySQL 转过来的工程师会问“ClickHouse 没有 B-Tree 索引全表扫描不会很慢吗”答案是ClickHouse 的全表扫描和 MySQL 的全表扫描完全不是一回事。3.1 稀疏索引 跳数索引ClickHouse 使用稀疏索引Sparse Index而非 B-Tree。主键索引每隔index_granularity默认 8192行记录一个索引条目。主键索引结构假设按 date 排序 Mark 0: date 2024-01-01 → 指向第 0-8191 行 Mark 1: date 2024-01-15 → 指向第 8192-16383 行 Mark 2: date 2024-02-01 → 指向第 16384-24575 行 ... 查询 WHERE date 2024-01-20 → 二分查找定位到 Mark 1只读取第 8192-16383 行的数据 → 跳过了其他所有 granule这种设计的代价是无法高效查找单行不适合点查但收益是索引极小百亿行的索引可能只有几十 MB完全可以常驻内存。3.2 分区裁剪CREATETABLEevents(event_dateDate,user_id UInt64,event_type String)ENGINEMergeTree()PARTITIONBYtoYYYYMM(event_date)ORDERBY(event_type,user_id);-- 查询自动裁剪分区只扫描 2024-01 的数据SELECTcount()FROMeventsWHEREevent_date2024-01-01ANDevent_date2024-02-01;分区裁剪 稀疏索引 列裁剪三者叠加后ClickHouse 的全表扫描实际读取的数据量可能只有物理总量的 0.1%。五、CPU Cache / SIMD 在 ClickHouse 中的作用4.1 CPU Cache 友好列式存储天然对 CPU Cache 友好。当你对一列连续的UInt64数组求和时数据在内存中是连续排列的CPU 的 L1/L2 Cache 预取机制可以高效工作。行式存储的内存访问模式Cache 不友好 [id, name, age, amount] [id, name, age, amount] [id, name, age, amount] ↑ 跳跃访问 amountCache Line 利用率低 列式存储的内存访问模式Cache 友好 [amount, amount, amount, amount, amount, amount, ...] ↑ 顺序访问Cache Line 100% 利用4.2 SIMD 加速ClickHouse 大量使用 SIMDSingle Instruction, Multiple Data 单指令多数据流是一种并行计算技术允许 CPU 或 GPU 仅通过一条指令同时处理多组数据指令。例如对一个UInt32数组求和一条 AVX2 指令可以同时处理 8 个 32 位整数。标量执行sum a[0]; sum a[1]; sum a[2]; ... 逐个累加 SIMD 执行sum_vec _mm256_add_epi32(sum_vec, load(a[0..7])); 8 个一起加ClickHouse 的源码中有大量针对不同 CPU 架构SSE4.2、AVX2、AVX-512的特化实现这也是它比很多同类列式数据库更快的原因之一。六、和 MySQL / Elasticsearch 的根本设计差异维度MySQL (InnoDB)ElasticsearchClickHouse存储模型行式BTree 聚簇索引倒排索引 列式Doc Values列式MergeTree优化目标单行事务 CRUD全文检索 聚合大规模聚合分析写入模型随机写WAL Buffer Pool近实时索引Refresh批量追加Append-only压缩率低1-2x中3-5x高5-20x聚合 10 亿行分钟级甚至不可行秒-十秒级亚秒-秒级点查单行毫秒级毫秒级十毫秒级不擅长并发能力高数千 QPS高低数十-百级 QPS核心差异一句话总结MySQL 为事务而生Elasticsearch 为搜索而生ClickHouse 为聚合而生。七、和时序数据库InfluxDB / TimescaleDB / TDengine的对比很多团队在选型时会纠结我的数据带时间戳到底该用时序数据库还是 ClickHouse6.1 时序数据库的核心设计时序数据库TSDB围绕一个核心抽象时间线Time Series——由一组固定标签tag标识的、按时间排列的数值序列。时间线模型 {metriccpu_usage, hostserver-01, regionus-east} → (t1, 72.5), (t2, 68.3), (t3, 75.1), ... {metriccpu_usage, hostserver-02, regionus-west} → (t1, 45.2), (t2, 51.0), (t3, 48.7), ...TSDB 针对这种模式做了大量优化自动按时间分片、内置降采样downsampling、自动过期删除retention policy、针对时间戳和浮点数的特殊压缩Gorilla 编码、Delta-of-Delta 等。6.2 核心差异对比维度InfluxDBTimescaleDBTDengineClickHouse数据模型时间线measurement tag field关系表基于 PostgreSQL超级表设备→子表通用列式表查询语言InfluxQL / Flux标准 SQL标准 SQL标准 SQL扩展内置降采样✅ Continuous Query / Task✅ 连续聚合✅ 流式计算❌ 需手动 MV Rollup内置 Retention✅ Retention Policy✅ 自动压缩策略✅ KEEP 参数✅ TTL需手动配置高基数标签⚠️ 性能急剧下降倒排索引膨胀✅ 较好B-Tree 索引⚠️ 子表数量爆炸✅ 天然擅长列式 稀疏索引聚合分析能力基础有限的 GROUP BY强完整 SQL中等极强窗口函数、数组函数、近似算法多表 JOIN❌ 不支持✅ 完整支持⚠️ 有限支持✅ 支持大表 JOIN 需注意写入吞吐中百万点/秒级中高百万点/秒级极高百万行/秒级压缩率高时序特化编码中高高可配置 Codec 组合生态与运维独立生态PostgreSQL 生态独立生态独立生态社区活跃6.3 高基数问题TSDB 的阿喀琉斯之踵时序数据库最大的痛点是高基数High Cardinality。当标签的唯一组合数量达到百万甚至千万级时比如用traceId、userId作为标签TSDB 的性能会急剧下降InfluxDB每个时间线对应一个倒排索引条目高基数导致索引膨胀、内存暴涨TDengine每个标签组合创建一张子表百万级子表的元数据管理成为瓶颈ClickHouse 没有时间线的概念高基数字段只是普通的列通过稀疏索引 列式扫描处理不会因为基数增长而出现性能悬崖。-- 在 ClickHouse 中traceId 这种高基数字段完全没问题SELECTtraceId,min(timestamp)ASstart_time,max(timestamp)-min(timestamp)ASdurationFROMtracesWHEREtimestampnow()-INTERVAL1HOURGROUPBYtraceIdORDERBYdurationDESCLIMIT10;-- 即使 traceId 有数亿个唯一值查询依然高效6.4 什么时候选 TSDB什么时候选 ClickHouse选时序数据库的场景纯粹的设备监控 / IoT 指标采集标签基数低 10 万时间线需要开箱即用的降采样、Retention、告警规则团队规模小不想维护复杂的 MV Rollup 体系数据模型高度规整固定的 metric tag value 结构选 ClickHouse 的场景数据包含高基数字段traceId、userId、requestId 等需要复杂的分析查询多维聚合、窗口函数、子查询、JOIN数据模型多样Trace、Log、Metrics、业务事件混合存储写入吞吐要求极高百万行/秒以上需要灵活的 SQL 能力和丰富的函数库实际趋势越来越多的可观测性平台如 Grafana Tempo、SigNoz、Uptrace选择 ClickHouse 作为 Trace 和 Metrics 的统一存储后端正是因为它在高基数场景下的优势和通用的 SQL 分析能力。ClickHouse 自身也在持续增强时序场景的支持如DateTime64纳秒精度、Gorilla/DoubleDelta编码等。八、什么场景下不应该用 ClickHouseClickHouse 不是银弹。以下场景请三思高并发点查如果你的业务是根据主键查单行如用户详情页ClickHouse 的并发能力和点查延迟都不如 MySQL 或 Redis。频繁更新/删除ClickHouse 的ALTER TABLE ... UPDATE和DELETE是重量级的 Mutation 操作会重写整个 Part。如果你的业务需要频繁更新行请用 OLTP 数据库。事务需求ClickHouse 没有传统意义上的事务无 BEGIN/COMMIT/ROLLBACK。如果你需要跨表原子操作它不适合。小数据量如果你的数据只有几十万行MySQL 加个索引就够了ClickHouse 的优势体现不出来。复杂 JOINClickHouse 的 JOIN 能力在持续改进但面对多表复杂 JOIN尤其是大表 JOIN 大表性能不如专门的 MPP 数据库如 Presto、Trino。总结ClickHouse 的速度来自一套系统性的设计选择列式存储减少 I/O ↓ 高压缩比减少磁盘和内存占用 ↓ 向量化执行减少函数调用开销 ↓ SIMD 指令单指令处理多数据 ↓ CPU Cache 友好顺序内存访问 ↓ 稀疏索引 分区裁剪减少扫描范围每一层都在为同一个目标服务让分析查询尽可能快地扫描尽可能少的数据。理解了这一点你就能理解 ClickHouse 后续所有的设计决策——包括下一篇要讲的 MergeTree 引擎家族。