广告:Codex Token 低价中转站稳定接口 · 快速接入 · 开发者备用通道
Engineering article

企业级 | ClickHouse | 零慢查询

企业在使用ClickHouse进行数据处理时,零慢查询(zero slow queries)是一个值得深究的技术点。如果你的数据仓库里经常出现慢查询,那你的ClickHouse配置就基本废了。我见过很多企业把ClickHouse用成在线数据库,结果频频遇到查询卡顿、资源占用高、耗时严重的问题。其实零慢查询的核心在于查询预处理和优化,不是简单的调参或者冷启动,

企业级 | ClickHouse | 零慢查询
配图来源于网络和AI生成,仅供参考。
企业在使用ClickHouse进行数据处理时,零慢查询(zero slow queries)是一个值得深究的技术点。如果你的数据仓库里经常出现慢查询,那你的ClickHouse配置就基本废了。我见过很多企业把ClickHouse用成在线数据库,结果频频遇到查询卡顿、资源占用高、耗时严重的问题。其实零慢查询的核心在于查询预处理和优化,不是简单的调参或者冷启动,而是对查询结构、数据模型、索引策略做出针对性调整。如果你的查询逻辑没有对字段进行索引,或者没有设计正确的表结构,那零慢查询就是一个伪命题。

数据模型设计是零慢查询的基础。我见过很多公司把数据按时间分区,结果查询时间字段的时候没有任何索引,导致每次都要扫描全表。ClickHouse的分区机制是智能的,但前提是你要在建表的时候明确指定分区键。比如`partition by toYYYYMM(toDateTime(timestamp))`,这样数据访问效率才会真正提上来。表结构设计上,不要做无谓的字段拼接,字段越多,查询性能越差。我有一个项目是把日志字段都合并到一个`json`列里,结果所有查询都要依赖`jsonExtract`,性能直接掉了一半。

索引策略是零慢查询的关键。ClickHouse的索引不是传统意义上的B-Tree索引,而是基于列的索引结构。所以要合理利用`index_granularity`和`max_parts`这些参数。比如设置`index_granularity = 8192`可以提升索引命中率,而`max_parts`控制分区数量,防止索引过多导致查询性能下降。我常用`ALTER TABLE table_name ADD INDEX index_name (column_name) TYPE minmax`来为字段添加索引,这样在过滤条件中就能命中索引快速返回结果。但要注意,索引多了会影响写入性能,所以得在读写平衡上做取舍。

查询优化是零慢查询的直接手段。我常见的是查询语句没有正确使用字段别名,导致ClickHouse无法使用缓存。比如`SELECT FROM table`会强制读取所有字段,而`SELECT id, name FROM table`才真正发挥缓存优势。另外,`JOIN`操作一定要注意顺序,让小表在前,大表在后。我在一个项目中,因为`JOIN`顺序错误,导致查询时间从3秒变成20秒。还有,要避免在`WHERE`子句里用`OR`连接多个条件,这样ClickHouse的优化器就无法有效利用索引。可以考虑用`UNION ALL`代替,或者用`materialized view`预处理数据。

查询缓存是ClickHouse内置的优化工具,但很多人不知道怎么用。我常用`SET max_cache_size = 100000000000`来调整缓存大小,这个参数控制的是最大内存占用。另外,`SET query_cache_type = 1`可以开启查询缓存,但要注意,如果查询结果太大,缓存可能无法命中。还有一个坑是,缓存是基于查询文本的,所以如果查询语句中有变量或者随机值,缓存就完全失效。我曾经在一次线上优化中发现,因为`WHERE`条件中包含`NOW()`函数,缓存根本无法使用,导致每次查询都要重新执行。

查询预处理是另一个关键点。我推荐使用`materialized view`来预处理数据,比如对日志表做聚合,生成统计表。这样在查询时就能直接从预处理表中获取结果,不需要每次都计算。比如创建一个`materialized view`,`SELECT toStartOfDay(timestamp) AS dt, COUNT() FROM logs GROUP BY dt`,这样每次查询时间范围的时候,直接调用预处理表,性能提升非常明显。另外,`ALTER TABLE table_name MATERIALIZE VIEW view_name`这个命令在数据量大的时候要小心使用,否则会占用大量磁盘空间。

查询计划分析是定位慢查询的必备技能。我习惯使用`EXPLAIN`和`SHOW CREATE TABLE`来查看查询计划和表结构,这两个命令能帮你快速发现查询是否走了索引,或者有没有不必要的数据扫描。比如`EXPLAIN SELECT FROM table WHERE id = 123`会显示查询是否命中`id`字段的索引。有时候我甚至会用`SELECT FROM table WHERE id = 123 LIMIT 10`来测试查询速度,而不是直接执行`EXPLAIN`,因为实际数据量大时,查询计划可能和真实情况有偏差。

查询执行计划的优化也是经验的积累。我见过很多开发人员不知道`ORDER BY`和`GROUP BY`的优化策略,结果查询效率低下。比如`ORDER BY`一定要和`GROUP BY`配合,否则排序会变得非常慢。还有一个经典问题,就是`LIKE`查询的问题。如果用`LIKE '%value'`,ClickHouse完全无法使用索引,只能全表扫描。我曾经用一个`materialized view`把`string`字段转成`array`,然后使用`arrayJoin`来处理模糊查询,效果还不错。但这种方法也有局限,只能在特定场景下使用。

