我用 EXISTS 把一条 SQL 从 18 秒优化到 6 秒,同事以为我改了索引

你好,我是 shengjk1,多年大厂经验,努力构建 通俗易懂的、好玩的编程语言教程。 欢迎关注!你会有如下收益:

  1. 了解大厂经验
  2. 拥有和大厂相匹配的技术等

希望看什么,评论或者私信告诉我!

故事从一条"跑得很慢"的 SQL 开始

线上报警,日志查询超时。我接手一看,是这段 SQL:

sql 复制代码
SELECT message
FROM   ods_log_sby_biz_log_di_realtime
WHERE  tid IN (
    SELECT DISTINCT tid
    FROM   ods_log_sby_biz_log_di_realtime
    WHERE  timestamp BETWEEN '2026-03-18 00:00:00' AND '2026-03-19 00:00:00'
    AND    container_name = 'lijianjin'
    AND    message LIKE '%发起商家转账-start%'
)
AND timestamp BETWEEN '2026-03-18 00:00:00' AND '2026-03-19 00:00:00'
AND container_name = 'lijianjin'

需求本身很简单: 找出所有包含"发起商家转账-start"关键字的线程 ID(tid),把该 tid 下的全部日志都捞出来,方便链路追踪。

逻辑没问题,结果也正确,但在千万级数据下跑了 18 秒

我没有加任何索引,只改了一种写法,直接降到 6 秒

同事问我改了什么,我说:把 IN 换成了 EXISTS

他沉默了三秒,然后说:"这东西有这么大区别?"

有。而且原理不复杂,看完这篇你就懂了。


先说结论,再讲原理

对比维度 IN + DISTINCT JOIN + DISTINCT EXISTS
子查询是否全量执行 ✅ 必须全量 ✅ 必须全量 ❌ 找到即停(短路)
是否需要去重 ✅ 需要 DISTINCT ✅ 需要 DISTINCT ❌ 不需要
匹配方式 逐个比较 Hash O(1) 碰撞 短路匹配
内存占用
数据量越大时 越慢 较快 最快
综合推荐 ⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐⭐

测试数据

为了把执行过程讲清楚,先构造一张简化的日志表:

tid container_name message timestamp 备注
T1 lijianjin 发起商家转账-start 2026-03-18 10:00 关键入口日志
T1 lijianjin 转账处理中 2026-03-18 10:01 中间过程
T1 lijianjin 转账完成 2026-03-18 10:02 结束日志
T2 lijianjin 发起商家转账-start 2026-03-18 11:00 关键入口日志
T2 lijianjin 转账失败 2026-03-18 11:01 结束日志
T3 lijianjin 其他业务日志 2026-03-18 12:00 无关记录

预期结果: T1(3条)+ T2(2条),共 5 条。T3 无关键字,过滤掉。


方式一:IN + DISTINCT

SQL 写法

sql 复制代码
SELECT message
FROM   ods_log_sby_biz_log_di_realtime
WHERE  tid IN (
    SELECT DISTINCT tid
    FROM   ods_log_sby_biz_log_di_realtime
    WHERE  timestamp BETWEEN '2026-03-18 00:00:00' AND '2026-03-19 00:00:00'
    AND    container_name = 'lijianjin'
    AND    message LIKE '%发起商家转账-start%'
)
AND timestamp BETWEEN '2026-03-18 00:00:00' AND '2026-03-19 00:00:00'
AND container_name = 'lijianjin'

执行步骤

第一步:执行子查询,全量扫描

数据库把整张表(或分区)从头到尾扫一遍,对每一行检查 WHERE 条件。满足条件的行收集其 tid,此时可能包含重复值:

css 复制代码
扫描结果(含重复):[T1, T2, T1, T2, ...]

注意:子查询必须完整跑完,不能提前停止。

第二步:DISTINCT 去重

对第一步结果执行去重,得到:

css 复制代码
去重后:[T1, T2]

这是一次额外的排序或哈希去重计算,tid 基数越大,开销越显著。

第三步:外层逐行匹配 IN 列表

外层扫描主表,对每一行的 tid 逐个与 [T1, T2] 比对:

ini 复制代码
T1 - 发起商家转账-start  → tid=T1,在列表里 ✅ 保留
T1 - 转账处理中          → tid=T1,在列表里 ✅ 保留
T1 - 转账完成            → tid=T1,在列表里 ✅ 保留
T2 - 发起商家转账-start  → tid=T2,在列表里 ✅ 保留
T2 - 转账失败            → tid=T2,在列表里 ✅ 保留
T3 - 其他业务日志        → tid=T3,不在列表 ❌ 过滤

