ClickHouse内存排查实战:从OOM根因到MemoryTracker调优 说实话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_queries30/max_concurrent_queries在用户配置层面也可以给特定用户设置配额登录配置里加profiles default max_memory_usage_for_user40000000000/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_size8/background_pool_size把它控制在一个既能追上写入速度又不至于吃满内存的范围比如8到16之间具体看机器核数。新加副本或恢复副本时可以选择在业务低峰期操作或者暂时调低后台池大小等初始同步完成后再调回来。如果merge任务堆积实在严重还有一招“暂停写入”的操作先把写入端停掉一段时间让merge队尾追上来再恢复写入。这个操作在上规模业务上线初期很有用因为那时的分区策略通常还没优化到位。4.4 Dictionary与Cache恒定内存比你想象的大字典是最容易被忽略的“恒定内存”。很多业务会拿ClickHouse做维表关联用flat类型加载一个几亿行的用户字典这种字典在启动时就会一次性载入内存完全走的是进程内RAM。如果你同时加载了多个这样的字典光这部分的固定开销就可能超过20GB。检查字典内存的SQLSELECT 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_CONFbackground_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_usage40000000000/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内存事故之后我的最大体会是内存问题就像漏水修好了一个孔如果不把整个管线检查一遍迟早会从另一个孔再漏。而且不要迷信“加大内存”这个方案很多时候你把内存加倍了问题反而变得更隐蔽因为它把真正的缺陷拖后了。排查的核心永远是把账本理清楚谁在持有、持有多大、能否释放、周期多长。数据清楚了解决方案自然就出来了。