图片

本文字数:3854;估计阅读时间:10 分钟

作者:Dale McDiarmid, Tom Schreiber and Geoff Genz

本文在公众号【ClickHouseInc】首发

图片

在 ClickHouse,我们始终在打磨和优化入门体验。ClickHouse 是一款复杂且功能强大的软件,它引入了许多对用户而言较为陌生的概念,因此在使用初期很容易出现各种错误。本文将总结我们在新用户中最常见的 13 个问题,分析它们产生的原因,并给出正确的处理方式。

对于在大规模场景下管理 ClickHouse 时遇到挑战的用户,ClickHouse Cloud 可以自动处理许多常见的入门问题以及后续的扩展难题。

1. 过多的数据分片 (parts) 

一个常见的 ClickHouse 报错如下:

DB::Exception: Too many parts

这个错误通常发生在数据插入期间,会出现在 ClickHouse 日志中,或者作为对 INSERT 请求的返回结果。要理解这一错误,首先需要理解 ClickHouse 中数据分片 (part) 的概念。

在 ClickHouse 中,一张表由多个数据分片 (part) 组成,这些分片中的数据会按照用户指定的主键进行排序(默认情况下为建表时的 ORDER BY 子句,详见 Index Design)。当向表中插入数据时,每一次插入都会生成独立的数据分片,并且每个分片内部都会按照主键进行字典序排序。例如,如果主键为 (CounterID, Date),则分片中的数据会先按 CounterID 排序,再在每个 CounterID 内按 Date 排序。在后台,ClickHouse 会持续对这些分片进行合并,以实现更高效的存储,这一机制类似于 Log-structured merge tree。每个分片都维护自己的主索引,以便在查询时能够高效定位数据所在位置。当分片被合并时,它们的主索引也会一并合并。

图片

随着分片数量不断增加,查询性能必然会下降,因为系统需要检查更多索引并读取更多文件。在分片数量过高的情况下,用户还可能遇到启动时间变慢的问题。分片过多会带来更多后台合并操作,同时增加系统维持低分片数量和高查询性能的压力。虽然合并过程是并发执行的,但在使用不当或配置不合理的情况下,分片数量可能会超过内部可配置的限制(parts_to_throw_insert, max_parts_in_total)。这些限制虽然可以调整,但通常会以牺牲查询性能为代价。更重要的是,一旦需要调高这些限制,往往意味着当前的使用模式存在问题。除了影响查询性能之外,在复制架构中,过多的分片还会给 ClickHouse Keeper 带来额外压力。

那么,为什么会产生如此多的分片呢?

分区键选择不当

一个常见原因是选择了基数过高的分区键。在创建表时,用户可以选择指定某一列作为分区键,数据将按照该键进行分区。每一个分区键值都会对应一个新的文件系统目录。分区通常用于数据管理,使用户能够在逻辑上对表中的数据进行清晰划分。例如,通过 DROP PARTITION 可以快速删除某个分区的数据子集。然而,这一强大的功能也很容易被误用,有些用户会将其误认为是一种简单的查询优化手段。

需要特别注意的是,不同分区之间的分片永远不会进行合并。如果选择了一个高基数的列(例如 date_time_ms)作为分区键,那么分片将分散在成千上万个目录中,彼此之间无法成为合并候选,从而超过预设限制,并在后续 INSERT 时触发 “Too many inactive parts (N). Parts cleaning are processing significantly slower than inserts” 错误。解决方法很简单:选择基数小于 1000 的分区键。

图片

大量小批量插入 

除了分区键选择不当之外,频繁的小批量插入也会导致这一问题。每一次向 ClickHouse 执行 INSERT,都会将当前插入块转换为一个新的分片。为了将分片数量控制在合理范围内,建议在客户端进行数据缓冲,并以批量方式插入数据——每次至少 1,000 行,而 10,000 到 100,000 行的批量大小通常更为理想。

