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

同样的 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 或任务重启。

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 语义,
      天然不需要去重,天然不会膨胀。
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 上踩过类似的坑,欢迎在评论区聊聊,一起把这个知识库补全。

相关推荐
IT_陈寒2 小时前
JavaScript开发者必看:5个让你的代码性能翻倍的隐藏技巧
前端·人工智能·后端
武子康2 小时前
大数据-252 离线数仓 - Airflow + Crontab 入门实战:定时调度、DAG 编排与常见报错排查
大数据·后端·apache hive
程序员Terry2 小时前
RocketMQ 使用指南
后端·rocketmq
AI茶水间管理员2 小时前
OpenClaw 的 Token 消耗怎么计算?(附实操优化方案)
后端
星浩AI2 小时前
现在最需要被 PUA 的,其实是 AI
人工智能·后端·github
程序员老赵3 小时前
超全 Docker 镜像源配置指南|Windows/Mac/Linux一键搞定,拉镜像再也不卡顿
linux·后端·容器
JavaGuide3 小时前
MiniMax M2.7 发布!Redis 故障排查 + 跨语言重构场景实测,表现如何?
redis·后端·ai·ai编程
架构师沉默3 小时前
程序员真的要失业了吗?
java·后端·架构