☰
ClickHouse内存排查实战:从OOM根因到MemoryTracker调优
2026/10/8 21:40:16 网站建设 项目流程

说实话,ClickHouse的内存问题,大部分时候不是“机器内存不够”,而是“不知道内存被谁吃掉了”。前阵子线上一个集群半夜报警,节点直接消失,systemd拉起来之后还没来得及处理完手头的查询,又被内核杀掉,dmesg里一行明晃晃的Out of memory: Killed process。那天晚上我在电脑前对着日志翻到天亮,最后发现根因不是配置给得太激进,而是后台merge和一张大字典把预留的buffer全吃完了。从那之后我就把排查ClickHouse内存问题的思路整理成了一套固定的流程,今天完整分享出来,希望能帮那些一遇到内存报警就慌着改配置的朋友少走弯路。

这篇文章适合谁看?无论你是刚把ClickHouse搭起来正在调优的运维,还是已经跑了一年多突然开始频繁OOM的数据平台负责人,或者是被“查询报内存超限”折磨的应用开发,下面这套从原理到实操的排查方法都能直接拿来用。我会先讲清楚ClickHouse内存追踪机制是怎么回事,再给出一条完整的定位链路,最后落到具体参数和监控预案上。

1. 从一次线上事故说起:先分清“内存被杀”和“查询被拒”

1.1 同是内存问题,两种完全不同的表现

很多人一登录服务器看到clickhouse进程没了,就下意识认为是查询太大把内存打爆了,然后急急忙忙去调max_memory_usage。这个方向不一定错,但往往治标不治本。我遇到过太多例子,参数调低之后查询反而大面积超时,问题一点没解决。

实际上ClickHouse内存问题首先要分两类:

第一类是进程级别被杀。表现为ClickHouse进程直接消失、重启、再消失。这类问题的元凶通常在内核OOM Killer,原因是整台机器的物理内存耗尽,内核按策略挑一个占用内存最大的进程杀掉,而ClickHouse作为常驻内存大户往往首当其冲。判断方法很直接,在服务器上执行:

dmesg -T | grep -i oom

如果看到类似Out of memory: Killed process 12345 (clickhouse)的输出,说明这就是全局物理内存爆掉导致的进程被杀。这时候你再去看max_memory_usage就可能完全找错方向,因为单条查询可能根本没超限,问题出在总账上。

第二类是查询级别被拒。表现是集群还在跑,但查询返回报错,常见的是:

DB::Exception: Memory limit exceeded

这种报错说明进程还活着,只是某个查询或者某个用户触发了内存配额上限。ClickHouse在内存分配时通过内部追踪器做了一层“软上限”,超过阈值就直接拒绝新的内存申请,而不是等到系统真的扛不住。

这两类问题虽然都叫“内存问题”,但排查入口完全不同。进程被杀的要去查整机内存账本、后台任务、并发叠加;查询被拒的要去看单查询峰值、用户配额、追踪器阈值。

1.2 登录之后第一件事:别改配置,先拍快照

不管哪类情况,我强烈建议在动任何参数之前,先把当前状态完整记录下来。原因很简单:ClickHouse内存是动态变化且恢复极快的,进程一重启很多证据就没了。你需要在上线窗口内快速抓到这些信息。

-- 当前全局内存追踪状态 SELECT metric, value, description FROM system.metrics WHERE metric LIKE '%Memory%' ORDER BY metric; -- 当前正在运行的查询实时内存 SELECT query_id, user, elapsed, memory_usage, read_rows, read_bytes FROM system.processes ORDER BY memory_usage DESC LIMIT 20;

另外把system.query_log里近几个小时的峰值查询也导出来,注意这张表默认只在内存里保留一定时间,崩溃重启后部分数据可能丢失,所以平时一定要有定期落盘的机制。

SELECT event_time, query_id, user, query, memory_usage, peak_memory_usage FROM system.query_log WHERE event_time > now() - interval 6 hour ORDER BY peak_memory_usage DESC LIMIT 20;