如果无法在客户端实现缓冲,也可以使用 async inserts 将这一工作交由 ClickHouse 处理。在这种模式下,ClickHouse 会先在内存中缓冲多次插入请求,然后将其作为一个批量分片统一刷新到底层表中。当达到以下任一可配置阈值时就会触发刷新:缓冲区大小限制(async_insert_max_data_size,默认 1MB)、时间阈值(async_insert_busy_timeout_ms,默认 1 秒),或排队查询数量上限(async_insert_max_query_number,默认 100)。由于数据在刷新之前会暂存在内存中,因此务必保持 wait_for_async_insert=1(默认值),确保客户端只有在数据安全写入磁盘之后才收到确认,从而避免在刷新前服务器异常崩溃时出现无提示的数据丢失。

图片

Buffer 表是一种历史遗留的替代方案。它的一个独特优势是,在数据刷新到目标表之前,缓冲区中的数据就已经可以被查询。然而,Buffer 表存在明显缺陷:不支持复制机制,与 FINAL 或 SAMPLE 不兼容,并且在服务器异常重启时可能发生数据丢失。在使用 SharedMergeTree 的 ClickHouse Cloud 环境中,每个节点都会维护独立的缓冲状态,这进一步增加了系统复杂性。对于绝大多数场景,推荐使用 async inserts。只有在确实需要在刷新前查询缓冲数据这一硬性需求下,才应考虑使用 Buffer 表。

过多的物化视图 

该错误的另一种常见原因是创建了过多的物化视图。物化视图本质上类似于一种触发器:当数据块被插入到源表时,它会自动执行。物化视图会对数据进行转换,例如通过 GROUP BY 进行聚合,然后将结果写入到另一张表中。这种方式通常用于在 INSERT 阶段预先计算聚合结果,从而加速后续查询。用户可以根据需要创建多个物化视图,但这也可能导致产生大量分片(parts)。因此,我们通常建议在创建物化视图时充分评估其成本,并在可能的情况下进行合并与精简。

图片

以上列举的情况并非该错误的全部原因。例如,mutations(如下文所述)同样会带来合并压力,并导致分片数量累积。需要指出的是,“Too many parts” 虽然是最常见的报错之一,但它只是上述错误配置的一种表现形式。分区键设计不当还可能引发其他问题,包括但不限于“文件系统 inode 不足”、备份耗时过长,以及复制延迟(同时导致 ClickHouse Keeper 负载升高)。

2. 过早进行横向扩展

我们经常遇到新的自托管用户询问关于编排方案以及如何扩展到数十甚至上百节点的建议。尽管 Kubernetes 等技术让部署多个无状态应用实例变得相对简单,但对于 ClickHouse 而言,在绝大多数情况下并不需要这样做。与某些数据库受限于固有瓶颈(例如 JVM 堆大小)不同,ClickHouse 从架构设计之初就能够充分利用单机的全部资源。我们常见的成功案例中,ClickHouse 部署在拥有数百 CPU 核心、数 TB 内存以及数 PB 存储空间的服务器上。大多数分析型查询都包含排序、过滤和聚合阶段,这些阶段都可以独立并行执行,并且默认会使用与 CPU 核心数相同的线程数,从而在单次查询中充分利用整台机器的资源。

图片

优先进行纵向扩展有诸多优势,最显著的是成本更低、运维复杂度更小,以及由于减少了诸如 JOIN 等操作中的网络数据传输而带来的更佳查询性能。当然,生产环境中仍然需要基础的冗余配置,但对于除超大规模场景外的绝大多数用例来说,两台机器通常已经足够。

ClickHouse Cloud 同时支持纵向扩展(提升副本规格)和横向扩展(增加副本数量)。得益于其计算与存储分离架构(SharedMergeTree),这两种扩展方式都可以平滑进行。有关扩展策略的更多细节,请参考 Cloud 扩展文档。我们强烈建议在考虑横向扩展之前,优先评估纵向扩展。简而言之:先纵向,后横向。

3. Mutation 带来的挑战

在 OLAP 场景中,修改数据的需求并不常见,但有时确实无法避免。ClickHouse 在处理不可变数据时性能最佳,因此任何需要在插入之后再更新数据的设计模式都应谨慎评估。不过,ClickHouse 仍然提供了两种就地修改数据的机制:

  • 通过 ALTER TABLE ... UPDATE 实现的经典 mutations —— 会重写整个受影响的数据分片(parts),适用于不频繁的大批量修改。

  • 通过基于 patch parts 的 UPDATE 实现的轻量级更新 —— 仅写入发生变更的列值,以紧凑的增量分片形式保存,更适合频繁或定向更新。

