☰
ClickHouse实战:从列式存储到查询优化的性能调优指南
2026/10/14 6:58:06 网站建设 项目流程

ClickHouse 这个拼写,我在各种讨论区和存档文档里见过太多次被写成 ClickHosue 的样子,但拼写的小错误并不影响它成为分析场景里绕不开的关键词。这两年聊大数据,十个人里有七八个会提到它,而且很多团队已经从“要不要试试”变成了“已经在线上跑了一段时间”。这篇不讲官方文档式的罗列,只聊我在一个分析型项目里实际落地时沉淀下来的理解:为什么它会这么快,建表阶段哪些做法会直接决定后续性能,查询慢的时候该按什么思路排查,以及分布式集群里最容易踩的坑。适合正在做技术选型、刚入门 ClickHouse、或者已经被线上慢查询搞到头疼的开发者。

1. 列式存储与向量化执行:ClickHouse“快”的根本逻辑

1.1 为什么列式存储对分析查询如此友好

很多人以为 ClickHouse 快是靠 C++ 写得好、编译器优化做得极致,这些确实存在,但最根本的差异在存储模型。它选择了列式存储,而传统关系型数据库大多是行式存储,两者在分析场景下的IO开销完全不在一个量级。

拿行李箱来打比方:行式存储像按人整理行李,每个人的所有物品放在一起,登机牌、护照、衣物、充电器都归在一个包里;列式存储像按物品类别整理,所有护照放进一个盒子,所有充电器放进另一个盒子。如果你要统计整个旅行团的充电器数量,按人整理的方式得把每个行李箱都翻一遍,按类别整理的方式直接打开充电器那个盒子数就行。

分析类查询恰恰就是这种“只看少数几列、但要把这一列所有值都算一遍”的场景。比如查某张十亿行行为表的日活,SQL 里可能只用了 user_id、event_time、event_type 三列,但行式存储必须把每一行的其他几十个字段也读出来才能拿到这三个字段,白白消耗大量磁盘IO和内存带宽。列式存储只读需要的那几列,扫描数据量可以小一个数量级以上。

这不是一个理论上的轻微优势,而是量级差异。我们的模拟项目里有一张宽表,单行有六十多个字段,未加任何优化前,一次跨天聚合查询在行存上的预估扫描量接近上百GB,换到 ClickHouse 后只扫描三列,实际IO降到几个GB,查询时间自然从分钟级变成秒级甚至毫秒级。存储模型选对了,后面很多事情都顺了。

1.2 稀疏索引与向量化:两个常被忽略的引擎特性

列式存储解决的是“少读数据”的问题,稀疏索引和向量化解决的是“读的时候还能再省一步”的问题。

ClickHouse 的索引和传统数据库索引长得不一样。它不是对每一行建一条索引记录,而是把数据按 ORDER BY 排序后,每 8192 行成为一个 granule(数据粒度块),索引只记录每个 granule 第一行对应的排序键值。这种设计叫稀疏索引,优点是索引体积非常小,轻轻松松全量载入内存;缺点是它没法像 B+树那样精确定位到某一行,只能粗粒度地跳过很多不相关的块。但在分析场景里,这个“跳过”已经足够了——你要的就是“别把整个分区扫一遍”的效果。

向量化执行则是把“逐行处理”变成“按批次处理”。同一个表达式,如果一行一行算,CPU 每处理一行都要重复取指令、解码、执行;如果一次从内存里拿一批连续数据,用 SIMD 指令批量算,中间开销会被摊薄非常多。列式存储天然适合这种模式,因为一列的数据在内存里是连续且同类型的,可以规整地交给CPU去批量处理。这也是为什么我在看执行计划时特别关注一个算子的输入数据量,如果某一步把数据打散成单行处理,性能通常会断崖式下跌。

1.3 适合这种引擎的业务形态

理解了列式存储和向量化,也就理解了 ClickHouse 的适用边界。它适合“一次性写入、高频聚合查询、按时间追加数据、查询时扫描大量行但只关心少数列”的场景,典型的就是日志分析、用户行为分析、监控指标存储、报表服务。