第四步:返回 5 条结果。

性能瓶颈在哪

  • 瓶颈一:子查询全量执行。 不管外层最终用多少行,内层都必须跑完。
  • 瓶颈二:DISTINCT 额外开销。 本质是一次 GROUP BY,数据量大时消耗大量 CPU 和内存。
  • 瓶颈三:IN 列表膨胀。 当子查询返回数万个不同 tid 时,IN 列表本身就是个负担。

方式二:JOIN + DISTINCT

SQL 写法

sql 复制代码
SELECT a.message
FROM   ods_log_sby_biz_log_di_realtime a
INNER JOIN (
    SELECT DISTINCT tid
    FROM   ods_log_sby_biz_log_di_realtime
    WHERE  timestamp BETWEEN '2026-03-18 00:00:00' AND '2026-03-19 00:00:00'
    AND    container_name = 'lijianjin'
    AND    message LIKE '%发起商家转账-start%'
) b ON a.tid = b.tid
WHERE  a.timestamp BETWEEN '2026-03-18 00:00:00' AND '2026-03-19 00:00:00'
AND    a.container_name = 'lijianjin'

执行步骤

第一步:执行子查询,同样全量扫描,DISTINCT 去重

与 IN 方式相同,得到小表 b:

css 复制代码
b 表:[{tid: T1}, {tid: T2}]

第二步:构建 Hash 表(Build Phase)

优化器通常选择 Hash Join,将小表 b 装入内存 Hash 表:

yaml 复制代码
Hash 表:{ T1: true, T2: true }

Hash 表查找复杂度 O(1),这是相比 IN 更快的关键。

第三步:外层扫描,Hash 探测(Probe Phase)

扫描主表 a,对每行的 tid 在 Hash 表中碰撞:

css 复制代码
T1 → H[T1] 命中 ✅ 保留
T3 → H[T3] 不存在 ❌ 过滤

速度比 IN 的逐个比较快很多。

第四步:返回 5 条结果。

相比 IN 的优势

  • Hash Join 替代线性查找,大数据集下优势明显
  • 优化器可以根据统计信息调整连接顺序,在 Flink/Spark SQL 中效果尤为显著

仍然存在的问题

仍然需要 DISTINCT。 子查询还是得去重,这一步无法省略,是 JOIN 方式最大的短板。


方式三:EXISTS(推荐)

SQL 写法

sql 复制代码
SELECT a.message
FROM   ods_log_sby_biz_log_di_realtime a
WHERE  a.timestamp BETWEEN '2026-03-18 00:00:00' AND '2026-03-19 00:00:00'
AND    a.container_name = 'lijianjin'
AND    EXISTS (
    SELECT 1
    FROM   ods_log_sby_biz_log_di_realtime b
    WHERE  b.tid            = a.tid
    AND    b.timestamp BETWEEN '2026-03-18 00:00:00' AND '2026-03-19 00:00:00'
    AND    b.container_name = 'lijianjin'
    AND    b.message LIKE '%发起商家转账-start%'
)

SELECT 1 是什么意思?

1 只是一个占位常量,写 SELECT *SELECT tid 效果完全一样。EXISTS 只关心子查询「有没有返回行」,根本不在乎返回什么内容,所以写 SELECT 1 是惯用写法,语义清晰,性能也最轻。

执行步骤

这里是 EXISTS 和前两种方式最本质的不同

第一步:外层取出第一行(T1 - 发起商家转账-start)

tid = T1 带入子查询执行:

ini 复制代码
扫描 b 表第 1 行:tid=T1,message 含关键字 ✅
→ 立即停止!不再继续扫描 b 表剩余行
EXISTS = TRUE,保留这行

第二步:外层取出第二行(T1 - 转账处理中)

tid 仍为 T1,再次带入子查询:

ini 复制代码
扫描 b 表第 1 行:命中 ✅ → 立即停止
EXISTS = TRUE,保留

第三步:T1 的第三行同理,保留。

第四步:外层取出第五行(T2 - 转账失败)

tid = T2 带入子查询:

ini 复制代码
扫描 b 表直到找到 tid=T2 且含关键字的行 → 命中 ✅ → 立即停止
EXISTS = TRUE,保留

第五步:外层取出第六行(T3 - 其他日志)

tid = T3 带入子查询:

ini 复制代码
扫描 b 表所有行,没有 tid=T3 且含关键字的行
→ 扫完了也没找到
EXISTS = FALSE,过滤掉

第六步:返回 5 条结果。

