内存管理与落盘:查询内存、spill to disk 与 OOM 防护

本文系统讲解 ClickHouse 的内存管理与落盘机制,从查询级与服务级内存限制参数出发,深入 spill to disk 外部聚合与排序、JOIN 内存策略与算法选择、标记缓存与查询缓存占用,并给出基于 system.query_log 的 OOM 排查步骤与熔断队列限额防护方案,最后附生产参数模板与速查表。实测 100GB 聚合可将单机 RSS 从 90GB 压到 12GB。

前置:/clickhouse-advanced-joins/(JOIN 算法与调优)、/clickhouse-sql-performance/(SQL 性能分析)、/clickhouse-production-performance-tuning/(生产性能调优)。

目录

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_ratio0.7 至 0.8服务级内存天花板
max_bytes_before_external_group_bymax_memory_usage 的 40%触发聚合落盘
max_bytes_before_external_sortmax_memory_usage 的 40%触发排序落盘
max_bytes_in_join8GB 至 16GBJOIN 构建侧内存上限
join_algorithmgrace_hash大表关联时降低内存
mark_cache_size按命中率调整主键标记缓存
query_cache_max_size_in_bytes1GB 至 2GB查询结果缓存上限

一句话记忆:内存治理就是「服务层留余量、查询层设熔断、大聚合走落盘、缓存只看命中率」,四条同时到位才稳。

延伸阅读

  • /clickhouse-advanced-joins/ — JOIN 算法原理与 max_bytes_in_join 的实战调优
  • /clickhouse-sql-performance/ — 用 EXPLAIN 与 ProfileEvents 分析 SQL 性能瓶颈
  • /clickhouse-production-performance-tuning/ — 生产环境整体性能调优的参数体系
  • /clickhouse-query-optimizer/ — 查询优化器如何影响内存与执行计划
  • /clickhouse-merge-tree-tuning/ — merge_max_block_size 与后台合并的内存影响
  • /clickhouse-monitoring-maintenance/ — 内存与缓存的监控指标与日常维护
  • /clickhouse-query-cache-warming/ — 查询缓存预热与容量规划
  • /clickhouse-insert-throughput-tuning/ — 写入侧内存与批次大小的平衡
  • 数据库专题

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「数据库」更多文章

  1. 用户自定义函数:executable UDF、SQL UDF 与性能边界
  2. 集群扩容与升级:分片重平衡、平滑升级与滚动重启
  3. 日志与指标存储:可观测性后端、Grafana 与成本治理