ClickHouse在国内火了很多年,但每次聊到它为什么快,最常见的答案就是三个字:列式存储。这个答案没错,但不够。你打开一条SQL执行计划,把一张四千多万行的订单表按天做聚合,从原来的数据库里跑出来要3秒,到了ClickHouse只要200毫秒——如果只靠列式存储和稀疏索引,解释不了这个量级的差距。真正把CPU的每一分计算潜力都压榨出来的,是它的向量化执行引擎。
这篇文章我想从执行引擎的角度,把ClickHouse为什么快这件事讲透。会先拆掉我们长期形成的“一行一行处理数据”的思维定式,再看向量化和列式存储为什么是天然搭档,然后扒一扒执行引擎内部,从Column到Block到SIMD,再放到Flink同步MySQL到ClickHouse、和Doris选型这些真实场景里,最后给出一份避坑清单。不管你是刚接触ClickHouse,还是已经用了大半年但始终没想明白原理,这篇应该都能帮上忙。
1. 向量化执行:先拆掉“逐行处理”的惯性思维
1.1 传统数据库的“逐行处理”是怎么回事
我们从小到大用关系型数据库,潜意识里都觉得数据是一行一行查出来的。底层也确实是这样工作的,尤其是传统执行引擎,普遍基于一个叫Volcano的模型:执行计划是一棵算子树,每个算子都有一个next()方法,上层调下层,一层层地拉数据。每次next()返回一个Row,聚合算子收到一行,累加一次,收到一行,再累加一次。
这个模型逻辑清晰,但性能上有两个天然的坑。
第一个坑是函数调用开销。每处理一行都要经过一次虚函数调用,几千万行的数据就是几千万次调用。第二个坑更隐蔽,是数据布局问题。行存表里一行的字段是交错存放的,比如(id, name, age, amount, time),相邻行的同一个字段在内存里离得很远。CPU从内存加载数据是按缓存行(64字节)来的,处理一行时,可能一多半的缓存带宽都浪费在加载“不相干”的字段上,缓存的命中率惨不忍睹。
在OLTP场景,这个模型还能接受,因为每次查询只取少量行。但到了OLAP场景,一次扫描就是几千万行,逐行处理就成了瓶颈的集中地。
1.2 用一张“安检传送带”理解向量化
怎么理解向量化执行?我身边很多人第一次听这个术语时,脑海里浮现的是“并行计算”,其实不对。向量化的本质不是开多个线程,而是让CPU在同一个时钟周期内,对一批数据执行同一条指令。
打个比方。传统逐行处理就像机场安检只有一个开包员,旅客一个一个来,开包,检查,合上,下一个。向量化执行则是让旅客把背包全部放到传送带上,一个包接着一个包,安检机一扫描就是一条线上的很多个包。安检机不是变多了,是“单次处理的能力”变大了。对应到CPU层面,就是SIMD(Single Instruction Multiple Data,单指令多数据),一条指令可以同时对多个数据执行同一个操作。
ClickHouse的向量化执行,简单说就是:不再把一行当成处理单元,而是把一列当成处理单元。一次处理一批数据(比如8192行),对这8192行的同一列执行加法、比较、逻辑判断。数据在内存里是紧凑的、类型统一的数组,正好是CPU最擅长处理的方式。
1.3 向量化和编译执行是两条路,ClickHouse选了前者
数据库执行引擎提速,路线不止一条。除了向量化,还有一种叫编译执行,典型代表是Hyper等内存数据库的思路:运行前根据SQL生成一段针对数据结构的机器码,把通用的解释执行流程编译成“定制化代码”,省掉虚函数调用和分支判断,直接一把梭。
ClickHouse也有一部分代码生成能力,但主路径一直坚定地走向量化执行。为什么?因为编译执行的门槛高,调试困难,而且对CPU指令集和运行环境更敏感。向量化则是一条更稳的路:执行引擎的逻辑还是解释执行那一套,但通过Column列式数据的批量操作,吃到了类似编译执行的性能红利,同时又保持了相当的通用性和可维护性。
这一点很重要,因为后面你调优时,会看到很多设计都围绕“批量处理”展开,而不是围绕“生成专用代码”展开。
2. 为什么ClickHouse必须押注向量化
2.1 列式存储和向量化是“原配”
ClickHouse本身就是列式存储,这个特性几乎是向量化的前提。一列数据在物理存储上是连续存放的,读出来以后,内存里也是一块连续的数组。连续的同类型数据,恰好就是SIMD指令最理想的输入。
反过来想,如果基于是行存,向量化会非常被动。行存里一行的字段类型五花八门,int、string、date挤在一起,你想用一条SIMD指令同时处理这一行的所有字段都不现实,因为类型都不一样。而列式存储天然就是一堆纯int数组、纯double数组、纯字符串区间,每条指令处理的元素类型是确定的,CPU不需要做类型判断。
所以你会发现,ClickHouse的向量化不是单纯在“执行层”做文章,而是从“存储层”就开始为它铺路。这也解释了为什么你在MySQL上套一个向量化引擎没什么意义,存储布局已经决定了收益天花板。
2.2 CPU流水线和分支预测:肉眼看不到的杀手
逐行处理的另一个隐形代价是分支预测失败。现代CPU为了加速,会做流水线,提前加载后面的指令。如果代码里有if、switch这类分支,CPU会猜测走哪条路,猜对了就飞速运行,猜错了整个流水线要冲刷掉,代价是几十个时钟周期。
传统逐行执行里分支太多了:每一行的类型判断、NULL判断、函数分发、状态机的条件跳转。聚合一列时,每行都要判断当前值应该累加还是替换;过滤时,每行都要判断满不满足条件。几千万行跑下来,分支预测单元一直在赌,赌错了就白干,CPU的大量周期都空耗在这种“猜谜”上。
向量化执行把分支大幅减少了。它不是对一行做条件判断,而是对一列做一个比较操作,得到一整块布尔掩码,再统一决定保留哪些行。CPU在这条路上几乎没有分支,就是一条道跑到底,流水线非常稳定。这个提升很难用一个具体数字描述,但在极限场景下,比指令本身带来的加速还要大。
2.3 压缩、IO和向量化:三层壁垒的乘法效应
很多人觉得ClickHouse快纯粹是压缩的功劳,这也不全面。真实情况是,列式存储的高压缩率减少IO,向量化执行提高CPU计算效率,两者是乘法关系。
举个例子。一个订单表存十亿行,按列压缩后,可能只需要扫描原来行存体积的十分之一。这十分之一的体积从磁盘读出来,已经比行存快了一个数量级。但读出来到内存之后,如果还用逐行循环去算聚合,CPU就成了新瓶颈。向量化在这里再加速一次,可能又差了三到五倍。
所以ClickHouse整体性能是“压缩降IO、向量化升CPU、稀疏索引砍扫描范围”三层壁垒的叠加效果。你理解向量化时,别把它当成全部,但也要明白,没有它在CPU这一层兜底,前面省下的IO时间会在计算阶段给还回去。
3. 扒开执行引擎:Column、Block、SIMD如何协同
3.1 Column和Block:ClickHouse执行引擎的“积木”
ClickHouse的每一条执行链路,从存储读到计算,处理的都不是“行”,而是Column和Block。
Column是某一列的数据集合,底层是一片连续内存数组,比如ColumnUInt64底下一个PODArray<uint64_t>。它既负责存放数据,也负责提供各种批量接口。Block是多个Column的集合体,代表“一批行”的横截面。比如一个Block可以包含100列,每列都有8192个元素,整体就代表8192行的数据。
ClickHouse里有一个核心参数叫max_block_size,默认值是8192。为什么是8192而不是100万?因为这是工程上的平衡点。处理单元太小,函数调用和流水线切换的固定开销占比就高,不划算;处理单元太大,一个Block占的内存过多,会挤压CPU缓存,反而不如小块跑得顺。8192这个数字,基本能保证一次Block处理的数据量刚好可以被L2/L3缓存容纳,同时SIMD一次可以对几十个元素展开运算,多轮迭代下来,缓存命中率非常理想。
你可以用这句SQL看一下当前实例的配置:
SELECT name, value FROM system.settings WHERE name = 'max_block_size';实测下来,绝大多数场景不需要动这个值,频繁调整反而可能破坏执行引擎内部的块对齐策略。
3.2 一条SQL从解析到向量化执行的完整旅程
拿一个最常见的查询来拆解:
SELECT sum(amount) FROM orders WHERE status = 'paid'这条SQL在传统数据库里是“一行一行判断status,符合就累加amount”。在ClickHouse里完全不是这个路径。
查询发送到服务端后,大体经过这几步:
- Parser把SQL解析成抽象语法树(AST)。
- Analyzer做语义分析,把库和表、列、函数绑定清楚。
- Optimizer做优化,比如把过滤条件下推、决定是否用PREWHERE策略。
- Planner生成QueryPlan,这是一个执行计划树。
- QueryPlan被翻译成Pipeline,一个可并行执行的流。每个线程独立处理一段数据。
- Pipeline里的每个Processor开始工作,其中最核心的就是存储层的读取和各个计算算子。
真正干活时,存储层从MergeTree的part里读出一个Block,这个Block只包含status和amount两列(其他列根本不读)。然后对status这一列做一个批量等于比较,结果是一个UInt8掩码数组,比如[0, 1, 1, 0, ...]。再根据掩码从amount列筛选出对应行,生成一个小的Column。最后对amount列调用批量求和函数,一次性算出结果。
整个过程里,你几乎找不到一个“循环处理单行”的代码逻辑。过滤是批量比较,收集是批量操作,聚合是批量求和。每个算子拿进来的是一整列,吐出去的也是一整列。
3.3 SIMD真正上场的地方:函数和表达式
理解了Block的流转,再往下钻一层,就到了CPU真正执行SIMD指令的地方。
ClickHouse对列的操作,最后会编译成二进制的循环,但这个循环不是咱们平时写的for(int i=0;i<n;i++),而是会被编译器自动向量化,或者调用底层用AVX2手写的实现。比如你想给一列数字全部加1,CPU不会一条条加,而是把8个数字一起装进一个256位的寄存器,一条VADD指令同时加完,再写入结果列。
实际执行时,ClickHouse会做一次CPU指令集检测,如果你的机器支持AVX2,很多底层函数会走AVX2实现;如果不支持,会退回普通的标量实现。用一条SQL就能查到你当前环境支持的编译参数:
SELECT * FROM system.build_options WHERE name LIKE '%ARCH%';或者看启动日志,ClickHouse启动时会打印当前CPU的指令集支持情况。我遇到过有人把ClickHouse部署在很老的虚拟机上,没有SSE4.2,性能直接掉了好几个量级。这个问题在Linux部署ClickHouse 21.8.15.7这类老版本时更容易踩,因为不少工具链文档年代久远,很少提醒你CPU特性这个前置条件。
4. 把向量化执行放进真实场景
4.1 一个典型OLAP查询的实测对照
理论说了半天,不如看一个实际对比。我基于一张约5000万行的订单流水表做过分组聚合测试,统计每个用户当月的订单总额。在原来的行存数据库里,同样的SQL跑了大约2.8秒;在ClickHouse里,同样的数据量,单线程首次冷查询大约320毫秒,热查询稳定在150毫秒左右。
这个差距怎么拆解?列式存储下IO量约为原来的七分之一,这是第一层;稀疏索引把扫描范围收缩到目标分区,这是第二层;真正进入CPU计算后,向量化把聚合循环的效率又提了至少三到四倍,这是第三层。如果没有向量化,前面两层带来的收益会被CPU计算时间吞掉一大半。
4.2 用Flink同步MySQL到ClickHouse:批次意识决定成败
很多人会用Flink CDC把MySQL数据同步到ClickHouse。我见过不少同步任务跑得很慢的案例,根因都是“批次太小”。
ClickHouse的写入链路也是面向Block设计的。一小批数据进来,会被拼成一个Block,然后走后面的压缩、编码、落盘。如果你用Flink一条一条地往ClickHouse里insert,相当于每个Block只有1行,向量化、压缩、MergeTree的批量写入机制全部失效,性能会非常惨烈。
正确的做法是让Flink攒批。ClickHouse官方Connector里,建议把sink.buffer-flush.max-rows设到5万到10万,或者根据数据量设置sink.buffer-flush.max-seconds为2到5秒。攒够一个批次再提交,让每条数据先在内存里凑成Block,写入时走批量编码。同步任务从“1万行每秒”变成“几十万行每秒”,经常只差这一个设置。
这里要提醒一句,批次也不能无限大。Flink端攒批过大会增加内存压力和故障恢复的causality。5万行到10万行是一个比较稳妥的区间,既能让MergeTree后台合并的压力变小,又不至于把Flink任务搞成内存杀手。
4.3 和Doris放在一起看:向量化是共性,差异在别处
最近几年,Apache Doris也把向量化执行引擎作为默认引擎。所以你会发现,光说“支持向量化”已经不足以区分ClickHouse和Doris了。
我给团队做技术选型时是这么看的。ClickHouse的单表聚合分析能力极其强悍,宽表、海量明细、高基数维度,这些场景下它能把向量化、稀疏索引、列压缩的优点全部发挥出来。Doris则在多表JOIN、分布式事务、大规模并发复杂查询上有更多积累,它的MPP架构对复杂查询的扩展性做得更好。
回到你的使用场景:如果业务是“一张超大的事实表,各种维度聚合”,ClickHouse更容易吃到向量化的全部红利;如果是“十几张表频繁JOIN出报表”,Doris的优化器优势会更明显。选型时不要纠结谁向量化得更彻底,要看你80%的查询是哪种形态。
5. 避坑清单与常见误区
5.1 三个几乎每个人都会有的误解
**误解一:ClickHouse快就等于向量化。**其实向量化只是计算层的一环。你观察一条CH慢查询,瓶颈常常在IO或解析,不在CPU。哪怕向量化做到满分,如果过滤条件写得不带任何索引,扫全表还是慢。理解向量化能帮你优化CPU瓶颈,但别用它解释所有性能问题。
**误解二:向量化等于多线程并行。**这两个概念经常被混在一起。向量化是在单核内同时处理多个数据元素;多线程是多个核分头处理不同的数据块。ClickHouse两者都有:Pipeline会拆成多个线程并行执行,同时每个线程内部又通过向量化把单核算力榨干。你排查性能时,要分清是并发度不够,还是单线程内的批量处理不够高效。
**误解三:所有的SQL都天然向量化。**实际上,如果你使用了自定义UDF,或者在SQL里写了复杂的逐行动态逻辑,ClickHouse可能不得不退回到较慢的执行路径。此外,Nullable列因为要多一套null标记处理,向量化的效率也比非Nullable列要低。能不用Nullable就不用,这句老话在这里同样适用。
5.2 慢查询排查:先分清IO慢还是CPU慢
定位慢查询时,我一般先查system.query_log,看这条查询的read_rows、read_bytes和memory_usage。如果一个查询read_rows好几亿,即使向量化再快,也是把大把时间花在读列上了。这时候你要考虑的应该是稀疏索引怎么命中、分区裁剪怎么生效、PREWHERE怎么用,而不是去调向量化参数。
反过来,如果read_rows并不大,但查询还是慢,且CPU飙满,那才是向量化或者表达式计算的问题。这时可以看看是不是查询里用了太多复杂的字符串函数、正则表达式,这些函数即使批量处理,单条的开销也远大于数值运算。
我之前优化过一个案例,线上一条查询要处理几十万条URL,用了substring、position、regexp这类函数做字段清洗。列存数据量不大,但CPU快被打满。后来把正则表达式整体简化,改成内部函数组合,并且把清洗逻辑从查询时改成物化列预先算好,查询时间从800ms降到90ms。这里的提速一部分是向量化,但更多是“少算”和“提前算”。
5.3 写出真正能吃满向量化的SQL
几条实操建议,都是我拿业务慢查询试出来的:
- 聚合和过滤尽量直接作用在原始列上,少在条件外层套函数。比如where date = today()就比where toDate(ts) = today()更能利用引擎的特性,因为后者让过滤条件无法下推,向量化只能处理更大范围的数据。
- 大表查询时,只SELECT你真正需要的列,别习惯性select *。列数越多,Block越大,缓存越容易装不下,向量化的效率会明显下滑。
- 如果同一列反复做计算,考虑在入库时就算好存成物化列,查询时直接读结果。你要的是最终聚合数据,不需要每次查询都重新演算一遍。
- 要充分理解PREWHERE。ClickHouse的PREWHERE会在读取过程中先做列过滤,再做其他列的物化,这对于一个表有几十列、过滤条件只依赖少数列的场景特别有用。它能保证进入向量化计算的数据量尽可能小。
| 场景 | 推荐做法 | 原因 |
|---|---|---|
| 过滤条件依赖的列 | 用PREWHERE | 减少参与向量化计算的数据量 |
| Nullable列 | 改为默认值+非Nullable | 减少Null掩码处理,向量化更彻底 |
| 大批量写入 | 攒批到数万行 | 保证写入端也是Block级别处理 |
| 复杂字符串清洗 | 物化列预计算 | 查询时避开高开销表达式 |
| CPU指令集 | 确认SSE4.2/AVX2支持 | 否则SIMD代码退化,性能损失巨大 |
5.4 关于Linux上部署ClickHouse 21.8.15.7这点事
最后聊个具体的版本。现在很多人上手直接装新版,但也确实有一大批生产环境还在用ClickHouse 21.8 LTS,比如21.8.15.7。这个版本在向量化执行上已经非常成熟,几乎所有主路径算子都是向量化的,稳定性也经过了大量生产验证。Linux部署时,除了常规的ulimit、内存配额,务必确认CPU指令集支持AVX2。我见过一个案例,生产集群跑同一份查询,一台机器比另一台慢4倍,最后定位到老机器不支持AVX2,ClickHouse自动回退到了标量实现。这个坑不显眼,一旦踩中很难从语句本身找原因。
我个人在实际操作中的体会是,向量化执行不像调参那样可以“今天调明天生效”,它更像一个地基:地基打得稳,你所有其他的优化手段才有效果。你先理解了系统是按Block处理、按Column批量运算,回头再看PREWHERE、看物化列、看分区裁剪、看Flink攒批,都会觉得豁然开朗。这些东西不是孤立的小技巧,全都是围绕“如何让向量化引擎处理更少、更整齐的数据”展开的。
最后再分享一个小习惯:建表时多想一想列的类型,别一上来就全用Nullable(String)。一个字段能固定为数值型,就千万别存成字符串。你每多做一次类型转换,就是在给向量化执行添加一道额外的批次处理负担。数据模型的整洁程度,会直接体现为查询引擎能跑多快。ClickHouse的向量化引擎可以承受不完美的数据,但你如果顺着它的脾性去设计,性能回报会非常慷慨。