ClickHouse 的查询缓存机制,很多人在接触这个数据库的第一年基本不会去碰它,等到集群规模上来、报表查询变慢,才想起来“是不是该开个缓存”。说实话,ClickHouse 的查询缓存和 MySQL 那种 Query Cache 完全不是一回事,它不是为了省掉重复计算那么简单,而是要解决“相似查询重复扫数据”和“高频仪表盘查询压垮集群”这类实际问题。这篇文章我不会只贴官方文档,而是会把缓存键怎么生成、命中条件、失效规则、参数怎么配、坑在哪里,全部按实操经验讲清楚。适合正在做大数据分析平台、监控大盘、BI 报表的同学参考,看完你至少知道该不该开缓存,以及开了之后怎么验证收益。
1. 内容整体设计与思路拆解
1.1 为什么 ClickHouse 需要一套独立的查询缓存机制
ClickHouse 本身在 OLAP 场景下的性能已经足够快,靠的是列式存储、稀疏索引、向量化执行这些底层能力。但我们做大数据分析平台时,经常遇到一个尴尬的现象:查询本身只要 300 毫秒,但前端仪表盘同时发起 50 个类似的聚合请求,每个请求都把底层几亿行数据重新刷一遍,CPU 直接被打满。这种场景已经不是查询性能问题,而是重复计算问题。
MySQL 的 Query Cache 曾经也想解决重复计算,但因为表更新就会全部失效、并发失效严重,最终在 MySQL 8.0 被彻底移除。ClickHouse 的查询缓存机制设计时就吸取了这些教训,它不缓存整表扫描结果,而是针对查询结果做可控的、可感知数据变更的缓存。核心思路是:如果一个查询的结果可以在 TTL 时间内复用,那就直接读缓存,跳过 MergeTree 表的数据扫描和聚合计算;一旦底层数据发生变化或者 TTL 过期,缓存自动失效,避免读到脏数据。
这套机制引入的时间并不算长,在较老版本里只能通过利用 MergeTree 的物化视图或者外部 Redis 来解决,但都存在明显的维护成本。ClickHouse 官方从 22.7 左右开始逐步提供查询缓存的能力,到 23.x、24.x 版本该功能已经趋于稳定,默认依然是关闭状态,需要服务端配置参数和客户端设置配合开启。整体设计上,它把“缓存结果集”和“执行查询计划”两层拆开了,缓存命中的时候走极短的执行路径,完全没有调度和计算开销。
1.2 查询缓存机制解决的典型痛点和适用边界
大数据集群上的查询负载通常分成几类:一是固定报表接口,比如每天看一次的业务看板,查询条件变化不大;二是即席分析,用户拖拽维度、筛选条件,每次都会生成不同的 SQL;三是监控告警类,周期性地查同一张表的同一聚合。查询缓存机制主要解决第一类和第三类问题,尤其是“高并发 + 相同或相似查询”的组合。如果你有 20 个 Dashboard 挂在同一个 ClickHouse 集群上,每个面板每 10 秒刷新一次,那查询缓存带来的收益会非常可观。
但查询缓存不是银弹。对于经常变更过滤条件、结果集非常大、底层表秒级写入的数据,缓存命中率会很低,甚至因为缓存失效频繁,白白增加管理开销。实际项目里,我在设计查询缓存方案时,会先分析查询模式,再看表的写入频率,最后才决定是否开启,以及给哪类用户开。不要把查询缓存当成全局加速器,它是一个针对特定查询模式的精准工具。
1.3 和外部缓存方案(Redis、代理层)的对比选择
在没有官方查询缓存之前,团队里常见的做法是在应用层加一层 Redis,把 SQL 的 MD5 作为 key,查询结果作为 value。这样做的好处是灵活,可以完全控制缓存策略,但问题也很明显:序列化和反序列化 JSON 有额外开销,Redis 内存有限,缓存结果超过一定大小后根本存不下,更麻烦的是无法感知 ClickHouse 底层数据的变化,只能设置一个比较短的 TTL,牺牲新鲜度换取一致性。
对比下来,ClickHouse 原生的查询缓存机制有几个独特的优势:它直接驻留在 ClickHouse 服务端内存中,查询命中时连网络都省了;它能够感知查询涉及表的 mutations、分区变更和 TTL 清理;它支持按用户、按查询条件做细粒度控制。缺陷是缓存结果不能跨集群共享,如果查询通过分布式表访问,命中逻辑会比本地表复杂一些。所以我的建议是:单集群内、查询模式固定、结果集不大,优先用原生查询缓存;跨集群或者需要复杂自定义策略,再考虑外部缓存层。
2. 核心机制拆解:缓存键、命中条件与失效逻辑
2.1 缓存键是怎么生成的
ClickHouse 的查询缓存在判定一次查询是否命中时,并不是简单拿 SQL 字符串做哈希。它会把查询语句解析成 AST(抽象语法树),再基于 AST 生成一个结构化的缓存键。这样做的好处是,SQL 中的空格、大小写、注释不会影响缓存命中,格式不同的等价查询也能复用同一个缓存结果。比如下面两条 SQL 在缓存层面可以认为是同一个查询:
SELECT count() FROM events WHERE day = today()SELECT count() FROM events WHERE day = today()它们在文本上不同,但 AST 结构相同,ClickHouse 会用相同的缓存键去查找。不过如果加了不同的settings参数(比如max_threads),缓存键也会改变,因为这些参数可能影响查询语义和执行计划。
另外,缓存键还区分用户和查询作用域。不同用户即使执行完全相同的 SQL,也不会共享缓存,这是为了避免敏感数据跨用户泄露。默认情况下,缓存建在单个节点上,也就是说system.query_cache只显示当前节点的缓存内容。在分布式表场景中,每个分片会各自维护一份缓存,如果查询被路由到不同节点,可能每台节点都有一份缓存,这也是内存使用量需要重点评估的原因。
如果需要查看实际的缓存键内容,可以通过查询system.query_cache系统表来观察,里面有一个key_hash字段,虽然无法直接还原原始 SQL,但能够看到缓存大小、过期时间、命中次数等信息。
2.2 命中缓存必须同时满足的条件
我见过不少同事以为开启use_query_cache = 1之后,所有查询都会走缓存,结果一查命中率是 0,然后跑来问为什么。这里把命中条件全部列出来,每个都值得仔细对一遍:
- 查询必须显式或通过 profile 设置启用查询缓存,常用做法是在查询级别加
settings use_query_cache = 1,或者在用户 profile 里默认开启。 - 查询必须是可缓存的类型,
SELECT查询可以,但包含INSERT、ALTER、SYSTEM等语句不行;涉及非确定性函数的查询(比如now()、rand()、uuid())默认不会被缓存,除非设置query_cache_nondeterministic_function_handling = 'save'。 - 查询结果不能太大,不能超过
query_cache_max_size_in_bytes的限制,单条缓存条目也有自己的大小上限(query_cache_max_entries默认 256,单条结果是 1 MB 左右,不同版本略有差异)。 - 查询不能是系统表查询,
system.*相关查询通常不走查询缓存,避免缓存自身的元数据反过来影响系统观测。 - 查询不能处于事务中(如果是在事务块里执行,缓存结果不会被写入也不会被读取)。
结果集的行数和查询执行时间也会影响缓存策略。ClickHouse 提供了query_cache_min_query_duration和query_cache_min_rows两个参数,默认值是 0 和 0,表示所有查询都可以尝试缓存。实际使用中建议把query_cache_min_query_duration设置为 1000 毫秒以上,因为执行时间低于 1 秒的查询,缓存命中节省的耗时和内存管理开销相比并不划算。
2.3 缓存失效机制:数据变了怎么办
ClickHouse 查询缓存令人放心的一点是它对数据变更的感知能力。当查询引用的 MergeTree 表发生数据插入、合并、分区删除或者 TTL 清理时,缓存条目会基于表的版本号检测到变更,在下次查询时自动失效并重建。注意,这个失效不是实时的,而是惰性的:数据变更不会立刻清除缓存,而是在下一次查询访问缓存时,发现表的元数据版本不匹配,才判断缓存过期,然后重新执行查询并更新缓存。
这带来一个微妙的问题:在一个 TTL 窗口内,如果表数据发生了不可见的变化(比如背景合并把旧分区合并成新分区,但未触发版本号变化),缓存可能暂时返回旧数据。不过 ClickHouse 的 MergeTree 引擎在合并完成后会产生新的 part 版本,这个版本信息会体现在表的状态中,所以多数情况下数据变更还是能被感知到的。对于实时性要求极高的业务,不应该依赖查询缓存,而是应该关闭它或者把 TTL 设到极短,比如 1 秒。
query_cache_ttl参数控制缓存的存活时间,默认值是 60 秒,也可以设置为0表示永不过期(但受表结构变更影响)。我这里建议报表类查询设置为 60 到 300 秒,监控类可以设置 10 到 30 秒。记住,TTL 只是最大存活时间,如果底层数据在 10 秒时就发生变化,实际缓存寿命可能只有 10 秒。
3. 实操过程与核心环节实现
3.1 环境准备和参数配置步骤
要在 ClickHouse 中启用查询缓存,最直接的方式是修改config.xml,加入查询缓存相关的配置。以 ClickHouse 24.8 版本为例,推荐在<query_cache>标签内设置:
<query_cache> <max_size_in_bytes>1073741824</max_size_in_bytes> <max_entries>1024</max_entries> <max_entry_size_in_bytes>10485760</max_entry_size_in_bytes> <max_entry_size_in_rows>3000000</max_entry_size_in_rows> </query_cache>这些参数表示:整个查询缓存最多占用 1 GB 内存,最多缓存 1024 个查询结果,单个缓存条目最大 10 MB 或 300 万行。设置好之后重启 clickhouse-server,然后用SELECT * FROM system.query_cache验证配置是否生效。
在实际生产环境中,我建议不要直接改全局config.xml,而是通过用户 profile 来做更细粒度的控制。创建用户级 profile:
CREATE PROFILE IF NOT EXISTS dashboard_profile SETTINGS use_query_cache = 1, query_cache_ttl = 120, query_cache_min_query_duration = 500, query_cache_min_rows = 10000;然后把这个 profile 绑定到 dashboard 用户。这样做的好处是,同一个集群的分析用户可以去单独关闭缓存,不会互相影响。
3.2 用示例场景验证缓存命中与观测
我用一个实际业务场景来演示,假设我们有一张app_events表,存储用户行为日志,每天新增几亿行,报表需要按小时统计不同渠道的 PV/UV。先执行一次查询并开启缓存:
SELECT toStartOfHour(event_time) AS hour, channel, count() AS pv, uniqExact(user_id) AS uv FROM app_events WHERE event_time >= now() - INTERVAL 1 HOUR GROUP BY hour, channel ORDER BY hour, channel SETTINGS use_query_cache = 1;第一次执行因为缓存中没有数据,会真正扫表,耗时可能在 1 秒多。之后再次执行相同查询,会发现耗时显著降低,可能只有几十毫秒。这时查看系统表:
SELECT query, hits, misses, memory_usage, result_size, expires_at FROM system.query_cachehits字段表示这个缓存条目被命中的次数,misses表示缓存失效后重新查询的次数。如果hits一直为 0,说明查询根本没命中缓存,需要按上一节的条件逐条排查。
为了验证缓存键是否受到 SQL 格式影响,我们可以把 SQL 换成全大写并且去掉多余空格再执行一次,会发现仍然命中缓存,这得益于 AST 缓存键设计。如果我们在 SQL 里加一个SETTINGS max_threads = 8,缓存键就会变化,之前的结果无法命中,会出现一次新的缓存写入。
3.3 在多用户和分布式场景下的配置要点
在多用户场景下,每个用户默认有独立的缓存空间。如果想让多个服务账号共享缓存,需要谨慎设计,因为 ClickHouse 默认不让用户读取彼此的系统表缓存信息。一种做法是把服务账号合并成一个,另一种是接受多份缓存的内存开销。
分布式表是一个容易踩坑的地方。当查询通过分布式表发起时,每个分片节点在执行子查询时可能各自生成一份缓存。若要减少重复,可以把query_cache_share_between_users设为 1(如果版本支持),但这又带来权限风险。我的建议是,分布式表查询优先让缓存作用在分片本地表上,而不是作用在分布式表外层。例如让应用直接查询本地表对应的视图,这样每个分片只缓存自己那部分结果,协调节点只做合并,整体收益反而更高。
另外一个关键点是查询缓存和异步插入、物化视图的配合。如果业务在写入时使用了异步插入,ClickHouse 可能不会立刻感知数据变化,查询缓存可能在短时间窗口内返回旧数据。为了降低风险,建议在异步插入场景下把 TTL 调到 5 秒以内,或者干脆关闭查询缓存,改用SYSTEM FLUSH LOGS来保证写入后的查询一致。
4. 常见问题与排查技巧实录
4.1 缓存命中率为 0,先查这五个地方
遇到缓存完全不生效的情况,不要急着改配置,按照下面的顺序排查,大概率能定位到问题。
检查use_query_cache是否真的被查询会话使用,可以在 SQL 中临时加settings use_query_cache = 1,然后用EXPLAIN查看执行计划,确认是否有CACHING相关步骤。如果 EXPLAIN 里完全没有缓存环节,说明配置没有传递到执行层。
检查查询是否使用了非确定性函数。默认情况下,now()、today()、rand()、uuid()等函数会导致查询不可缓存。有人写了一个 SQL,里面用today()过滤数据,结果缓存永远无法命中。这时要么把函数值显式提取出来作为参数传入,要么设置query_cache_nondeterministic_function_handling = 'save',让 ClickHouse 在函数结果变化时自动使缓存失效。
检查结果集是否超过大小限制。默认单条缓存结果如果超过 1 MB 就会拒绝写入,大查询的聚合结果动辄几十 MB,自然缓存不上。调大max_entry_size_in_bytes虽然可行,但必须评估内存压力,建议优先考虑在 SQL 层面做分页或限制维度数量,而不是无脑放开大小限制。
检查查询是否走了缓存但又被频繁失效。这种情况表现为系统表里缓存条目经常消失,misses很高。底层表如果高频写入,每一次写入都会导致表版本变化,缓存频繁失效。可以执行SHOW CREATE TABLE app_events查看表引擎,检查min_insert_block_size_rows和写入频率。若确认是高频写入导致,方案是缩短 TTL 或者放弃缓存,改用prewhere和索引优化查询本身。
最后检查是不是查询了system表。对system.query_cache自身的查询不会写入缓存,某些系统指标视图也不会。如果业务需要缓存系统状态,建议把数据定期同步到普通 MergeTree 表再查询。
4.2 缓存导致查询结果看起来变旧,怎么处理
这是缓存机制最容易引发投诉的问题。表现是仪表盘上的数据比实际写入滞后,用户觉得报表不准。处理方式有几个层次。
最直接的是调低query_cache_ttl,比如从 60 秒降到 5 秒。这个方法简单但会降低缓存命中率。另一个办法是用query_cache_min_query_duration设定阈值,只缓存执行时间超过 1 秒的查询,这样快速变化的场景不会被缓存干扰。
更精细的做法是使用条件缓存:只对统计维度不太敏感、对实时性要求不高的查询开启缓存。在业务层可以维护一个“缓存白名单”,把实时看板请求打到一个不开启缓存的普通用户上,把离线报表请求打到专门的缓存用户上。这个方法需要业务方配合改造,但能同时满足实时和性能两方面的要求。
我在项目里还遇到过一种情况:ClickHouse 的异步插入让数据已经提交到 Kafka 但还没落到表里,此时查询缓存刚好在 TTL 窗口内,返回了旧结果。解决思路是在写入链路中调用SYSTEM FLUSH ASYNC INSERT QUEUE强制刷盘后再触发下游查询,或者在写入后延迟 1 秒再刷新看板。这种问题通常不是缓存本身的 bug,而是数据链路时序问题,必须从整体设计上规避。
4.3 内存占用过大和缓存抖动问题
查询缓存本质是用内存换性能。如果一个集群并发查询很多,每条结果集都很大,可能会把内存打爆。注意 ClickHouse 的查询缓存内存并不是精确限制的,max_size_in_bytes只是软限制,实际内存使用可能略高。监控内存使用可以看system.metrics中的QueryCacheBytes指标,一旦接近 80% 就要考虑调小缓存容量或者限制缓存条目。
缓存抖动问题也很常见。数据表高频写入时,缓存条目刚生成就失效,然后再次写入,反复消耗 CPU 和内存。这种场景下缓存不是辅助,反而成了负担。我会检查system.query_cache中条目寿命,如果每个条目存活时间都在几秒以内,说明缓存命中率极低,应该关闭缓存,并将资源用于优化表索引或增加预聚合。
4.4 查询缓存失败排查速查表
| 现象 | 可能原因 | 解决动作 |
|---|---|---|
| 缓存命中率为 0 | 查询没有启用use_query_cache | 在 profile 或查询级设置 |
| 命中率为 0 | 查询包含now()等非确定性函数 | 设置query_cache_nondeterministic_function_handling = 'save' |
| 命中率为 0 | 结果集超过max_entry_size_in_bytes | 调大条目上限,或减少查询维度 |
| 命中但很快失效 | 底层表高频写入 | 缩短 TTL 或关闭缓存 |
| 命中但返回旧数据 | TTL 太长或异步插入未刷盘 | 调低 TTL,增加 flush 机制 |
| 内存占用暴涨 | 缓存条目过多、结果太大 | 调低max_size_in_bytes、max_entries |
| 分布式表缓存不一致 | 各节点独立缓存 | 通过本地表查询或调整路由策略 |
这张表不是我随便整理的,都是实际支持业务时高频遇到的问题。尤其最后一条,多节点集群中,如果应用连接到不同的 ClickHouse 节点,同一个查询可能在每个节点都生成一个缓存条目,整体内存开销被放大,而且各节点缓存内容不一定同步,排查时容易误判为数据不一致。
5. 个人实操中的体会与扩展思路
关于 ClickHouse 查询缓存,我个人的建议是:不要把它当作默认开启的功能,而是先做好查询负载分析再决定。曾经有一个监控系统,每天跑着几百个指标查询,我刚开始想全部开启缓存,结果发现很多查询的过滤条件带有now(),永远无法命中。后来把查询语句里的时间函数统一改成由程序生成具体时间参数,命中率一下子就上去了,集群 CPU 使用率从 70% 降到 30% 左右。这个改动不大,但收益非常明显,也让我意识到:查询缓存的收益取决于查询的可复用性,而查询可复用性往往取决于业务层如何构造 SQL。
如果你希望这套机制发挥最大价值,我建议后续可以从两个方向做扩展。第一是结合物化视图:把高频聚合结果提前物化到一个小表,然后对小表开启查询缓存,这样既减少了扫描数据量,又能利用缓存挡住突发流量。第二是做一个缓存命中率的监控面板,定期从system.query_cache拉取指标,分析各个业务线的缓存使用情况,用数据来决定是否调整 TTL 和内存上限。查询缓存不是“配置完就完事”的功能,它需要持续观察和调优,才能和大数据集群的实际负载匹配起来。