经典 mutations

经典 mutations 的工作方式是重写包含受影响列的整个数据分片。该过程与后台合并共享同一个线程池。在自托管的复制环境中,每个副本都需要独立执行 mutation。因此,这类操作会消耗大量 CPU 和 IO 资源,应谨慎安排,并通常仅限管理员执行。

mutation 带来的资源压力可能以多种形式表现出来。最常见的是正常的后台合并被积压,进而再次触发前文提到的 “too many parts” 问题。此外,还可能出现复制延迟。管理员可以通过 system.mutations 表查看当前排队或执行中的 mutation。需要注意的是,可以通过 KILL MUTATION 取消 mutation,但无法回滚已经执行的更改。

图片

基于 patch parts 的轻量级更新

轻量级更新采用完全不同的实现方式。它不会重写整个数据分片,而是生成一个小型且紧凑的 “patch part”,其中只包含变更后的列值以及用于定位受影响行的元数据。这些 patch 在读取时会动态应用(因此修改可以立即生效),并在常规后台合并过程中高效物化——利用 ClickHouse 原本就要执行的合并操作。在许多工作负载下,这种方式的性能可能比经典 mutations 快高达 1,000 倍。相同机制也可用于删除操作:通过 patch part 设置 _row_exists = 0 标记,而无需重写整列数据。关于 patch parts 的详细原理,可参考我们关于 ClickHouse 快速 UPDATE 的系列文章:Part 1 — purpose-built engines,以及 Part 2 — SQL-style updates。

ClickHouse Cloud 中的 mutations

在 ClickHouse Cloud 中,表使用 SharedMergeTree 引擎。数据存储在共享对象存储中,元数据通过 ClickHouse Keeper 协调管理。这种架构改变了 mutation 的执行方式:由于副本之间无需直接通信,且数据统一存放在共享存储中,因此 mutation 不再需要在每个副本上分别执行。重写后的分片会写入共享存储,并通过元数据更新对所有副本可见。这不仅加快了 mutation 的执行速度,也避免了自托管集群在高强度 mutation 负载下可能出现的复制延迟。在 ClickHouse Cloud 中,经典 mutations 和基于 patch parts 的轻量级更新均可使用。

去重

我们经常看到用户因为存在重复数据而手动安排合并。通常建议在上游系统中解决重复问题,在数据写入 ClickHouse 之前完成去重。如果无法做到,也可以采用以下方式:在查询阶段去重,或者使用 ReplacingMergeTree 引擎。

在查询阶段去重,可以通过对唯一标识一行数据的字段进行分组,并结合带有时间字段的 argMax 函数来获取其他列的最新值。ReplacingMergeTree 会在合并过程中,对具有相同排序键(ORDER BY 键)的记录进行去重。但需要注意,这种去重方式属于“尽力而为”,因为后台合并是在非确定性时间间隔内执行的,某些分片可能暂时不会被合并,因此无法保证绝对无重复。如果需要在查询时强制去重,可以使用 FINAL 修饰符。虽然 FINAL 会增加一定开销,但在最近的版本中已获得显著性能优化(包括多线程处理),能够满足许多生产环境需求。

当需要从 ClickHouse 中删除数据(例如出于合规或去重目的)时,也可以使用轻量级删除而非 mutation。轻量级删除通过带 WHERE 条件的 DELETE 语句实现,仅将符合条件的行标记为已删除。这些标记会在查询时生效,并在后续分片合并时被清理。

图片

轻量级删除已达到生产可用级别,在大多数场景下都比使用 mutation 更高效,除非是执行大规模批量删除操作。需要注意的是,轻量级删除目前不支持带有 projections 的表。

4. 处理复杂与半结构化数据

除了常见的基础数据类型外,ClickHouse 还原生支持多种复杂类型,例如 Nested、Tuple、Map 和 JSON。总体原则很简单:如果数据结构是已知且稳定的,显式定义列始终能够获得最佳的压缩率、写入性能和查询速度。但当数据本身具有半结构化特性,或者 Schema 持续演进时,ClickHouse 提供的原生 JSON 类型则是更合适的选择。

