只更新了 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,好家伙,差一口气没喘上来。

机器配置是 4C 32G,ClickHouse 内存上限设的 ~27.58 GiB,查询试图用 27.59 GiB —— 完美地"踩线翻车"。

而且这个任务有个诡异的特点:重启 ClickHouse 后,同样的 SQL 又能跑成功了。这到底是内存泄漏,还是查询本身的问题?

二、排查现场

1. 定位报错 SQL

从调度日志中提取出实际执行的 SQL(省略了 SELECT 字段列表):

INSERT INTO dim.dim_user_profile (...)
SELECT ...
FROM dw.dw_user_info AS ui
JOIN tmp.tmp_changed_users_202603160940 AS t1
    ON ui.user_id = t1.user_id
LEFT JOIN dw.dw_user_ext_info AS uei
    ON ui.user_id = uei.user_id
LEFT JOIN dw.dw_device_info AS di
    ON ui.user_id = di.binduser_id
LEFT JOIN (
    SELECT user_id,
        toDateTime(argMax(start_time, tuple(update_time, id)), 'Asia/Shanghai') AS start_time,
        toDateTime(argMax(end_time, tuple(update_time, id)), 'Asia/Shanghai') AS end_time
    FROM dw.dw_user_membership
    WHERE category = 1
    GROUP BY user_id
) AS um_club ON ui.user_id = um_club.user_id
LEFT JOIN (
    SELECT user_id,
        toDateTime(argMax(start_time, tuple(update_time, id)), 'Asia/Shanghai') AS start_time,
        toDateTime(argMax(end_time, tuple(update_time, id)), 'Asia/Shanghai') AS end_time
    FROM dw.dw_user_membership
    WHERE category IN (2,3,4)
    GROUP BY user_id
) AS um_vip ON ui.user_id = um_vip.user_id
LEFT JOIN (
    SELECT user_id,
        toUInt8(if(argMax(source_type, tuple(start_time, update_time, id)) IN (1, 2), 0, 1))
            AS is_current_vip_gift
    FROM dw.dw_user_membership
    WHERE category IN (2,3,4)
      AND is_active = 1
      AND now() BETWEEN start_time AND end_time
    GROUP BY user_id
) AS um_vip_gift ON ui.user_id = um_vip_gift.user_id
LEFT JOIN (
    SELECT user_id,
        ...
    FROM dw.dw_user_key_node
    GROUP BY user_id
) AS ukn ON ui.user_id = ukn.user_id
WHERE ui.user_type = 1

2. 理解业务背景

这条 SQL 属于一个用户维表增量更新工作流,分两步:

  1. 找出变更用户 → 写入临时表(tmp_changed_users),本次约 2,500 行
  2. 基于临时表组装完整用户画像 → 写入维表(就是上面报错的 SQL)

3. 用 EXPLAIN 分析执行计划

先看预估读取量(EXPLAIN ESTIMATE):

数据库parts行数marks
dwdw_user_key_node107,142,208900
dwdw_user_info15810,582,40241,429
dwdw_device_info24212,540,5911,727
dwdw_user_ext_info23410,611,23241,603
dwdw_user_membership32815,512,3982,119
tmptmp_changed_users_…12,5011

看到了吗?临时表只有 2,501 行,但其他表全是千万级别。这就是问题的味道。

再看执行管道(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 的核心指标。

SELECT
    query_id,
    formatReadableSize(memory_usage) AS peak_memory,
    read_rows,
    formatReadableSize(read_bytes) AS read_size,
    query_duration_ms / 1000 AS duration_sec
FROM system.query_log
WHERE type IN ('QueryFinish', 'ExceptionWhileProcessing')
  AND query LIKE '%INSERT INTO dim.dim_user_profile%'
  AND query NOT LIKE '%QueryFinish%'
ORDER BY event_time DESC
LIMIT 5;

结果:

peak_memoryread_rowsread_sizeduration_sec
8.62 GiB56,420,2639.58 GiB5.9s
8.73 GiB56,422,0969.58 GiB8.9s
8.63 GiB56,419,5889.58 GiB5.9s
8.62 GiB56,419,4459.58 GiB5.9s
8.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_info:1061 万行,全量加载到 Hash Table
  • dw_device_info:1254 万行,全量加载到 Hash Table
  • dw_user_membership:1551 万行,扫了 3 次(um_club、um_vip、um_vip_gift)
  • dw_user_key_node:714 万行,全表 GROUP BY

用一句话总结:为了找 2500 个人的信息,把整个城市的户籍档案从头翻到尾,翻了好几遍。

2. 为什么重启 ClickHouse 后又能跑?

这不是内存泄漏,而是安全余量不够

查询本身需要 ~8.6 GiB,ClickHouse 总内存上限 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),让聚合只处理变更用户的数据。