性能监控是零慢查询的第二层防线。我建议使用`system.query_log`和`system.parts`来监控查询执行情况,这两个系统表能帮你发现哪些查询特别慢,哪些表没有被正确分区。比如`SELECT query, elapsed, rows_read, rows_received FROM system.query_log WHERE type = 1 ORDER BY elapsed DESC LIMIT 10`,这个查询能快速找到耗时最长的查询。另外,`SELECT FROM system.parts WHERE table = 'table_name'`可以查看各个分区的数据量和状态,帮助你判断是否需要合并或者拆分分区。

写入优化是查询优化的前提。我常见的是写入时没有使用`INSERT`的`execute`方式,导致写入速度变慢。比如使用`INSERT INTO table SELECT ... FROM source`会比直接调用`INSERT`快很多,因为减少了中间转换。还有一个坑是,写入时没有设置合适的`part_size`,这会直接影响分区的大小和查询性能。我习惯设置`part_size = 1024 1024 1024`,也就是1GB,这样查询时能更高效地扫描分区。但要注意,如果数据量特别大,分区数量会迅速膨胀,这时候要限制`max_parts`参数。

查询预热是零慢查询的补丁。我见过很多人尝试过查询缓存,但发现预热没效果,其实是因为缓存还没被填充。我推荐使用`SELECT FROM table WHERE id IN (1,2,3,4,5)`这样的命令来预热缓存,这样就能让后续的查询命中缓存,避免重新计算。另外,`cache`相关的配置项比如`max_cache_size`和`max_cache_ttl`也要合理设置,太大会影响内存,太小则无法有效存储结果。我通常把`max_cache_ttl`设为`3600`,也就是1小时,这样缓存不会过早失效。

日志分析和查询统计是零慢查询的诊断手段。我使用`system.query_log`来查看每个查询的耗时、行数、错误信息等,这些数据能帮助你找到性能瓶颈。比如`SELECT query, elapsed, rows_read FROM system.query_log WHERE type = 1 AND elapsed > 1000`可以快速定位耗时超过1秒的查询。另外,`system.part_log`也能帮你跟踪分区的创建和删除情况,判断是否需要优化分区策略。我曾经用这个表发现,某个查询导致分区频繁分裂,最终影响了整体性能,这就是一个典型的踩坑场景。

分布式查询和集群配置是零慢查询的高级玩法。我常用`DISTRIBUTED`表来处理大规模数据查询,但要注意,`DISTRIBUTED`表的查询需要把计算逻辑下推到各个节点上。比如`SELECT FROM distributed_table WHERE id = 123`,如果`id`字段没有索引,就会导致全表扫描,反而更慢。所以要在`DISTRIBUTED`表上加上索引,或者在查询时使用`JOIN`方式,让计算尽可能在数据节点上完成。另外,`clickhouse-server`的配置文件里,`max_insert_threads`和`max_read_threads`这两个参数能帮你提升写入和读取速度,但要根据集群规模调整。

查询语句的写法也会影响性能。我见过很多开发人员在`SELECT`中不加`LIMIT`,导致每次查询都返回全量数据,这在实时查询中非常危险。正确的做法是,先用`LIMIT`快速获取结果,再根据结果扩展查询。还有一个常见错误是,使用`JOIN`时没有指定`JOIN`类型,比如`JOIN ... USING`和`JOIN ... ON`的性能差异很大,前者更快但限制多。我经常用`JOIN ... ON`来让ClickHouse自己判断关联方式,但也可以通过`JOIN`的配置调整性能。

查询语句的语法规范也会影响性能。我见过很多人在`WHERE`子句中使用`=`号来匹配`NULL`值,这会导致索引失效。正确的做法是用`IS NULL`或者`IS NOT NULL`来判断。另外,`IN`和`NOT IN`的使用也要谨慎,尤其是在处理枚举值时,最好用`arrayJoin`来展开数组,这样查询计划会更清晰。还有一个问题,就是`CASE`语句的使用,如果写得复杂,ClickHouse的优化器可能无法将其下推,导致全表扫描。

查询语句的索引使用也是关键。我常见的是没有合理使用`WHERE`中的索引字段,比如在`GROUP BY`和`ORDER BY`中没有使用分区字段。这样查询会变成全表扫描,速度非常慢。正确的做法是,把`ORDER BY`和`GROUP BY`的字段设置成分区字段,比如`ORDER BY toYYYYMM(toDateTime(timestamp))`。这样查询就能充分利用分区,减少数据扫描量。我曾经用这种方式优化了一个日志分析系统,把查询时间从3秒降到0.3秒。

查询语句的缓存策略要动态调整。我见过很多企业在缓存策略上混淆了`query_cache`和`materialized view`,导致缓存无法命中。正确的做法是,把频繁查询的计算逻辑放到`materialized view`中,而不是依赖`query_cache`。比如`SELECT dt, COUNT() FROM logs GROUP BY dt`,如果放在一个`materialized view`里,每次查询都能直接从预处理表中获取结果。另外,`query_cache`的缓存大小和存活时间要根据业务需求调整,否则会占用过多内存。

查询语句的索引选择器配置也很重要。我习惯在配置文件中设置`index_granularity = 8192`和`max_index_parts = 1024`,这样能在写入和查询之间取得平衡。如果索引太多,查询会变慢,如果太少,命中率又会下降。我曾经在一个项目中因为`max_index_parts`设置太小,导致索引无法覆盖全部数据,最终查询性能下降。所以索引配置要根据实际数据量和查询频率动态调整。