为了获得最高性能,分析型数据库对其内部数据存储和处理管道的每个步骤进行优化。但数据库执行得最好的工作是根本不需要做的工作!缓存是一种特别流行的技术,通过存储早期计算的结果或远程数据(访问成本很高)来避免不必要的操作。ClickHouse 广泛使用缓存,例如,缓存 DNS 记录、本地和远程 (S3) 数据、推断的模式、编译的查询和正则表达式。在今天的博客文章中,我们介绍了 ClickHouse 缓存家族的最新成员:查询缓存,它最近在 v23.1 中作为实验性预览功能添加。
查询缓存
查询缓存基于这样的理念,即在某些情况下,缓存昂贵的 SELECT
查询的结果是可以接受的,这样可以使相同查询的后续执行可以直接从缓存中获取。根据查询的类型,这可以显著减少 ClickHouse 服务器的延迟和资源消耗。例如,考虑像 Grafana 或 Apache Superset 这样的数据可视化工具,它们显示了过去 24 小时的汇总销售数字报告。在大多数情况下,一天内的销售额变化较为缓慢,我们可以仅(例如)每三小时刷新一次报告。从 ClickHouse v23.1 开始,可以为 SELECT
查询提供“生存时间”,在此期间,服务器只计算查询的第一次执行,后续的执行则直接从缓存中获取结果,无需进一步计算。
在简要介绍之后,让我们试用一下查询缓存。为此,我们将使用GitHub 事件 数据集,该数据集包含自 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
执行它。查询缓存使用查询结果的默认生存时间 (TTL) 为 60 秒。此超时时间对于本示例的目的足够了,但如果需要,可以使用设置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_cache
和enable_reads_from_query_cache
实现,这两个设置默认情况下都为true
。 -
为了仅缓存运行时开销大或频繁的查询,您可以使用设置 `use_query_cache_min_query_duration` 和 `use_query_cache_min_query_runs` 指定查询至少需要运行多长时间(以毫秒为单位)以及多少次才能缓存其结果。
-
默认情况下,使用非确定性函数(如 `rand()` 和 `now()`)的查询结果不会被缓存。如果需要,可以使用设置 `query_cache_store_results_of_queries_with_nondeterministic_functions` 更改此行为。
-
最后,出于安全原因,查询缓存中的条目默认情况下不会在用户之间共享。但是,可以通过使用设置 `query_cache_share_between_users` 运行查询来将单个缓存条目标记为可供其他用户读取。
设计
一般来说,可以区分事务一致和不一致的查询缓存。
在事务一致的缓存中,如果关联的 `SELECT` 查询的结果发生变化或可能发生变化,数据库就会使缓存条目失效。可以更改查询结果的明显操作包括插入、更新和删除表数据。ClickHouse 还有一些管理操作,例如合并折叠,可能会修改表数据。事务一致缓存的概念尤其适用于 MySQL、Postgresql 和 Oracle 等具有严格一致性要求的 OLTP 数据库。
相比之下,ClickHouse 作为 OLAP 数据库,默认情况下使用事务不一致的查询缓存。假设缓存条目与生存时间相关联(在此时间之后它们会过期),并且底层数据在此期间只会发生少量变化,因此可以容忍略微不准确的查询结果。插入、更新、删除和内部管理操作不会使缓存条目失效。因此,这种设计避免了高吞吐量场景下困扰 MySQL 查询缓存 的可扩展性问题。
与 MySQL 查询缓存的另一个区别是 ClickHouse 的查询缓存使用 `SELECT` 查询的 抽象语法树 (AST) 而不是查询文本来引用查询结果。这意味着缓存对大小写变化不敏感,例如 `SELECT 1` 和 `select 1` 被视为同一个查询。
未来改进
目前,缓存将其条目存储在一个简单的哈希表中,默认情况下最多包含 1024 个元素(确切容量可配置)。如果插入新条目但缓存已满,则会迭代映射并删除所有过期的条目。如果仍然没有足够的空间,则不会插入新条目。用户还可以使用语句 `SYSTEM DROP QUERY CACHE` 手动清除缓存的内容。我们计划在将来支持更复杂的驱逐策略,例如最近最少使用 (LRU) 或基于大小的驱逐。这将允许用户为从缓存读取的 `SELECT` 查询指定一个最小“新鲜度级别”(与为写入缓存的查询指定最大生存时间相反),并另外提供对高度倾斜的查询流的更好处理。
对查询缓存的进一步计划改进包括
- 能够压缩缓存条目,例如使用 ZSTD 编解码器,
- 将缓存条目分页到磁盘上,以便它们在服务器重启后仍然存在,
- 缓存子查询和中间查询结果,以及
- 更多配置设置,以根据特定用例调整缓存,例如每个用户缓存大小或分区缓存。
到目前为止,关于查询缓存的反馈非常积极,并且未来还有很多令人兴奋的事情,敬请关注!