这一套快照做下来,基本能判断出:是单条大查询引起的峰值,还是并发叠加导致的总量超限,或者是后台任务在偷偷吃内存。后面所有排查都建立在这份初始数据上。

2. 内存到底花在哪:看懂ClickHouse的内存账本

2.1 Server级与Query级的双重追踪机制

ClickHouse是纯C++实现,不依赖JVM那种统一堆内存,这意味着它的内存分配路径非常分散。为了把这些分散的内存管起来,它内部实现了一套叫做MemoryTracker的追踪器机制。

这套机制最核心的设计是分层记账。最上层是Server级别的全局追踪器,记录整个进程从启动到现在累计申请且尚未释放的内存总量。往下是查询级别追踪器,每个执行的查询都会挂在全局追踪器下面,记录自己那一份内存占用。再细分还有用户级别的配额追踪器、后台任务追踪器等。你可以把它想象成一套多重账本:公司有一个总账,每个部门有分账,每个项目又有独立明细,任一级别超出红线都会触发熔断。

server级硬限制的开关是max_server_memory_usage,它控制的是整个ClickHouse进程最多能用多少内存,默认配置下通常需要手动设置。查询级限制则是max_memory_usage,控制单条查询最多能吃多少内存。这两个参数作用域不同,排查时必须分开看。

还有一个关键点容易被忽略:ClickHouse还会在内存接近上限时拒绝嵌套子查询和内存分配,表现为报错信息里带Memory limit exceeded,但进程本身还活着。所以如果只是偶尔几条查询报错,不要怀疑机器有问题,先想想是不是谁把配额设得太满了。

2.2 单条查询内部有哪些“大胃王”

理解了追踪器之后,我们要知道查询内部哪些环节最容易吃内存。根据我处理过的案例,按常见度排序如下:

  • 聚合哈希表:GROUP BY操作会在内存里构建哈希表,如果分组键基数非常大(比如对高基数的用户ID分组),哈希表可能轻松涨到几十GB。
  • 排序缓冲:ORDER BY需要把所有排序列加载进内存排序,数据量大时占用的临时缓冲区同样可观。
  • JOIN的Build表:ClickHouse的JOIN默认会把右表全部加载到内存构建哈希表,小表JOIN大表可能还好,但大表JOIN大表就非常危险。
  • IN子查询的结果集:如果你在WHERE里写一个大IN子查询,这个子查询的结果集通常也会被物化到内存里,作为外层查询的过滤条件。
  • 读取列数据的解压缓冲:稀疏列扫描、多列读取时,解压后的数据块也会临时堆积在内存中。
  • 分布式查询的中间结果:查询带distributed表时,远端节点会先把中间结果发送给发起点,发起点需要缓冲这些数据。

这些大胃王并不是每次查询都会全部触发,但如果慢查询日志里发现某个查询的peak_memory_usage一直居高不下,就要按上面这个列表逐一排查SQL本身能不能拆分或改写。

2.3 还有一类被你忽略的固定消耗:cache与字典

查询消耗只是“波动”的部分,另外还有一部分内存是“恒定”占用的。最常见的是这几块:

  • Mark缓存:定位数据块位置用的索引缓存,由mark_cache_size控制。
  • 解压缓存:默认关闭,开启时用uncompressed_cache_size控制。
  • 字典:flat和hashed类型的字典默认全部加载到内存,数据量大时非常占内存。
  • 内置监控异步指标:虽然量级小,但长时间运行也会累积。

很多人查内存问题只看system.processes,觉得当前没有大查询就万事大吉,结果固定内存已经悄悄占了机器的一半。这就是为什么我强调要拍全量快照,而不是只看查询。

3. 从现象到根因:一条完整的排查链路

3.1 第一步:确认是OS层还是ClickHouse层

拿到一台报警节点之后,我习惯按下面的顺序走,每一步都有明确的结论产出,避免瞎猜。

先做OS层面的确认:

free -g cat /proc/meminfo | grep -E "MemTotal|MemFree|MemAvailable|Cached|SwapTotal" top -o RES -b -n 1 | head -20

