同样的 SQL 逻辑,三种写法,在 10 亿条数据下性能差距超过 10 倍。你踩过这个坑吗?
背景:一个让人崩溃的线上问题
生产环境,Flink SQL 任务,日志关联查询,数据量 10 亿+。
任务跑了 40 分钟没出结果,看 TaskManager 日志,卡在一个算子上:
erlang
Shuffle 数据量:238 GB
GC 时间:占比 61%
反压:持续 RED
定位到问题 SQL:
sql
SELECT message
FROM dwd_log_detail
WHERE tid IN (
SELECT DISTINCT tid
FROM dwd_log_detail
WHERE dt = '2026-03-18'
AND container_name = 'payment-service'
AND message LIKE '%TransferStart%'
)
AND dt = '2026-03-18'
AND container_name = 'payment-service'
逻辑没问题,结果也正确,但在分布式引擎下,这是一颗定时炸弹。
改一种写法,任务从 40 分钟降到 4 分钟 ,Shuffle 数据量从 238 GB 降到 19 GB。
本文就来彻底讲清楚:为什么会这样,以及怎么选。
先把结论放在最前面
| 对比维度 | IN + DISTINCT | JOIN + Group By | EXISTS(Semi Join) |
|---|---|---|---|
| 本地预聚合 | ❌ 无 | ✅ 有 | ✅ 有 |
| Shuffle 数据量 | 全量原始数据 | 预聚合后(极小) | 按 tid 分区(小) |
| 是否需要去重 | ✅ 全量去重 | ✅ 预聚合后去重 | ❌ 不需要 |
| 结果膨胀风险 | ❌ 无 | ⚠️ 需 DISTINCT 防膨胀 | ❌ 无 |
| IN 列表广播风险 | ⚠️ 列表过大会 OOM | ❌ 无 | ❌ 无 |
| 大数据量性能 | 💀 最差 | 🟡 中等 | ✅ 最优 |
| 推荐指数 | ⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
一、IN + DISTINCT:看起来最直觉,实际最危险
SQL 写法
sql
SELECT message
FROM dwd_log_detail a
WHERE tid IN (
SELECT DISTINCT tid
FROM dwd_log_detail
WHERE dt = '2026-03-18'
AND container_name = 'payment-service'
AND message LIKE '%TransferStart%'
)
AND dt = '2026-03-18'
AND container_name = 'payment-service'
分布式执行原理
在 Flink SQL / Spark SQL 中,这条语句会被翻译成以下执行链路:
css
┌─────────────────────────────────────────────────────────┐
│ 阶段一:子查询各节点本地扫描(无预聚合) │
│ │
│ 节点1 扫描结果: │
│ [T1, T1, T1, T2, T2, ...] ← 含大量重复,原样输出 │
│ │
│ 节点2 扫描结果: │
│ [T2, T3, T3, T1, T2, ...] ← 同上,原样输出 │
└─────────────────┬───────────────────────────────────────┘
│
│ ⚠️ 全量原始数据 Shuffle(最贵的一步)
│ 数据量 = 满足 LIKE 条件的所有原始行
▼
┌─────────────────────────────────────────────────────────┐
│ 阶段二:全局 DISTINCT 去重 │
│ │
│ [T1,T1,T1,T2,T2,T3,T3,...] → 去重 → [T1, T2, T3] │
│ │
│ 去重方式:哈希聚合 或 排序聚合,内存压力极大 │
└─────────────────┬───────────────────────────────────────┘
│
│ ⚠️ IN 列表广播到所有节点
│ 列表大时(几十万个 tid)广播本身就是瓶颈
▼
┌─────────────────────────────────────────────────────────┐
│ 阶段三:外层各节点扫描主表,逐行匹配 IN 列表 │
│ │
│ 每行 tid → 在 IN 列表中查找 → 匹配则输出 │
└─────────────────────────────────────────────────────────┘
两个致命问题
问题一:Shuffle 的是未压缩的原始数据。
假设满足 LIKE 条件的日志有 5000 万条,每条平均 500 字节,那么 Shuffle 数据量高达:
yaml
5000 万 × 500 字节 = 25 GB 网络传输
而这 5000 万条去重后可能只有 10 万个不同的 tid,真正有价值的数据量只有:
10 万 × 几十字节 = 几 MB
你在用 25 GB 的传输量,换几 MB 的结果。
问题二:IN 列表过大会直接 OOM。
Flink/Spark 在执行 IN (子查询) 时,默认会尝试把子查询结果广播到所有节点。当 tid 去重后有几十万甚至上百万个时,这份广播数据会撑爆 TaskManager 内存,触发 OOM 或任务重启。
Flink SQL EXPLAIN 示例
sql
EXPLAIN SELECT message FROM dwd_log_detail
WHERE tid IN (SELECT DISTINCT tid FROM dwd_log_detail WHERE ...)
scss
== Physical Plan ==
HashJoin(joinType=[LeftSemiJoin], ...) ← 优化器尝试转换
TableScan(dwd_log_detail)
Exchange(distribution=[broadcast]) ← ⚠️ 广播子查询结果
Aggregate(group=[tid]) ← DISTINCT 的实现
TableScan(dwd_log_detail)
Filter(...)
注意
Exchange(distribution=[broadcast]),当子查询结果集超过广播阈值(Flink 默认 1 MB,Spark 默认 10 MB),优化器会降级为 Shuffle Hash Join,数据量直接爆炸。
二、JOIN + Group By:工程上常用,理解清楚才能用好
SQL 写法
sql
SELECT a.message
FROM dwd_log_detail a
INNER JOIN (
SELECT tid
FROM dwd_log_detail
WHERE dt = '2026-03-18'
AND container_name = 'payment-service'
AND message LIKE '%TransferStart%'
Group By
) b ON a.tid = b.tid
WHERE a.dt = '2026-03-18'
AND a.container_name = 'payment-service'
分布式执行原理
JOIN 方式最大的改进在于本地预聚合,执行链路如下:
css
┌─────────────────────────────────────────────────────────┐
│ 阶段一:子查询各节点本地扫描 + 本地预聚合 │
│ │
│ 节点1: │
│ 原始:[T1,T1,T1,T2,T2] → 本地 GROUP BY → [T1,T2] │
│ │
│ 节点2: │
│ 原始:[T2,T3,T3,T1] → 本地 GROUP BY → [T1,T2,T3]│
│ │
│ ✅ 数据量已大幅压缩,从千万行变成几万行 │
└─────────────────┬───────────────────────────────────────┘
│
│ ✅ Shuffle 的是预聚合后的小数据
│ 数据量 = tid 的 distinct count × 几十字节
▼
┌─────────────────────────────────────────────────────────┐
│ 阶段二:全局聚合去重,构建小表 b │
│ │
│ [T1,T2,T1,T2,T3] → 全局去重 → b 表 = [T1, T2, T3] │
└─────────────────┬───────────────────────────────────────┘
│
│ 优化器根据 b 表大小自动选择 Join 策略
▼
┌─────────────────────────────────────────────────────────┐
│ 阶段三:Join 执行 │
│ │
│ b 表小(< 广播阈值)→ Broadcast Hash Join │
│ b 表广播到每个节点,主表本地探测,无需 Shuffle ✅ │
│ │
│ b 表大(> 广播阈值)→ Shuffle Hash Join │
│ 主表和 b 表都按 tid 分区 Shuffle,各分区本地 Join │
└─────────────────────────────────────────────────────────┘
和 IN + DISTINCT 的核心差异
| 对比点 | IN + DISTINCT | JOIN + Group By |
|---|---|---|
| Shuffle 内容 | 全量原始行 | 预聚合后的 tid(极小) |
| Shuffle 数据量(举例) | 25 GB | 约 5 MB |
| Join 策略 | 只能广播(超限则降级) | 广播 or 分区 Shuffle,自动选择 |
本地预聚合是 JOIN 方式碾压 IN + DISTINCT 的核心原因。
⚠️ 使用 JOIN 必须注意的坑:结果膨胀
如果子查询没有去重,JOIN 会产生笛卡尔积:
sql
-- ❌ 危险写法:忘记 DISTINCT
SELECT a.message
FROM dwd_log_detail a
INNER JOIN (
SELECT tid -- 没有 DISTINCT!
FROM dwd_log_detail
WHERE message LIKE '%TransferStart%'
) b ON a.tid = b.tid
假设 T1 有 3 条含关键字的日志,主表 T1 有 100 条日志,JOIN 结果会膨胀到:
ini
100 × 3 = 300 条(期望是 100 条)
所以 JOIN 方式一定要加 DISTINCT,这不是可选项。
三、EXISTS(推荐):原理最干净,性能最优
SQL 写法
sql
SELECT a.message
FROM dwd_log_detail a
WHERE a.dt = '2026-03-18'
AND a.container_name = 'payment-service'
AND EXISTS (
SELECT 1
FROM dwd_log_detail b
WHERE b.tid = a.tid
AND b.dt = '2026-03-18'
AND b.container_name = 'payment-service'
AND b.message LIKE '%TransferStart%'
)
分布式执行原理:Left Semi Join
这是 EXISTS 和前两种方式最本质的差异 ------在分布式引擎中,EXISTS 会被优化器直接翻译成 Left Semi Join,而不是先生成结果集再过滤。
css
┌─────────────────────────────────────────────────────────┐
│ 优化器将 EXISTS 转换为 Left Semi Join │
│ │
│ 主表 a(全量日志) 子表 b(含关键字的日志) │
│ ↓ ↓ │
│ 按 tid 分区 Shuffle 按 tid 分区 Shuffle │
│ ↓ ↓ │
│ └──────── 同分区节点本地执行 Semi Join ────┘ │
│ ↓ │
│ Semi Join 规则: │
│ a 的某行 tid,只要在 b 中找到≥1条匹配 → 输出 a 这行 │
│ 找到第一条即停止,不继续扫描 b 的剩余行 │
│ 天然不会产生重复,不需要去重 │
└─────────────────────────────────────────────────────────┘
优化器不一定选 Left Semi Join
不同引擎、不同版本、不同数据分布,EXISTS 可能被翻译成: 物理算子触发条件Left Semi Join(Nested Loop)小数据量,内层有索引Left Semi Join(Hash)大数据量,分布式场景主流选择Left Semi Join(Broadcast)子表足够小,可以广播普通 Filter + Subquery优化器没有识别出可转换的模式
Semi Join 和普通 Join 的区别
css
普通 INNER JOIN:
a 的 1 行 × b 的 N 条匹配 → 输出 N 行(结果膨胀)
Left Semi Join:
a 的 1 行,b 有≥1条匹配 → 输出 a 这 1 行(不膨胀)
a 的 1 行,b 有 0 条匹配 → 不输出
结论:Semi Join 天然等价于 EXISTS 语义,
天然不需要去重,天然不会膨胀。
Flink SQL EXPLAIN 示例
sql
EXPLAIN SELECT a.message FROM dwd_log_detail a
WHERE EXISTS (SELECT 1 FROM dwd_log_detail b WHERE b.tid = a.tid AND ...)
scss
== Physical Plan ==
HashJoin(joinType=[LeftSemiJoin], ...) ← ✅ 直接 Semi Join
Exchange(distribution=[hash[tid]]) ← 按 tid 分区
TableScan(dwd_log_detail)
Exchange(distribution=[hash[tid]]) ← 按 tid 分区
TableScan(dwd_log_detail)
Filter(...)
关键点:没有 Aggregate 算子,没有去重步骤,直接 Semi Join。
对比 IN + DISTINCT 的执行计划,少了:
Aggregate(group=[tid])--- 去重算子Exchange(distribution=[broadcast])--- 广播(改为分区 Shuffle,更稳定)
为什么 EXISTS 在分布式下赢的原因和单机不同
| 场景 | EXISTS 赢在哪 |
|---|---|
| 单机数据库 | 短路执行,找到第一条匹配即停止扫描内层表 |
| 分布式引擎 | Left Semi Join,无去重算子,无结果膨胀,Shuffle 一次搞定 |
四、实测数据对比
测试环境
- 引擎:Flink 1.17 + Hive Catalog
- 数据量:10 亿条日志,tid 去重后约 80 万个
- 集群:20 个 TaskManager,每个 8 核 16 GB
- 查询目标:找出含关键字 tid 下的所有日志
测试结果
| 指标 | IN + DISTINCT | JOIN + DISTINCT | EXISTS |
|---|---|---|---|
| 执行时间 | 41 min 🔴 | 9 min 🟡 | 4 min ✅ |
| Shuffle 数据量 | 238 GB 🔴 | 31 GB 🟡 | 19 GB ✅ |
| 内存峰值(单节点) | 13.2 GB 🔴 | 5.8 GB 🟡 | 3.1 GB ✅ |
| GC 时间占比 | 61% 🔴 | 18% 🟡 | 6% ✅ |
| 任务稳定性 | 频繁反压,重启 1 次 | 偶发反压 | 全程平稳 |
说明:IN + DISTINCT 在测试中触发了一次 OOM 重启,计入总时间。
Shuffle 数据量为什么差这么多?
sql
IN + DISTINCT:
满足 LIKE 条件的原始行数 = 约 4 亿条
每条平均大小 = 约 600 字节
Shuffle 数据量 ≈ 4 亿 × 600 B = 240 GB ≈ 实测 238 GB ✅
JOIN + DISTINCT:
本地预聚合后每节点输出 distinct tid 数 ≈ 4 万个(80 万 / 20 节点)
每个 tid 约 8 字节
Shuffle 数据量 ≈ 20 节点 × 4 万 × 8 B = 6.4 MB(全局去重阶段)
主表按 tid 分区 Shuffle ≈ 31 GB(主表本身的 Shuffle)
EXISTS(Semi Join):
主表按 tid 分区 Shuffle ≈ 19 GB
子表按 tid 分区 Shuffle ≈ 只传含关键字的行,远小于主表
无去重 Shuffle
六、总结
回到文章开头那个跑了 40 分钟的任务,问题的本质是:
用了一种在单机数据库上"能用"的写法,放到分布式引擎上,触发了全量 Shuffle,把 238 GB 数据在网络上搬了一遍。
三种写法在大数据场景下的本质差异只有一句话:
- IN + DISTINCT: 无预聚合,全量数据 Shuffle,IN 列表广播有 OOM 风险,大数据场景慎用
- JOIN + DISTINCT: 本地预聚合大幅压缩 Shuffle 量,Join 策略灵活,但必须去重防膨胀
- EXISTS: 被翻译成 Left Semi Join,天然无去重,天然不膨胀,Shuffle 一次搞定,大数据场景首选
选型口诀:
只判断「有没有」→ EXISTS ,让优化器翻译成 Semi Join 需要「关联字段」→ JOIN + DISTINCT ,关注广播阈值 少量枚举固定值 → IN,仅此场景合理
写 SQL 不难,难的是理解它在分布式引擎里究竟做了什么。
希望这篇文章能帮你在下次遇到慢查询时,多一个排查方向。
如果你在 Flink SQL 或 Spark SQL 上踩过类似的坑,欢迎在评论区聊聊,一起把这个知识库补全。