EXISTS 为什么更快?核心是两个字:短路

短路执行(Short-circuit Evaluation)。 EXISTS 一旦在子查询中找到第一条匹配行,立即返回 TRUE,不再扫描剩余行。

举个极端的例子:tid = T1 的日志有 10 万条,b 表里第 1 行就是 T1 的关键字日志。那么:

  • IN / JOIN: 子查询要把 10 万行全扫完,去重,然后再返回
  • EXISTS: 扫到第 1 行就停了,剩下 99999 行直接跳过

数据量越大,这个差距越夸张。

无需 DISTINCT。 EXISTS 的本质是"存在性检查",只要找到一条就行,天然不需要去重,省去了排序/哈希的全部开销。


性能基准参考

以下数据基于 1000 万行日志表(tid 基数约 10 万)的模拟测试:

指标 IN + DISTINCT JOIN + DISTINCT EXISTS
执行时间 18.3 s 11.7 s 6.2 s
内存峰值 1240 MB 820 MB 310 MB
全表扫描次数 2 次(均全量) 2 次(均全量) 2 次(内层短路)
是否需要去重排序

⚠️ 上述数据为模拟基准,不同引擎(MySQL、Flink SQL、Spark SQL、Hive)因执行计划差异结果会有所不同。建议在实际环境中用 EXPLAIN 验证。


适用场景怎么选?

方式 推荐用它当... 不推荐用它当...
IN + DISTINCT 子查询返回少量固定枚举值;数据量小对性能不敏感 子查询返回大量 distinct 值;高并发实时查询
JOIN + DISTINCT 需要同时获取关联表的其他字段;优化器 Hash Join 支持好 仅需存在性判断(此时 EXISTS 更优)
EXISTS 存在性检查;日志链路追踪;大数据量高频查询;子查询关联字段有索引 需要返回关联表字段(用 JOIN 更合适)

选型口诀:

只判断「有没有」→ EXISTS 需要「关联字段」→ JOIN 「枚举少量固定值」→ IN


最终优化 SQL

sql 复制代码
-- 推荐写法:EXISTS 短路优化
SELECT a.message
FROM   ods_log_sby_biz_log_di_realtime a
WHERE  a.timestamp BETWEEN '2026-03-18 00:00:00' AND '2026-03-19 00:00:00'
AND    a.container_name = 'lijianjin'
AND    EXISTS (
    SELECT 1
    FROM   ods_log_sby_biz_log_di_realtime b
    WHERE  b.tid            = a.tid
    AND    b.timestamp BETWEEN '2026-03-18 00:00:00' AND '2026-03-19 00:00:00'
    AND    b.container_name = 'lijianjin'
    AND    b.message LIKE '%发起商家转账-start%'
)
ORDER BY a.tid, a.timestamp

总结

三种写法的本质差异只有一句话:

  • IN + DISTINCT: 先把所有符合条件的 tid 全捞出来去重,再去主表匹配。两步都很重。
  • JOIN + DISTINCT: Hash Join 加速了匹配,但去重这步还是省不掉。
  • EXISTS: 以外层驱动,内层找到一条就停,不去重,不全量扫,是纯粹存在性判断的最优解。

下次写 SQL 遇到"某字段在不在另一张表"的场景,先想想能不能换成 EXISTS,可能只是一个关键字的距离,但性能的差距会让你惊喜。


如果这篇文章对你有帮助,欢迎点赞收藏,也欢迎在评论区分享你遇到过的 SQL 慢查询场景。

相关推荐
AskHarries2 小时前
openclaw对接企业微信
后端·ai编程
森林里的程序猿猿2 小时前
Spring Aop底层源码实现(一)
java·后端·spring
耗子会飞2 小时前
小白学习springboot项目如何连接RocketMQ
后端·rocketmq
ZTrainWilliams2 小时前
swagger-mcp-toolkit 让 AI编辑器 更快“读懂并调用”你的接口
前端·后端·mcp
cylgdzz1112 小时前
PageIndex:一种不靠向量检索的长文档 RAG 实现思路
后端
Later2 小时前
Apache Doris 深度讲解:从核心概念到实战项目
后端
攒了一袋星辰2 小时前
SequenceGenerator高并发有序顺序号生成中间件 - 架构设计文档
java·后端·spring·中间件·架构·kafka·maven
码农刚子2 小时前
字符串拼接用“+”还是 StringBuilder?别再凭感觉写了
后端·代码规范
茶杯梦轩2 小时前
面试常问:DNS,CDN,Cookie,Session和Token详解及实战避坑指南
后端·网络协议·面试