ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

ClickHouse 内存爆了?一次增量 SQL 从 8.6 GiB 干到 115 MiB 的实战复盘

ClickHouse 内存爆了?一次增量 SQL 从 8.6 GiB 干到 115 MiB 的实战复盘 摘要ClickHouse 增量更新维表报 Code:241 内存超限只更新 2500 个用户却扫描 5600 万行、峰值内存 8.6 GiB。用 EXPLAIN PIPELINE 看到 FillingRightJoinSide根因是 LEFT JOIN 的右表全量加载进 Hash Table。三轮优化右表全包子查询加 IN 过滤、用 argMaxIf/countIf 把三次 membership 扫描合并为一次、主表也加 IN 与 WHERE 下推峰值内存降约 98%读取量与耗时同步大降。只更新了 2500 个用户却扫描了 5600 万行数据这不是 Bug这是用大炮打蚊子。一、问题现象早上打开调度平台一片红——好几个任务齐刷刷报错场面堪比双十一零点的服务器监控面板。点开其中一个任务的报错节点日志关键信息如下Code: 241. DB::Exception: (total) memory limit exceeded: would use 27.59 GiB (attempt to allocate chunk of 4.05 MiB bytes), current RSS: 9.06 GiB, maximum: 27.58 GiB.就差0.01 GiB好家伙差一口气没喘上来。 报错里的current RSS指Resident Set Size——进程当前真正占着物理内存的那部分top里的RES列就是它。这里是 9.06 GiB。机器配置是4C 32GClickHouse 内存上限设的 ~27.58 GiB查询试图用 27.59 GiB —— 完美地踩线翻车。而且这个任务有个诡异的特点重启 ClickHouse 后同样的 SQL 又能跑成功了。这到底是内存泄漏还是查询本身的问题二、排查现场1. 定位报错 SQL从调度日志中提取出实际执行的 SQL省略了 SELECT 字段列表INSERTINTOdim.dim_user_profile(...)SELECT...FROMdw.dw_user_infoASuiJOINtmp.tmp_changed_users_202603160940ASt1ONui.user_idt1.user_idLEFTJOINdw.dw_user_ext_infoASueiONui.user_iduei.user_idLEFTJOINdw.dw_device_infoASdiONui.user_iddi.binduser_idLEFTJOIN(SELECTuser_id,toDateTime(argMax(start_time,tuple(update_time,id)),Asia/Shanghai)ASstart_time,toDateTime(argMax(end_time,tuple(update_time,id)),Asia/Shanghai)ASend_timeFROMdw.dw_user_membershipWHEREcategory1GROUPBYuser_id)ASum_clubONui.user_idum_club.user_idLEFTJOIN(SELECTuser_id,toDateTime(argMax(start_time,tuple(update_time,id)),Asia/Shanghai)ASstart_time,toDateTime(argMax(end_time,tuple(update_time,id)),Asia/Shanghai)ASend_timeFROMdw.dw_user_membershipWHEREcategoryIN(2,3,4)GROUPBYuser_id)ASum_vipONui.user_idum_vip.user_idLEFTJOIN(SELECTuser_id,toUInt8(if(argMax(source_type,tuple(start_time,update_time,id))IN(1,2),0,1))ASis_current_vip_giftFROMdw.dw_user_membershipWHEREcategoryIN(2,3,4)ANDis_active1ANDnow()BETWEENstart_timeANDend_timeGROUPBYuser_id)ASum_vip_giftONui.user_idum_vip_gift.user_idLEFTJOIN(SELECTuser_id,...FROMdw.dw_user_key_nodeGROUPBYuser_id)ASuknONui.user_idukn.user_idWHEREui.user_type12. 理解业务背景这条 SQL 属于一个用户维表增量更新工作流分两步找出变更用户→ 写入临时表tmp_changed_users本次约 2,500 行基于临时表组装完整用户画像→ 写入维表就是上面报错的 SQL3. 用 EXPLAIN 分析执行计划先看预估读取量EXPLAIN ESTIMATE数据库表parts行数marksdwdw_user_key_node107,142,208900dwdw_user_info15810,582,40241,429dwdw_device_info24212,540,5911,727dwdw_user_ext_info23410,611,23241,603dwdw_user_membership32815,512,3982,119tmptmp_changed_users_…12,5011看到了吗临时表只有2,501 行但其他表全是千万级别。这就是问题的味道。把这张表加起来是5639 万行和下面query_log实测的5642 万几乎严丝合缝——说明这次EXPLAIN ESTIMATE估得很准。记住这个「准」第二轮优化后它就不准了见 §4.2 末尾。再看执行管道EXPLAIN PIPELINE太长了只摘关键部分(Join) JoiningTransform × 4 2 → 1 ... FillingRightJoinSide ← 关键RIGHT 侧被全量加载到内存 ... MergeTreeSelect(pool: ReadPool, algorithm: Thread) × 4每个FillingRightJoinSide都意味着RIGHT 侧表被完整读入内存构建 Hash Table。千万级的表直接往内存里塞不炸才怪。4. 查看历史执行的真实内存消耗EXPLAIN只能看读取量无法直接得知内存峰值。真正能看内存的是system.query_log中的memory_usage字段memory_usage记录的是该查询在整个生命周期中占用内存的峰值即最高水位线不是结束时的内存也不是平均值。它直接反映了查询最吃内存的那一刻用了多少是判断查询是否会 OOM 的核心指标。SELECTquery_id,formatReadableSize(memory_usage)ASpeak_memory,read_rows,formatReadableSize(read_bytes)ASread_size,query_duration_ms/1000ASduration_secFROMsystem.query_logWHEREtypeIN(QueryFinish,ExceptionWhileProcessing)ANDqueryLIKE%INSERT INTO dim.dim_user_profile%ANDqueryNOTLIKE%QueryFinish%ORDERBYevent_timeDESCLIMIT5;结果peak_memoryread_rowsread_sizeduration_sec8.62 GiB56,420,2639.58 GiB5.9s8.73 GiB56,422,0969.58 GiB8.9s8.63 GiB56,419,5889.58 GiB5.9s8.62 GiB56,419,4459.58 GiB5.9s8.64 GiB56,416,7079.58 GiB10.9s稳定在~8.6 GiB 峰值内存读取5600 万行。三、原因分析1. 为什么更新 2500 人却要扫 5600 万行虽然主表通过JOIN tmp_changed_users缩小到了 2500 行但LEFT JOIN 的 RIGHT 侧表完全没有过滤dw_user_ext_info1061 万行全量加载到 Hash Tabledw_device_info1254 万行全量加载到 Hash Tabledw_user_membership被3 个子查询各扫一遍um_club、um_vip、um_vip_gift上面EXPLAIN ESTIMATE里那1551 万行就是这三次的合计dw_user_key_node714 万行全表 GROUP BY用一句话总结为了找 2500 个人的信息把整个城市的户籍档案从头翻到尾翻了好几遍。2. 为什么重启 ClickHouse 后又能跑这不是内存泄漏而是安全余量不够。查询本身需要 ~8.6 GiBClickHouse 总内存上限 27.58 GiB。刚启动时缓存几乎为空剩余内存充足查询能跑过去。但运行一段时间后mark cache、uncompressed cache、后台 merge 任务等逐渐占用内存剩余空间不够 8.6 GiB 时就会 OOM。本质是查询太胖了不是 ClickHouse 有毛病。四、优化方案1. 第一轮子查询加 IN 过滤效果有限最直觉的想法在子查询里加WHERE user_id IN (SELECT user_id FROM tmp_changed_users)让聚合只处理变更用户的数据。LEFTJOIN(SELECTuser_id,...FROMdw.dw_user_membershipWHEREcategory1ANDuser_idIN(SELECTuser_idFROMtmp.tmp_changed_users_...)-- 新增GROUPBYuser_id)ASum_clubONui.user_idum_club.user_id-- um_vip、um_vip_gift、ukn 同理跑完看 query_logpeak_memoryread_rowsread_size备注7.91 GiB50,399,2689.17 GiB优化后8.62 GiB56,420,2639.58 GiB优化前从 8.6 GiB → 7.9 GiB少了一点点聊胜于无。为什么效果不明显IN 过滤对子查询um_club、um_vip等确实生效了但真正的内存大户是直接 LEFT JOIN 的大表dw_user_ext_info、dw_device_info它们没有任何过滤条件通过FillingRightJoinSide被全量加载到 Hash Table。只优化了几个配菜主菜纹丝未动。2. 第二轮全面优化釜底抽薪两个核心改动改动 A所有 RIGHT 侧 JOIN 都包子查询 IN 过滤不只是聚合子查询直接表 JOIN 也要包一层-- 优化前全表 1061 万行加载到 Hash TableLEFTJOINdw.dw_user_ext_infoASueiONui.user_iduei.user_id-- 优化后只加载 2500 行到 Hash TableLEFTJOIN(SELECT*FROMdw.dw_user_ext_infoWHEREuser_idIN(SELECTuser_idFROMtmp.tmp_changed_users_...))ASueiONui.user_iduei.user_iddw_device_info同理。改动 B合并 3 次dw_user_membership扫描为 1 次用argMaxIf/countIf条件聚合一次扫描提取所有需要的信息LEFTJOIN(SELECTuser_id,-- Club 会员toDateTime(argMaxIf(start_time,tuple(update_time,id),category1),Asia/Shanghai)ASclub_start_time,toDateTime(argMaxIf(end_time,tuple(update_time,id),category1),Asia/Shanghai)ASclub_end_time,-- VIP 会员toDateTime(argMaxIf(start_time,tuple(update_time,id),categoryIN(2,3,4)),Asia/Shanghai)ASvip_start_time,toDateTime(argMaxIf(end_time,tuple(update_time,id),categoryIN(2,3,4)),Asia/Shanghai)ASvip_end_time,-- 当前 VIP 是否为赠送if(countIf(categoryIN(2,3,4)ANDis_active1ANDnow()BETWEENstart_timeANDend_time)0,toUInt8(if(argMaxIf(source_type,tuple(start_time,update_time,id),categoryIN(2,3,4)ANDis_active1ANDnow()BETWEENstart_timeANDend_time)IN(1,2),0,1)),NULL)ASis_current_vip_giftFROMdw.dw_user_membershipWHEREuser_idIN(SELECTuser_idFROMtmp.tmp_changed_users_...)GROUPBYuser_id)ASumONui.user_idum.user_id一次扫描三个结果优雅。改动部署后先看预估读取量的变化数据库表parts行数marksdwdw_user_key_node107,103,210893dwdw_user_info15110,588,13341,444dwdw_user_membership561,756,307246dwdw_device_info1364,384,704653dwdw_user_ext_info1721,885,5917,457tmptmp_changed_users_…24,8742对比优化前dw_user_membership从 1551 万 → 175 万合并 3 次为 1 次 IN 过滤dw_user_ext_info从 1061 万 → 188 万dw_device_info从 1254 万 → 438 万。预估读取量大幅下降。再看实际执行的 query_log最近 2 条为第二轮优化后后 3 条为第一轮优化peak_memoryread_rowsread_sizeduration_sec备注115.09 MiB44,168,1295.05 GiB1.7s第二轮优化134.17 MiB44,947,1675.10 GiB1.7s第二轮优化7.89 GiB49,971,9749.15 GiB4.3s第一轮优化7.91 GiB49,972,1659.15 GiB7.1s第一轮优化7.88 GiB49,971,3729.15 GiB4.4s第一轮优化从 7.9 GiB 直接干到 115 / 134 MiB两次执行降了约 98.5%。这才叫优化前面那轮只能叫意思意思。⚠️顺带看一眼 ESTIMATE 和实测的裂口上面那张预估表加起来才2572 万行而实际跑出来读了4417 万——差了 1.7 倍。而优化前那次两者只差 2.9 万5639 万 vs 5642 万。原因是IN (子查询)让优化器没法准确预估命中量。这正是 §4.3 那句「ESTIMATE 是预估、query_log 才是真相」的第一个实例下一轮还会再撞一次。你可能注意到 read_rows 还有 4400 万为什么内存却降了这么多因为read_rows统计的是所有表扫描过的行数dw_user_info本身就有 1000 万行作为主表必须扫但内存峰值取决于 Hash Table 的大小——RIGHT 侧从千万行压到几千行Hash Table 自然从 GiB 级降到了 MiB 级。扫描多不可怕往内存里塞多才可怕。把前后两版放一起看这条路径就很清楚了3. 第三轮主表也加 IN 过滤意外收获到这里所有 RIGHT 侧的表都优化过了但 LEFT 侧的主表dw_user_info1000 万行还在全量扫描。虽然它是流式处理不吃内存但能不能也减少一下读取量-- 优化前主表全量扫描 1000 万行FROMdw.dw_user_infoASuiJOINtmp.tmp_changed_users_...ASt1ONui.user_idt1.user_id...WHEREui.user_type1-- 优化后主表也包子查询 IN 过滤 WHERE 下推FROM(SELECT*FROMdw.dw_user_infoWHEREuser_type1ANDuser_idIN(SELECTuser_idFROMtmp.tmp_changed_users_...))ASuiJOINtmp.tmp_changed_users_...ASt1ONui.user_idt1.user_id这里有个小插曲EXPLAIN ESTIMATE显示改动后其他表的预估读取量反而上升了差点让我回滚。但实际跑出来的 query_log 数据peak_memoryread_sizeduration_sec备注166.60 MiB1.42 GiB1.05s第三轮优化137.48 MiB1.33 GiB0.90s第三轮优化161.55 MiB1.42 GiB1.05s第三轮优化143.50 MiB1.34 GiB2.03s第三轮优化160.70 MiB5.14 GiB1.78s第二轮优化137.16 MiB5.10 GiB1.86s第二轮优化read_size 从 5.1 GiB 降到 1.4 GiB又砍掉了 73%。peak_memory 基本持平duration 略有改善。教训EXPLAIN ESTIMATE是预估system.query_log才是真相。优化效果好不好永远以实际执行数据为准。4. 最终效果对比指标优化前第一轮第二轮第三轮peak_memory~8.6 GiB~7.9 GiB115~134 MiB137~167 MiBread_size9.58 GiB9.15 GiB5.05 GiB1.4 GiBduration~6s~4s~1.7s~1s为什么第三轮内存反而比第二轮高不是退步——第三轮拿一点内存换了读取量read_size 从 5.1 GiB 砍到 1.4 GiB、耗时再降近一半。标题里的 115 MiB 是第二轮跑出的最低值最终落地在 137~167 MiB。 内存这一行给的是区间每轮多次执行的实测范围不是单次最好成绩——第二轮 2 次分别是 115 / 134 MiB第三轮 4 次在 137~167 MiB 之间波动。别只记最漂亮的那个数。从 8.6 GiB / 9.58 GiB / 6s 到 ~150 MiB / 1.4 GiB / 1s三轮优化下来内存降了98%读取量降了85%耗时降了83%5. 优化原理总结轮次改动效果第一轮子查询加 IN 过滤内存 8.6→7.9 GiB效果有限直接 JOIN 的大表没动第二轮全部 RIGHT 侧包子查询 IN 过滤合并 3 次 membership 扫描为 1 次内存 7.9 GiB→115~134 MiB第三轮主表也包子查询 IN 过滤 WHERE 下推read_size 5.1→1.4 GiB五、补充ClickHouse 内存排查工具箱遇到 ClickHouse 内存问题时这几个工具最常用1. 查看历史查询内存峰值SELECTquery_id,formatReadableSize(memory_usage)ASpeak_memory,read_rows,formatReadableSize(read_bytes)ASread_size,query_duration_ms/1000ASduration_secFROMsystem.query_logWHEREtypeIN(QueryFinish,ExceptionWhileProcessing)ANDqueryLIKE%你的关键词%ORDERBYevent_timeDESCLIMIT10;2. 实时监控正在跑的查询SELECTquery_id,formatReadableSize(memory_usage)AScurrent_memory,formatReadableSize(peak_memory_usage)ASpeak_memory,elapsedFROMsystem.processesWHEREqueryLIKE%你的关键词%;3. 预估查询读取量EXPLAINESTIMATESELECT...;-- 返回各表的 parts、rows、marks帮助判断查询贵不贵4. 应急手动释放缓存治标不治本SYSTEMDROPMARK CACHE;SYSTEMDROPUNCOMPRESSED CACHE;SYSTEMDROPCOMPILED EXPRESSION CACHE;⚠️ 这只是给 ClickHouse “续一口气”根本解决方案还是优化查询本身。六、总结阶段做了什么学到了什么发现问题查调度日志定位报错 SQLCode: 241 内存超限分析原因EXPLAIN query_logFillingRightJoinSide RIGHT 侧全量入内存第一轮优化子查询加 IN 过滤只改子查询不够直接 JOIN 的大表才是大头第二轮优化全部 RIGHT 侧加过滤 合并重复扫描内存 8.6 GiB → 115~134 MiB第三轮优化主表包子查询 IN 过滤 WHERE 下推read_size 5.1 GiB → 1.4 GiB核心教训增量更新场景中不要只卡住主表的数据量LEFT JOIN 的每一张右表都要瘦身。否则你以为在做增量其实 ClickHouse 在做全量。同一张表被多个子查询扫描时优先考虑用条件聚合argMaxIf、countIf合并为一次扫描。EXPLAIN PIPELINE里看到FillingRightJoinSide就要警觉 —— 它意味着 RIGHT 侧被全量加载到内存。EXPLAIN ESTIMATE是预估system.query_log才是真相——优化效果好不好永远以实际执行数据说话。七、留个思考题本文的优化是针对增量场景——有临时表做 IN 过滤把右表数据量从千万级压到几千行。但如果换成全量初始化呢没有临时表千万用户全量 JOIN 千万级的扩展表、设备表、会员表……内存照样会爆。这时候你会怎么优化欢迎评论区聊聊你的思路。延伸阅读ClickHouse 内存只涨不降之谜MemoryTracking 20G 但 MemoryResident 才 2.5G内存到底跑哪去了 —— 换个角度看 CK 内存这次不是查询太胖而是计数器漂移在虚报。1800 行 INSERT 跑了 16 分钟ClickHouse 并发洪流踩坑 多账号资源隔离方案 —— 同样用 system.query_log 追凶只是瓶颈从内存换成了并发线程。ClickHouse Flink DolphinScheduler中小厂三件套搞定离线实时数仓告别 Hadoop 全家桶 —— 本文这条维表增量 SQL正是跑在这套数仓的调度链路里。最后更新2026-09-29订正一处会让人算错的表述——§三.1 原写「dw_user_membership1551 万行扫了 3 次」照这么读会算出 4653 万行和实测的 5642 万对不上1551 万本身就是EXPLAIN ESTIMATE里那三次的合计。另把效果对比表的内存换成多次实测区间第二轮 115~134 MiB、第三轮 137~167 MiB原先的单值只是区间里的低点。
返回列表