反过来,它不适合“频繁单行更新、强事务、高并发点查”的场景。这个边界不是 ClickHouse 做得不够好,而是引擎的架构假设从一开始就不在这些方向上。很多人踩坑,不是因为 ClickHouse 没用对,而是拿一台跑 OLTP 的思维来跑 OLAP,最后发现事务和更新能力跟不上,就抱怨引擎不行。定位清楚了,后面所有设计决策都会顺很多。

2. 建表设计:性能在写入之前就已经定型

2.1 MergeTree 引擎家族到底怎么选

ClickHouse 最常被提起的引擎是 MergeTree,但 MergeTree 并不是一个引擎,而是一整个家族。选错引擎或者选错 MergeTree 变体,后面查询可能要绕很大弯路。

最基础的 MergeTree 适合“只追加、不改不删”的数据。ReplacingMergeTree 会在后台合并时按排序键去重,适合“同一主键可能写入多条、最终只要一条”的场景,比如来自消息队列的重复数据。SummingMergeTree 会在合并时对数值列做求和,适合“只关心累计值”的场景。CollapsingMergeTree 和 VersionedCollapsingMergeTree 专门处理实时计算里的“撤销更新”问题,比如用“加一条正记录、再写一条负记录抵消”的方式实现事实表更新。

有一个很重要的点很多人没意识到:ReplacingMergeTree 的去重只在后台合并时发生,不是写入时立即生效。你写入两条排序键相同的数据,在合并真正发生之前去查,可能两条都在。所以如果业务要求“写入后立刻只能查到最新一条”,ReplacingMergeTree 并不能满足,你需要在查询侧额外处理,比如用 argMax 按版本取最新值。这点在接实时数仓时特别容易爆雷。

把典型场景列成表会更直观:

场景推荐引擎说明
纯追加日志、行为数据MergeTree基础选择,扩展性好
有重复写入、最终保留最新ReplacingMergeTree合并时去重,非即时生效
只关心累计指标SummingMergeTree合并时自动求和
需要抵消旧数据CollapsingMergeTree配合正负记录使用

2.2 ORDER BY、PARTITION BY、PRIMARY KEY 三者的关系

建表时最影响性能的三个字段是 ORDER BY、PARTITION BY 和 PRIMARY KEY。ORDER BY 定义了数据在每个分区内的物理排序顺序,同时决定了稀疏索引的排列结构,这是整个表性能的核心。PRIMARY KEY 在 ClickHouse 里默认只是稀疏索引的构成,它允许重复,完全不像 MySQL 里的主键那样有唯一约束。

设计原则可以浓缩成一句话:把查询里过滤最频繁的等值字段放在排序键前面,把范围过滤字段放在后面。比如一个多租户的行为分析表,最常见的报表查询是“某个租户在某段时间内的指标”,那排序键可以设计成(tenant_id, event_time)。这样查询时既能快速定位到对应租户的数据块,又能在该租户内按时间范围进一步裁剪。反过来设计成(event_time, tenant_id),跨租户时间过滤会好一点,但租户维度的裁剪会明显变差。

PARTITION BY 的作用是让数据在物理上按指定表达式拆分成多个分区目录,查询时能跳过无关分区。最常见的是按时间分区,比如PARTITION BY toYYYYMMDD(event_time)按天分区,或者toYYYYMM(event_time)按月分区。这里有个经验:分区粒度不要比数据量更细。如果一张表每天就几万行,按小时分区会产生大量小目录,后台 merge 任务会被这些小分区淹没,查询反而变慢。分区是为了“裁剪”,但如果分区数量过多,裁剪收益会被管理成本吃掉。

2.3 字段类型与压缩编码:压缩率和查询性能的平衡

ClickHouse 的压缩能力强,前提是你得给它合适的数据类型。最基础的原则是:能用整数就不用字符串,能用 DateTime 就不用 String 去存时间,能用数值型枚举就不用长文本。很多人图省事,把埋点里的时间直接写成字符串存进去,结果这一列压缩率上不去,排序时还按字符串字典序排,日期范围查询也很别扭。