面向半结构化数据的 JSON 类型

ClickHouse 提供了专为优化 JSON 数据存储与处理而设计的原生 JSON 数据类型。在内部实现上,ClickHouse 会将 JSON 路径拆分并存储为专用子列 —— 相当于在后台自动将 JSON 扁平化为真实列,同时保留完整的压缩能力和向量化执行性能。

默认情况下,最多会将 1,024 个唯一路径存储为独立的子列。超过该数量的路径,则会存储在一种高效的共享数据结构中。

JSON 子列同样可以用于排序键表达式以及数据跳过索引,从而享有与普通列相同的查询优化能力。

需要强调的是,JSON 类型并不是 Schema 设计的替代方案。对于结构已知且稳定的字段,显式列定义始终能够提供最佳性能。合理的做法是:对结构明确的数据部分使用显式列,而对真正动态变化的部分使用 JSON 类型。

5. 过度使用 Nullable

我们经常看到用户大量使用 Nullable 类型,它允许列存储 Null 值。从底层实现来看,这会额外生成一个 UInt8 类型的辅助列。每当查询或处理 Nullable 列时,这个额外列都需要参与计算。

这不仅会增加存储空间开销,而且几乎总会对查询性能产生负面影响。

在大多数场景下,我们建议尽量避免使用 Nullable 列。更推荐的做法是使用一个能够表示“空值”的默认值,例如在 String 列中使用空字符串。

6. 在插入阶段去重

许多初次接触 ClickHouse 的用户,都会对其去重机制感到意外。这通常发生在重复插入相同数据却似乎“没有生效”的情况下。例如,考虑以下示例:

CREATE TABLE temp
(
   `timestamp` DateTime,
   `value` UInt64
)
ENGINE = MergeTree
ORDER BY tuple()

INSERT INTO temp VALUES ('2022-10-21', 10), ('2022-10-22', 20), ('2022-10-23', 15), ('2022-10-24', 18)
INSERT INTO temp VALUES ('2022-10-21', 10), ('2022-10-22', 20), ('2022-10-23', 15), ('2022-10-24', 18)

clickhouse-cloud :) SELECT * FROM temp

SELECT *
FROM temp

┌───────────timestamp─┬─value─┐
│ 2022-10-21 00:00:00 │    10 │
│ 2022-10-22 00:00:00 │    20 │
│ 2022-10-23 00:00:00 │    15 │
│ 2022-10-24 00:00:00 │    18 │
└─────────────────────┴───────┘

请注意,在上述示例中共插入了 8 行数据,但 SELECT 查询结果仅显示 4 行。这种行为通常会让新用户感到困惑。其根本原因在于 replicated_deduplication_window 这一设置。

当数据写入 ClickHouse 时,会生成一个或多个数据块(parts)。在复制环境中(例如 ClickHouse Cloud),系统还会在 ClickHouse Keeper 中记录这些数据块的哈希值。之后插入的新数据块会与已有哈希进行比对,如果检测到完全一致的块,则该插入会被自动忽略。

这种机制的优势在于:当客户端因网络中断等原因未收到写入确认时,可以安全地重试插入操作,而无需担心数据被重复写入。需要注意的是,这种去重仅在数据块完全一致时才会生效——包括块大小相同、行内容相同且顺序一致。

默认情况下,系统仅保存最近 100 个数据块的哈希值(该数值可调整)。但如果将该值设置得过高,会因为需要进行更多哈希比较而降低插入性能。

图片

对于非复制环境,也可以通过设置 non_replicated_deduplication_window 启用相同的去重行为。在这种情况下,哈希值会存储在本地磁盘上。

7. 主键选择不当

许多刚接触 ClickHouse 的用户,对其主键机制理解不够深入。与基于 B(+)-Tree 的 OLTP 数据库不同,后者主要针对快速定位单行数据进行优化,ClickHouse 使用的是稀疏索引结构,专为每秒百万级写入和 PB 级数据规模设计。不同于 OLTP 数据库通过索引直接定位单行,ClickHouse 的索引依赖磁盘数据的有序存储,用于快速识别可能满足查询条件的数据块——这正是分析型查询的典型需求。换句话说,在数据被送入执行引擎之前,索引会先快速定位 part 文件中可能匹配的区域。关于磁盘数据布局的详细说明,强烈建议阅读相关指南。