LEFT JOIN (
    SELECT user_id, ...
    FROM dw.dw_user_membership
    WHERE category = 1
      AND user_id IN (SELECT user_id FROM tmp.tmp_changed_users_...)  -- 新增
    GROUP BY user_id
) AS um_club ON ui.user_id = um_club.user_id
-- um_vip、um_vip_gift、ukn 同理

跑完看 query_log:

peak_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_clubum_vip 等)确实生效了,但真正的内存大户是 直接 LEFT JOIN 的大表dw_user_ext_infodw_device_info),它们没有任何过滤条件,通过 FillingRightJoinSide 被全量加载到 Hash Table。只优化了"几个配菜",主菜纹丝未动。

2. 第二轮:全面优化(釜底抽薪)

两个核心改动:

改动 A:所有 RIGHT 侧 JOIN 都包子查询 + IN 过滤

不只是聚合子查询,直接表 JOIN 也要包一层:

-- 优化前:全表 1061 万行加载到 Hash Table
LEFT JOIN dw.dw_user_ext_info AS uei
    ON ui.user_id = uei.user_id

-- 优化后:只加载 2500 行到 Hash Table
LEFT JOIN (
    SELECT * FROM dw.dw_user_ext_info
    WHERE user_id IN (SELECT user_id FROM tmp.tmp_changed_users_...)
) AS uei
    ON ui.user_id = uei.user_id

dw_device_info 同理。

改动 B:合并 3 次 dw_user_membership 扫描为 1 次

argMaxIf / countIf 条件聚合,一次扫描提取所有需要的信息:

LEFT JOIN (
    SELECT
        user_id,
        -- Club 会员
        toDateTime(argMaxIf(start_time, tuple(update_time, id), category = 1), 'Asia/Shanghai')
            AS club_start_time,
        toDateTime(argMaxIf(end_time, tuple(update_time, id), category = 1), 'Asia/Shanghai')
            AS club_end_time,
        -- VIP 会员
        toDateTime(argMaxIf(start_time, tuple(update_time, id), category IN (2,3,4)), 'Asia/Shanghai')
            AS vip_start_time,
        toDateTime(argMaxIf(end_time, tuple(update_time, id), category IN (2,3,4)), 'Asia/Shanghai')
            AS vip_end_time,
        -- 当前 VIP 是否为赠送
        if(
            countIf(category IN (2,3,4) AND is_active = 1 AND now() BETWEEN start_time AND end_time) > 0,
            toUInt8(if(argMaxIf(source_type, tuple(start_time, update_time, id),
                category IN (2,3,4) AND is_active = 1 AND now() BETWEEN start_time AND end_time
            ) IN (1, 2), 0, 1)),
            NULL
        ) AS is_current_vip_gift
    FROM dw.dw_user_membership
    WHERE user_id IN (SELECT user_id FROM tmp.tmp_changed_users_...)
    GROUP BY user_id
) AS um ON ui.user_id = um.user_id

一次扫描,三个结果,优雅。

改动部署后,先看预估读取量的变化:

数据库parts行数marks
dwdw_user_key_node107,103,210893
dwdw_user_info15110,588,13341,444
dwdw_user_membership561,756,307246
dwdw_device_info1364,384,704653
dwdw_user_ext_info1721,885,5917,457
tmptmp_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 MiB,降了 98.6%。这才叫优化,前面那轮只能叫"意思意思"。

你可能注意到 read_rows 还有 4400 万,为什么内存却降了这么多?因为 read_rows 统计的是所有表扫描过的行数dw_user_info 本身就有 1000 万行作为主表必须扫),但内存峰值取决于 Hash Table 的大小——RIGHT 侧从千万行压到几千行,Hash Table 自然从 GiB 级降到了 MiB 级。扫描多不可怕,往内存里塞多才可怕。

3. 第三轮:主表也加 IN 过滤(意外收获)

到这里,所有 RIGHT 侧的表都优化过了,但 LEFT 侧的主表 dw_user_info(1000 万行)还在全量扫描。虽然它是流式处理不吃内存,但能不能也减少一下读取量?