字段编码 CODEC 是另一个值得细抠的地方。默认的 LZ4 压缩快但压缩率一般,ZSTD 压缩率高但稍微费CPU。对于时间序列数据,Delta 编码效果很好,因为相邻行的数值往往变化不大,存差值会比存原始值小很多。Gorilla 编码对缓慢变化的浮点监控指标特别合适。T64 编码适合取值范围固定的整数列。

举个例子,监控指标里的 CPU 使用率如果用Float64 CODEC(Gorilla),在连续采样的场景下压缩率能比默认高出好几倍。而行为表里的成本字段,如果波动不大,用Float64 CODEC(ZSTD(3))也能拿到不错的性价比。

建表时另一个容易被忽略的是LowCardinality类型。对于枚举值有限的字段,比如事件类型、渠道来源、国家代码,声明成LowCardinality(String)会让 ClickHouse 把字典编码和压缩结合,查询速度和存储占用都会改善。但要注意,如果字段的基数很高,比如用户ID,就不要用 LowCardinality,字典本身会变得比数据还大,得不偿失。

2.4 物化视图:把聚合成本前置到写入阶段

很多报表查询慢,是因为每次查询都去扫原始明细表、现场做 GROUP BY。明细表动辄几十亿行,就算列式存储再快,聚合几十亿行也要花不少时间。物化视图的解决思路是:写入时就把聚合结果算好,查询时直接读聚合表。

ClickHouse 的物化视图不是传统数据库里的“视图”,它的本质是一个触发器,数据插入源表时,会把视图 SQL 计算后的结果写入另一张目标表。比如我做一个按小时聚合的物化视图:

CREATE MATERIALIZED VIEW events_hourly_mv TO events_hourly AS SELECT tenant_id, toStartOfHour(event_time) AS hour, count() AS cnt, uniq(user_id) AS uv FROM events_raw GROUP BY tenant_id, hour;

之后只要往 events_raw 写入数据,events_hourly 就会自动更新对应小时的聚合结果。查询端直接查 events_hourly 表,数据量只有明细表的几十分之一甚至几百分之一,聚合查询自然快到毫秒级。

但物化视图有几个容易踩的坑。第一,它只处理“新写入”的数据,不负责回填历史数据。存量数据你需要手动写一条 INSERT 进去。第二,视图 SQL 如果写错了,修改起来比普通表麻烦,需要删除重建再回填。所以建物化视图之前,我习惯先在源表上跑一遍同样的 SQL,确认结果符合预期再固化下来。第三,聚合粒度不要设得太细,比如按秒聚合,否则聚合表可能比明细表还大,失去了意义。

3. 查询优化实操:把慢SQL降到毫秒级的全链路排查

3.1 用 EXPLAIN 看懂一条查询到底干了什么

遇到慢查询,第一反应不应该是加内存或者加机器,而是先看执行计划。ClickHouse 提供了一系列 EXPLAIN 变体,最常用的是EXPLAIN PIPELINE,它可以展示查询被拆成了哪些执行步骤,每个步骤的数据量大概是什么量级。

EXPLAIN PIPELINE SELECT tenant_id, count() FROM events_raw WHERE event_time >= today() - 1 GROUP BY tenant_id;

执行计划会列出从读取分区、扫描数据块、聚合、合并结果到最终输出的完整链路。我一般重点看两个位置:一是最底部的读取阶段,确认它扫描的分区数和数据块数是否符合预期;二是聚合阶段有没有出现数据被单线程处理的瓶颈。如果一个聚合算子在计划里标记为单线程,那这个查询基本不可能快起来。

EXPLAIN SYNTAX则用来检查 ClickHouse 有没有改写你的 SQL,有时候你以为自己写了很聪明的子查询,优化器可能把它翻成了一堆不那么聪明的嵌套,从语法树里一眼就能看出来。