图片

这种机制在查询性能和数据压缩方面的效果,很大程度上取决于用户在建表时通过 ORDER BY 子句选择的主键列。通常,应优先选择那些经常出现在过滤条件中的列,而且主键列数量一般不应超过 2 到 3 个。列的顺序同样至关重要,它不仅会影响压缩效果,还会影响对非首列字段的过滤效率。为了同时优化查询中过滤次级键列的效率以及列文件的压缩率,通常建议按照列基数从低到高的顺序排列主键列。完整的原理分析可参考相关文档。

8. 过度使用数据跳过索引

在需要优化查询时,主键通常是首选工具。然而,一张表只能定义一个主键,而实际查询模式往往多种多样,不可避免会出现无法高效利用主键的情况。在这些场景下,ClickHouse 可能需要在应用 WHERE 条件时扫描整张表的数据。多数情况下这依然足够快,但部分用户会进一步引入数据跳过索引,希望借此显著提升查询性能。

数据跳过索引会增加额外的数据结构,使 ClickHouse 能够在确定某些数据块不满足 WHERE 条件时直接跳过读取。更具体地说,它们会在数据块粒度(即 marks)上建立索引,从而在条件不匹配时跳过对应的粒度范围。

图片

在特定场景下,数据跳过索引确实可以加速查询。但在实践中,它们往往被滥用,设计难度较高,且效果并不直观。我们经常看到,它们反而增加了表结构的复杂度,降低了写入性能,却很少真正改善查询效率。因此,我们始终建议在使用前充分理解其原理与最佳实践。

在大多数情况下,只有在尝试并评估过其他优化手段之后,才应考虑数据跳过索引。例如,可以优先考虑优化主键设计、使用 projections 或物化视图。通常,只有当主键与目标的非主键列或表达式之间存在较强相关性时,跳过索引才有意义。如果缺乏这种相关性,索引往往会对大多数数据块都产生匹配,导致所有粒度仍需读入并评估。此时不仅没有收益,反而增加了额外开销,使全表扫描变得更慢。

9. LIMIT 并不总能提前结束查询 + 点查询

我们经常看到来自 OLTP 背景的用户使用 LIMIT 子句来“优化”查询,希望通过减少返回行数来提升性能。从 OLTP 的角度来看,这似乎合乎逻辑:返回更少数据,查询就更快——对吗?答案是:视情况而定。

LIMIT 是否能够带来性能收益,取决于查询能否以流式方式执行。例如,SELECT * FROM table LIMIT 10 这样的查询,只需扫描前几个 part 的少量粒度,在获取 10 行结果后即可返回。如果按主键字段排序(且 optimize_read_in_order 默认为 1),同样可以实现提前结束。

但如果执行 SELECT a FROM table ORDER BY b LIMIT N,而表是按 a 排序而非按 b 排序,那么 ClickHouse 无法避免扫描整张表,也就无法提前终止查询。

对于聚合查询,情况更加复杂。除非查询按主键分组,并将 optimize_aggregation_in_order 设置为 1,否则通常仍需扫描整张表。在满足条件时,一旦获得足够的结果,系统会发出终止信号。如果查询的前置步骤(例如过滤)支持流式处理,那么查询可以提前结束。但在多数情况下,聚合操作必须先读取并处理全部数据,最后再应用 LIMIT。

举例来说,我们加载 UK Property Price Paid 教程中的数据表,该表包含 2755 万行数据,可在 play.clickhouse.com 环境中体验。

当 optimize_aggregation_in_order=0 时,即使是按主键分组的聚合查询,也会在应用 LIMIT 1 之前完成全表扫描:

clickhouse-cloud :) SELECT
    postcode1, postcode2,
    formatReadableQuantity(avg(price)) AS avg_price
FROM uk_price_paid
GROUP BY postcode1, postcode2
LIMIT 1;

┌─postcode1─┬─postcode2─┬─avg_price───────┐
│ AL4       │ 0DE       │ 335.39 thousand │
└───────────┴───────────┴─────────────────┘