这一套看下来能知道整机内存总量、剩余量、页面缓存占用以及哪些进程占用大。这里有个很容易踩的坑:free命令里看到used很高,但其实大部分是buff/cache,属于可回收的内存,不一定真的是问题。ClickHouse这类数据库本身会利用page cache加速读取,所以看到大量的cached是正常的。但如果MemAvailable长期低于总内存的5%,再加上swap几乎为零,那才是真正危险的信号。

然后看ClickHouse进程自己:

ps aux | grep clickhouse-server

重点关注%MEM和RSS列。如果RSS已经逼近物理内存总量,说明ClickHouse自己就快把机器吃满了,这时候继续往OS层面找意义不大,要马上进入进程内部查账。

3.2 第二步:查内部账本,看总量和分布

登录ClickHouse后用之前给的那几条SQL,重点观察system.metrics里的MemoryTracking值。这个值就是全局追踪器记录的当前总内存占用,包括所有查询、后台任务、数据字典和缓存。如果这个值长期维持在偏高水平,说明是“恒定内存”出了问题;如果这个值本身不高但机器OOM了,那要么是page cache过多,要么是OS层面有其他进程抢内存。

然后看运行中的查询:

SELECT query_id, query, user, round(memory_usage / 1024 / 1024 / 1024, 2) AS mem_gb, elapsed, read_rows, read_bytes FROM system.processes WHERE query != '' ORDER BY memory_usage DESC;

我见过不少案例,运行中的查询只有两三个,但每个都吃了20多GB,而max_memory_usage设成了100GB,全局追踪器又没设硬顶,结果就是机器物理内存直接被打穿。这种情况根因就是“参数给了太多并发空间”,后面我们会专门展开。

3.3 第三步:查历史峰值,找模式而不是个例

如果当前进程已经重启,system.processes查不到什么东西了,那就得翻历史账。在我这边,system.query_log几乎是排查内存问题的核心数据源。

SELECT toStartOfHour(event_time) AS hour, count() AS queries, max(peak_memory_usage) AS max_peak_mem FROM system.query_log WHERE event_time > now() - interval 3 day GROUP BY hour ORDER BY hour;

还可以进一步定位是哪类查询吃内存:

SELECT query, round(max(peak_memory_usage) / 1024 / 1024 / 1024, 2) AS max_mem_gb, count() AS cnt FROM system.query_log WHERE event_time > now() - interval 3 day GROUP BY query ORDER BY max_mem_gb DESC LIMIT 20;

这一步的重点是找模式:是某个固定的慢查询每次跑都吃满内存,还是每天某个时间段集中出现内存暴涨,又或者是某种写入模式触发了大量merge导致后台任务吃内存。只有找到模式,后面才能对症下药。

3.4 第四步:检查后台任务,尤其是Merge和副本同步

这是最容易漏掉的一步。很多人会忽略系统表里的后台任务,因为它们不在system.processes里显示为“查询”。

-- 正在进行的后台合并 SELECT database, table, part_id, rows, bytes_on_disk, elapsed, memory_usage FROM system.merges ORDER BY memory_usage DESC;

后台合并任务会触发新part的写入,合并过程中会读取旧part的数据,并创建新part的硬链接。虽然大部分操作是磁盘IO密集型的,但如果合并的任务堆积太多,同时进行的merge数量又很大,内存一样会被推高。而且这些merge任务的内存不挂在任何用户查询上,排查时很容易被漏掉。

同样的道理,副本同步也有影响。在复制表场景里,从节点拉取远端part时,需要解压并校验数据,这些操作也会临时占用内存。如果你用的是ReplicatedMergeTree,建议同步检查一下system.replication_queue里的堆积情况。

3.5 第五步:把多个证据拼起来,形成根因结论

做完上面几步,一般会有好几组数据:整机内存水位、全局追踪器总量、正在运行查询的列表、历史查询峰值、后台merge情况。把这些放在一张表里看,基本就能锁定问题属于哪一类。

我自己的判断矩阵大概是这样的:

