博客 / 工程

介绍 ClickHouse 查询缓存

author avatar
Robert Schulze
2 月 9 日, 2023 - 12 分钟阅读

Cache2.png

为了实现最佳性能,分析型数据库会优化其内部数据存储和处理管道的每个步骤。但是,数据库执行的最佳工作类型是根本不做的工作!缓存是一种特别流行的技术,通过存储先前计算或远程数据的结果来避免不必要的工作,因为访问远程数据成本很高。ClickHouse 广泛使用缓存,例如,缓存 DNS 记录、本地和远程 (S3) 数据、推断模式、编译查询和正则表达式。在今天的博文中,我们将介绍 ClickHouse 缓存家族的最新成员:查询缓存。它最近在 v23.1 版本中作为实验性预览功能添加。

查询缓存

查询缓存基于这样的想法:有时在某些情况下,可以缓存昂贵的 SELECT 查询的结果,以便可以直接从缓存中为同一查询的后续执行提供服务。根据查询的类型,这可以显着减少 ClickHouse 服务器的延迟和资源消耗。例如,考虑像 Grafana 或 Apache Superset 这样的数据可视化工具,它显示过去 24 小时汇总销售额的报告。在大多数情况下,一天内的销售额变化相当缓慢,我们可以承受每三小时(例如)才刷新一次报告。从 ClickHouse v23.1 开始,SELECT 查询可以提供一个 "time-to-live"(生存时间),在此期间服务器将仅计算查询的首次执行,而后续执行将直接从缓存中返回答案,无需进一步计算。

在简要介绍之后,让我们试用一下查询缓存。为此,我们将使用 GitHub Events 数据集,其中包含自 2011 年以来 GitHub 平台上的所有事件,总共包含 31 亿行。如果您想继续操作,请确保使用导入说明将数据集导入到 ClickHouse 中。

由于查询缓存仍处于实验阶段,我们首先需要启用它。一旦查询缓存正式发布,此步骤将变得过时。

SET allow_experimental_query_cache = true

作为昂贵查询的示例,我们现在计算“一天内获得最多星标的存储库”。在我的 32 核服务器上,此查询大约需要 8 秒才能完成。

SELECT repo_name, toDate(created_at) AS day, count() AS stars FROM github_events WHERE event_type = 'WatchEvent' GROUP BY repo_name, day ORDER BY count() DESC LIMIT 1 BY repo_name LIMIT 50 ┌─repo_name──────────────────────────────────────┬────────day─┬─stars─┐ │ 996icu/996.ICU │ 2019-03-28 │ 76056 │ │ M4cs/BabySploit │ 2019-09-08 │ 46985 │ │ x64dbg/x64dbg │ 2018-01-06 │ 26459 │ │ [...] │ [...] │ [...] │ └────────────────────────────────────────────────┴────────────┴───────┘ 50 rows in set. Elapsed: 8.998 sec. Processed 232.12 million rows, 2.73 GB (25.80 million rows/s., 303.90 MB/s.)

要为查询启用缓存,请使用设置 use_query_cache 运行它。查询缓存对查询结果使用 60 秒的默认生存时间 (TTL)。此超时对于本示例的目的来说效果很好,但如果需要,可以使用设置 query_cache_ttl 指定不同的 TTL,可以在查询级别 (SELECT ... SETTINGS query_cache_ttl = 300) 或会话级别 (SET query_cache_ttl = 300) 指定。

SELECT repo_name, toDate(created_at) AS day, count() AS stars FROM github_events WHERE event_type = 'WatchEvent' GROUP BY repo_name, day ORDER BY count() DESC LIMIT 1 BY repo_name LIMIT 50 SETTINGS use_query_cache = true ┌─repo_name──────────────────────────────────────┬────────day─┬─stars─┐ │ 996icu/996.ICU │ 2019-03-28 │ 76056 │ │ [...] │ [...] │ [...] │ └────────────────────────────────────────────────┴────────────┴───────┘ 50 rows in set. Elapsed: 8.577 sec. Processed 232.12 million rows, 2.73 GB (27.06 million rows/s., 318.81 MB/s.)

首次使用 SETTINGS use_query_result_cache = true 运行查询会将查询结果存储在查询缓存中。同一查询的后续执行(也使用设置 use_query_cache = true)并在查询生存时间内将从缓存中读取先前计算的结果并立即返回。让我们再次运行查询。