Elapsed: 3.028 sec, read 27.55 million rows, 209.01 MB.

当 optimize_aggregation_in_order=1 时,查询则可以提前结束,从而处理更少的数据:

clickhouse-cloud :) SELECT
       postcode1, postcode2,
       formatReadableQuantity(avg(price))  AS avg_price
FROM uk_price_paid
GROUP BY postcode1, postcode2
LIMIT 1
SETTINGS optimize_aggregation_in_order = 1;

┌─postcode1─┬─postcode2─┬─avg_price───────┐
│ AL4       │ 0DE       │ 335.39 thousand │
└───────────┴───────────┴─────────────────┘

Elapsed: 0.999 sec, read 4.81 million rows, 36.48 MB.

此外,即使是有经验的用户,也常在多节点分片环境中忽视 LIMIT 的实际行为。分片允许将数据拆分或复制到多个 ClickHouse 实例。当带有 LIMIT N 的查询发送到分片表(例如通过分布式表)时,该 LIMIT 会被下推到每个分片。每个分片都需要各自计算前 N 条结果,再汇总到协调节点。

当查询本身需要全表扫描时,这种模式尤其消耗资源。典型场景是点查询(point lookup),即仅希望获取少量特定行数据。虽然通过合理的索引设计可以实现高效点查询,但如果设计不当,即使加上 LIMIT,也可能导致极高的资源消耗。

10. 表意外变为只读

在自托管的复制环境中,如果某个节点与协调服务之间的连接中断,相关表可能会意外变为只读状态。一旦发生这种情况,该节点将无法继续参与复制流程,并且在连接恢复之前拒绝所有写入请求。最常见的原因是协调服务资源不足——例如在生产环境中将其与 ClickHouse 部署在同一台服务器上,或为其分配了过少的 CPU 与内存资源。通常的解决方式是将协调服务部署在资源充足的专用机器上。

对于自托管部署,推荐使用 ClickHouse Keeper 作为协调服务。它专为 ClickHouse 设计,采用 C++ 编写(无需 JVM 调优),并且在元数据处理模式上比 ZooKeeper 与 ClickHouse 更加契合。如果当前仍在使用 ZooKeeper,建议迁移至 ClickHouse Keeper。

在 ClickHouse Cloud 中不存在该问题,因为协调服务由平台统一托管和管理。

11. 查询内存超限

对于新用户来说,ClickHouse 往往像“魔法”一样——即使面对超大规模数据集或复杂查询,也能保持极高性能。但在真实生产环境中,查询复杂度最终可能触及系统资源上限。查询内存超限通常由多种原因引起,其中最常见的是在高基数字段上执行大规模 JOIN 或聚合操作。

如果这些查询对业务至关重要,且对性能有严格要求,最直接的方式通常是提升实例规格——在 ClickHouse Cloud 中,这一过程可以自动完成,以确保查询始终保持良好响应。在自托管环境下,扩容往往更复杂,但仍有多种优化手段可供选择。

聚合 

对于内存消耗较大的聚合或排序场景,可以分别使用 max_bytes_before_external_group_by 和 max_bytes_before_external_sort 两个设置。前者允许在聚合内存超过阈值时,将中间结果写入磁盘(即 external group by)。虽然这会降低查询性能,但可以避免因内存不足导致查询失败。后者则针对排序操作,当排序数据超出可用内存时,同样可以溢写到磁盘。

在分布式环境中,这一点尤为重要。例如,当协调节点接收来自各个分片的已排序结果时,可能需要对超出自身内存容量的数据集进行再次排序。通过设置 max_bytes_before_external_sort,可以允许排序过程使用磁盘作为补充空间。此外,当查询中存在 GROUP BY 后再执行 ORDER BY 并带 LIMIT 的情况(尤其是在分布式查询中),该设置也非常有帮助。

JOIN

ClickHouse 支持所有标准 SQL JOIN 类型,以及 ANY、ASOF、SEMI 和 ANTI 等专用变体,这些变体在常见分析场景中可以显著提升性能。但需要注意的是,JOIN 操作本身通常是高内存消耗的,理解不同 JOIN 策略之间的权衡,对于避免内存问题至关重要。