现象组合结论方向
MemoryTracking高 + 运行中大查询多单查询或并发查询超卖
MemoryTracking高 + 运行查询很少 + merge堆积后台merge或同步任务吃内存
MemoryTracking不高 + OS OOM固定内存或page cache叠加外部进程
查询报limit exceeded + 全局不高配额参数设置不合理,单查询触顶

4. 高频根因分类与破解方案:别急着加内存

我排查过那么多案例之后总结下来,ClickHouse内存问题翻来覆去就那么几类根因。下面挨个拆解,附带实际能落地的方案。

4.1 大聚合与大排序:让HashTable把内存吃穿

这是最典型的一类。SQL里一个高基数的GROUP BY,比如按用户ID、设备ID这种几千万基数的维度做聚合,哈希表瞬间就膨胀到不可控。同类问题还有大型ORDER BY、大表JOIN、超大IN子查询。

破解思路主要从两个方向走:参数倾斜到磁盘和SQL改写。

参数方面,ClickHouse提供了两个核心开关:

SET max_memory_usage = 20000000000; -- 单查询硬顶20G SET max_bytes_before_external_group_by = 5000000000; -- 聚合哈希表超过5G写磁盘 SET max_bytes_before_external_sort = 5000000000; -- 排序缓冲超过5G写磁盘

这里有一个很重要的理解:max_bytes_before_external_group_by不是限制查询用多少内存,而是触发“外部聚合”的阈值。超过这个值之后,ClickHouse会把哈希表的一部分数据分块刷到磁盘临时文件,等聚合完再合并结果。代价是查询时间大幅变慢,但内存占用被稳稳顶住了。所以在能接受查询变慢的前提下,这两个参数是保命手段。

但我要提醒一句:不要一上来就无脑开启外部聚合。如果查询本来5秒能跑完,开启之后可能变成5分钟,这种体验用户是忍不了的。正确的做法是先看查询慢在哪,能不能通过加过滤条件缩小扫描范围,或者把大查询拆成小批跑。参数永远只是兜底方案。

4.2 并发超卖:每个查询都不大,加起来却爆了

这是最隐蔽的一类。单看任何一条查询都不超过max_memory_usage,但如果同一时间有几十条查询并发执行,每个查询各自吃到一半上限,总量就远远超过了进程物理内存上限。

很多人以为设置好单查询限制就万事大吉,但ClickHouse的内存资源模型里,并发数是和内存配额并行的维度。一个查询限20G,并发10个就是200G,你物理机才128G,不爆才怪。

破解方案分三层:

第一层,限制并发上限。在config.xml里设置:

<max_concurrent_queries>30</max_concurrent_queries>

在用户配置层面也可以给特定用户设置配额,登录配置里加:

<profiles> <default> <max_memory_usage_for_user>40000000000</max_memory_usage_for_user> </default> </profiles>

这个参数限定单个用户所有并发查询的内存总和上限,比只限单查询更实用。

第二层,限流。ClickHouse本身对并发控制的能力相对有限,建议在前面加一层查询网关或代理(如自研代理、基于负载的接入层排队),在SQL入口做用户级限流和排队。我之前就是直接改接入层,把单用户并发超过5条时新查询排队,效果立竿见影。

第三层,超时熔断。给查询加上max_execution_time和内存熔断,比如:

SET max_execution_time = 300; SET timeout_overflow_mode = throw;

让异常查询尽快死掉,不要一直挂在内存里占着配额不释放。

4.3 Merge与Replication:后台任务里藏着的隐形炸弹

正常情况下,MergeTree引擎的后台合并是磁盘密集型任务,内存占用不大。但有几个场景会突然让merge变成内存杀手。

第一种是大量小分区集中写入。如果你用程序高频往表里插数据,每个批次都生成一个分区,后台就会积压大量的merge任务。ClickHouse会同时跑多个merge来追赶进度,每个merge虽然内存占用不大,但多路叠加也很可观。