SELECT repo_name, toDate(created_at) AS day, count() AS stars FROM github_events WHERE event_type = 'WatchEvent' GROUP BY repo_name, day ORDER BY count() DESC LIMIT 1 BY repo_name LIMIT 50 SETTINGS use_query_cache = true ┌─repo_name──────────────────────────────────────┬────────day─┬─stars─┐ │ 996icu/996.ICU │ 2019-03-28 │ 76056 │ │ [...] │ [...] │ [...] │ └────────────────────────────────────────────────┴────────────┴───────┘ 50 rows in set. Elapsed: 8.451 sec. Processed 232.12 million rows, 2.73 GB (27.47 million rows/s., 323.56 MB/s.)

令我们惊讶的是,第二次执行查询再次花费了 8 秒以上。显然,未使用查询缓存。让我们深入研究一下以了解发生了什么。为此,我们首先检查系统表 system.query_cache 以找出哪些查询结果存储在缓存中。

SELECT * FROM system.query_cache Ok. 0 rows in set. Elapsed: 0.001 sec.

查询缓存确实是空的!在执行 SET send_logs_level = 'trace' 后再次运行查询很快指出了问题所在。

2023.01.29 12:15:26.592519 [ 1371064 ] {a645c5b7-09a2-456c-bc8b-c506828d3b69} QueryCache: Skipped insert (query result too big), new_entry_size_in_bytes: 1328640, new_entry_size_in_rows: 50, query: SELECT repo_name, toDate(created_at) AS day, count() AS stars FROM github_events WHERE event_type = 'WatchEvent' GROUP BY repo_name, day ORDER BY count() DESC LIMIT 1 BY repo_name LIMIT 50 SETTINGS [...] 2023.01.29 12:15:40.697761 [ 1373583 ] {af02656c-e3e4-41c9-8f48-b8a1db145841} QueryCache: No entry found for query SELECT repo_name, toDate(created_at) AS day, count() AS stars FROM github_events WHERE event_type = 'WatchEvent' GROUP BY repo_name, day ORDER BY count() DESC LIMIT 1 BY repo_name LIMIT 50 SETTINGS

缓存条目的大小至少为 1328640 字节(约 1.26 MiB),而默认的最大缓存条目大小为 1048576 字节(1 MiB)。因此,缓存认为查询结果略微过大,因此未存储它。幸运的是,我们可以更改大小阈值。它目前在 ClickHouse 的服务器配置文件中作为服务器级设置提供。

<query_cache>
    <size>1073741824</size>
    <max_entries>1024</max_entries>
    <max_entry_size>1048576</max_entry_size>
    <max_entry_records>30000000</max_entry_records>
</query_cache>