3.2 索引没生效的典型原因:函数包裹和排序键前缀

日常排查慢查询时,最常见的根因是“索引裁剪没生效”。索引裁掉的是 granule,是整块整块的数据块,一旦失效,查询就退化成全表扫描。常见的情况有三种。

第一种是对排序键字段套了函数。比如原始表里存的是event_time DateTime,你写WHERE toDate(event_time) = today(),优化器无法直接判断这个条件下 event_time 的索引范围,因为它要先对每一行做 toDate 转换才能比较。正确写法是WHERE event_time >= today() AND event_time < today() + INTERVAL 1 DAY,让排序键字段保持裸奔状态,索引才能生效。

第二种是过滤条件没有遵循排序键的字段前缀顺序。如果排序键是(tenant_id, event_time),只按 event_time 做时间范围过滤时,ClickHouse 虽然能利用时间字段做一定裁剪,但因为前缀字段 tenant_id 没有出现在过滤条件里,裁剪粒度会明显变差。这种场景下,你需要根据真实查询频率重新设计排序键,或者把最常用的过滤字段提到前面。

第三种是 OR 条件把索引剪裁弄散了。WHERE tenant_id = 1 OR event_type = 'click'这种写法往往会让优化器放弃精准的粗粒度索引,改用扫描更多数据块。能改写成UNION ALL或者拆成两个查询再合并的,尽量拆开。

3.3 高基数聚合的内存控制与函数取舍

GROUP BY 是 ClickHouse 的强项,但当分组基数特别高时,它也是最容易把节点内存打爆的操作。比如按 user_id 做聚合,一个十亿行的时间段里可能有几千万个不同用户,聚合状态会全部驻留在内存里,直到最后合并结果。内存不够时,查询会直接报DB::Exception: Memory limit exceeded。

处理手段有几个方向。最直接的是打开外部聚合,让聚合中间状态溢出到磁盘:

SET max_bytes_before_external_group_by = 20000000000; SET max_memory_usage = 50000000000;

设置之后,超过 20GB 的聚合会开始写磁盘临时文件,查询会变慢,但节点不至于挂掉。对于报表类场景,我建议把max_memory_usage在会话级别设置好,别让一条查询吃掉整个节点的内存。

另一个容易被忽略的细节是精确去重函数的选择。uniqExact保证精确结果,但会为每个分组维护一个较重的去重集合;uniq和uniqHLL12是近似算法,结果有千分位级别的误差,但性能和内存开销小很多。分析看板里展示 UV 这类指标时,大部分业务根本不需要精确到个位数的去重,用uniq完全能打。只有对账、计费等需要精确数字的场景,才值得为uniqExact付出代价。

3.4 一次报表查询从 30 秒到 300 毫秒的完整排查

这里记录一个我在模拟行为分析平台里实际遇到过的案例。需求是按活动ID统计一天的参与用户数和收入,最初 SQL 长这样:

SELECT activity_id, uniqExact(user_id) AS uv, sum(revenue) AS revenue FROM events_raw WHERE toDate(event_time) = yesterday() GROUP BY activity_id;

这个查询每天要扫大量数据,跑一次要三十多秒,报表页面一打开就超时。排查过程分三步。

第一步用 EXPLAIN 看读取层,发现分区裁剪预期内只扫了昨天一个分区,但数据块裁剪效率很低。问题出在 WHERE 里对 event_time 套了toDate()函数,索引完全无法利用,整个分区的数据全量扫描。

第二步把条件改成范围过滤:

WHERE event_time >= yesterday() AND event_time < today()

扫描量降了一些,但问题没有根本解决,因为这张明细表每天的写入量本身就很大,即使全引擎优化,现场聚合几十亿行的成本依然高。

第三步采用物化视图,把明细写入时同步聚合到小时粒度表,报表查询直接落到聚合表。聚合表数据量只有明细表的几百分之一,原本三十多秒的查询变成三百毫秒以内。优化的根本思路不是让计算跑得更快,而是让需要计算的数据量变得更少。这个优先级永远排在加机器前面。

