ClickHouse这个数据库我接触得早从最早的v19就开始在生产环境折腾。很多人对它最直观的印象就是快。但真要说清楚为什么快、怎么让它更快大部分人第一反应是“列存、压缩、向量化”。这些确实对但真正拉开差距的地方是它对并行查询和多核CPU的调度能力。也就是说同样的SQL给ClickHouse 8颗核心它真的能把8颗核心都跑起来给传统数据库8颗核心很可能只有1个线程在忙其余7颗在看热闹。这篇文章想聊的就是ClickHouse并行查询这件事。我会从它的并行模型讲起把max_threads这类核心参数掰开揉碎然后给出一套在实际环境中诊断和调优的步骤最后把我踩过的一些坑比如重启报错failed to flush system log already exists、并行调高后IO性能明显下降、线程设太高反而变慢等全部整理出来。适合正在做ClickHouse性能调优、准备上生产环境或者单纯想搞懂“为什么我的ClickHouse没跑满CPU”的读者。1. 并行查询的整体设计与核心思路1.1 ClickHouse为什么能跑满多核ClickHouse的并行能力来源于它的执行引擎从设计之初就不是“单线程串行”的思路。传统关系型数据库的SQL执行通常是一个火山模型每行数据像流水线一样从叶子节点一层层往上流很多实现里一次只能跑一个线程最多做查询内并行时也常常被锁和数据结构冲突拖累。ClickHouse的选择不同它是列式存储加向量化执行数据不是一行一行处理而是按批处理一次操作一整列的一段数据天然适合拆成多段交给不同线程去算。再加上MergeTree存储引擎在物理上把数据切成一个一个的part每个part内部又按index_granularity默认是8192行分成若干个granule。这个设计太关键了。查询时每个granule都是一个可以被独立扫描和计算的小任务于是执行引擎可以把成千上万个granule分发给一个线程池。这才是ClickHouse并行查询的底层逻辑不是把一条SQL拆成几个大阶段然后每个阶段用几个线程而是把数据本身切成大量细粒度任务让多核CPU的自然并行能力去消化这些任务。换句话说想让多核CPU真正忙起来前提是数据本身的粒度足够细。ClickHouse的part和granule就是这个“细粒度”的来源。如果你的表只有一个大part且granule很少max_threads设成128也喂不饱CPU。理解这一点后面很多调优动作就顺了。很多人问我为什么表数据量不小但查询还是慢我第一反应就是去看part数和granule数而不是急着调参。1.2 线程池与执行管线ClickHouse查询执行时有一个全局线程池ThreadPool默认大小通常跟CPU核数相关有些版本按逻辑核动态调整有些按物理核检测。查询会被构建成一个Pipeline每个stage包含若干Processor节点这些节点会被调度到线程池中执行。这里和很多人的直觉不一样ClickHouse不是照搬“线程池队列任务”这种简单模型而是有自己的一套执行管线优化能尽量减少线程切换和等待。我在看EXPLAIN PIPELINE输出时经常发现同样一条SQL不同表结构、不同过滤条件下管线差异非常大。比如有PREWHERE的查询会在读取阶段就把过滤条件下压丢弃大量无关granule后面聚合阶段处理的线程压力就小很多。这提醒我们一点并行度并不是“全局开越大越好”它和查询管线里每个环节的数据量是联动的。如果拿人来做类比线程池就像一家餐厅的后厨。厨师多当然好但每道菜的切配、炒制、装盘都有先后依赖。如果备菜台不够厨师再多也只能干等。ClickHouse的Pipeline调度就是在解决“让厨师们尽量各干各的、又不互相抢锅铲”的问题。所以遇到并行没效果不要第一反应怪CPU少先看是不是“备菜台”不够也就是granule和part数量太少。1.3 并行发生在哪些阶段ClickHouse的并行不是只在一个环节而是几乎所有重算子都有并行版本。常见的有数据扫描和过滤多个granule由多个线程同时读、同时过滤这也是最底层的并行来源。聚合GROUP BY会先做局部聚合每个线程处理自己分到的数据最后再做合并聚合。排序ORDER BY在多线程下先部分排序然后归并。JOIN右表可以并行构建哈希表左表数据也并行去探测。分布式查询一个Distributed表查询会被拆成多个分片子查询分别在不同节点上执行结果汇总。这些并行环节叠加起来才是ClickHouse在复杂聚合查询上能跑出成绩的原因。但要注意叠加也意味着“短板效应”。如果某个阶段因为数据倾斜或串行算子拖后腿整体查询速度就会被该阶段拖住。这也能解释为什么有时候max_threads从16调到32查询时间没有变短——瓶颈可能根本不在扫描或者聚合而在合并阶段的单线程上或者网络IO、数据均衡等。2. max_threads与多核配置细节2.1 max_threads并行度的总开关max_threads应该是ClickHouse里最常被问到的参数。它的含义是执行一次查询时单个节点上最多允许启动多少个线程来并行执行。官方默认值一般是根据CPU核数自动检测的但不同版本策略不同有的检测逻辑核有的检测物理核所以我在生产环境里从来不做“默认党”都是显式设置。设置方式有三种粒度全局配置在users.xml的对应用户profile里加一行max_threads16/max_threads。会话级别执行SET max_threads 16。查询级别SELECT ... SETTINGS max_threads 16。三者的优先级是查询级别最高会话次之全局最低。调优时我喜欢先用SET在本会话里试确认效果后再落到全局配置避免一上来改全局配置影响其他业务。那max_threads到底设多少合适我的经验是如果机器是独占的设为物理核数通常是最稳的起点。如果是超线程机器比如32逻辑核但只有16物理核先试试16再试试24最后试32拿真实查询做对比。因为超线程的核心对计算密集型任务收益很有限有时候反而会因为争抢L1/L2缓存和内存带宽导致整体变慢。这里给个实际对比我曾经在一台16核32线程的服务器上跑一个大GROUP BY查询数据量大约5亿行。max_threads16时耗时6.8秒调到24时7.2秒调回16后稳定在6.5秒左右。肉眼可见逻辑核全开并没有带来线性收益。这说明并行度的选择不能只看“核心数量”要实测。2.2 写链路与后台任务也有并行参数很多人只盯着查询的max_threads忽略了写入和后台Merge同样会影响整体CPU占用。INSERT阶段有两个重要参数max_insert_threads控制INSERT时用于数据解析和part写入的线程数。对于INSERT SELECT这种大批量写入默认值可能不够我会根据写入量级调大比如8或16能看到明显吞吐提升。max_threads_for_view物化视图写入时的线程数如果物化视图很多且写入压力大这个也要关注。后台任务方面background_pool_size控制后台Merge、Mutation的线程池大小默认情况下和CPU核数相关。这里有个常见矛盾后台Merge太积极会抢查询的CPU太消极又会导致part数量暴涨查询时扫描的开销变大。生产环境里我一般会结合parts_to_throw_insert和parts_to_delay_insert来控制part数量而不是一味压background_pool_size。还有一个容易被忽略的参数是max_parallel_replicas使用ReplicatedMergeTree时它允许一个查询把不同分片的数据请求分散到多个副本来并行读取可以把多副本的CPU也利用起来。想用它需要配合集群配置里的internal_replication和allow_experimental_parallel_reading_from_replicas这类设置。注意这里说的“并行读取副本”和普通的“副本容灾”是两码事它是在同一个分片内把读压力分散到多副本可能会牺牲一部分主副本的缓存命中率我一般只在缓存命中率不敏感的分析场景才开。2.3 CPU亲和性、NUMA与容器限制ClickHouse默认不会主动绑核但生产环境尤其是容器化部署时硬件层面的CPU限制非常关键。如果容器只分配了4个CPU但宿主机有64核而ClickHouse又自动检测到了64核那max_threads默认值会很不合理查询时会因为线程频繁切换导致性能下降。所以容器部署时第一步就是确认容器实际可用的核心数并显式设置max_threads。NUMA架构下还有一个隐藏问题。一台两路服务器两个CPU各自有本地内存跨NUMA访问内存的延迟会比本地访问高不少。ClickHouse的线程池默认不会感知NUMA如果大量的线程都跑在一个NUMA节点上但数据在另一个节点上性能就会打折扣。我自己处理过一例一台32核机器只有部分查询慢后来发现是查询线程集中在某个NUMA节点。临时解决方案是用numactl指定启动参数或者通过taskset限制进程的CPU集合让ClickHouse进程不要跨NUMA随意飘。关于CPU隔离如果ClickHouse和别的业务混合部署我建议用cpuset给ClickHouse划一个独立的核心区间同时把irqbalance等中断处理尽量绑定到其他核心。这个操作在systemd下可以直接通过CPUAffinity配置实现比如[Service] CPUAffinity0-15这样ClickHouse进程只会在0到15号核心上运行能减少干扰也方便你用pidstat观察这16个核的利用率。如果你是裸机部署同样可以用taskset -c 0-15来启动clickhouse-server。3. 实操从诊断到调优一个慢查询3.1 先用EXPLAIN看执行计划别急着改参数我发现很多人调优第一步就是改max_threads这是本末倒置的。正确顺序是先用EXPLAIN看看执行计划确认瓶颈在哪。ClickHouse的EXPLAIN支持多种输出我最常用的是EXPLAIN PIPELINE和EXPLAIN PLAN。EXPLAIN PIPELINE会输出执行管线的详细信息包括每个环节用了几个线程。比如一条聚合查询的片段可能是这样┌─explain──────────────────────────────────────────────┐ │ (Expression) │ │ ExpressionTransform │ │ (Aggregating) │ │ AggregatingTransform │ │ (SettingQuotaAndLimits) │ │ (ReadFromMergeTree) │ │ MergeTreeThread(0, 8) │ └───────────────────────────────────────────────────────┘重点看MergeTreeThread后面的数字0表示第一个线程8表示这个读取环节一共起了8个线程。如果这里显示的线程数远小于你的max_threads说明数据切分粒度不够不是参数问题。如果这里满了接下来再往下看聚合、排序部分有没有被压缩成单线程的环节。EXPLAIN PLAN则更偏向表结构和索引选择可以确认有没有走到主键索引有没有做分区裁剪。我用这两条命令的时间加起来不超过两分钟但能少走很多弯路。记住EXPLAIN只是静态计划实际运行时的线程变化和资源等待还要结合后面的system.query_log来看。这里提醒一句EXPLAIN里显示的线程数是你设置的上限并不代表一定跑得满所以别只看静态输出就下了结论。3.2 用system.query_log验证真实并行情况ClickHouse的system.query_log里记录了查询的很多元信息调优时我常看这几个字段max_threads本次查询的线程数设置。thread_ids实际参与查询的线程ID数组。elapsed查询耗时。read_rows、read_bytes实际读取的数据量。memory_usage峰值内存。query_kind查询类型。调优时我会这样用跑完一条SQL后立即从query_log里查这条查询的记录看thread_ids数组的长度。如果thread_ids的长度远小于max_threads说明并行度没吃满。比如max_threads16但thread_ids只有4个那问题大概率在数据分布或管线串行节点上而不是参数设置得不够。如果thread_ids有16个说明并行确实拉满了但查询还是慢那就要看read_rows和read_bytes是否合理。如果读取的数据量比预期大很多说明索引或分区裁剪没生效扫描了太多无用数据。如果读取量正常但聚合慢可能需要查内存设置、临时磁盘排序这类问题。这里我提供一个简单的排查SQLSELECT query, max_threads, length(thread_ids) AS actual_threads, read_rows, read_bytes, memory_usage, elapsed FROM system.query_log WHERE query LIKE %orders% ORDER BY event_time DESC LIMIT 10;看actual_threads和max_threads的差距再对比read_rows基本能定位到瓶颈方向。这里要提醒的是thread_ids记录的是参与线程的ID数组长度基本上能反映实际并行度但极端情况下有些线程可能很快退出这个字段照样能记录到所以还是结合elapsed一起看更稳。3.3 一次完整调优实操记录光讲理论没有手感我列一个实际调优的case。场景一台16核CPU的主机跑的是ClickHouse 23.8我有一条统计订单表daily聚合的SQL表有2.5亿行主键是(order_id, order_date)查询条件按天过滤。SELECT order_date, count(), sum(amount) FROM orders WHERE order_date 2024-06-01 AND order_date 2024-06-30 GROUP BY order_date;第一次执行耗时3.4秒。查看EXPLAIN PIPELINE发现MergeTreeThread(0, 4)也就是底层读取只启动了4个线程但明明max_threads默认是16。原因是这个查询只命中了一个分区下的4个partpart太少了每个part的granule数量也不多线程池吃不饱。这时候我不会去调max_threads而是考虑另外两个方向一是检查表的分区设计是否合理比如这个查询如果经常按月扫描但表按年分区那么part的粒度就太大了二是看数据有没有做定期OPTIMIZEpart数量太少也会影响并行度。实际上这里part少是因为刚做了一次OPTIMIZE FINAL把part合并得太彻底了。对于大范围聚合查询来说part越多并行拆分越容易但part太多又会让读取元数据开销变大这是个需要平衡的地方。我后来做了一个折中不再对这个表做那么频繁的OPTIMIZE让part数量维持在一个比较自然的水平再配合显式SET max_threads 16。再次执行同样的SQL耗时降到2.1秒。可以看到这次调优本质上是调整了part数量和线程池的匹配关系而不是单纯改一个参数。这个案例也说明调优前先看数据分布有多重要。3.4 内存限制与外部排序的联动并行度开高以后内存压力会跟着上来。每个线程都有自己的中间状态特别是GROUP BY用了大量distinct值或者ORDER BY要对大数据量排序时内存消耗会按线程数成倍增加。ClickHouse默认设置了max_memory_usage默认是10GB在并行度高的情况下很容易触发内存超限报错。如果查询因为并行度高导致内存不足可以打开外部排序和外部聚合设置max_bytes_before_external_sort和max_bytes_before_external_group_by让数据在超过阈值后落到磁盘。但注意落盘会放大IO压力如果之前用iostat观察磁盘本身吞吐已经很高那这一项不要开得太激进。另外一个细节是max_memory_usage_for_user和max_memory_usage_for_all_queries在多用户共享一个实例时单个查询并行度再高也要受这两条限制约束。我在线上遇到过并行查询没把CPU打满却先因为内存限制被kill的情况这种问题用上面的query_log根本看不出来必须结合system.events里的MemoryLimit相关计数和error log一起排查。简单说并行度、内存、磁盘是一组联动参数改任何一个都要考虑另外两个的承受能力。4. 常见问题与排查技巧实录4.1 查询并行后IO性能明显下降怎么办“为什么我的ClickHouse并行度调高之后IO性能反而明显下降了”这个问题我遇到过不止一次。最典型的原因是并行扫描把所有数据都压到磁盘上同时page cache被大量新读入的数据冲刷掉导致缓存命中率骤降整体吞吐反而降低。排查步骤是先用iostat -x 1观察磁盘的util和await。如果util接近100%且await过高说明磁盘已经成了瓶颈如果util不高但查询变慢更可能是page cache被污染或者是存储层的问题。ClickHouse的读路径是很依赖操作系统page cache的如果每次都绕过缓存读盘性能会很难看。解决办法有几个方向一是适当降低查询级并行度比如从16降到8二是检查是否开启了过多的并发查询导致总并发线程数远超核心数三是考虑把热数据放到性能更好的NVMe存储上。还有一个容易被忽视的点是磁盘IO调度器。在传统的HDD上deadline调度器效果不错SSD和NVMe上noopnone通常更合适。这个可以通过修改/sys/block/设备名/queue/scheduler来调但要注意这是操作系统层面的改动会影响整机上所有进程。4.2 重启报错failed to flush system log already exists这个报错我猜不少人都搜到过因为它出现得挺玄学ClickHouse正常关停后重启结果server起不来错误日志里写着类似failed to flush system log already exists。我当时第一次遇到也是一头雾水。后来梳理了一下这个错误通常是system.query_log这类系统日志表在flush时出了问题可能原因包括磁盘空间满了、目录权限不对或者是上次非正常退出导致系统表数据目录留下了不一致的残留。处理时我建议按顺序排查。第一步先看磁盘和inodedf -h df -i如果磁盘满了先清理ClickHouse的日志和临时文件再启动。第二步看权限确认clickhouse用户对/var/lib/clickhouse和/var/log/clickhouse-server有正确的读写权限。第三步才是处理系统表本身。如果确认是系统表数据损坏常规做法是停服后把system.query_log对应的part目录备份走。备份完成后重新启动ClickHouse会自动重建系统表。注意这个操作会丢失一部分system.query_log历史记录对线上监控有影响的话最好先确认有没有其他日志归档通道然后再动手。另外要提醒一下遇到系统表异常时千万不要直接去删store目录里的其他表的数据文件很容易把用户表也误伤。我的习惯是动任何目录之前先用ls对照一下system.tables确认它确实是系统表的数据目录再操作。4.3 线程数设置过高反而变慢很多刚接触ClickHouse的人有个误区觉得把max_threads往死里调就能变快。我见过有人把一台16核机器的max_threads调到256结果查询时间从3秒变成11秒的。这里有两个隐藏成本线程上下文切换和内存带宽争抢。当线程数远大于CPU核心数时大量线程处在排队状态操作系统不得不频繁切换上下文而每次切换都要保存和恢复寄存器、刷新TLB这些开销在计算密集型的OLAP场景里会被无限放大。同时多线程并行访问同一块数据区域时内存带宽也会成为瓶颈。尤其对于扫描密集的查询数据要从内存或磁盘搬到CPU内存通道的带宽是固定的线程再多也不可能超过物理上限。我的经验是先用pidstat或top观察单核有没有跑满如果单核已经接近100%说明CPU能力确实用上了再去加线程是没有意义的如果单核才50%那就要找阻塞点而不是盲目加线程。另外不同查询类型的最优并行度差别很大扫描型查询和复杂聚合型查询对线程的敏感度完全不同。我通常会在测试环境跑一组max_threads从4到32的对比用一个脚本自动记录耗时找出拐点后再上生产。4.4 超线程和高并发查询的取舍超线程Hyper-Threading在很多计算密集型场景下其实是帮倒忙的。逻辑核之间共享物理核的执行单元两个逻辑核同时跑计算密集任务时每个逻辑核只能拿到物理核的一部分能力线程数反而越多越慢。ClickHouse的默认max_threads如果按逻辑核检测就可能踩到这个坑。我处理过一个case一台32核64线程的机器同时跑20个分析任务任务队列堆积严重。当时第一反应是CPU不够后来发现每个任务默认并行度64总并发线程达到1280个远超CPU承受能力。更合理的方式是限制单个查询的并行度为物理核数32同时用max_concurrent_queries控制并发查询数为CPU核数的一半这样总线程数控制在合理范围内任务的完成时间反而大幅缩短。如果你的机器不是独占的还要考虑给系统进程和其他服务留核心。我的做法是在容器或systemd服务里通过CPUAffinity限制ClickHouse只能使用物理核心的一部分剩下核心给业务侧的其他服务用。尤其是和JVM系服务混部时JVM经常因为GC暂停导致延迟抖动把ClickHouse和这类服务放同一台机器时CPU隔离比什么都管用。4.5 容易被忽略的并行细节除了上面这些问题还有几个小点值得提一下查询级别用SETTINGS临时调优是非常推荐的因为不会污染全局配置试错成本低。使用max_execution_time可以防止并行度过高导致查询无法结束收不到结果时至少有个保护。system.processes里也能实时看到每个查询的elapsed和read_rows用来判断查询是否卡在哪个环节。如果开了分区裁剪仍然没生效检查表引擎是不是MergeTree家族以及表的分区键是否和查询条件匹配。长期运行的服务器建议打开log_query_threads和log_query_views这样system.query_thread_log和system.query_views_log能记录线程级信息对事后分析很关键。5. 写在最后的小心得调ClickHouse并行查询我最深的体会是max_threads不是越高越好它的本质是给执行引擎一个“最多能用多少线程”的上限而不是“用多少线程更快”。真正决定速度的还是数据在物理上怎么存储、查询的管线怎么编排、以及机器硬件和并发任务之间的平衡。每次调优前先回答三个问题CPU真的吃满了吗数据扫描量合理吗内存和IO有没有成为新的瓶颈把这三个问题答清楚80%的性能问题都能迎刃而解。最后分享一个不起眼但很实用的技巧在测试环境准备一批典型SQL写个小脚本循环执行每次SET不同的max_threads用system.query_log记录耗时和thread_ids画出一条耗时曲线。曲线拐点对应的那个值就是你当前环境的最优并行度。这个值会因为表结构、机器配置、并发压力而变所以别指望一个固定的推荐值能用一辈子。过段时间数据量涨了part分布变了有必要重新再测一轮。这也是为什么我一直强调性能调优不是一次性的任务而是一个持续跟着业务数据走的习惯。这个内容其实可以继续扩展的点还有很多比如分布式查询在多节点之间的并行协调、物化视图与并行写入的联合调优、存储引擎级别的分区生命周期管理对并行扫描的影响等等后面有机会再单独写。
