
上一篇【第07篇】ClickHouse执行引擎架构——Parser、Interpreter与Function体系下一篇【第09篇】ClickHouse安装部署全攻略——从环境准备到服务启动摘要ClickHouse能在十亿行级别数据的聚合查询中实现毫秒级响应绝非偶然。这种极致性能的背后是一整套经过深思熟虑的设计哲学在支撑——从底层硬件特性的充分利用到算法选择的极致务实再到对新技术的大胆采纳。本文系统阐述ClickHouse的五大核心设计哲学着眼硬件先想后做、算法在前抽象在后、勇于尝鲜不行就换、特定场景特殊优化、持续测试持续改进。深入理解这些设计哲学不仅能帮助我们更好地使用ClickHouse更能为构建其他高性能系统提供宝贵的借鉴。关键词CPU缓存、SIMD向量化、零开销抽象、JIT编译、性能基准测试、列式存储、NUMA感知1 引言性能的秘密不只是列式存储业界普遍将ClickHouse的高性能归功于列式存储——这个答案当然正确但远不够深刻。列式存储只是ClickHouse性能大厦的一块基石真正让这座大厦高耸入云的是散布在代码库各个角落、贯穿整个架构的设计决策与工程取舍。ClickHouse的源码中随处可见这样的注释// We use this instead of std::vector to avoid initialization overhead// This method is always inlined — no virtual call overhead// Manual loop instead of STL to enable auto-vectorization这些看似琐碎的优化累积起来产生了惊人的效果——ClickHouse在某些查询上比Parquet格式的Spark快100倍以上。本文的目的是揭开这些秘密的面纱揭示支撑这些优化决策的五大设计哲学。2 哲学一着眼硬件先想后做2.1 硬件特性是性能的上限ClickHouse的设计者有一个核心信念不理解硬件就无法写出高性能代码。现代CPU的体系结构充满了隐藏的规则违背这些规则性能可能差100倍。理解硬件要从三个维度出发CPU性能的三重约束 第一重: 算术逻辑单元 (ALU) ← 每核每秒数十亿次操作 ↑ 瓶颈不在这里 第二重: 内存带宽 (Memory Bandwidth) ← DDR4: ~50GB/s ↑ 大多数数据处理任务的瓶颈 第三重: 内存延迟 (Memory Latency) ← DRAM: ~100ns ↑ 比 L1 缓存 (~1ns) 慢100倍!2.2 CPU缓存友好让数据留在身边CPU缓存Cache是现代处理器中最容易被忽视的性能杠杆。以Intel Skylake为例缓存层级如下缓存级别大小延迟带宽L1 Data Cache32KB/核~1ns (4 cycles)~1TB/sL2 Cache256KB/核~4ns (12 cycles)~500GB/sL3 Cache1-2MB/核~15ns (50 cycles)~200GB/sDRAM数百GB~100ns~50GB/s一次L3缓存未命中Cache Miss意味着浪费100倍的时间ClickHouse通过以下策略最大化缓存命中率策略一数据按列存储减少无关数据加载-- 查询: SELECT avg(price) FROM orders;-- 仅有 price 列被加载到缓存行式存储(MySQL,PostgreSQL):Row#1: [id1, date2024-01-01, customerAlice, price99.9, ...]Row#2: [id2, date2024-01-02, customerBob, price149.9, ...]每次加载: 整行数据(假设1KB/行)→ L1 缓存仅能容纳32行 列式存储(ClickHouse): price列:[99.9,149.9,79.5,299.9,...]每次加载: 仅 price 列 → L1 缓存可容纳~40000个浮点数策略二数据按处理批次对齐到缓存行ClickHouse的PaddedPODArray确保数组元素的起始地址按64字节一个缓存行对齐// src/Common/PaddedPODArray.h// 普通的 PODArray 使用 sizeof(T) 对齐// PaddedPODArray 强制使用 64 字节对齐缓存行大小templatetypenameT,size_t Alignment64classPaddedPODArray{private:T*data_;// 保证 data_ 的地址是 64 的倍数public:// alignas(64) 确保结构体本身也对齐// 这对于 SIMD 操作至关重要}__attribute__((aligned(64)));这种对齐使得SIMD加载指令可以一次性读取多个元素而不会跨越缓存行边界跨缓存行的访问会导致额外的内存操作。2.3 SIMD向量指令一条指令做四件事SIMDSingle Instruction Multiple Data允许CPU用一条指令同时处理多个数据元素。对于数据密集型的OLAP查询这是性能提升的利器。// 标量计算: 每次处理1个元素voidscalarAdd(float*a,float*b,float*c,size_t n){for(size_t i0;in;i)c[i]a[i]b[i];// 每次循环: 加载2次 加法1次 存储1次 4条指令}// SIMD向量化: 每次处理8个float (AVX2, 256bit)voidsimdAdd(float*a,float*b,float*c,size_t n){size_t i0;for(;i8n;i8){__m256 va_mm256_loadu_ps(ai);// 一次加载8个float__m256 vb_mm256_loadu_ps(bi);__m256 vc_mm256_add_ps(va,vb);// 一次加法处理8个元素_mm256_storeu_ps(ci,vc);// 一次存储8个float}// 处理剩余元素...}性能对比在Intel i9-9900K上测试数据可完全放入L2缓存实现方式吞吐量相对于标量的加速比标量2.5 GB/s1xSSE (4元素)9.8 GB/s3.9xAVX2 (8元素)19.2 GB/s7.7xAVX-512 (16元素)35.0 GB/s14xClickHouse的列式存储天然适合SIMD操作——同一列的数据类型相同、连续存储这正是SIMD发挥作用的最优数据布局。2.4 内存预取提前把数据请进缓存内存预取Prefetch是一种提前通知CPU加载数据的技术。CPU的内存预取器会识别访问模式并提前发起加载减少实际计算时的等待时间。ClickHouse中的预取策略// 聚合过程中的预取voidAggregatingTransform::consume(Chunk chunk){autocolumnschunk.getColumns();// 预取下一批数据跨循环迭代预取if(next_chunk_available){// hint CPU: 接下来会读取这些地址的数据__builtin_prefetch(next_columns_data[0],0,3);// 3 highest locality}// 当前批次处理for(size_t i0;irows;i){// 此时数据很可能已在 L1/L2 缓存中processRow(columns,i);}}// MergeTree 读取时的预取// 数据文件按 Mark 划分顺序扫描时可预测下一个 Mark 的位置for(size_t mark0;marknum_marks;mark){// 预取下一个 Mark 的数据if(mark1num_marks){size_t next_offsetmarks[mark1].offset_in_compressed_file;prefetch(compressed_filenext_offset);}processMark(marks[mark]);}2.5 NUMA感知让内存就近服务在多路服务器2路/4路上内存访问存在本地和远程的差异。NUMANon-Uniform Memory Access架构中每个CPU socket有自己本地的内存访问远程socket的内存延迟更高。NUMA 架构示意 (2路服务器): Socket 0 Socket 1 ┌─────────────┐ ┌─────────────┐ │ CPU Core 0 │◀────────────│ CPU Core 4 │ │ CPU Core 1 │ QPI Link │ CPU Core 5 │ │ CPU Core 2 │ (~100ns) │ CPU Core 6 │ │ CPU Core 3 │ │ CPU Core 7 │ ├─────────────┤ ├─────────────┤ │ Local Mem │ │ Local Mem │ │ (0-64GB) │ │ (64-128GB) │ │ 延迟: ~80ns │ │ 延迟: ~80ns │ └─────────────┘ └─────────────┘ 访问对方内存: ~180ns (100ns跨插槽延迟)ClickHouse通过ErrorHandler和MemoryTracker的配合实现了基础的NUMA感知每个线程尽量绑定到固定的NUMA节点数据处理时优先分配本地内存Merge操作时尽可能在本地节点完成3 哲学二算法在前抽象在后3.1 不为抽象牺牲性能ClickHouse的设计者有一个鲜明的立场当抽象与性能冲突时选择性能。这是一个务实到骨子里的哲学。许多现代软件系统追求零成本抽象Zero-Cost Abstraction即高层的抽象不引入任何运行时开销。但不引入任何开销的前提是抽象层本身的设计足够精妙。当抽象无法达到零开销时ClickHouse选择直接写代码。// ClickHouse 的做法: 拒绝过度抽象// ❌ 不推荐: 用标准库的 sort性能不够可控autosortedstd::sort(data.begin(),data.end());// ✓ 推荐: 写专用的高性能排序// 代码位置: src/Common/ColumnCompare.htemplatetypenameTvoidsortWithDefaultAlgorithm(T*data,size_t size){// 已知数据类型T可以使用最快的排序算法// 已知数据分布可以选择最优的Pivot策略// 已知内存布局可以预取数据pdqsort(data,size);// Pattern-Defeating Quicksort}// 或者完全内联的手写实现inlinevoidsortFloat(float*data,size_t n){// 浮点数排序有特殊优化空间// NaN 和 Inf 的处理可以专门优化std::sort(data,datan,[](floata,floatb){returnab;// 全内联无虚函数});}3.2 编译期多态 vs 运行时多态C提供了两种多态机制编译期的模板多态和运行时的虚函数多态。虚函数虽然灵活但每次调用都需要通过虚表vtable间接查找函数地址并可能破坏CPU的分支预测和指令流水线。ClickHouse的策略是优先使用模板编译期多态仅在运行时多态不可避免时才使用虚函数// 虚函数使用场景: 真正需要运行时替换的场景classIFunction{// 运行时需要动态选择函数public:virtualvoidexecute(Block,constColumnNumbers,size_t)0;virtual~IFunction()default;};// 模板使用场景: 类型在编译期确定无需运行时多态templatetypenameTclassColumnVector:publicIColumn{public:// 编译时确定类型无虚函数调用开销TgetElement(size_t n){returndata[n];}// 模板方法允许编译器完全内联templatetypenameOpvoidapplyUnary(constOpop){for(size_t i0;idata.size();i)data[i]op(data[i]);// 编译器完全内联}};性能对比实测Intel i9-9900K调用方式10亿次调用的耗时相对性能普通函数调用85ms1x虚函数调用220ms0.39x模板内联12ms7x模板 SIMD3ms28x3.3 特化优于泛化ClickHouse的Column实现为每种数据类型提供了专门的类——而不是用泛型覆盖所有情况。这种特化优于泛化的策略允许每种类型在底层采用最优的存储和处理方式// ClickHouse 的特化 Column 实现// Int8 (1字节整数) 专用列classColumnInt8{PaddedPODArrayInt8data;// 紧凑存储voidfilter(...){/* ... */}};// Float64 (8字节浮点) 专用列classColumnFloat64{PaddedPODArrayFloat64data;// SIMD友好对齐voidfilter(...){/* SIMD优化版本 */}};// 字符串专用列 - 完全不同实现classColumnString{StringRefs offsets;// 偏移量数组ArenaPtr arena;// 字符串数据池voidfilter(...){/* 字符串特有的复制逻辑 */}};// 对比: 通用设计// class GenericColumn { void * data; size_t element_size; }// ↑ 需要运行时判断类型增加分支和类型转换开销这种设计的代价是代码量增加——每种类型都需要独立的实现。但对于OLAP场景这是完全值得的类型种类有限~30种核心类型特化带来的性能收益远大于维护成本。4 哲学三勇于尝鲜不行就换4.1 ClickHouse对新技术的态度ClickHouse的代码库是业界新技术的试验场。它的设计者愿意大胆采用前沿技术但同时保持着清醒的判断——如果新技术表现不如预期就会果断放弃。ClickHouse积极采纳的新技术包括LLVM JIT编译将复杂表达式编译为机器码SIMD指令集从SSE4.2到AVX2再到AVX-512ZSTD压缩算法比LZ4更好的压缩率更快的解压速度C20协程在某些异步IO场景替代回调CRoaring位图高效的位图操作库4.2 JIT编译运行时代码生成ClickHouse的JITJust-In-Time编译功能是一个典型的勇于尝试案例。当表达式树过于复杂时解释执行仍会产生不可忽视的虚函数调用开销。ClickHouse通过集成LLVM在运行时将表达式编译为优化后的机器码// JIT 编译的工作流程// 代码位置: src/Interpreters/JITclassJITCompiler{LLVMContext context;IRBuilderbuilder;std::unique_ptrModulemodule;public:// 将表达式树编译为函数std::functionvoid(Block)compileExpression(constActionsDAGdag){// 1. 构建 LLVM IRFunctionType*ftFunctionType::get(builder.getVoidTy(),{block_ptr_type},false);Function*funcFunction::Create(ft,Function::ExternalLinkage,expr_func,module.get());// 2. 生成机器码// - 内联所有函数调用// - 选择最优的 SIMD 指令// - 重排指令以减少流水线停顿// 3. 编译并返回可执行函数autocompiledcompileModule(std::move(module));returncompiled;}};4.3 性能测试驱动决策ClickHouse为每项新技术的采纳设置了严格的门槛新技术必须通过基准测试证明确实有效才会被合并。这种数据驱动的决策方式避免了很多看起来很美的优化陷阱// 基准测试: 比较JIT与非JIT的性能voidbenchmarkJIT(){autoschemamakeSchema();autoquerySELECT sum(amount * price * 0.95) FROM orders GROUP BY category;// 非JIT执行autostart_jitlessnow();for(inti0;i1000;i){executeQuery(query,use_jitfalse);}autotime_jitlessnow()-start_jitless;// JIT执行autostart_jitnow();for(inti0;i1000;i){executeQuery(query,use_jittrue);}autotime_jitnow()-start_jit;std::coutJIT speedup: time_jitless/time_jitx\n;// 只有当 speedup 1.2 时才启用JIT}5 哲学四特定场景特殊优化5.1 OLAP场景的独特性ClickHouse的一切优化都围绕着OLAP场景展开。理解这个场景的特点才能理解这些优化的必要性OLAP 场景的四大特点 1. 读多写少 写入: 批量INSERT每天/每小时一次批量导入 读取: 大量并发SELECT高频分析查询 → 优化策略: 牺牲写入速度换取读取性能 2. 大宽表 多列聚合 表可能有100列单次查询可能聚合10-20列 → 优化策略: 列式存储 列裁剪 向量化聚合 3. 条件过滤 范围扫描 WHERE date BETWEEN 2024-01-01 AND 2024-01-31 → 优化策略: 主键稀疏索引 分区裁剪 跳表 4. 近似计算可接受 count(DISTINCT) 可以用 HyperLogLog 近似 → 优化策略: 提供多种精度/速度权衡的算法5.2 列式存储 稀疏索引的协同优化传统观点认为列式存储和稀疏索引是独立的设计决策。ClickHouse的创新在于将二者深度融合MergeTree 的数据组织: 稀疏索引 (每8192行一个索引点): ┌─────────────────────────────────────────────────────────┐ │ Mark#0: key1001 → Granule#0 (行0-8191) │ │ Mark#1: key2156 → Granule#1 (行8192-16383) │ │ Mark#2: key3892 → Granule#2 (行16384-24575) │ └─────────────────────────────────────────────────────────┘ 查询: WHERE user_id 3000 二分查找索引: Mark#0(key1001) 3000 Mark#1(key2156) 3000 Mark#2(key3892) 3000 ← 找到! 只需读取 Granule#2 IO量: 从 10亿行 减少到 8192行 (减少 ~12万倍!)5.3 聚合查询的特殊优化聚合是OLAP中最常见的操作ClickHouse为此设计了多层次优化优化一两阶段聚合SELECTuser_id,sum(amount)FROMeventsGROUPBYuser_id;阶段1(数据所在节点):SELECTuser_id,sum(amount)aspartialFROMeventsGROUPBYuser_id → 输出: {user_id: partial_sum} × N个不同的user_id 阶段2(汇聚节点):SELECTuser_id,sum(partial)FROMstage1_resultsGROUPBYuser_id → 输出: 最终结果 效果: 网络传输量从 全量数据 减少到distinct(user_id)×8字节 通常减少100-10000倍优化二聚合组合器的使用-- 原始查询计算不同城市的用户数SELECTcity,uniqExact(user_id)asexact_cnt,uniqHLL(user_id)asapprox_cnt,groupArray(limit5)(user_id)assample_usersFROMusersGROUPBYcity;-- uniqExact: 精确去重 (慢但准)-- uniqHLL: HyperLogLog近似 (快~100倍, 误差~2%)-- groupArray: 保留样本 (用于验证近似结果的准确性)优化三High-Cardinality优化的哈希聚合对于高基数的GROUP BY如用户IDClickHouse使用特殊的哈希聚合实现// 哈希聚合的向量化实现templatetypenameKey,typenameValuestructAggregatedDataWithOverflow{// 主哈希表: 存储大部分键值对FlatHashMapKey,Valuemain_table;// 溢出哈希表: 处理哈希冲突和极端情况FlatHashMapKey,Valueoverflow_table;};voidaggregateWithHash(Blockblock){// 对每一行: 计算hash → 查找/插入哈希表 → 更新聚合值// 所有操作都是向量化友好的for(size_t i0;iblock.rows();i){autokeykey_column-getHash(i);// SIMD hashautoithash_table.find(key);// 查找if(ithash_table.end()){hash_table[key]initial_value;// SIMD写入}else{it-secondvalue_column[i];// SIMD累加}}}5.4 低基数字符串的字典编码当某一列的取值种类很少时如国家代码、性别、状态枚举ClickHouse会自动使用字典编码Dictionary Encoding来优化存储和查询性能// 低基数列的存储结构classColumnLowCardinality:publicIColumn{private:// 字典: 存储所有不重复的值ColumnPtr dictionary;// 索引: 每行存储字典中的索引通常用 UInt32/UInt16PaddedPODArrayUInt32indexes;public:// 查询效果: 比较操作变为整数比较// WHERE country China// → 变为 WHERE index dictionary.find(China)// → 整数比较比字符串比较快 10-50 倍// 聚合效果: 哈希聚合只需对 UInt32 索引做哈希// 比直接对字符串哈希快 5-20 倍};6 哲学五持续测试持续改进6.1 基准测试文化ClickHouse有一个深入骨髓的测试文化。每一个性能优化都必须通过严格的基准测试验证否则不会被接受。ClickHouse维护的基准测试套件涵盖了端到端查询基准TPC-H、TPC-DS、自定义OLAP查询集微基准测试特定操作如哈希、排序、SIMD的单独测试回归测试确保优化不会引入性能退化压测工具clickhouse-bench、clickhouse-performance-test# 运行 ClickHouse 性能测试./clickhouse-bench --benchmark-filequeries.tsv\--iterations3\--max-time60s\--concurrency166.2 Yandex团队的内部实践在YandexClickHouse的诞生地ClickHouse的性能被持续追踪。每一次PR的性能影响都会被量化报告PR #45321: Optimize sum() with AVX-512 基准测试结果 (1亿行, 单核): ┌─────────────────────┬──────────────┬──────────┬─────────┐ │ 查询 │ 优化前 (ms) │ 优化后(ms) │ 加速比 │ ├─────────────────────┼──────────────┼──────────┼─────────┤ │ SELECT sum(x) │ 342 │ 89 │ 3.8x │ │ SELECT sum(xy) │ 521 │ 142 │ 3.7x │ │ SELECT avg(x) │ 378 │ 98 │ 3.9x │ │ SELECT sum(cnt) │ 289 │ 76 │ 3.8x │ └─────────────────────┴──────────────┴──────────┴─────────┘ 结论: AVX-512 优化在所有测试用例中均带来 ~3.8x 加速 未观察到缓存污染问题 合并: ✅ APPROVED6.3 版本迭代中的性能进化ClickHouse的版本历史本身就是一部性能优化的编年史版本关键性能改进性能提升1.1初始版本向量化执行引擎基准19.x物化视图正式版向量聚合2-5x20.xProcessor框架JIT编译实验1.5-3x21.xSIMD全面升级ZSTD压缩2-4x22.x持续聚合列式JOIN优化2-10x23.xAVX-512支持SIMD全面应用1.5-3x24.x更多JIT优化更好的NUMA感知持续改进6.4 使用clickhouse-benchmark进行对比测试对于ClickHouse的用户来说了解自己的查询性能瓶颈同样重要-- 使用EXPLAIN分析查询计划EXPLAINPIPELINESELECTuser_id,sum(amount)FROMeventsWHEREevent_date2024-01-01GROUPBYuser_idORDERBYsum(amount)DESCLIMIT10;/* 输出: (2) Sorting (LIMIT 10) │ Expression:(user_id, sum(amount)) │ Limit: 10 └────(1) Aggregating Expression: user_id Aggregating: sum(amount) Filter: event_date 2024-01-01 Parallel: Aggregating: 8 Repartition: 8 */通过EXPLAIN可以发现聚合操作的并行度8个并行流排序是否下推到了聚合前/后是否有不必要的表达式计算7 五大哲学的协同效应这五大设计哲学并非孤立存在它们在ClickHouse的架构中相互交织、相互强化┌──────────────────────────────────────────────────────────────┐ │ │ │ 着眼硬件 ─────────┐ │ │ │ │ │ │ │ ┌───────────┘ │ │ │ │ │ │ ├──→ 缓存友好 NUMA感知 高缓存命中率 ──→ 减少内存延迟 │ │ │ │ │ │ │ └───────────────────────────────────┐ │ │ │ ▼ │ │ │ 减少内存带宽争用 │ │ │ │ │ │ ├────────────────────────────────────────┘ │ │ │ │ │ 算法在前 ─────────┐ │ │ │ │ │ │ │ ┌───────────┘ │ │ │ │ │ │ ├──→ SIMD向量化 ◄──── 编译期特化 ◄──── 列式存储天然适配 │ │ │ │ │ │ │ └───────────────────────────────────┐ │ │ │ ▼ │ │ │ 单条指令处理多元素 │ │ │ │ │ │ ├────────────────────────────────────────┘ │ │ │ │ │ 特定场景特殊优化 ──→ 稀疏索引 谓词下推 IO量最小化 │ │ │ │ │ └──────────────────────────────────────────────┐ │ │ ▼ │ │ 综合效果: 亿级数据 毫秒级响应 │ │ │ │ 勇于尝鲜 ───→ JIT编译 ───→ 运行时优化 ───┐ │ │ │ │ │ 持续测试 ───→ 基准验证 ───→ 性能不退化 ───┘ │ │ │ └──────────────────────────────────────────────────────────────┘8 总结给我们的启示ClickHouse的五大设计哲学不仅是构建一款高性能OLAP数据库的方法论更是一套通用的性能工程原则着眼硬件先想后做在追求性能的道路上不理解硬件就如同盲人摸象。CPU缓存、SIMD指令、NUMA架构——这些硬件特性不是实现细节而是性能优化的第一性原理。算法在前抽象在后抽象是软件工程的好朋友但性能敏感代码需要警惕抽象带来的隐性成本。模板优于虚函数、特化优于泛化——这些选择需要根据具体场景权衡。勇于尝鲜不行就换技术世界变化迅速保持开放心态去尝试新技术是必要的。但尝试必须建立在数据基础之上——基准测试是唯一可信的判断标准。特定场景特殊优化通用的解决方案在特定场景下往往不是最优的。为OLAP场景量身定制的稀疏索引、字典编码、两阶段聚合都是特化优于泛化哲学的具体实践。持续测试持续改进性能优化不是一劳永逸的工作。建立完善的基准测试体系持续追踪性能变化才能确保系统在高水平上持续演进。掌握这五大哲学不仅能让我们用好ClickHouse更能帮助我们在任何需要极致性能的软件工程场景中做出正确的技术决策。上一篇【第07篇】ClickHouse执行引擎架构——Parser、Interpreter与Function体系下一篇【第09篇】ClickHouse安装部署全攻略——从环境准备到服务启动参考资料ClickHouse Architecture GuideLLVM DocumentationIntel 64 and IA-32 Architectures Optimization Reference Manual《计算机体系结构量化研究方法》《性能之巅系统、企业与云的可视化方法》ClickHouse GitHub Benchmarks