大数据工程师必看:为什么你的 IN 查询在 Flink/Spark 上慢到离谱?

0 阅读9分钟

同样的 SQL 逻辑,三种写法,在 10 亿条数据下性能差距超过 10 倍。你踩过这个坑吗?


背景:一个让人崩溃的线上问题

生产环境,Flink SQL 任务,日志关联查询,数据量 10 亿+。

任务跑了 40 分钟没出结果,看 TaskManager 日志,卡在一个算子上:

Shuffle 数据量:238 GB
GC 时间:占比 61%
反压:持续 RED

定位到问题 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 + DISTINCTJOIN + Group ByEXISTS(Semi Join)
本地预聚合❌ 无✅ 有✅ 有
Shuffle 数据量全量原始数据预聚合后(极小)按 tid 分区(小)
是否需要去重✅ 全量去重✅ 预聚合后去重❌ 不需要
结果膨胀风险❌ 无⚠️ 需 DISTINCT 防膨胀❌ 无
IN 列表广播风险⚠️ 列表过大会 OOM❌ 无❌ 无
大数据量性能💀 最差🟡 中等✅ 最优
推荐指数⭐⭐⭐⭐⭐⭐⭐⭐

一、IN + DISTINCT:看起来最直觉,实际最危险

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 中,这条语句会被翻译成以下执行链路:

┌─────────────────────────────────────────────────────────┐
│  阶段一:子查询各节点本地扫描(无预聚合)                 │
│                                                          │
│  节点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 数据量高达:

5000  × 500 字节 = 25 GB 网络传输

而这 5000 万条去重后可能只有 10 万个不同的 tid,真正有价值的数据量只有:

10 万 × 几十字节 = 几 MB

你在用 25 GB 的传输量,换几 MB 的结果。

问题二:IN 列表过大会直接 OOM。

Flink/Spark 在执行 IN (子查询) 时,默认会尝试把子查询结果广播到所有节点。当 tid 去重后有几十万甚至上百万个时,这份广播数据会撑爆 TaskManager 内存,触发 OOM 或任务重启。

Flink SQL EXPLAIN 示例

EXPLAIN SELECT message FROM dwd_log_detail
WHERE tid IN (SELECT DISTINCT tid FROM dwd_log_detail WHERE ...)
== 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 写法

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 方式最大的改进在于本地预聚合,执行链路如下:

┌─────────────────────────────────────────────────────────┐
│  阶段一:子查询各节点本地扫描 + 本地预聚合               │
│                                                          │
│  节点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 + DISTINCTJOIN + Group By
Shuffle 内容全量原始行预聚合后的 tid(极小)
Shuffle 数据量(举例)25 GB约 5 MB
Join 策略只能广播(超限则降级)广播 or 分区 Shuffle,自动选择

本地预聚合是 JOIN 方式碾压 IN + DISTINCT 的核心原因。

⚠️ 使用 JOIN 必须注意的坑:结果膨胀

如果子查询没有去重,JOIN 会产生笛卡尔积:

-- ❌ 危险写法:忘记 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 结果会膨胀到:

100 × 3 = 300 条(期望是 100 条)

所以 JOIN 方式一定要加 DISTINCT,这不是可选项。


三、EXISTS(推荐):原理最干净,性能最优

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,而不是先生成结果集再过滤。

┌─────────────────────────────────────────────────────────┐
│  优化器将 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 的区别

普通 INNER JOIN:
  a1 行 × b 的 N 条匹配 → 输出 N 行(结果膨胀)

Left Semi Join:
  a1 行,b 有≥1条匹配 → 输出 a1 行(不膨胀)
  a1 行,b0 条匹配 → 不输出

结论:Semi Join 天然等价于 EXISTS 语义,
      天然不需要去重,天然不会膨胀。

Flink SQL EXPLAIN 示例

EXPLAIN SELECT a.message FROM dwd_log_detail a
WHERE EXISTS (SELECT 1 FROM dwd_log_detail b WHERE b.tid = a.tid AND ...)
== 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 + DISTINCTJOIN + DISTINCTEXISTS
执行时间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 数据量为什么差这么多?

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 上踩过类似的坑,欢迎在评论区聊聊,一起把这个知识库补全。