4. 分布式部署:分片、副本与协调服务之间的取舍

4.1 Distributed 表只是路由层,不是存储层

ClickHouse 的单机性能再强,数据量到了一定程度还是要上分布式。很多新手在理解分布式架构时会有一个误区:以为 Distributed 表会自己把数据均匀存到所有节点上,像某些分布式数据库那样做到完全透明。实际上,ClickHouse 的 Distributed 表本质上只是一个路由层,它背后对应的是每个节点上的本地表,数据真正的存储和合并都发生在本地表上。

常见做法是先建本地表,再建分布式表做逻辑统一入口:

CREATE TABLE events_all AS events_local ENGINE = Distributed(my_cluster, db, events_local, rand());

写入时往分布式表写,数据会按照分片键分发到各节点的本地表;查询时分布式表会把请求发到所有相关节点,再把结果汇总回来。这个设计的好处是灵活、透明,坏处是很多操作并不会自动“分布式”:比如ALTER TABLE需要手动 on cluster,OPTIMIZE也需要逐个节点触发。习惯单机数据库操作习惯的人,初期很容易在这里栽跟头。

4.2 分片键的选择:均匀性和查询裁剪的博弈

分片键决定了写入时数据去哪个分片,也决定了查询时能不能只扫部分分片。理想分片键要同时满足两个条件:分布均匀,避免数据倾斜;和常用查询条件相关,便于裁剪。

用rand()做分片键是最均匀的,写入时完全随机分发,没有热点问题。但坏处是,任何带租户维度或时间维度的查询都要扫描所有分片,再汇总结果,集群越大,跨节点聚合的开销越明显。用业务字段做分片键,比如tenant_id,单租户查询可以精准路由到单个分片,但如果某个租户的数据量特别大,这个分片就成了热点,整个集群的负载会严重不均。

实际项目里我通常这样折中:如果各租户数据量比较平均,就用tenant_id做分片键,换取查询裁剪能力;如果租户之间数据量差距悬殊,就退一步用(tenant_id, user_id)这种组合,或者干脆用cityHash64(tenant_id)取模,让每个租户的数据也能被打散。核心思想是:分片键必须站在最频繁的查询模式上去想,而不是仅仅考虑写入均匀。

4.3 ReplicatedMergeTree 与协调服务的隐性依赖

副本机制让 ClickHouse 具备高可用能力,但它依赖协调服务来管理副本间的元数据同步。每写入一个数据块,都需要在协调服务里注册对应的元数据,副本节点通过监听日志来决定自己该拉取哪些数据块。这套机制整体是可靠的,但在一些极端场景下会成为瓶颈。

我遇到过的故障大多是“小分区灾难”引发的。比如某张表按小时分区,但每个分区里的数据块又切得极碎,导致协调服务里的元数据节点数量爆炸,副本同步不断积压,后台日志里反复出现等待同步的提示。表面上看是协调服务出问题了,根子其实是分区粒度和写入批次没控制好。

实践经验是:不要为了追求“裁剪更精细”就把分区粒度设得太细,至少保证每个分区目录里有成规模的数据量。同时,单次 INSERT 的数据量尽量大一些,默认的异步插入也建议开起来,避免大量微型批次冲进来。副本数量也不是越多越好,三副本在很多场景下已经非常充裕,再往上加,同步延迟和元数据压力都会非线性上升。

另外,新版 ClickHouse 自带的 Keeper 已经可以替代传统的协调服务组件,部署维护成本低了不少。如果是新集群,我建议直接上 ClickHouse Keeper,省掉一套外部依赖,整个集群的排查链路会干净很多。

4.4 节点扩容和数据迁移:别让副本元数据打架

扩容是迟早的事。最理想的情况是数据本身还在消息队列里留了备份,重新向新集群消费一份,老集群按生命周期逐步下线。这个方案对数据链路完整、有 Kafka 之类的缓冲层时最省心。如果数据已经只存在于 ClickHouse 集群里,那就只能靠物理迁移。

