
从“点查变慢”说起一次 ClickHouse 缓存排查的复盘前阵子帮朋友排查一个 ClickHouse 集群的性能问题现象很典型点查接口延迟从几十毫秒涨到几百毫秒但 CPU 和磁盘 IO 看起来都不高load 也很平稳。一开始怀疑是并发问题把max_threads调低、连接池收紧效果有但不明显。后来看了一眼system.metrics里的缓存计数才发现CacheMisses高得离谱命中的都是一些小块的、低价值的 Page真正昂贵的大块读取几乎全在走磁盘。这就是 ClickHouse 缓存体系里最容易踩的坑你以为开了缓存就万事大吉实际上缓存的准入、淘汰、预热、失效每一个环节都有隐藏规则。而且很多团队对“读取缓存”的理解停留在“Redis 那种缓存”的层面——热点数据放进去下次直接拿但 ClickHouse 的读取缓存根本不是这么运作的。它是一套围绕不可变 Part 设计的文件级缓存系统目标是让“重复查询”变快不是让“所有查询”变快。这篇文章就围绕读取缓存这条主线展开我会从整体设计思路讲起拆解缓存的准入标准、淘汰策略、生命周期、源码级实现细节再到可直接抄作业的参数配置和问题排查。内容会尽量做到既能让刚接触 ClickHouse 的同学看懂基础概念也能让有经验的工程师从中找到一些值得回味的设计细节。1. 读取缓存的整体设计思路为什么 ClickHouse 的缓存和别人不一样先明确一个核心认知ClickHouse 的读取缓存本质上是文件系统缓存Page Cache的扩展与精细化管控。它不像 MySQL 的 InnoDB Buffer Pool 那样缓存“行数据”也不像 Redis 那样缓存“查询结果”而是缓存“文件块的解压后数据”。这个设计背后是 ClickHouse 存储引擎的两个底层特性。首先是数据不可变性ClickHouse 的 MergeTree 表写入后生成不可变的 Part后续修改靠后台 merge 生成新 Part旧 Part 被标记删除。数据一旦落盘就不变了这让缓存不需要处理“源数据被修改导致缓存变脏”的问题——缓存里的数据永远不会过期只有“被淘汰”和“被跳过”。其次是列式存储的读取模式分析查询通常只读取部分列、部分行组如果缓存的是“完整行”代价太高如果缓存的是“压缩块”解压成本仍然很高。所以 ClickHouse 选择把读取路径上的“未压缩数据块”作为缓存单位命中后直接交给执行引擎省掉磁盘 IO 和解压这两个最贵的环节。如果把 ClickHouse 的读取缓存类比成厨房备菜可能更好理解。磁盘上的压缩数据相当于冰箱里的冻肉解压相当于解冻执行引擎相当于炒菜。读取缓存就是提前解冻好、放在操作台上的净菜。如果客人点同一道菜直接从台面拿就行不用再开冰箱、等解冻。但问题来了操作台空间有限放哪些菜、放多少、什么时候换掉这就是缓存准入与淘汰策略要解决的事。理解了这一层很多现象就有了解释。比如为什么数据集不大的时候ClickHouse 查询还是很快因为操作系统本身的页缓存Page Cache已经把热数据文件缓存住了读磁盘的次数很少。再比如为什么有时候你把use_uncompressed_cache打开了性能提升却不大因为你的查询模式是“一次性全扫描”数据还没有形成可复用的缓存热点就已经被后续的数据冲掉了。从架构分层上看ClickHouse 的缓存涉及三个层面操作系统页缓存读取磁盘文件时由 OS 自动管理ClickHouse 无法直接控制但可以间接影响比如用 O_DIRECT 绕过它。未压缩数据缓存use_uncompressed_cacheClickHouse 用户态缓存存储的是解压后的数据块重复查询命中后可以完全跳过磁盘 IO 和解压这是本文的主角。文件系统缓存FileCache / LocalCache在分布式场景或存算分离架构下缓存远端存储的数据让后续查询不需要再从 HDFS/S3 拉取。这三个层面的缓存是叠加关系但在不同场景下的开销和收益完全不同。单机 MergeTree 场景下真正需要你手动调优的主要是第二层分布式和存算分离场景下第三层的权重会大大增加。很多团队一上来就猛调max_uncompressed_cache_size结果发现瓶颈在远端存储拉取的网络 IO 上这就是没分清缓存层级。2. 核心概念拆解读取缓存、写入缓存与缓存的更新失效机制2.1 读取缓存到底是什么未压缩数据块缓存我们把读取缓存拆开看它其实只解决一个问题让“重复读取同一份数据”的成本降到最低。具体来说当查询需要读取某个 Part 的某段数据时ClickHouse 会先定位到对应的压缩块然后有两种选择直接读磁盘、解压、返回数据或者先查缓存命中则直接返回内存中的未压缩数据。前者每次都要付出磁盘 IO 解压的代价后者只需要一次内存拷贝。这个缓存的粒度需要仔细设计。如果粒度过大比如缓存整个列文件热点数据会迅速占满内存而且不同查询访问的列范围差异很大缓存放进去很快就变成“死数据”。如果粒度过小比如缓存单行元数据开销远大于收益。所以 ClickHouse 选择了一个折中粒度按压缩块compressed block的粒度来缓存默认块大小在 64KiB 到 1MiB 之间具体取决于列的类型和编码方式。每次读取时把需要的范围映射到对应的块缓存命中的块直接返回未命中的块从磁盘加载。这里有非常重要的一点读取缓存缓存的是“未压缩块”。这样做的原因很直接——重复查询节省的不只是磁盘 IO还有解压的 CPU 开销。分析型查询往往要扫大量数据解压开销占比相当可观命中缓存后这部分 CPU 直接就省掉了。但要特别注意一个误区很多人以为开启了这个缓存所有重复查询都会显著加速。实际上如果两个查询读取的是同一个 Part 的不同列缓存的收益是有限的因为缓存是“按列块”组织的不同列的块并不共享。所以在设计表结构时把经常一起查询的列尽量靠近放比如同一个主键前缀下能提高缓存的“有效命中率”。2.2 缓存是怎么被写入的不只是“查询过了就缓存”很多刚接触 ClickHouse 的同学会有个朴素疑问是不是我查过的数据都会被缓存答案是否定的。ClickHouse 的读取缓存写入时机远比你想的要克制。它有一套准入门槛只缓存那些“值得缓存”的数据块。写入缓存的触发点主要有这么几类查询执行过程中读数据时发现块不在缓存且符合缓存准入条件就会实时加载进去。这是最主流的写入路径。后台预读MergeTree 在合并或获取分区时会顺手把新生成的 Part 数据置入缓存为后续查询暖身。显式预热你可以对目标表先跑一个轻量查询比如SELECT count() FROM table WHERE ...让数据经过读路径从而填充缓存。有三类数据通常不会被缓存。第一类是过大的块如果读取的块非常大超过了一定阈值缓存会直接跳过避免把超大块塞进缓存挤掉其他热点。第二类是过久远的访问缓存有容量上限和淘汰策略很久没被访问的块会被清理不是永久驻留。第三类是用户显式禁用的场景通过设置use_uncompressed_cache 0或者某些系统内部任务如 merge强制绕过缓存这写路径就不会产生缓存。注意很多人有个误解以为只要查询过的数据都会被缓存。实际上对于大表全扫、或者表达式计算量很大的查询ClickHouse 会评估收益不一定会往缓存里写。缓存不是垃圾桶不能什么都往里塞。缓存写入的并发控制也值得一提。ClickHouse 的缓存写入是并发安全的多个线程同时读同一块数据时只有一个线程会真正去加载数据进缓存其他线程要么等待、要么直接使用未缓存的数据。这里面有个“singleflight”机制非常巧妙它能有效避免“缓存击穿”导致的并发放大——如果 100 个查询同时要读同一个未缓存的块没有 singleflight 的话磁盘 IO 会被打爆 100 次有了这个机制只会真的读一次磁盘其他人都在等待或走冷读路径。这个细节在生产环境高并发场景下是避免雪崩的关键。开启缓存的正确姿势取决于你的主 workload。如果查询主要是点查、小范围过滤、重复性高建议开启use_uncompressed_cache 1如果主要是大范围扫描、增量聚合、写入密集建议保持关闭默认就是关闭。因为后者缓存命中率低写缓存反而是负担。这个选择没有银弹要根据 workload 来调我把决策逻辑整理成了一张对照表放在本章最后。2.3 缓存的更新与失效机制比你想象的更微妙数据不变缓存就不会变这是很多人对缓存的朴素理解。但在 ClickHouse 里“更新”和“失效”是两件分开的事。先看更新。当一次 INSERT 写入数据后新数据会以新的 Part 形式出现在磁盘上。请注意新写入的数据不会自动出现在读取缓存里。Part 在写入过程中直接写磁盘而不是先写缓存再刷盘。所以新数据要等“之后被查询”或者“被 merge 成新 Part”后才会真正走进缓存。这就意味着缓存里并不实时包含所有最新数据。如果你在做实时报表刚写入的数据要等下一次查询才会真正进缓存这个延迟在“实时性要求极高”的场景下需要特别注意。再看失效。读取缓存失效的典型时机包括Part 被删除或替换旧 Part 被 merge 或删除时缓存中对应的块会标记为失效等待回收缓存容量不够需要淘汰旧块被淘汰的块视为失效显式刷新执行SYSTEM DROP FILESYSTEM CACHE之类的命令手动清空文件系统缓存。这里要特别提醒SYSTEM DROP FILESYSTEM CACHE是全局级别的命令会把整个节点的文件系统缓存清掉生产环境执行后后续所有查询直接读磁盘性能会出现断崖式下跌。除非你明确要测试冷启动性能否则别在生产环境乱用更别写进定时任务里“定期清理缓存”。关于一致性ClickHouse 的缓存有个其他缓存系统羡慕不来的特性它天然不需要处理“缓存与源数据强一致”的问题。因为数据不可变Part 一旦写入就不再修改缓存里的块永远能对应上某个历史版本的 Part 数据。你在 ClickHouse 里永远不会因为缓存而读到“脏数据”。这跟 Redis 完全不同——Redis 缓存你需要处理缓存与数据库的一致性、穿透、雪崩ClickHouse 基本不用操心这些。这个特性靠什么实现靠的是 Part 命名带上 min/max block 编号以及索引文件的版本信息。查询时ClickHouse 先定位到具体的 Part再根据 Part 的版本去命中缓存版本变了缓存就不会命中。这个机制也解释了一个经典问题为什么 ClickHouse 的缓存命中率上不去原因往往不是缓存容量不够而是数据分区或 Part 太多每次查询都涉及不同的 Part缓存碎片化严重查询模式太分散每个查询摸不同块命中率低写入导致 Part 频繁合并合并后的新 Part 会让旧缓存立即失效缓存不断重建。所以想提高命中率核心思路是减少 Part 数量、聚合热点查询、设计合理的主键和分区键而不是单纯调大缓存容量。2.4 一图看懂读取缓存 vs 写入缓存很多同学会把“读取缓存”和“写入缓存”混为一谈其实这是两套完全不同的系统。我把它们的分工整理成了一张表维度读取缓存未压缩块缓存写入缓存Insert Buffer / 后台合并作用阶段查询读取路径数据写入路径存储介质内存为主可扩展到本地 SSD内存/磁盘最终落盘为 Part数据形态未压缩数据块行数据/Batch以 Part 形式落盘主要目标加速重复查询的读取提高写入吞吐批量落盘失效策略淘汰/清理/显式命令合并后旧版本自然失效一致性负担天然不可变无强一致问题依赖 MergeTree 机制保证最终一致读取缓存的目标是让第二次查询比第一次快让重复查询的 IO 从磁盘变成内存写入缓存的目标是让高频写入先攒着攒够一批再落盘减少对磁盘的压力。两者节奏完全不同调优思路也得分开。如果你想快速评估当前集群的缓存状态不要只看一个指标而是结合一组指标CacheReadBytes读路径读取缓存字节数、CacheWriteBytes写路径写入缓存字节数、CacheHits / CacheMisses命中与未命中次数。这套组合拳能让你一眼看出瓶颈在读还是在写。3. 读取缓存的完整生命周期一次查询的旅程从这一章开始我们把“读取缓存”当作一个独立的子系统来观察。它不只是“开一个开关”而是一套完整的生命周期管理机制数据从磁盘读出后如何被识别、缓存、淘汰、命中查询如何与缓存交互缓存最终如何失效。搞清楚这条流水线你就知道该怎么去调优了。一次查询的内部旅程大概是这样的客户端发起查询ClickHouse 解析 SQL生成执行计划执行计划中的每个读任务最终落到某个 Part 的数据读取读数据时先计算需要读取的块范围对于每个需要的块先查读取缓存——如果命中直接把内存中的未压缩块返回给执行引擎如果未命中从磁盘读取压缩块、解压、按准入条件决定是否写进缓存然后返回数据查询结束命中/未命中统计指标更新。这段流程看似简单实则每个环节都有优化空间和坑。下面逐个拆开讲。3.1 缓存的准入标准什么数据能进缓存不是所有读出来的数据都能进缓存。ClickHouse 的读取缓存有严格的准入逻辑我总结下来主要看三个条件。条件一块大小是否合适。数据读取时ClickHouse 会把数据切成固定大小的块Block缓存会评估这些块。太小的块缓存收益低因为频繁的元数据操作可能抵消收益太大的块缓存风险高可能挤占其他热点块。所以 ClickHouse 会为每个候选块计算一个“价值”只有价值超过阈值的块才会被缓存。这个“价值”可以简单理解为这块数据在未来被重复查询的概率乘以解压成本。这是读取缓存最精妙的设计之一它不是一个单纯的“读过的都放进去”而是一个有经济模型的决策器。条件二读取方式是否适合缓存。ClickHouse 的查询路径有多种读取方式对缓存的态度也不同。顺序扫描适合缓存因为顺序扫描往往意味着后续还有分析型查询要复用随机读取缓存价值最高因为随机读盘的成本最大预读的缓存价值一般需要动态评估。实际上ClickHouse 对不同的读取方式会差异化地计算缓存收益从而决定是否缓存。这也能解释为什么同样是查询有的查询命中率很高有的几乎为零——跟读取模式直接相关。条件三读路径是否有缓存标记。这一点很关键。ClickHouse 的主键索引和二级索引在规划读取路径时会标记某些读操作必须忽略缓存。比如查询要求实时读取最新数据可能标记为绕过缓存某些系统内部任务如 merge、mutation会强制绕过缓存来避免污染用户缓存。实操中如果你希望某些特定查询“不走缓存”、不污染热点数据可以在查询级别设置SET use_uncompressed_cache 0强制走冷读路径。这在缓存治理时非常有用。3.2 缓存的替换与淘汰策略谁走谁留缓存不可能无限膨胀所以替换与淘汰策略直接决定了缓存的质量。ClickHouse 的替换策略有几个典型实现我分别说一下思路。最基础的是 LRULeast Recently Used缓存按访问时间排序最久没被访问的块先被淘汰。实现简单、效果稳定ClickHouse 的基础读取缓存就是标准的 LRU 思路。但 LRU 有个天然缺陷一个块如果只被访问了一次就会长期占据缓存空间直到被其他访问挤出去这会造成“一次性热点”污染。所以进阶方案是 2Q 或 ARC。2Q 把缓存分成两个队列一次性命中队列和频繁命中队列。一个块只被访问一次时先进一次性队列再次访问后升级到频繁队列。这样有效避免了“一次性热点”污染。ARC 则更进一步自适应调整两个队列的比例根据访问模式动态调优。你完全可以把缓存想象成一个书架LRU 就是“常翻的书放前面不翻的挪到后面放不下时把最后面的丢出去”2Q 就是“新书先放临时书架被翻第二次才上正式书架”。不同策略适合不同的访问模式没有绝对的好坏。ClickHouse 实际使用的是混合策略基础淘汰走 LRU但会结合块的大小、访问频率和状态标记做动态调整本质上是一个“加权 LRU”。所以调优时不能只调整缓存大小还要关注命中率、块大小分布和淘汰次数。淘汰流程是惰性的。缓存并不会“有容量就立刻淘汰”而是当新数据要写入缓存、而容量不够时才开始执行淘汰流程。这个时机点很关键因为它决定了缓存的操作成本——淘汰发生在“插入路径”上如果淘汰太频繁每次插入都要扫描候选队列CPU 开销会上升。观察淘汰情况可以用system.metrics里的CacheBytes当前占用字节数、CacheCount当前块数量、CacheEvictions累计淘汰次数。如果CacheEvictions非常高先别急着加内存先看是不是并发太高把缓存反复挤掉了。实战经验我见过不少团队一发现缓存淘汰率高就盲目加内存最后内存加了一倍命中率也就提升 5%。真正的瓶颈往往不是缓存不够大而是查询并发太高导致缓存频繁被挤掉。调优顺序应该是先降并发再调缓存策略最后才考虑加内存。3.3 缓存的命中与未命中如何正确评估缓存效果很多人在 ClickHouse 上做完缓存优化后发现性能提升不明显就开始怀疑“缓存没用”。其实是你评估的方式不对。命中率不是唯一指标甚至不是最重要的指标。我习惯关注三个核心指标。命中率CacheHits 除以 CacheHits 加 CacheMisses反映缓存质量但不是最终性能体现字节命中率CacheHitBytes 除以 CacheHitBytes 加 CacheMissBytes更精确地反映 IO 节省情况因为不同块的价值差异很大缓存收益则直接比较“有缓存”和“无缓存”的查询耗时差异这才能真正反映缓存为你省了多少时间。这里有一个很典型的坑命中率 99% 不代表一切 OK。假设命中率 99%但命中的都是小块、低价值的 Page未命中的都是大块、高价值的 Page那你的缓存其实是“高命中率低性价比”。反过来命中率 70%但命中的都是大块、昂贵的读取未命中的都是小块廉价数据缓存收益可能反而更高。所以评估缓存效果的正确姿势是用字节命中率评估 IO 节省用收益评估评估对查询延迟的实际影响用系统负载评估对集群整体稳定性的贡献。ClickHouse 的system.query_log表记录了每次查询的详细统计信息其中与缓存相关的字段包括ProfileEvents.CacheHits、ProfileEvents.CacheMisses、ProfileEvents.CacheReadBytes、ProfileEvents.CacheWriteBytes。你可以用 SQL 对 query_log 做聚合分析精准定位缓存命中率低的查询。我常用的一个排查查询是SELECT query, sum(ProfileEvents[CacheHits]) AS hits, sum(ProfileEvents[CacheMisses]) AS misses, hits / (hits misses) AS hit_rate FROM system.query_log WHERE event_time now() - INTERVAL 1 DAY GROUP BY query ORDER BY hit_rate ASC LIMIT 10;这个查询能帮你快速定位“缓存杀手”——那些命中率极低的查询它们很可能是全表扫描型、或者索引选择不佳型。想提高命中率核心思路是减少 Part 数量Part 太多缓存必然碎片化优化主键和分区键让查询读取范围更集中减少并发避免缓存抖动对核心报表查询定期预热。我见过一个团队通过把一张 2TB 的大表从按小时分区改成按天分区Part 数量从几万个降到几千个缓存命中率从 60% 提升到 92%查询 P95 延迟下降了 40%。这个收益比盲目加内存大多了。4. 读取缓存的实现细节解构一个“供热系统”的视角前面讲的是使用视角这一章我们稍微深入一下实现层面。如果你只想调参前面几章基本够了但如果你想在非常规场景下把缓存调出极致性能排查一些诡异的问题就需要知道底层在做什么。ClickHouse 的读取缓存实现位于源码中的Cache相关目录核心类包括FileCache文件缓存管理缓存文件和元数据、CacheManager缓存管理器统筹多个缓存实例、LRUFileCacheLRU 策略的文件缓存实现、FileSegment文件分片管理一个文件的一段范围、KeyType缓存的 Key标识缓存内容。一个读取请求的完整路径大致是根据文件路径和偏移量生成 KeyType在缓存中查找对应的 FileSegment如果存在检查其状态已下载就直接返回正在下载就等待或直接读磁盘如果不存在创建新的 FileSegment状态设为下载中从磁盘或远端读取数据写入缓存状态改为已下载如果有其他线程在等待同一段唤醒它们。这里有两个设计值得展开。第一是 singleflight 机制多个线程请求同一个未缓存的文件段时ClickHouse 保证只有一个线程实际加载数据其他线程等待或直接走冷读路径。这个机制能有效避免缓存击穿导致的并发放大是高并发点查场景下的隐形守护者。第二是文件段的粒度FileSegment 是缓存管理的最小单位ClickHouse 会把文件切成固定大小的段默认可能是 256KiB 或 1MiB 级别每段独立缓存。这个粒度是权衡后的结果——太细了元数据开销大太粗了热点数据容易占满整个缓存。4.1 用“供热系统”理解缓存架构为了让你直观理解这套机制我再用一个生活化类比。想象你家有一个中央供热系统每个暖气片相当于一个 FileSegment负责一段房间文件的一段范围供热系统LRUFileCache负责管理所有暖气片哪个该供热、哪个该停、哪个正在加热。当你在家查询需要某个房间的温度数据你会先摸一下暖气片热不热热命中就直接用凉未命中就等供热系统加热下载缓存或者直接开窗通风读磁盘。供热系统的调度策略LRU决定了哪些房间优先供热。如果屋子太大缓存容量不足供热系统只能让部分房间保持温度其他轮流停暖淘汰。这个类比里把握全局的不是用户而是供热系统的调度器。对于 ClickHouse 来说你需要站在调度器的角度去配置缓存容量、淘汰策略和预热逻辑而不是站在用户角度去操心某个块是否被缓存。这也是为什么单纯调大缓存容量常常无效——你只是在扩大屋子却没有优化供热调度。4.2 缓存 Key 的生成与碰撞处理缓存的 Key 不是一个简单字符串。ClickHouse 用文件系统的 inode、文件路径、偏移量、文件大小等维度生成一个复合 Key保证不同文件、不同偏移量产生的 Key 不冲突。缓存命中本质上就是通过 Key 在缓存 hash map 中查找对应的 FileSegment查找复杂度 O(1)这也是缓存命中非常快的原因——它不涉及磁盘 IO只涉及内存查找。虽然 Key 设计合理但缓存池是共享的极小概率下两个不同文件段可能生成相同 Key。ClickHouse 的处理方式很简单后写入的覆盖先写入的旧缓存失效。这个处理代价很低但依赖 hash 的整体质量。如果你怀疑 Key 冲突导致命中率异常这个概率极低可以检查相关 collision 指标。日常运维基本不用关注这个场景。4.3 元数据与磁盘状态管理缓存的元数据包括文件段的偏移量、长度、缓存状态下载中、已下载、跳过、最后访问时间、引用计数等。ClickHouse 把元数据保存在内存中的 hash map 里同时也会在磁盘上维持元数据镜像防止重启后全部丢失。为什么需要磁盘上的元数据ClickHouse 重启时内存中的缓存内容全部丢失但如果磁盘上保留了元数据镜像重启后可以快速恢复部分缓存状态避免整盘扫描的冷启动代价。这个机制在需要快速恢复服务性能的场景下很有价值。但要注意磁盘元数据和实际缓存数据需要一致性维护如果元数据损坏缓存会退化为不可用状态查询走磁盘但不会导致查询失败。另外不同版本的 ClickHouse 对磁盘元数据的格式和处理逻辑不同升级时要谨慎。关于缓存介质的选择也是一个容易忽略的点。ClickHouse 的缓存不一定全在内存里在分布式或存算分离架构下缓存可能落在本地 SSD 或远端存储上。如果你用 SSD 作为缓存盘要注意 SSD 的写寿命有限高写入频率会加速磨损同时 SSD 的随机读性能远好于机械盘适合作为读取缓存的落盘层。介质选型上内存缓存优先、SSD 缓存兜底是通用原则。对于热数据量不大但延迟敏感的场景内存缓存就够热数据量很大的场景SSD 分层缓存能显著提升性价比。5. 调优实战缓存参数配置与场景化方案到了最实用的部分。我把开发工作中最常用的缓存调优参数、场景化配置和踩坑经验总结成章你可以直接参考。5.1 核心缓存参数详解参数默认值作用推荐设置use_uncompressed_cache0是否启用未压缩缓存读取缓存见场景对照max_uncompressed_cache_size0未压缩缓存最大字节数总内存的 10%-20%min_bytes_for_wide_part1048576010MB宽格式 Part 的最小大小默认即可max_memory_usage0查询最大内存默认即可max_threads0查询最大线程数默认即可filesystem_cache_max_size0文件系统缓存最大值磁盘容量的 10%-20%重点说几个关键参数。use_uncompressed_cache是读取缓存总开关默认是 0关闭。很多人不知道这一点以为 ClickHouse 自带缓存是开着的。打开后查询读取的未压缩数据块会缓存在内存里重复查询能大大减少磁盘 IO 和解压 CPU 开销。但要注意开启后内存占用会增加如果内存不足会导致 OOM 或频繁内存回收反而劣化性能。max_uncompressed_cache_size控制缓存最大内存占用。设置太小命中率低太大挤占查询内存。我的经验值是总内存的 10%-20%。比如 64GB 内存的机器设置 8GB-12GB 比较合理。但如果线上查询本身内存压力大要相应调低别卡死查询。max_threads不是缓存专属参数但对缓存命中率影响巨大。并发太高时缓存容易抖动命中率下降。对核心查询建议限制线程数避免过度并发打满缓存。注意这些参数都是“按节点”配置的不是“按集群”配置的。ClickHouse 的缓存是本地资源分布式查询时每台节点单独调优节点间缓存不能共享。5.2 场景化配置方案可以直接参考的三套方案我根据几种典型 workload整理了三套可参考的配置方案。注意这只是起点需要根据监控数据微调。场景A高并发点查OLTP-like 查询特征大量短查询、点查为主、键值定位、对延迟极度敏感。SET use_uncompressed_cache 1; SET max_uncompressed_cache_size 10000000000; -- 10GB按总内存调整 SET max_threads 8;优化思路点查的 IO 成本最高命中缓存收益最大所以一定要开读取缓存并保证缓存足以容纳热点数据。场景B分析型聚合查询OLAP 典型场景特征大批量聚合、全表扫描、对吞吐更敏感。SET use_uncompressed_cache 1; SET max_uncompressed_cache_size 6000000000; -- 6GB SET max_threads 16;优化思路如果查询重复度高开缓存收益明显如果查询每次都是新的大范围扫描缓存命中率低开缓存意义不大。建议结合查询日志先评估重复查询占比。场景C写入密集 近实时查询特征高频写入、数据实时可见、查询范围集中在最新分区。SET use_uncompressed_cache 1; SET max_uncompressed_cache_size 4000000000; -- 4GB SET max_threads 4; SET min_bytes_for_wide_part 100000000; -- 100MB优化思路写入密集场景下Part 频繁合并缓存会不断失效。通过调大min_bytes_for_wide_part让 Part 更大、合并更少缓存更稳定。同时限制线程数减少缓存抖动。5.3 缓存治理的实战经验调优不只看命中率我想强调一个很容易忽略的点缓存调优是系统级工作不是只调缓存参数。你改use_uncompressed_cache 1只是第一步真正的收益来自对查询模式的优化。我习惯把缓存治理分四板斧。第一板斧是减少 Part 颗粒度如果表的 Part 数量过多缓存元数据膨胀、碎片化严重命中率上不去。通过合理设置分区键、调整 merge 策略、定期执行OPTIMIZE TABLE ... FINAL强制合并小 Part能明显改善。第二板斧是优化主键和排序键主键决定了数据在存储中的物理顺序如果查询条件能命中主键前缀扫描的块范围大幅缩小缓存命中率自然提升。第三板斧是限制并发ClickHouse 并发太高时缓存会被瞬间打满、频繁淘汰基本处于“刚写入就被淘汰”的状态。配合监控把并发压到合理范围往往立竿见影。第四板斧是预热核心缓存有些核心报表查询每天固定跑可以设计一个轻量预热任务凌晨把热点数据加载进缓存白天的查询基本命中内存体验非常流畅。预热脚本可以用任何 ClickHouse 客户端实现核心思路就是提前对目标数据跑一次轻量查询让数据经过读路径从而填充缓存# 伪代码示例预热核心报表缓存 from clickhouse_driver import Client client Client(hostclickhouse-node, port9000) # 预热最近 7 天数据 client.execute( SELECT count() FROM events WHERE event_date today() - 7 ) # 预热核心维度表 client.execute( SELECT count() FROM dim_users ) print(预热完成)重要提示预热并不总是有效。如果查询模式每天都在变比如用户总是查最新数据预热的收益会很低。要结合业务实际别盲从。6. 常见问题排查与避坑指南最后这一章我把这些年踩过的坑、见过的问题都整理一下附带排查思路。有些来自官方文档没说透的部分有些是生产环境用真金白银换来的教训。6.1 缓存命中率低问题可能不在缓存本身现象CacheHits 占比很低查询走磁盘的耗时明显。排查步骤建议按这个顺序来先确认缓存是否开启用SELECT * FROM system.settings WHERE name use_uncompressed_cache确认缓存容量设置检查 Part 数量用SELECT count() FROM system.parts WHERE table your_table AND active 1检查查询模式用 query_log 看最近高频查询的扫描范围再用 system.metrics 看命中与未命中的比例。常见原因和解法整理如下原因解法缓存未开启打开use_uncompressed_cache缓存容量太小增大max_uncompressed_cache_sizePart 数量过多合并分区、调整分区键设计查询范围太散优化主键、调整查询条件并发过高导致抖动限制线程数查询每次都不同无复用考虑业务层查询缓存我的经验是绝大多数命中率低的问题根源不是缓存容量而是查询本身不可复用。很多团队上线后把大量即席查询丢给 ClickHouse每条查询摸不同数据范围缓存命中率当然低。这种场景下调再大的缓存都属于浪费内存应该考虑在业务层加结果缓存。6.2 缓存占满内存OOM 风险与应对现象开启缓存后内存使用率飙高节点 OOM 或频繁报内存超限。排查思路上先看system.metrics里的MemoryTracking和CacheBytes再看 ClickHouse 日志是否出现内存超限的报错确认机器总内存和 ClickHouse 可用的max_server_memory_usage。常见原因和解法原因解法缓存容量设置过大调小max_uncompressed_cache_size查询本身占用内存高限制max_memory_usage并发查询过多限制max_concurrent_queries机器内存本身就紧张考虑扩容或关闭不必要缓存我的经验是内存是 ClickHouse 最昂贵的资源缓存吃内存、查询也吃内存两者要平衡。不要为了追求命中率把缓存加到极致否则容易出现“缓存把查询挤死了”的尴尬。一个实用参考公式安全缓存大小约等于机器总内存减去查询并发峰值预估内存乘以 0.5。比如 64GB 机器查询并发峰值预估要 20GB那么安全缓存上限就是(64 - 20) * 0.5 22GB。6.3 缓存不生效即使开了缓存查询还是很慢现象use_uncompressed_cache 1已设置但查询耗时没有明显下降。排查方向有四个。第一数据量太大一次扫描超过缓存容量缓存只能缓存最近读的块全表扫描时缓存会被冲掉。第二查询是第一次跑本来就没有缓存慢是正常的要看第二次、第三次是否变快。第三查询优化器选择不当比如选择了全表扫描而不是索引缓存了也没用。第四缓存被并发查询打乱多个查询交错执行缓存还没来得及命中就被淘汰。解题思路上对冷查询先跑一次热身查询让数据进缓存再测真实性能对全表扫描如果业务允许尽量加筛选条件缩小范围用EXPLAIN看执行计划确认读路径是否合理EXPLAIN SELECT count() FROM events WHERE event_date 2024-06-01;如果执行计划里显示扫描的 parts 数量很多说明需要优化主键或分区键而不是继续调缓存。6.4 清理缓存什么时候该清什么时候千万别清什么时候该清理缓存测试环境需要测试冷启动性能时明确要释放内存、缓存占用过高、业务内存紧张时缓存元数据损坏需要重置状态时。什么时候千万别清生产环境高峰期清了缓存所有查询都走磁盘性能断崖没有明确业务诉求时缓存是宝贵资源不要随手清。清理命令方面ClickHouse 提供SYSTEM DROP FILESYSTEM CACHE命令清空文件系统缓存。但要注意这个命令是全局的影响所有查询清理后缓存不会自动重建需要重新预热这是高危操作在线环境建议在维护窗口执行。我的做法是如果确实需要清理先记录当前缓存大小和命中率清理后对比性能作为调优依据同时在低峰期执行并准备回滚方案。7. 写在最后的个人经验写了这么多最后说几句实在的。ClickHouse 的读取缓存不是什么神秘黑科技它就是一个精心设计的、带准入控制的、加权 LRU 文件缓存。我在实际调优中最深的体会是它的目标是“让重复查询变快”不是“让所有查询变快”。如果你的查询模式本身没有重复性缓存再大也没用。与其执着于调大缓存容量不如回头看看表结构设计和查询模式是否合理——减少 Part 数量、优化主键、控制并发往往比加内存带来更明显的收益。另一个体会是缓存是“尽力而为”的它永远不会成为查询的阻断点。反过来你也不应该把它当成性能银弹。真正让 ClickHouse 查询变快的秘诀永远是设计好的表结构加上合理的查询模式再配上适当的缓存容量三者缺一不可。如果你刚接触 ClickHouse建议用官方 Docker 镜像起一个单节点实例连上clickhouse-client边看system.metrics边跑查询亲手感受命中与未命中的差异比背文档管用得多。关于缓存体系的下半部分也就是写入缓存、分布式查询缓存以及如何用system.query_log和轻量 profiling 工具分析缓存真实收益我会在后续文章里继续拆解。如果你在 ClickHouse 缓存调优上遇到过什么不好排查的问题也欢迎在评论区留言说不定你的问题就是下一篇要写的内容。