第二种是大分区合并。一个巨型分区(比如几十GB)在做合并时,需要读取旧part的数据写入新part,数据经过压缩和解压会占用额外的临时内存,这种场景下内存曲线会有一个明显的尖峰。

第三种是副本恢复或新加副本。新增一个副本节点时,从远端拉取所有part的数据,同时本地还在跑常规合并,内存和IO都会冲到高位。

对应的解法如下:

  • 控制写入频率和批次大小,尽量用小批量、大批次的方式写入,减少生成过多小分区。
  • 在配置里限制后台合并的并发度:
<background_pool_size>8</background_pool_size>

把它控制在一个既能追上写入速度又不至于吃满内存的范围,比如8到16之间,具体看机器核数。

  • 新加副本或恢复副本时,可以选择在业务低峰期操作,或者暂时调低后台池大小,等初始同步完成后再调回来。
  • 如果merge任务堆积实在严重,还有一招“暂停写入”的操作:先把写入端停掉一段时间,让merge队尾追上来,再恢复写入。这个操作在上规模业务上线初期很有用,因为那时的分区策略通常还没优化到位。

4.4 Dictionary与Cache:恒定内存比你想象的大

字典是最容易被忽略的“恒定内存”。很多业务会拿ClickHouse做维表关联,用flat类型加载一个几亿行的用户字典,这种字典在启动时就会一次性载入内存,完全走的是进程内RAM。如果你同时加载了多个这样的字典,光这部分的固定开销就可能超过20GB。

检查字典内存的SQL:

SELECT name, type, round(bytes_allocated / 1024 / 1024 / 1024, 2) AS mem_gb, source FROM system.dictionaries;

如果发现字典占用过高,首先要考虑字典数据源设计是否合理,能不能用complex_key_hashed或sparse_hashed这种更省内存的结构;其次可以考虑把字典改成cache类型,只缓存命中的key,但代价是查询速度下降。对于超大数据集的维表,更稳妥的做法是把它做成普通MergeTree表,用JOIN或者子查询代替字典关联,虽然写法重一些,但内存完全可控。

再说cache。ClickHouse的mark_cache_size和uncompressed_cache_size这两个参数,如果设置得过大,会固定占用大量内存。尤其在使用分布式大表查询时,mark缓存吃满是很常见的事情。我建议这两个值不要拍脑袋设大,按表分片数和常见查询模式来估算,比如mark缓存够覆盖热表每天扫描的granule数量就好。初期可以设小一点,观察缓存命中率曲线再慢慢调整。

4.5 内存碎片与内存分配器:长期运行后莫名变慢

还有一种情况比较烧脑:服务器没OOM,查询也没超限,但运行几个星期之后,ClickHouse对内存的申请开始频繁变慢,甚至出现Cannot allocate memory。这种问题往往和内存碎片化有关系。

ClickHouse默认使用jemalloc作为内存分配器,它的特点是响应快、碎片少,但在极端场景下也会产生内存碎片,尤其是频繁申请释放超大块内存时。长期运行后,碎片会占用很多虚拟内存,虽然不一定会转化为实际物理内存占用,但会导致大块连续内存申请失败。

我实践中比较有效的处理方式有几个:

  • 在维护窗口内重启ClickHouse节点,让内存重新整合。重启前确认有副本兜底,或者选择一个业务低峰期,因为ClickHouse重启后需要重新加载元数据并恢复副本队列,通常会有几分钟的不可用窗口。
  • 通过环境变量给jemalloc开启后台回收线程:
MALLOC_CONF=background_thread:true,dirty_decay_ms:5000,metadata_thp:auto

配置方式是在/etc/clickhouse-server/clickhouse-server的环境变量文件里加,不同版本文件位置略有差异,本质是把MALLOC_CONF传给启动进程。

这种方法能让内存脏页更及时地归还给操作系统,降低碎片化程度。实测在长期运行的内存型实例上效果明显,建议新集群直接开上。

5. 把“不爆内存”变成常态:参数预算、监控与日常预案

5.1 一套我常用的内存预算分配表