物理迁移时要特别注意:不要手动把一个节点的 part 目录直接拷到另一个节点的 detached 目录里,然后直接 ATTACH。ReplicatedMergeTree 的副本之间是靠协调服务里的元数据感知彼此的,手动搬数据很容易造成元数据冲突。更稳的做法是使用官方提供的迁移工具,按分区粒度搬移,或者在新节点上先建好空表,再通过INSERT INTO SELECT FROM remote(...)从旧节点把数据重新写入。后一种方式会重新走一遍分布式写入链路,慢一点,但副本元数据是自然生成的,不会打架。

扩容完成后,记得检查 Distributed 表配置里的分片信息是否包含新节点,然后执行SYSTEM RELOAD CONFIG让配置重新加载。我们线上有一次新增节点后忘了这步,查询是跑到新节点上了,但新节点根本没有对应的本地表,直接报了 404 一样的表不存在错误,排查了半天,居然是配置没重载,低级但真实的坑。

5. 使用边界:别让ClickHouse去扛不擅长的工作

5.1 高频点查场景:查询服务应该交给别人

ClickHouse 在报表和分析场景里很强,但如果你需要的是一个能扛每秒几万次单行查询的在线服务,它并不是首选。虽然它的稀疏索引能加速点查,但它没有成熟的二级索引机制,一个等值条件能不能走索引,很大程度取决于排序键怎么设计。而且高并发查询下,每个查询都要经过较重的执行引擎调度,延迟和吞吐都比专门的在线数据库要差。

我见过一个反面案例:项目组把用户画像的实时查询接口直接压到 ClickHouse 上,上线后并发一上来,节点 CPU 先是飙到 90% 以上,然后大量查询超时,最后不得不紧急加缓存层。问题的本质不是 ClickHouse 不行,而是在线查询和离线分析对引擎的要求完全不同。合理的架构应该是在线请求走 Redis 或传统关系型数据库,ClickHouse 只负责它擅长的批量聚合和报表查询。

5.2 强事务和精确更新场景:别把约束当缺点

ClickHouse 对事务的支持比传统关系型数据库弱很多,它没有跨表事务,也没有唯一约束这类强力保障。它的 MergeTree 家族虽然在合并阶段能完成去重、求和等操作,但这个“最终一致”的窗口期是真实存在的。对账、交易、库存这类业务,对数据准确性和事务边界要求极高,把这些系统硬搬到 ClickHouse 上,后续的补数据、修复数据流程会让你相当难受。

倒不是说 ClickHouse 不能做实时数仓,而是说它适合的场景是“明细流式写入、指标聚合输出”,真正需要强一致性的业务系统应该放在边界之外。数据进入 ClickHouse 之前,最好在上游已经完成了必要的事务处理,ClickHouse 只是分析层,而不是业务系统的存储底座。

5.3 在数据仓库和实时链路中的定位

实际架构里,ClickHouse 很少单独存在。它最常见的定位是“查询加速层”:上游 Kafka 把实时数据流进来,ClickHouse 承接明细和预聚合结果,下游报表、看板、监控告警全部从它这里读数据。而规模庞大、需要复杂 ETL 的批处理任务,通常还是交给 Hive 或 Spark 生态里的框架去做。

两种引擎各有各的位置:Hive 擅长处理海量数据的批式转换,吞吐高但对单次查询延迟不敏感;ClickHouse 的优势是查询速度,但它不太适合做特别复杂的多表关联和超大 ETL。我见过一些团队试图让 ClickHouse 干 Spark 的活,把几十张表的大 Join 都塞进来处理,结果是查询优化器压力巨大,内存和临时数据疯狂增长,效果远不如把这一步留在数仓里完成。了解每个引擎在哪一层干活,比追求某个引擎的“全能”要重要得多。

5.4 选型前的最后一道自检清单