为了演示的目的,让我们将最大缓存条目大小(即 max_entry_size)从 1 MiB (= 1'048'576 字节) 更改为总查询缓存大小 1 GiB (= 1'073'741'824 字节)。新设置在服务器重启后生效。如您所见,我们可以以相同的方式配置最大总缓存大小(以字节为单位)、最大缓存条目数以及每个缓存条目的最大记录数。

如果我们再次运行查询,我们看到第二次调用是从缓存中提供的,并立即返回。

SELECT repo_name, toDate(created_at) AS day, count() AS stars FROM github_events WHERE event_type = 'WatchEvent' GROUP BY repo_name, day ORDER BY count() DESC LIMIT 1 BY repo_name LIMIT 50 SETTINGS use_query_cache = true ┌─repo_name──────────────────────────────────────┬────────day─┬─stars─┐ │ 996icu/996.ICU │ 2019-03-28 │ 76056 │ │ [...] │ [...] │ [...] │ └────────────────────────────────────────────────┴────────────┴───────┘ 50 rows in set. Elapsed: 0.04 sec.

使用日志和设置

我们还可以调查查询日志中查询缓存的命中和未命中情况,并再次查看 system.query_cache

SELECT query, ProfileEvents['QueryCacheHits'] FROM system.query_log WHERE (type = 'QueryFinish') AND (query LIKE '%github_events%') [...] Row 8: ────── query: SELECT repo_name, toDate(created_at) AS day, count() AS stars FROM github_events WHERE event_type = 'WatchEvent' GROUP BY repo_name, day ORDER BY count() DESC LIMIT 1 BY repo_name LIMIT 50 SETTINGS use_query_cache = true arrayElement(ProfileEvents, 'QueryCacheHits'): 1 SELECT * FROM system.query_cache Row 1: ────── key_hash: SELECT repo_name, toDate(created_at) AS day, count() AS stars FROM github_events WHERE event_type = 'WatchEvent' GROUP BY repo_name, day ORDER BY count() DESC LIMIT 1 BY repo_name LIMIT 50 SETTINGS expires_at: 2023-01-29 17:55:29 stale: 1 shared: 0 result_size: 1328640 1 row in set. Elapsed: 0.005 sec.

如我们所见,system.query_log 现在也显示了我们查询的条目。但是,由于自缓存查询结果以来经过的时间大于缓存条目的生存时间(默认为 60 秒),因此该条目标记为“陈旧”。这意味着进一步运行查询将不会使用缓存的查询结果,而是刷新缓存条目。另外,请注意,查询提供的 SETTINGS 子句仅部分显示。这是因为在将查询用作查询缓存的键之前,内部会修剪所有与查询缓存相关的设置。这可能会有点令人困惑,但会导致更自然的缓存行为。

如果需要,可以使用以下配置设置更详细地控制缓存行为。与最大缓存条目大小不同,这些设置可以是每个查询或每个会话。

  • 有时希望仅被动地使用缓存(= 尝试从中读取但不写入)或仅主动地使用缓存(= 尝试写入但不从中读取)。这可以使用设置 enable_writes_to_query_cacheenable_reads_from_query_cache 来实现,这两个设置默认都为 true

  • 要仅缓存昂贵的(在运行时方面)或频繁的查询,您可以指定查询至少需要运行多长时间(以毫秒为单位)和运行频率,以便使用设置 use_query_cache_min_query_durationuse_query_cache_min_query_runs 来缓存其结果。

  • 默认情况下,不缓存具有非确定性函数(例如 rand()now())的查询结果。如果需要,可以使用设置 query_cache_store_results_of_queries_with_nondeterministic_functions 更改此设置。

  • 最后,出于安全原因,默认情况下查询缓存中的条目不会在用户之间共享。但是,可以通过使用设置 query_cache_share_between_users 运行单个缓存条目来将其标记为可供其他用户读取。

设计

一般来说,可以区分事务一致性和非一致性查询缓存。

在事务一致性缓存中,如果关联的 SELECT 查询的结果发生更改,甚至可能发生更改,则数据库会使缓存条目失效。可能更改查询结果的明显操作包括表数据的插入、更新和删除。ClickHouse 还有某些内务操作(例如折叠合并),可能会修改表数据。事务一致性缓存的概念对于具有严格一致性期望的 OLTP 数据库(例如 MySQL、Postgresql 和 Oracle)尤其有意义。

相比之下,ClickHouse 作为 OLAP 数据库,使用的查询缓存在设计上是事务不一致的。可以容忍略微不准确的查询结果,前提是缓存条目与生存时间相关联,在此之后它们会过期,并且在此期间底层数据仅发生少量更改。插入、更新、删除和内部内务操作不会使缓存条目失效。因此,这种设计避免了在高吞吐量场景中困扰 MySQL 的查询缓存的可伸缩性问题。

与 MySQL 查询缓存的另一个区别是,ClickHouse 的查询缓存使用 抽象语法树 (AST) 而不是其查询文本来引用查询结果。这意味着缓存与大小写更改无关,例如 SELECT 1select 1 被视为相同的查询。

未来改进

目前,缓存将其条目存储在简单的哈希表中,默认情况下最多包含 1024 个元素(确切的容量是可配置的)。如果插入新条目,但缓存已满,则会迭代映射,并删除所有陈旧的条目。如果仍然没有足够的空间,则不会插入新条目。用户还可以使用语句 SYSTEM DROP QUERY CACHE 手动清除缓存的内容。计划将来我们将支持更复杂的逐出策略,例如,最近最少使用 (LRU) 或基于大小的逐出。这将允许用户为从缓存读取的 SELECT 查询指定最小的“新鲜度级别”(而不是为写入缓存的查询指定最大生存时间),并另外提供对高度倾斜的查询流的更好处理。

计划对查询缓存的进一步改进包括:

  1. 压缩缓存条目的能力,例如使用 ZSTD 编解码器,
  2. 将缓存条目分页到磁盘,以便它们在服务器重启后仍然存在,
  3. 缓存子查询和中间查询结果,以及
  4. 更多配置设置,以根据特定用例定制缓存,例如,每个用户的缓存大小或分区缓存。

到目前为止,关于查询缓存的反馈非常积极,令人兴奋的事情还在后面,敬请期待!

分享这篇文章

订阅我们的新闻通讯

随时了解功能发布、产品路线图、支持和云服务!
正在加载表单...
关注我们
X imageSlack imageGitHub image
Telegram imageMeetup imageRss image