在 ClickHouse 中实现高效 JOIN 的通用原则包括:

  • 选择适合数据特征的 JOIN 算法。通过 join_algorithm 设置,可以在多种算法之间切换,以在内存使用和执行性能之间取得平衡。Hash join 性能高,但依赖充足内存。Grace hash 会将数据划分为多个桶,并在内存耗尽时写入磁盘。Sort-merge(partial_merge、full_sorting_merge)适用于已排序数据或当两侧数据规模过大无法全部载入内存时。Direct 算法在右表基于字典或小型内存表时,可实现高效键值查找。将 join_algorithm 设置为 'auto',可以让 ClickHouse 根据运行时资源状况自动选择最优算法。

  • 使用专用 JOIN 类型。ANY JOIN 仅返回右表的首条匹配记录,非常适合用于维表关联或数据补充场景,可显著降低内存占用。ASOF JOIN 专为时间序列设计,用于查找最接近的匹配记录,而非精确匹配。

  • 尽早过滤。在可能的情况下,在执行 JOIN 之前应用 WHERE 条件,以减少参与 JOIN 的数据量。

  • 将较小的表放在右侧。对于默认的 Hash JOIN,ClickHouse 会基于右表构建内存哈希表。因此,将较小表置于右侧可以有效降低内存消耗。尽管查询优化器在某些情况下可以自动调整 JOIN 顺序,但理解这一原则依然十分重要。

异常或不受控查询

内存问题的另一常见来源是缺乏限制的用户查询。在没有设置配额或查询复杂度限制的情况下,用户可能提交高消耗的“异常查询”。如果 ClickHouse 面向广泛且多样化的用户群体开放,这些控制机制对于保障服务稳定性至关重要。我们在 play.clickhouse.com 环境中就通过配额与限制机制,确保系统在共享场景下依然稳定。

ClickHouse 还支持 Memory overcommit(内存超额分配)机制。过去,查询会受到 max_memory_usage(默认 10GB)这一硬限制的约束。这种方式较为粗放,虽然可以通过提高阈值满足单个查询需求,但可能会影响其他用户。

启用 Memory overcommit 后,在系统资源允许的情况下,可以运行更高内存消耗的查询。当服务器达到最大内存限制时,ClickHouse 会识别哪些查询的内存超额程度最高,并尝试终止其中一个查询。被终止的未必是触发限制的那个查询。如果当前查询未被终止,它会短暂等待,以便高内存查询被清理后继续执行。

这种机制确保低内存查询始终能够运行,而高资源消耗查询则会在服务器空闲时获得执行机会。相关行为可以在服务器级别或用户级别进行细粒度配置。

12. 物化视图相关问题

物化视图是 ClickHouse 最强大的功能之一。它允许在数据写入阶段预先完成数据转换与聚合,将计算负担从查询阶段前移到写入阶段。这种机制常用于优化特定查询模式、在不同主键下重组数据,或为下游汇总表提供预聚合结果。

ClickHouse 支持两种物化视图类型。增量(连续)物化视图本质上充当插入触发器:当数据写入源表时,视图中的 SELECT 查询会针对新插入的数据块执行,并将结果写入目标表。整个过程无需手动调度,能够实现接近实时的数据转换。可刷新物化视图则采用不同机制,会按照预定周期(例如 REFRESH EVERY 1 HOUR)重新构建完整结果集。它支持通过 DEPENDS ON 形成视图链,可执行复杂的多表查询,同时不会为每次插入增加额外开销。当需要实时结果时,应选择增量视图;当可以接受周期性更新且希望避免写入阶段额外负担时,应选择可刷新视图。

理解触发模型 

一个常见误区是认为增量物化视图能够感知整个源表的数据状态。实际上并非如此。增量物化视图仅在发生新的插入时触发,并且只对新写入的数据块执行计算。它无法感知源表上的合并操作、分区删除或 mutations。因此,如果通过 mutations 或分区操作修改源表数据,物化视图的目标表不会自动同步这些变化。系统不会进行任何自动回溯更新。用户需要自行管理这一过程,例如重建物化视图的目标表,或在允许全表重算的场景下改用可刷新物化视图。