每次做技术选型,我都会先问自己和团队几个问题:这个数据是不是写多读少、按时间追加?查询是不是以聚合、过滤、扫描为主?对事务和精确更新的要求高不高?并发是几十个报表查询还是几万个在线请求?数据量是不是已经大到传统关系型数据库扛不住?

如果前面几个问题答案都是“是”,ClickHouse 大概率是合适的。如果里面混入了“在线高并发”或“强事务”这类选项,就要考虑混合架构,而不是指望一个引擎解决所有问题。选型这事,技术热情要克制一点,先看清楚边界,再决定要不要上。

6. 运维踩坑记录:从内存被打爆到副本同步延迟

6.1 内存爆掉的那一刻,先从SQL找原因而不是加机器

ClickHouse 节点被查询打挂,最常见的原因就是高基数 GROUP BY。某个开发同事跑了一个按 user_id 聚合一整月数据的查询,几亿的用户ID全部要维护在内存里做去重和聚合状态,几台机器瞬间全部告警,生产报表跟着一起遭殃。

这种事故靠加内存是治标不治本。我后来给所有报表查询会话统一设置了内存上限和扫描行数上限:

SET max_memory_usage = 40000000000; SET max_rows_to_read = 1000000000; SET max_execution_time = 60;

超出限制的查询会直接失败,而不是把整个节点拖死。同时,在账号体系上把只读账号区分出来,报表查询统一用只读账号跑,避免开发人员直接拿管理账号在线上任意执行大查询。这个操作不复杂,但在保护集群稳定性上的收益立竿见影。

6.2 parts 数量爆炸:后台合并跟不上写入

ClickHouse 的 MergeTree 会不断把小的数据块合并成更大的数据块,后台自动执行。但如果写入的频率太高、每次写入的数据量又小,parts 数量就会快速增长,超过合并的消化能力。parts 太多时,查询时需要打开的目录和文件数量剧增,IO 和内存都会吃紧。

判断方式很简单,直接看系统表:

SELECT table, count() AS parts, sum(rows) AS total_rows FROM system.parts WHERE active GROUP BY table ORDER BY parts DESC;

如果发现某张表的 parts 数量长期维持在几百上千,就要检查写入模式了。解决方案包括:调大写入批次,让单次 INSERT 至少落几万行;开启异步插入,让写入端批量攒一批再发;对已存在的碎片用OPTIMIZE TABLE xxx FINAL手动触发一次合并。不过 OPTIMIZE 是很重的操作,不要在高峰期频繁执行,线上我把这类操作放在凌晨低峰期用后台任务跑。

6.3 慢查询定位和“僵尸查询”清理

慢查询的定位主要靠system.query_log这个系统表,它记录了每一次查询的耗时、扫描行数、内存消耗等关键信息。遇到线上偶发变慢,我一般会这样查:

SELECT query_id, query, query_duration_ms, read_rows, memory_usage FROM system.query_log WHERE event_date = today() AND query_duration_ms > 5000 ORDER BY query_duration_ms DESC LIMIT 20;

把查询耗时、扫描行数、内存使用量三个指标放在一起看,基本能判断是数据量问题、索引失效问题还是聚合太重问题。还有一些查询会卡在执行阶段,占着线程和内存不释放,这种“僵尸查询”别等它自己超时,直接按 query_id 杀掉:

KILL QUERY WHERE query_id = 'xxxx-xxxx-xxxx';

运维经验里最让我受益的一条是:给所有外部查询统一设置合理的超时时间,别让一条失控的查询无限期占用资源。这一条配合内存上限,基本上能挡住九成以上的“集群被拖垮”事故。上线初期我们对超时不够重视,结果半夜被报警电话叫起来处理慢查询的次数一只手数不过来。后来把查询超时、内存限制、只读账号都配齐了,集群的半夜事故率明显降了下来。

现在如果让我给一个新集群总结最实用的经验,我会说:先把建表的排序键想清楚,再把物化视图的预聚合搭好,最后把查询超时和内存上限设好,这三件事做完,ClickHouse 基本就能在一个项目里稳稳当当地持续输出价值了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询