ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

ClickHouse读取缓存详解:从Page Cache到Query Cache

ClickHouse读取缓存详解:从Page Cache到Query Cache 同一个 SQL第一次跑要 3 秒第二次只要 0.3 秒。这种体验在 ClickHouse 里太常见了但很多人不知道这个差距背后的主角是一整套读取缓存体系而不只是某一条配置。这篇先聊读路径上的缓存下一篇再聊写入侧的缓存和后台任务对缓存的破坏。如果你正在用 ClickHouse 做分析报表、实时同步链路或者正在折腾部署调优建议把这篇看完。我见过不少人遇到查询时快时慢就直接去调max_threads、改内存参数折腾半天没效果。其实问题大概率出在缓存层是文件系统缓存被挤掉了还是 mark 缓存没命中还是查询结果缓存压根没开。搞清楚读取链路上有哪些缓存、各自管什么、什么时候失效排障思路会清晰很多。1. 读取一次数据ClickHouse 要过几道缓存1.1 一条 SELECT 从发起到返回中间经历了什么要理解缓存先得理清一条 SELECT 在 MergeTree 上到底怎么读数据。以最常见的SELECT sum(x) FROM t WHERE id 123为例大概要经过这几步语法解析、生成执行计划这里不涉及磁盘。根据分区键裁剪出要扫描的 part 列表如果表建得好这一步会砍掉大部分文件。对每个 part读取主键索引primary.idx做二分查找定位到可能命中的行区间。MergeTree 的索引是稀疏索引每index_granularity行才记录一个索引项默认 8192 行一个。拿到候选行区间后需要知道这些行在列文件里具体从哪个位置开始读这就需要读 mark 文件.mrk/.mrk2。根据 mark 里的偏移量去读.bin列文件中的压缩块。这个压缩块通常包含多个 granule 的数据。把压缩块读进内存解压成原始列数据再交给后面的过滤、聚合算子处理。你发现没有从第 3 步开始每一步都可能命中缓存。主键索引文件很小基本在文件系统缓存里躺着mark 文件有专门的内存缓存压缩块的原始字节靠 OS 的文件页缓存解压后的列块理论上也可以被缓存。最后如果开了查询结果缓存整条 SQL 的结果集还能被直接复用。1.2 四类缓存都在缓存什么我把读取链路上真正值得关注的缓存整理成了一张表。注意这里说的是服务端/文件系统层面的缓存各种分布式缓存、应用层 Redis 不在讨论范围内缓存缓存的对象粒度管理方默认状态典型失效方式OS Page Cache读过的磁盘文件页含压缩块原始字节4KB 页 / 压缩块Linux 内核开启内存回收、主动 drop_cachesMark Cachemark 文件里的 granule 偏移信息单条 mark 记录ClickHouse 服务端开启LRU 淘汰Uncompressed Cache解压后的列块数据单个压缩块对应的解压结果ClickHouse 服务端关闭LRU 淘汰Query Cache最终查询结果集一条 SQL 的完整结果ClickHouse 服务端关闭TTL 到期、数据版本变化这四类缓存的共同点是它们都不负责数据一致性而是把磁盘上已经存在的字节复制一份放近处减少重复 IO 和重复计算。1.3 别用 Web 缓存的思维套 ClickHouse很多从 MyBatis、Spring 三级缓存、Redis 缓存治理那套技术栈转过来的同学会下意识问一个问题ClickHouse 的缓存一致性怎么保证缓存穿透怎么办这个问题本身就用错了模型。OLTP 系统的旁路缓存是缓存层 数据库两层结构缓存是数据的代理所以需要考虑一致性、穿透、击穿。ClickHouse 的读缓存则完全不同它更像是 CPU 的 L1/L2/L3 缓存底层是同一份数据上层缓存只是读过的数据块的临时副本。它不代理写入也不承担一致性的承诺磁盘上的数据变了缓存自然就失效了。想明白这一点后面看很多调参建议就不会被带偏。2. 被多数人忽略的 OS Page CacheClickHouse 最大的缓存池2.1 为什么它比任何 ClickHouse 参数都重要先做一个实验。你找一台内存 64GB 的服务器部署好 ClickHouse对一张 200GB 的表跑一个全表聚合。第一次跑可能要用 40 秒紧接着再跑一次可能只要 8 秒。这中间你什么都没配置第二次快的唯一原因就是 Linux 内核把读过的文件页留在了内存里也就是 OS Page Cache。ClickHouse 的 MergeTree 存储设计是压缩列存。读数据时实际从磁盘读出来的是压缩块的原始字节反复读同一个 part 时这些字节大概率已经在 page cache 里了根本不用碰磁盘。对于 512GB 内存的分析服务器page cache 随手就能缓存几十 GB 到上百 GB 的热数据这个规模远超任何内部缓存配置能提供的量级。我自己在部署 21.8.15.7 那批机器时做过一次测试把 page cache 清掉后跑同一个报表查询耗时从 2.8 秒涨到了 11.4 秒。也就是说这台机器上性能的 70% 以上依赖的是文件系统缓存而不是 ClickHouse 本身的参数。很多所谓调优没效果是因为你根本没意识到最大的缓存池在内核里。所以理解 page cache 的管理机制比调 ClickHouse 配置更基础。它占据的是服务器可用内存的一部分内存紧张时内核会优先回收它。如果你的服务器一直在跑大查询、频繁申请内存page cache 就可能被反复挤掉表现出来就是查询时快时慢。2.2 部署 21.8.15.7 时和内核缓存相关的几个参数如果你是照着网上的老教程部署 ClickHouse大概率会看到一堆内核参数调整建议。我实际用下来真正和缓存相关的就三个值得关注第一个是vm.swappiness。这个参数控制内核在内存紧张时是优先换出匿名内存页还是优先回收 page cache。分析型服务器上社区经验值一般给到 1~10。设得太高内核可能会把一部分不常用的进程内存换到 swap拖垮整体响应设成 0 在某些旧内核版本上又可能触发 OOM 误杀。我习惯设成 1配合足够大的物理内存效果比较稳。第二个是vm.dirty_ratio和vm.dirty_background_ratio。它管的是脏页写回策略。纯分析场景不用太动但如果你用 Flink 持续导入数据、高频写入默认值偏高可能导致写回集中间接影响读取 IO。我一般把vm.dirty_background_ratio调小到 5 左右让内核更积极地写回脏页减少积压但不会把vm.dirty_ratio压得过低避免写入吞吐下降。第三个是内核的 NUMA 策略。这个严格说不是缓存参数但影响很大。跨 NUMA 节点访问内存时延迟会变高page cache 命中后读取也会受影响。生产环境建议关掉自动 NUMA balancing或者用numactl --interleaveall跑 clickhouse-server避免内存访问不均匀。2.3 一个我踩过的坑不要随便 drop_caches刚接手一套 ClickHouse 集群的时候看到free -h显示内存被吃掉几百 GB第一反应是内存泄漏。于是执行了echo 3 /proc/sys/vm/drop_caches瞬间干干净净心里还挺舒服。结果第二天业务侧就受不了了所有查询集体变慢部分高并发报表直接超时。原因很简单我手动清理 page cache 的动作等于把服务器上几百 GB 的热数据备份一把火烧了。之后几天每次查询都是冷读磁盘 IO 直接被打满而内核本来会自动在内存和 page cache 之间做平衡根本不需要人工干预。后来我把这条经验固化成了运维规范任何情况下不手动 drop_caches。如果实在要清理必须在业务低峰期并且要预期到接下来一段时间段查询性能会有明显回退。内存回收这种事情Linux 内核做得比你想象中好得多别去抢它的活。3. Mark Cache稀疏索引定位背后的坐标本3.1 先明白 primary index、granule、mark 三者关系很多人把 ClickHouse 主键索引想得太万能以为是像 MySQL 那样的 B 树能精确定位到每一行。实际上 MergeTree 的primary.idx非常小只记录了每个 granule 的起始行的索引列值。它的作用是帮你快速排除掉不可能命中的 granule而不是找到精确行。granule 是 ClickHouse 读取的最小数据块单位默认 8192 行。granule 和列文件之间的映射关系就存在 mark 文件里。你可以把 mark 理解成坐标本它记录了这个 part 里每个 granule 在.bin列文件里的起始偏移量和压缩块大小。查询要读某个 granule 的某列时必须先查这个坐标本才能在列文件里找到真正要读的字节位置。这个坐标本可能只有几十 KB但它决定了每一次数据读取的起点。如果每次读数据前都要从磁盘重新加载 mark 文件小 IO 会多到吓人——尤其在查询涉及大量 part 的时候。Mark Cache 就是把这个坐标本长驻在内存里用 LRU 淘汰冷数据。3.2 mark_cache_size 到底该调多大Mark Cache 的大小由mark_cache_size控制默认值大概在 5GB 左右。它是服务端全局的内存缓存不属于单条查询的内存配额。关于这个参数我的观点是绝大多数集群不需要调大重点要看的是 part 数量而不是参数大小。为什么因为 mark 文件本身很小一个 part 的 mark 文件也就是几百 KB 到几 MB 级别。默认 5GB 的缓存能容纳的 part 数量已经相当可观。真正的问题是当你有几万个细小 part 时哪怕每个 part 的 mark 只有 200KB累积起来也超过了缓存容量热数据反而会被挤掉此时调大mark_cache_size是治标不治本。治本是减少 part 数量——让后台 merge 跑得动或者控制导入频率避免抖动产生大量小 part。Flink 同步 MySQL 到 ClickHouse 的场景里持续小批量写入很容易堆出大量 part这时候更该看的是 merge 相关的设置而不是调 mark 缓存。如果你一定要调整注意mark_cache_size是消耗服务器总内存的它会和查询内存、page cache 抢空间。在 64GB 机器上给 mark cache 硬塞 20GB可能反而把 page cache 挤小整体性能不升反降。3.3 怎么观察 mark cache 的命中情况ClickHouse 提供了几个直观的指标来观察 mark cache 状态SELECT metric, value FROM system.metrics WHERE metric LIKE %MarkCache%;这条语句能看到当前 mark cache 占了多少字节、多少文件SELECT event, value FROM system.events WHERE event LIKE %MarkCache%;这条语句看的是累计命中/未命中次数字段包含MarkCacheHits和MarkCacheMisses。我个人的判断标准是单条查询要结合system.query_log里的ProfileEvents来看只盯着服务端全局的命中率意义不大。比如一个大范围扫描的查询mark cache miss 很多是正常的因为本来就要读一大堆 part但如果你发现一个点查WHERE id xxx的查询每次都有大量 mark cache miss那就说明两种可能要么查询裁剪后的 part 数量太多要么 mark cache 被其他业务冲掉了。还有一种情况容易被忽略use_skip_indexes跳数索引的读取走的是文件系统缓存和 mark cache 是两套机制。有人把max_mark_cache_size调大想加速 skip index方向就错了。跳数索引读的是.idx文件加速它要靠 page cache 容量。4. use_uncompressed_cache一个被误解多年的参数4.1 它缓存的是解压后的数据默认却是关的这是 ClickHouse 里最容易让人产生误解的参数之一。字面意思很清楚开启后解压出来的列块数据会被放进内存 LRU下次读取同一个压缩块时跳过解压步骤直接拿现成的数据。听起来很划算但官方默认是关闭的并且这个开关在后来的一些主干版本里干脆被移除了。为什么因为大多数分析场景下它的收益小于成本。第一LZ4 解压速度极快解压 1MB 数据也就几十微秒级别省下的这几十微秒对整个查询时间影响微乎其微。第二page cache 已经缓存了磁盘上的压缩块原文重复读同一个压缩块时磁盘 IO 的瓶颈已经消除了剩下的只是解压一次还是两次的差别。第三分析查询大多数是顺序扫一遍同一个压缩块被反复读取的场景本身就不多缓存命中率不会高。用一个场景来类比你去食堂打饭米饭是现成的压缩块端到餐桌上需要拆开保鲜膜热一下解压。把热好的米饭存在保温箱里下次再去取一份同样的米饭就能直接用。问题是大家每次都点不同的菜保温箱里的米饭根本没人认领还占了操作台的地方。4.2 什么情况下值得开一次试试虽然默认关闭但确实有一种场景值得测试高频重复读取同一个较小的数据块。典型例子是反复查一个小的维度表比如SELECT * FROM dim_area WHERE id 42这种点查被调用几万次。判断方法是看system.events里的两个累计事件SELECT event, value FROM system.events WHERE event LIKE %Uncompressed%;关注UncompressedCacheHits和UncompressedCacheMisses。如果 Hits / (Hits Misses) 的比例很高说明确实在反复命中同一个解压结果此时开启use_uncompressed_cache 1能带来实打实的收益。但我要泼一盆冷水在我见过的生产系统里90% 的场景下这个比例都很难看。那些反复执行的报表查询要么结果集被 query cache 接住了要么本来就是全表扫描型的分析不存在稳定的热点解压块。所以我的建议是这个参数当作历史研究可以不要一上来就全局开启。4.3 这个参数还能牵出 Doris 和 ClickHouse 的选型对比网上经常有人问 Doris 和 ClickHouse 怎么选这个 use_uncompressed_cache 的差异其实是很好的切入点。ClickHouse 在很长一段时间里把缓存重心放在 OS page cache 上压缩块的解压结果默认不缓存。原因很简单分析型查询的复用粒度太粗解压结果大概率一次用不上第二次让操作系统的页缓存去兜底更划算。Doris 那边则更倾向于在 BE 进程内做自管理的页缓存LRU把缓存调度控制权握在自己手里而不是完全交给内核。它的好处是缓存行为可以配置、可观测对高并发小查询的重复命中更友好代价是进程内存占用更重缓存策略需要自己调。所以如果你面临 Doris 和 ClickHouse 选型可以从负载特征出发如果大量是高并发、小结果集、有固定热点数据的查询Doris 这种自管理缓存架构在命中率上通常更可预期如果是大吞吐宽表扫描、复杂分析、对并发数不那么敏感ClickHouse 的让内核管缓存路线更省心。缓存设计本质上反映了两个系统对谁该为读放大负责这个问题的不同回答没有绝对优劣。5. Query Cache查询结果缓存到底要不要开5.1 21.x 时代的查询缓存还比较实验ClickHouse 的 Query Cache 机制在 21.x 中后期已经以实验性功能的形式存在了。它和前面的缓存完全不同前面缓存的是数据块它缓存的是最终查询结果集。同一个 SQL 语句再次执行时如果命中缓存直接返回结果连执行计划都不重新生成。以 21.8 这个常见的部署版本为例相关配置大致包括use_query_cache、query_cache_ttl、query_cache_max_entries、query_cache_min_query_duration等。注意这个功能默认是关闭的需要显式打开。我的态度是它对特定业务有用但不要把它的角色想成数据库自带的 Redis。它对查询模式极其挑剔开之前必须想清楚你的报表 SQL 是否真的重复且固定。5.2 适合开和坚决别开的场景先给结论性判断适合开 Query Cache坚决别开固定报表Dashboard 每秒刷一次同一个 SQLSQL 里带now()、rand()等每次结果都不同的函数结果集小几百到几千行结果集几十万行缓存内存扛不住对数据实时性不敏感容忍秒级延迟写入后要求立即查询到最新数据的业务查询频次高且完全重复高并发但每个查询条件都不同命中率极低我踩过一个教训把 Query Cache 开了之后某张表每天凌晨被 Flink 任务批量更新白天 Dashboard 拉报表看起来一切正常。直到有一次业务方修改了上游数据重新跑历史分区结果 Dashboard 还是显示旧数据——因为查询结果被缓存了还没到 TTL。这种看起来正常其实是旧数据的状态比查询慢更危险。5.3 Flink 持续写入场景下我为什么一般不开热词里有一条 使用 Flink 实现 MySQL 同步到 ClickHouse这正好是 Query Cache 使用中一个关键反例。Flink 同步链路的特点是高频写入、数据持续变化。如果此时开了 Query Cache会面临三个问题一是命中率。表数据一直在变每个 SQL 下次跑的时候可能涉及的分区已经变了缓存条目很容易失效实际命中率远低于预期。二是新鲜度。即便缓存还没失效用户查到的也是 TTL 之前的旧结果。对实时数仓来说这是难以接受的数据质量问题。三是内存风险。缓存条目不失效、不淘汰就会一直堆积。配置了query_cache_max_entries后倒是不会 OOM但大量失效条目反复写入又淘汰会带来内存碎片和 GC 压力。所以在 Flink 同步链路里我的做法是ClickHouse 这层不开 Query Cache把结果缓存留给更上游的应用层去做比如报表服务自己加一层 Redis 或本地内存缓存。这样既拿到了缓存收益又不会牺牲查询新鲜度。5.4 顺带说透缓存穿透在 ClickHouse 里的含义每次聊缓存总会有人提缓存穿透。这个概念在 Web 系统里的意思是查询一个不存在的数据每次都要穿透缓存打到数据库。放在 ClickHouse 里最接近的形态是高并发查询一个根本不存在的 key每次都触发全表扫描或者大量索引跳转。但 ClickHouse 里应对这个问题的思路和 Redis 完全不同。它不是靠缓存空结果来挡穿透的而是靠两件事分区裁剪和稀疏索引。你只要把分区键设计得合理查询条件能精确落到一两个分区就算 key 不存在扫描的数据量也极小根本不叫穿透。真正可怕的是查询条件不带分区键那不管缓存怎么开都是全表扫描的灾难。这种问题的解法是改表结构、改查询习惯而不是加缓存。6. 围绕读取缓存我在生产环境里的调参与排障经验6.1 一个时快时慢的典型排查链路这是一次真实的排障经历。某张报表表业务反馈查询时间忽高忽低有时 0.5 秒有时突然跑到 5 秒找不到规律。我先是查了system.query_log对比两次执行同一 SQL 的记录重点看read_bytes和ProfileEvents。发现一个非常明显的现象慢查询那次的read_bytes比快查询的时候高了两个数量级。这说明慢的那次实际从磁盘读的数据量剧增而那段时间并没有导入新数据——唯一的解释是之前本来应该命中 page cache 的数据没有被命中。接着我看了system.metrics里的MarkCacheBytes发现 mark cache 占用率并不高排除 mark cache 问题。再查服务器内存发现问题的根源同一时间点另一个团队在跑一个巨大的JOIN查询max_memory_usage没有控制一下子申请了大量内存。内核在内存压力下开始回收 page cache把热点数据全挤出去了。等那个大查询跑完page cache 还没有重新填充于是后续查询集体冷读性能暴跌。整个排查链路走完结论是不是 ClickHouse 配置坏了而是内存资源被抢了。后面处理方式很简单给那个大查询套上资源限制错峰运行问题就消失了。我列一下这次排障用到的关键命令和判断方法排查对象命令 / 视图判断逻辑冷读 / 热读差异system.query_log查看read_bytes同一 SQL 的 read_bytes 波动大说明缓存命中不稳定Mark Cache 状态system.metrics的MarkCacheBytes数值接近上限且查询慢考虑 part 过多或缓存被挤占解压缓存命中system.events的UncompressedCacheHits比例极低时没必要开use_uncompressed_cache系统内存压力free -h查看 page cache 大小page cache 长期很小说明内存在被其他东西抢大查询内存占用system.processes的memory_usage排查哪些查询在挤压 page cache6.2 几个真正有效的调参习惯做完上面那次排障后我给自己定了几条规矩第一max_server_memory_usage不要设满。很多人喜欢把保留内存压到很低觉得机器内存不用白不用。但这样一来一旦有大查询内核为了保证 ClickHouse 的内存请求会不断回收 page cache最直接的后果就是所有查询变成冷读。我一般预留 20% 左右给操作系统和 page cache长期看整体吞吐反而更高。第二max_memory_usage对高并发场景要有上限。单条查询可以宽松但并发总和必须受控。最好的工具是system.processes里看当前内存占用一旦发现某个查询吃了近一半内存立刻评估是不是要限流。第三关注 part 数量而不是只调各种 cache size。前面反复提到part 多了mark 文件总量就大缓存压力自然上去。定期查看system.parts里每个分区的 part 数量如果持续积累优先排查 merge 配置而不是加缓存内存。第四压测时一定要做缓存区分测试。连续跑同一查询 5 次记录第一次和第五次的差异。如果差异巨大说明系统整体依赖缓存你要回答的问题是缓存被挤掉后系统还能不能扛住业务峰值。6.3 缓存调优的本质一路看下来你会发现ClickHouse 的读取缓存调优核心其实不在 ClickHouse 参数里而在内存资源的分配格局上。四类缓存里page cache 体量最大完全由内核控制mark cache 体量中等由 ClickHouse 控制但占用的是同一块内存池uncompressed cache 默认不开开了也只是用内存换解压时间query cache 是业务侧的可选项用内存换查询重复执行的成本。它们之间存在一个朴素的竞争关系所有缓存都在抢内存内存总量是固定的此消彼长。网上那些把各种 cache size 都调大的教程基本都是没有考虑这种竞争关系。真正合理的分配是让 page cache 有足够空间mark cache 保持默认uncompressed cache 不开query cache 按业务精确开关。我在实际运营中还有个体会很多缓存问题其实是表结构问题。比如查询条件永远不带分区键导致扫描量巨大比如字段太多、SELECT *满天飞导致 read 放大比如索引设计不合理导致稀疏索引定位不到几个 granule。这些问题不管缓存开多大都只是临时缓解最终还是要回到数据模型本身来解决。最后再分享一个小技巧排查缓存问题时先做一个对照实验——重启服务或者清掉 page cache 前后跑同一个查询看耗时变化。如果清掉后性能暴跌说明你的负载很依赖缓存这时候该考虑的是内存容量够不够、资源分配合不合理如果清掉后性能几乎不变说明查询本身就在冷读加缓存参数的收益也微乎其微该去优化索引和分区裁剪。下篇我会接着聊写入侧的缓存行为、后台 merge 如何破坏缓存热度以及字典缓存和元数据缓存的坑。如果你在项目里也遇到过查询时快时慢的诡异现象建议先把今天这套排查链路走一遍很大概率能定位到真正的原因。
返回列表