单表上创建过多视图

物化视图并非零成本。每一个附加在表上的增量视图,在每次插入时都必须执行一次 SELECT,并在目标表中生成新的分片(part)。如果在同一张表上附加过多视图——通常超过 50 个就已明显过多——将显著拖慢写入性能。这不仅来自每个视图带来的计算开销,也来自多个目标表产生的分片压力,最终可能触发前文提到的 “Too Many Parts” 问题。

在可能的情况下,应合并执行相似转换逻辑的视图。同时可以评估 parallel_view_processing 设置,通过并发执行多个视图而非顺序执行,以减轻写入延迟。

高 CPU 消耗的状态函数

状态函数(state functions)允许通过 Aggregate 函数在写入阶段进行增量聚合,是非常强大的能力。但如果物化视图中计算大量状态函数(尤其是 quantile 状态),可能会显著增加 CPU 消耗,从而降低写入速度。因此,应审慎选择需要预计算的聚合逻辑,并确保查询阶段节省的计算成本能够抵消写入阶段增加的开销。

视图与目标表 Schema 不匹配

物化视图常见的错误之一,是其输出列与目标 AggregatingMergeTree 或 SummingMergeTree 表结构不匹配。目标表的 ORDER BY 子句必须与物化视图 SELECT 语句中的 GROUP BY 保持一致。此外,视图 SELECT 输出的列名必须与目标表列名完全一致——不要依赖列顺序匹配。应通过别名确保列名对齐。目标表可以定义默认值,因此视图输出列可以只是目标表列的子集。下面给出了正确示例:

CREATE MATERIALIZED VIEW test.basic
ENGINE = AggregatingMergeTree() ORDER BY (CounterID, StartDate)
AS SELECT
    CounterID,
    StartDate,
    sumState(Sign)    AS Visits,
    uniqState(UserID) AS Users
FROM test.visits
GROUP BY CounterID, StartDate;
CREATE MATERIALIZED VIEW test.summing_basic
ENGINE = SummingMergeTree
ORDER BY (CounterID, StartDate)
AS SELECT CounterID, StartDate, count() AS cnt
FROM source
GROUP BY CounterID, StartDate;

请注意,在示例中需要将 count() 使用别名 counter,以匹配目标表中的列名。

CREATE MATERIALIZED VIEW
test.mv1 (timestamp Date, id Int64, counter Int64)
ENGINE = SummingMergeTree
ORDER BY (timestamp, id)
AS
SELECT timestamp, id, count() as counter
FROM source
GROUP BY timestamp, id;

13. 在生产环境中使用实验性功能

ClickHouse 会持续发布新功能。其中部分功能会被标记为 “experimental” 或 “beta”。Beta 功能由 ClickHouse 团队正式支持,正处于逐步迈向生产可用阶段。Experimental 功能则通常是由团队或社区推动的早期原型,尚未提供正式支持。

这些功能最终要么成熟为生产级特性,要么在发现并不适用于通用场景,或存在更优实现方式时被弃用。

我们鼓励用户在测试环境中尝试 beta 与 experimental 功能,但不建议在生产系统中依赖 experimental 功能,更不应围绕其构建核心业务逻辑。两类功能均需通过显式设置启用,例如:

SET allow_experimental_variant_type = 1

在 ClickHouse Cloud 中,如需启用 experimental 功能,则需要通过支持渠道申请。

结论

如果你读到这里,相信已经对在生产环境中运行 ClickHouse 集群有了较为全面的认识,至少可以避开许多常见陷阱。尽管如此,即便是经验丰富的运维人员,在管理 PB 级数据规模的 ClickHouse 集群时也会面临挑战。

如果希望在降低运维复杂度的同时,继续享受 ClickHouse 的高性能与强大能力,可以考虑使用 ClickHouse Cloud。

征稿启示

面向社区长期正文,文章内容包括但不限于关于 ClickHouse 的技术研究、项目实践和创新做法等。建议行文风格干货输出&图文并茂。质量合格的文章将会发布在本公众号,优秀者也有机会推荐到 ClickHouse 官网。请将文章稿件的 WORD 版本发邮件至:Tracy.Wang@clickhouse.com

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