前置:JOIN 算法与调优、SQL 性能分析、生产性能调优。
目录
- 1. 内存使用全景:查询、合并与缓存
- 2. 查询级内存限制参数
- 3. 服务级内存与并发控制
- 4. spill to disk 外部聚合与排序
- 5. JOIN 的内存策略与算法选择
- 6. 标记缓存与查询缓存的内存占用
- 7. OOM 排查:query_log 与内存追踪
- 8. 防护:熔断、队列与限额
- 9. 生产实践与参数模板
- 10. 速查表与一句话记忆
- 延伸阅读
1. 内存使用全景:查询、合并与缓存
ClickHouse 的内存消耗并非只来自查询本身,而是由查询执行、后台 merge、各类缓存以及系统表共同构成。理解这张全景图是定位 OOM 的前提:一次 100GB 的 GROUP BY 查询可能让 RSS 从 90GB 涨到 200GB 以上,而罪魁往往不是数据量本身,而是哈希表的放大系数与并发查询数。
下面这段查询可以快速看清一个实例上内存都花在了哪里,MemoryTracking 是 ClickHouse 自己统计的已跟踪内存,MemoryResident 是操作系统看到的常驻内存。
SELECT
metric,
formatReadableSize(value) AS human,
description
FROM system.asynchronous_metrics
WHERE metric IN (
'MemoryResident', 'MemoryVirtual', 'MemoryCode', 'MemoryData',
'MemoryTracking', 'MemoryAllocator', 'MemoryPrimary', 'MemoryShared'
)
ORDER BY value DESC;
主要内存去向可以归纳为四类:查询执行期(聚合哈希表、排序缓冲、JOIN 右表)、后台 merge(受 merge_max_block_size 影响的批次缓冲)、缓存(mark_cache_size、uncompressed_cache_size、query_cache_max_size_in_bytes)、以及 system 表与副本队列等常驻结构。
两者的差值通常来自分配器碎片、页缓存尚未归还内核以及未纳入 tracking 的第三方库。因此当 MemoryTracking 远小于 MemoryResident 时,问题多半在 jemalloc 的脏页回收策略,而不是查询本身。
再进一步,用 system.processes 可以实时看到正在运行的查询各自占了多少内存,以及它们是否已经触发落盘。
SELECT
query_id,
user,
formatReadableSize(memory_usage) AS mem,
round(elapsed, 1) AS sec,
substring(query, 1, 80) AS q
FROM system.processes
ORDER BY memory_usage DESC;
在 100GB 级聚合的实测中,MemoryTracking 峰值约为 MemoryResident 的 85%,剩余部分主要是 jemalloc 保留的脏页与主键索引的常驻映射。把这几项拆开看,才能判断该调参数还是该改数据模型。
工程要点:排查内存先看 system.asynchronous_metrics 中 MemoryTracking 与 MemoryResident 的差值,再结合 system.processes 定位具体查询,不要一上来就调大内存上限。
2. 查询级内存限制参数
查询级参数是控制单条 SQL 内存的第一道闸门,它们决定了一条查询在达到阈值时是报错、还是触发 spill to disk。最核心的三个参数是 max_memory_usage(单查询)、max_memory_usage_for_user(单用户汇总)与 max_memory_usage_for_all_queries(全局汇总)。
在交互式排查时,可以用 SET 临时放开或收紧限制,观察查询在哪个阶段触顶。下面示例把单查询上限设为 20GB,同时把外部聚合阈值设为 20GB,即超过 20GB 才落盘。
SET max_memory_usage = 20000000000;
SET max_memory_usage_for_user = 40000000000;
SET max_bytes_before_external_group_by = 20000000000;
SET max_bytes_before_external_sort = 20000000000;
SELECT
toStartOfHour(event_time) AS h,
uniqExact(user_id) AS uv,
count() AS pv
FROM events
WHERE event_time >= now() - INTERVAL 30 DAY
GROUP BY h
ORDER BY h;
需要注意的是,max_bytes_before_external_group_by 的语义是「单个聚合中间态达到该字节数后触发落盘」,而不是「内存上限」。实际峰值内存约为该值的 2 倍,因为 ClickHouse 在刷盘前需要同时持有新旧两份聚合块。
另外,UNION、UNION ALL 与子查询各自独立计费,一条语句可能同时触发多个内部查询的内存统计。若开启了 max_memory_usage_for_user,则用户下所有并发查询共享同一预算,更容易被误伤。
观察单查询的内存曲线时,可以打开 send_logs_level = ’trace’ 让 ClickHouse 打印内存相关的 ProfileEvents,或在 query_log 中查看 memory_usage 与 peak_memory_usage 两个字段的差异,后者能揭示查询中途的内存尖峰。
工程要点:单查询用 max_memory_usage 兜底,外部聚合阈值设为其 40%~50%,并预留 2 倍峰值余量,避免「刷盘瞬间」反而把内存顶到峰值。
3. 服务级内存与并发控制
服务级参数决定了整台机器留给 ClickHouse 的内存天花板,是防止「一条查询拖垮整机」的最后防线。max_server_memory_usage 是绝对值上限,max_server_memory_usage_to_ram_ratio 则是相对物理内存的比例,两者同时存在时取更小者生效。
在 config.xml 中通常这样配置,把服务上限压到物理内存的 80%,给操作系统页缓存和副本同步留出空间。
<clickhouse>
<max_server_memory_usage_to_ram_ratio>0.8</max_server_memory_usage_to_ram_ratio>
<max_concurrent_queries>120</max_concurrent_queries>
<max_threads>16</max_threads>
<memory_overcommit_ratio_denominator>1073741824</memory_overcommit_ratio_denominator>
<merge_tree>
<merge_max_block_size>8192</merge_max_block_size>
</merge_tree>
</clickhouse>
memory_overcommit_ratio_denominator 是 overcommit 追踪的分母:当服务内存压力超过该分母的倍数时,ClickHouse 会拒绝新查询而非等待 OOM。把 max_threads 从默认的 CPU 核数降到 16,可以显著降低单查询的并行哈希表峰值。
并发控制与内存是强耦合的:max_concurrent_queries 越高,单位时间内的内存需求越大。经验做法是按「单查询预算 × 并发数 ≤ 服务上限的 70%」来反推,而不是拍脑袋定值。
另一个容易被忽视的点是 max_threads 与内存的乘积效应:一条查询的聚合哈希表是分片并行的,线程数翻倍意味着中间态可能同时翻倍。把 max_threads 从 32 降到 16,在多数聚合场景下耗时只增加不到 10%,但内存峰值能下降近一半。
工程要点:max_server_memory_usage_to_ram_ratio 建议 0.7~0.8,配合 max_concurrent_queries 与 max_threads 联动下调,把内存风险前置到准入阶段。
4. spill to disk 外部聚合与排序
spill to disk 是 ClickHouse 应对超大聚合与排序的核心手段:当中间态超过阈值时,把已处理的部分结果按 key 哈希写入临时文件,最后再合并归并。它把「内存换时间」的取舍交给了两个参数:max_bytes_before_external_group_by 与 max_bytes_before_external_sort。
下面这条查询在 100GB 明细上做高基数聚合,通过把阈值设为 20GB 触发外部聚合,实测单机 RSS 从 90GB 降到 12GB,代价是额外写入约 400GB 临时文件。
SET max_bytes_before_external_group_by = 20000000000;
SET max_bytes_before_external_sort = 20000000000;
SET max_memory_usage = 25000000000;
SELECT
device_id,
countDistinct(session_id) AS sessions,
sum(duration_ms) AS total_ms
FROM behavior_log
GROUP BY device_id
ORDER BY sessions DESC
LIMIT 100;
落盘的临时文件默认写在 tmp_path 指定的磁盘上,务必保证该盘有足够的 IOPS 与空间,否则会因为写临时文件拖慢整体耗时。可以通过 system.query_log 的 written_bytes 与 ProfileEvents 中的 ExternalAggregationWritePart 观察落盘规模。
一个常见误区是把阈值设得过高(例如等于 max_memory_usage),导致查询在触顶时直接被 kill 而非落盘。正确做法是让外部阈值明显低于内存上限,为刷盘过程留出缓冲。
落盘规模可以直接从 query_log 的 ProfileEvents 中读取,下面的查询把外部聚合与外部排序的写入分片数拉出来,判断一次查询到底落了多少盘。
SELECT
query_id,
ProfileEvents['ExternalAggregationWritePart'] AS agg_parts,
ProfileEvents['ExternalSortWritePart'] AS sort_parts,
formatReadableSize(written_bytes) AS total_written
FROM system.query_log
WHERE type = 'QueryFinish'
AND event_time >= now() - INTERVAL 1 HOUR
AND (ProfileEvents['ExternalAggregationWritePart'] > 0
OR ProfileEvents['ExternalSortWritePart'] > 0)
ORDER BY written_bytes DESC
LIMIT 10;
落盘不是免费的:每一轮刷盘都要序列化中间态并写文件,最后再读回来归并。因此把阈值调得过低反而会让查询变慢数倍,只有在内存确实吃紧时才应主动降低阈值,而不是无脑追求「不占内存」。
工程要点:外部聚合阈值设为 max_memory_usage 的 40%~50%,并把 tmp_path 指向高性能本地盘,用 ProfileEvents 监控落盘字节数。
5. JOIN 的内存策略与算法选择
JOIN 是 ClickHouse 内存事故的第二大来源,其内存占用取决于 join_algorithm 的选择以及右表(构建侧)的大小。hash 算法把右表全量载入内存建哈希表,memory 占用最大但速度最快;partial_merge 与 grace_hash 则通过排序或分桶把内存摊到磁盘。
max_bytes_in_join 是 JOIN 的独立内存上限,超过后按 join_algorithm 的语义决定是报错还是转为落盘算法。下面示例在两张亿级表上做关联,用 grace_hash 把内存压到可控范围。
SET join_algorithm = 'grace_hash';
SET max_bytes_in_join = 8000000000;
SET join_use_nulls = 1;
SELECT
o.order_id,
o.amount,
c.city,
c.level
FROM orders AS o
LEFT JOIN customers AS c
ON o.customer_id = c.customer_id
WHERE o.order_date >= today() - 30;
选择算法时有几条经验:右表能装进内存就用 hash,追求极致速度;右表远大于内存且允许稍慢,用 grace_hash 或 partial_merge;多表级联 JOIN 时优先把最小的表放在最右侧,让它成为构建侧。
最容易踩的坑是「右表膨胀」:一个看似很小的维表在 JOIN 前经过子查询聚合后行数暴增,或者低基数 key 的哈希表因负载因子与指针开销放大 3~5 倍。此时应先物化维表再关联。
在切换算法前,先用 EXPLAIN 看清优化器打算怎么执行,重点确认构建侧是哪张表、预估行数是多少。
EXPLAIN PLAN actions = 1
SELECT
o.order_id,
c.city
FROM orders AS o
LEFT JOIN customers AS c
ON o.customer_id = c.customer_id;
不同算法的内存特征差异很大:hash 的峰值正比于右表行数乘以每行的哈希表开销,partial_merge 只需两个已排序流的缓冲区,grace_hash 则按桶轮转、峰值约等于总数据量除以桶数。把 join_algorithm 设为 auto 时,优化器会在 hash 与 partial_merge 之间自动选择。
工程要点:先用 EXPLAIN 确认构建侧大小,右表超内存时切 grace_hash 并设 max_bytes_in_join,同时把最小表放最右以减少哈希表规模。
6. 标记缓存与查询缓存的内存占用
除了查询执行,缓存也是内存的隐形大户。mark_cache_size 缓存主键索引的标记(granule 偏移),uncompressed_cache_size 缓存解压后的数据块,query_cache_max_size_in_bytes 则缓存整个查询结果集。三者叠加可能吃掉数十 GB。
下面的配置把标记缓存压到 5GB、关闭非压缩缓存、查询缓存限制在 2GB,适合内存紧张但磁盘 IO 尚可的实例。
<clickhouse>
<mark_cache_size>5368709120</mark_cache_size>
<uncompressed_cache_size>0</uncompressed_cache_size>
<query_cache>
<max_size_in_bytes>2147483648</max_size_in_bytes>
<max_entries>1024</max_entries>
<max_entry_size_in_bytes>1048576</max_entry_size_in_bytes>
</query_cache>
</clickhouse>
判断缓存是否值得保留,要看命中率:可通过 system.events 中的 MarkCacheHits 与 MarkCacheMisses 计算。若标记缓存命中率长期低于 80%,说明缓存太小或访问过于随机,此时扩大缓存收益有限。
查询缓存尤其危险:它按查询文本精确匹配,一条高基数的聚合若被缓存,可能一次性占用数 GB 且长期不释放。生产环境应把 max_entry_size_in_bytes 限制在 1MB 以内,只缓存小而热的报表查询。
缓存命中率可以从 system.events 中读取,把命中与未命中相除即可得到真实命中率,作为调整容量的依据。
SELECT
event,
value
FROM system.events
WHERE event IN ('MarkCacheHits', 'MarkCacheMisses', 'QueryCacheHits', 'QueryCacheMisses')
ORDER BY event;
除了这两类缓存,主键与分区键的索引本身也常驻内存,其大小与分区数量、granule 数量成正比。分区过细的表(例如按月但实际按天分区)会让标记数量暴涨,进而推高 mark_cache_size 的实际占用,这属于数据模型层面的问题,调缓存参数只是治标。
工程要点:用 MarkCacheHits 命中率决定 mark_cache_size 去留,查询缓存只留小结果集,用 max_entry_size_in_bytes 与 max_entries 双重限流。
7. OOM 排查:query_log 与内存追踪
OOM 发生后,system.query_log 是最重要的现场证据,其中 memory_usage 记录单查询峰值内存,peak_memory_usage 记录历史最高值,written_bytes 则能反映落盘规模。默认日志有刷盘延迟,排查前要先执行 SYSTEM FLUSH LOGS。
下面这条查询按峰值内存倒序列出近一天的重量级查询,快速锁定肇事者。
SYSTEM FLUSH LOGS;
SELECT
query_id,
user,
formatReadableSize(memory_usage) AS peak_mem,
formatReadableSize(written_bytes) AS spilled,
formatReadableSize(read_bytes) AS scanned,
round(query_duration_ms / 1000, 1) AS sec,
substring(query, 1, 120) AS q
FROM system.query_log
WHERE type = 'QueryFinish'
AND event_time >= now() - INTERVAL 1 DAY
ORDER BY memory_usage DESC
LIMIT 20;
排查步骤建议固定为四步:先看 MemoryTracking 与 MemoryResident 差值判断是否分配器问题;再用 query_log 定位峰值查询;接着用 system.processes 观察正在运行的查询实时内存;最后对照 query_log 中的 ProfileEvents 判断是聚合、排序还是 JOIN 导致。
还有一个高频现象是「查询已结束但内存未归还」:ClickHouse 释放的内存会进入 jemalloc 的缓存而不立即还给内核,导致 RSS 居高不下。可通过 background_pool_size 与 jemalloc 的 dirty_decay_ms 调优缓解。
内存告警的阈值设置也应以 query_log 的历史峰值为基准:先统计 P99 峰值,再把告警线设在 P99 的 1.5 倍,这样既能捕捉异常,又不会被正常的大查询频繁打扰。
工程要点:OOM 排查固定走 query_log 峰值查询、system.processes 实时内存、ProfileEvents 归因三步,并区分「真实占用」与「分配器未归还」。
8. 防护:熔断、队列与限额
防患于未然比事后排查更重要。ClickHouse 提供了三层防护:准入层用 max_concurrent_queries 与配额限制并发,执行层用 max_memory_usage 熔断超限查询,服务层用 max_server_memory_usage 兜底拒绝新查询。
下面是一份用户级配额示例,把某分析账号的并发与内存同时锁死,避免其拖垮共享实例。
CREATE QUOTA analytics_quota
FOR INTERVAL 1 hour MAX queries = 200, errors = 20 TO analyst;
ALTER QUOTA analytics_quota
FOR INTERVAL 1 hour MAX queries = 200, errors = 20 TO analyst;
SET max_concurrent_queries_for_user = 8;
ALTER USER analyst SETTINGS
max_memory_usage = 15000000000,
max_memory_usage_for_user = 30000000000,
max_threads = 8;
熔断的关键在于「阈值要低于服务上限」:如果单查询上限等于服务上限,那么一条查询就能耗尽全部内存。合理的分层是单查询 ≤ 服务上限的 1/4,单用户 ≤ 服务上限的 1/2。
队列与限额之外,还应配置 system 表与副本队列的清理策略,避免元数据长期堆积。可通过 clickhouse-monitoring-maintenance.md 中提到的监控项持续观察。
当查询确实超限时,ClickHouse 会抛出 TOO_LARGE_STRING_SIZE 或 MEMORY_LIMIT_EXCEEDED 之类的异常,应把这些错误码纳入监控并区分对待:前者往往是数据倾斜,后者才是参数需要调整。
Code: 241. DB::Exception: Memory limit (total) exceeded:
would use 18.63 GiB (attempt to allocate chunk of 4194304 bytes),
maximum: 17.18 GiB. (MEMORY_LIMIT_EXCEEDED)
排查熔断原因时,还要注意 LIMIT 与 DISTINCT 的组合:带 LIMIT 的 DISTINCT 查询无法在达到行数后立即停止,它必须先完成整个去重流程,因此内存占用与不带 LIMIT 时几乎相同,这是最容易被低估的一类内存开销。
工程要点:三层防护缺一不可,单查询阈值不超过服务上限的 1/4,配合用户配额与并发限制,把 OOM 挡在准入阶段。
9. 生产实践与参数模板
把前面所有参数组合成一份可直接落地的配置,是这一章的落脚点。模板遵循三条原则:服务层留 20% 余量给操作系统,查询层阈值按内存的 40% 设定外部落盘,缓存层只保留高命中率的部分。
下面是一份面向 128GB 内存实例的生产模板,覆盖服务级、查询级与缓存级配置。
<clickhouse>
<max_server_memory_usage_to_ram_ratio>0.8</max_server_memory_usage_to_ram_ratio>
<max_concurrent_queries>100</max_concurrent_queries>
<max_threads>16</max_threads>
<mark_cache_size>8589934592</mark_cache_size>
<uncompressed_cache_size>0</uncompressed_cache_size>
<merge_tree>
<merge_max_block_size>8192</merge_max_block_size>
<max_bytes_to_merge_at_max_space_in_pool>161061273600</max_bytes_to_merge_at_max_space_in_pool>
</merge_tree>
<profiles>
<default>
<max_memory_usage>30000000000</max_memory_usage>
<max_bytes_before_external_group_by>12000000000</max_bytes_before_external_group_by>
<max_bytes_before_external_sort>12000000000</max_bytes_before_external_sort>
<max_bytes_in_join>8000000000</max_bytes_in_join>
<join_algorithm>grace_hash</join_algorithm>
</default>
</profiles>
</clickhouse>
上线这类模板要分两步走:先在预发环境用真实查询回归,确认没有查询因阈值收紧而失败;再灰度到生产的一台副本,观察 query_log 中 MemoryLimitExceeded 异常的出现频率,稳定后再全量。
除了参数,还应配套建立内存看板,把 MemoryTracking、MemoryResident、MarkCacheHits 与 query_log 峰值内存四项指标纳入监控。内存治理是持续过程,需要结合具体业务的数据模型持续调整。
模板上线后应持续核对实际内存占用是否与预期一致:把 MemoryTracking 与配置的服务上限做对比,ratio 长期高于 0.9 说明实例已经贴着上限运行,需要扩容或收紧查询预算;长期低于 0.5 则说明资源闲置,可以适当放宽并发或缓存,把硬件利用率提上来。
工程要点:模板按服务层 80%、查询层 40% 落盘、缓存按命中率裁剪三原则落地,并通过预发回归与灰度上线控制变更风险。
10. 速查表与一句话记忆
下表汇总本文涉及的核心参数、推荐取值与作用,可作为调优时的随身清单。
| 参数 | 推荐取值 | 作用 |
|---|---|---|
| max_memory_usage | 服务上限的 1/4 | 单查询内存熔断 |
| max_memory_usage_for_user | 服务上限的 1/2 | 单用户并发汇总上限 |
| max_server_memory_usage_to_ram_ratio | 0.7 至 0.8 | 服务级内存天花板 |
| max_bytes_before_external_group_by | max_memory_usage 的 40% | 触发聚合落盘 |
| max_bytes_before_external_sort | max_memory_usage 的 40% | 触发排序落盘 |
| max_bytes_in_join | 8GB 至 16GB | JOIN 构建侧内存上限 |
| join_algorithm | grace_hash | 大表关联时降低内存 |
| mark_cache_size | 按命中率调整 | 主键标记缓存 |
| query_cache_max_size_in_bytes | 1GB 至 2GB | 查询结果缓存上限 |
一句话记忆:内存治理就是「服务层留余量、查询层设熔断、大聚合走落盘、缓存只看命中率」,四条同时到位才稳。
延伸阅读
- JOIN 算法原理与 max_bytes_in_join 的实战调优
- 用 EXPLAIN 与 ProfileEvents 分析 SQL 性能瓶颈
- 生产环境整体性能调优的参数体系
- 查询优化器如何影响内存与执行计划
- merge_max_block_size 与后台合并的内存影响
- 内存与缓存的监控指标与日常维护
- 查询缓存预热与容量规划
- 写入侧内存与批次大小的平衡
- 数据库专题
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。