排查解决完现有问题之后,接下来要做的是预防,也就是给ClickHouse规划一张“内存资产负债表”。我一般按这个思路给生产集群分配:

假设物理机内存是64GB:

项目分配量说明
操作系统与内核4GB固定预留
Page Cache期望8GB磁盘读取加速,可回收
ClickHouse恒定内存8GB字典、缓存、内部元数据
ClickHouse查询弹性上限40GBserver级硬顶,即最大可用
突发缓冲与后台任务4GBMerge峰值、突发内存申请

落到配置上,就是在config.xml里设置:

<max_server_memory_usage>40000000000</max_server_memory_usage>

等于给ClickHouse进程设了40GB的硬顶。然后单查询max_memory_usage按弹性上限的一半左右来设,也就是20GB。这样即使同时跑了几个大查询,总账也不会超出服务器能承受的范围。

这里有个经验之谈:不要试图把物理内存的95%都分配给ClickHouse。很多人觉得内存不用白不用,但实际上数据库内存长期处于高位会带来连锁反应:页面回收频繁、系统响应变慢、OOM阈值邻近。留出20%左右的安全垫,稳定性会好很多。

5.2 一套有用的内存监控SQL与告警

预防的第二个关键是监控。我整理了一套日常巡检用的SQL,可以直接抄下来定时跑:

当前整体水位:

SELECT value / 1024 / 1024 / 1024 AS mem_gb FROM system.asynchronous_metrics WHERE metric = 'MemoryTracking';

单节点峰值记录,写入system.query_log后可以按天统计:

SELECT toDate(event_time) AS day, max(peak_memory_usage) / 1024 / 1024 / 1024 AS peak_gb, argMax(query_id, peak_memory_usage) AS max_query_id FROM system.query_log WHERE event_time > now() - interval 14 day GROUP BY day;

运维侧我建议把ClickHouse的system.asynchronous_metrics和OS层面的内存使用量(node_memory_MemAvailable_bytes)同时接入Prometheus,告警规则设两条:

  • MemoryTracking> 机器总内存的80% 持续5分钟,触发P1告警。
  • MemAvailable< 机器总内存的10% 持续3分钟,触发P0告警。

另外强烈建议给system.query_log做好定期清理和归档,甚至通过TTL机制自动淘汰旧数据。否则它在本地磁盘上越积越大,有一天可能反过来成为磁盘问题。

5.3 零成本的小技巧与日常预案

最后分享几个排查和处理过程中学到的小技巧,都是在真实环境里验证过的。

第一,临时验证参数不需要重启。如果你想验证某个内存参数能否解决问题,直接用SQL客户端执行SET max_memory_usage = xxx,这个只对当前会话生效,不改变全局配置,非常适合做A/B验证。等确认有效后再改配置文件,否则每次改配置重启都是在赌。

第二,查询前先看计划。利用EXPLAIN可以直观看到聚合、排序、JOIN的层级结构,判断哪一步会吃内存。比如EXPLAIN PIPELINE能看到聚合步骤使用的算法,以及是否走了外部聚合。

第三,分区策略是对内存问题的预防。如果表经常需要扫描最近一天的数据,你应该按天分区并在查询条件里带上分区键,这样扫描的数据量大幅下降,聚合哈希表自然不会膨胀。很多内存问题本质上是查询写得太粗暴,底层引擎再怎么优化也挡不住全表扫描。

第四,削峰最好的手段是错峰执行。把定时任务、ETL作业、报表查询错开高峰期,比任何参数调整都有效。内存是瞬时资源,高峰叠加就会出问题,错峰后同一时间只有一个重查询,就不会爆。

处理过这么多ClickHouse内存事故之后,我的最大体会是:内存问题就像漏水,修好了一个孔,如果不把整个管线检查一遍,迟早会从另一个孔再漏。而且不要迷信“加大内存”这个方案,很多时候你把内存加倍了,问题反而变得更隐蔽,因为它把真正的缺陷拖后了。排查的核心永远是把账本理清楚:谁在持有、持有多大、能否释放、周期多长。数据清楚了,解决方案自然就出来了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询