-- 优化前:主表全量扫描 1000 万行
FROM dw.dw_user_info AS ui
JOIN tmp.tmp_changed_users_... AS t1 ON ui.user_id = t1.user_id
...
WHERE ui.user_type = 1

-- 优化后:主表也包子查询 + IN 过滤 + WHERE 下推
FROM (
    SELECT * FROM dw.dw_user_info
    WHERE user_type = 1
      AND user_id IN (SELECT user_id FROM tmp.tmp_changed_users_...)
) AS ui
JOIN tmp.tmp_changed_users_... AS t1 ON ui.user_id = t1.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 GiB~115 MiB~140 MiB
read_size9.58 GiB9.15 GiB5.05 GiB1.4 GiB
duration~6s~4s~1.7s~1s

从 8.6 GiB / 9.58 GiB / 6s 到 140 MiB / 1.4 GiB / 1s,三轮优化下来:

  • 内存降了 98.4%
  • 读取量降了 85%
  • 耗时降了 83%

5. 优化原理总结

轮次改动效果
第一轮子查询加 IN 过滤内存 8.6→7.9 GiB(效果有限,直接 JOIN 的大表没动)
第二轮全部 RIGHT 侧包子查询 + IN 过滤;合并 3 次 membership 扫描为 1 次内存 7.9 GiB→115 MiB
第三轮主表也包子查询 + IN 过滤 + WHERE 下推read_size 5.1→1.4 GiB

五、补充:ClickHouse 内存排查工具箱

遇到 ClickHouse 内存问题时,这几个工具最常用:

1. 查看历史查询内存峰值

SELECT
    query_id,
    formatReadableSize(memory_usage) AS peak_memory,
    read_rows,
    formatReadableSize(read_bytes) AS read_size,
    query_duration_ms / 1000 AS duration_sec
FROM system.query_log
WHERE type IN ('QueryFinish', 'ExceptionWhileProcessing')
  AND query LIKE '%你的关键词%'
ORDER BY event_time DESC
LIMIT 10;

2. 实时监控正在跑的查询

SELECT
    query_id,
    formatReadableSize(memory_usage) AS current_memory,
    formatReadableSize(peak_memory_usage) AS peak_memory,
    elapsed
FROM system.processes
WHERE query LIKE '%你的关键词%';

3. 预估查询读取量

EXPLAIN ESTIMATE SELECT ...;
-- 返回各表的 parts、rows、marks,帮助判断查询"贵不贵"

4. 应急:手动释放缓存(治标不治本)

SYSTEM DROP MARK CACHE;
SYSTEM DROP UNCOMPRESSED CACHE;
SYSTEM DROP COMPILED EXPRESSION CACHE;

⚠️ 这只是给 ClickHouse “续一口气”,根本解决方案还是优化查询本身。

六、总结

阶段做了什么学到了什么
发现问题查调度日志,定位报错 SQLCode: 241 = 内存超限
分析原因EXPLAIN + query_logFillingRightJoinSide = RIGHT 侧全量入内存
第一轮优化子查询加 IN 过滤只改子查询不够,直接 JOIN 的大表才是大头
第二轮优化全部 RIGHT 侧加过滤 + 合并重复扫描内存 8.6 GiB → 115 MiB
第三轮优化主表包子查询 + IN 过滤 + WHERE 下推read_size 5.1 GiB → 1.4 GiB

核心教训:

  1. 增量更新场景中,不要只卡住主表的数据量,LEFT JOIN 的每一张右表都要"瘦身"。否则你以为在做增量,其实 ClickHouse 在做全量。
  2. 同一张表被多个子查询扫描时,优先考虑用条件聚合argMaxIfcountIf)合并为一次扫描。
  3. EXPLAIN PIPELINE 里看到 FillingRightJoinSide 就要警觉 —— 它意味着 RIGHT 侧被全量加载到内存。
  4. EXPLAIN ESTIMATE 是预估,system.query_log 才是真相——优化效果好不好,永远以实际执行数据说话。

七、留个思考题

本文的优化是针对增量场景——有临时表做 IN 过滤,把右表数据量从千万级压到几千行。

但如果换成全量初始化呢?没有临时表,千万用户全量 JOIN 千万级的扩展表、设备表、会员表……内存照样会爆。这时候你会怎么优化?

欢迎评论区聊聊你的思路。


如果这篇文章帮你省下了一次"重启 ClickHouse"的操作,点赞收藏走一波 👍 下次 OOM 的时候少走弯路。

更多推荐