一条 SQL 从 30 秒到 300 毫秒:聊聊 MySQL 的 Hash Join

一条 SQL 从 30 秒到 300 毫秒:聊聊 MySQL 的 Hash Join

同样一条 SQL,两张行数差不多的表(20 万行 vs 20 万行),一台 MySQL 5.6 三十秒跑不出来,一台 MySQL 8.4 一秒内出结果。差的不是硬件,甚至不是索引------事实上两台的索引都没建。本文从一场真实的慢查询复盘讲起,把 MySQL 处理 join 的三副面孔(嵌套循环、Hash Join、B+ 树索引)一次讲透。

一、先看一场慢查询的解剖

场景是一个电商后台的"渠道月报"接口,主角两张表:

  • orders:订单表,约 20 万行,id 是 bigint 主键,带 create_timechannelamount
  • order_items:订单商品明细表,约 20 万行,order_id 指向订单表 ------ 注意,它是 varchar(32)。这是整个故事的总开关。

查询本身平平无奇:按时间范围拉订单,join 出明细,按渠道聚合:

sql 复制代码
SELECT   o.channel,
         SUM(o.amount)               AS total_amount,
         COUNT(DISTINCT i.device_id) AS device_count
FROM     orders o
LEFT JOIN order_items i ON i.order_id = o.id
WHERE    o.create_time BETWEEN '2025-08-07' AND '2025-09-07'
GROUP BY o.channel;

EXPLAIN 一跑:

复制代码
| table | type | possible_keys | key  | rows   | Extra                                         |
|-------|------|---------------|------|--------|-----------------------------------------------|
| o     | ALL  | NULL          | NULL | 200000 | Using where; Using temporary; Using filesort  |
| i     | ALL  | NULL          | NULL | 198000 | Using where; Using join buffer (Block NL)     |

三个信号:

  1. type=ALL:两张表都在全表扫描;
  2. possible_keys=NULL :注意,这不是"优化器没用索引",而是"压根没有索引可选"。possible_keys 列出的是候选,NULL 是没有候选;
  3. Extra 里的 Block Nested Loop:MySQL 宣告了它准备采用的 join 算法------传统艺能,嵌套循环的批量版。

更隐蔽的雷埋在 join 条件里:order_id 是 varchar,id 是 bigint。MySQL 不肯直接拿字符串和数字比较,而是把两边都转成 DOUBLE 再比 (在 MySQL 8 的 EXPLAIN 警告信息里能看到 CAST(order_id AS DOUBLE))。这个转换对优化器是死刑判决------转换后的表达式上,任何索引都无法生效。换句话说:这种情况下就算你把索引建了,它也假装看不见

二、嵌套循环:384 亿次比较是怎么来的

把问题缩到最小:orders 只有 3 行(id 为 1、2、3),order_items 只有 4 行(order_id 为 9、1、1、3)。任务:给每个订单找出它的全部明细。

嵌套循环(Nested Loop)的做法朴素到残忍:拿订单 1,和 4 行明细挨个比较(4 次);拿订单 2,再从头比一遍(4 次);订单 3 再来一轮。总共 3 × 4 = 12 次比较。

BNL(Block Nested Loop)是它的节流版:先把一批外层行攒进 join buffer,再扫一遍内表,每扫一行和 buffer 里整批比对------省掉了"每行都重扫内表"的 IO,但比较次数仍然是外层 × 内层的乘积级

放大到真实规模:20 万 × 20 万 ≈ 400 亿次比较,每次比较还要先把 varchar 现场翻译成 DOUBLE。这就是"三十秒跑不完"的全部来源。

O(N×M) 的可怕不在"慢",在于"表翻一倍,耗时翻四倍"。

三、Hash Join:建表与探测

MySQL 8.0.18 给等值 join 换了另一副牌:Hash Join 。它分两个阶段------建表(build)和探测(probe),对应两个动作。

阶段一:建表(build)。 把内表 order_items 整张扫一遍,每行拿 order_id 代入一个哈希函数,算出一个"格子号",把这行扔进那个格子。就像一个按手机尾号分格的柜子:order_id=1 的两行进了 3 号格,=3 的进 5 号格,=9 的进 7 号格。内表只摸这一遍

阶段二:探测(probe)。 拿外表 orders 每一行的 id,算出格子号,直接跳到那个格子 ,只和格子里的几行做真实值比较:id=1 → 3 号格 → 格里两行,命中两条明细;id=2 → 8 号格 → 空的,零成本。外表也只摸这一遍

还是 3×4 的例子:建表 4 次操作 + 探测 3 次操作 = 7 次,替代原来的 12 次。放大到 20 万级别:

复制代码
嵌套循环:20万 × 20万 ≈ 400 亿次比较
Hash Join:20万(建表)+ 20万(探测)= 40 万次操作

十万倍的工作量差,就是"30 秒"和"1 秒"的距离。内存放不下中间结果时,MySQL 会把哈希表按分区落盘再逐区处理(grace hash),思路不变,只是多了几次顺序读写。

嵌套循环是"拿一张卡,翻遍整个柜子";Hash Join 是"先按尾号把柜子分好格,再按号码直接跳格"。

四、那索引呢?永久目录 vs 现场目录

讲到这里你必须问:修这个问题的正道难道不是索引吗?是。而且实验结果非常有戏剧性。

给 5.6 那台补上两步:ALTER TABLE order_items MODIFY order_id bigint(消灭隐式转换),再在 order_id 上建索引。EXPLAIN 立刻变脸:

复制代码
| table | type | possible_keys | key           | ref   | rows   |
|-------|------|---------------|---------------|-------|--------|
| o     | ALL  | idx_ct        | NULL          | NULL  | 200000 |
| i     | ref  | idx_oid       | idx_oid       | o.id  | 1      |

i 表变成了 ref:外层每行拿 id 去 B+ 树里一次定位rows=1。BNL 消失,30 秒变 2.8 秒。

但注意一个反直觉的实测数字:修好索引的 5.6(2.8 秒),还是输给了坏着 schema 的 8.4(0.83 秒,靠 Hash Join 兜底)。为什么?

  • B+ 树点查:19.4 万次独立的"从树根走到树叶",每次 3~4 层页访问,访问模式是跳跃的;叠加 5.6 古老的逐行执行器,每行都有固定的解释开销。
  • Hash Join:两张表顺序往下流,探测是纯内存数组操作,访问模式顺流而下,CPU 缓存友好。

两者的关系可以这么总结:

B+ 树索引是"写库时预先建好、所有人共用"的永久目录;Hash Join 是"查询进来时现场搭、用完就扔"的一次性目录。

5.6 不会现场搭目录,但只要永久目录在,它也能从死刑缓期变回正常发挥;8.4 的杀手锏是------就算你一个索引都没建,它现场搭一个也快(8.0.20 起,BNL 更是被 Hash Join 彻底取代)。所以"修 schema"和"升级数据库"不是二选一:前者是根治,后者是给你的失误兜底。

五、案例收尾:修复清单

三步,顺序不能反:

sql 复制代码
-- 1. 消灭隐式转换(不修类型,索引建了也白发)
ALTER TABLE order_items MODIFY order_id bigint NULL;

-- 2. 给 join 列和过滤列建索引
CREATE INDEX idx_order_items_order_id ON order_items (order_id);
CREATE INDEX idx_orders_create_time   ON orders (create_time);

-- 3. EXPLAIN 验证:ref 出现、Block Nested Loop 消失

实验机上三组实测:

环境 耗时 现象
5.6,varchar + 无索引 30s+ 跑不完 BNL + 隐式转换
5.6,修类型 + 建索引 2.8s ref,rows=1
8.4,未修(Hash Join 兜底) 0.83s hash join on DOUBLE

一个插曲提醒:5.6 上执行 ALTER 前先 SHOW FULL PROCESSLIST 看一眼。客户端超时不等于服务端停了------一条跑飞的查询能把你的 ALTER 堵在 metadata lock 上,看起来像"改表改了一小时",实际是它在排队。

六、FAQ

Q:Hash Join 是不是类似布隆过滤器?

直觉对了一半。"用哈希定位、不挨个扫"完全一致。区别在于:布隆过滤器的格子里只存比特位、不存数据,所以误报无法消除------它没有东西可供验证;Hash Join 的格子里存的是完整的行,冲突只是多比几行,结果永远精确。

Q:哈希冲突了,结果会不会错?

不会。哈希只负责"缩小范围",格子里逐行做真实值的等值比较才输出。错行进得了格子,进不了结果集。最坏情况(劣质哈希把所有行塞进一个格子)也只是退化回嵌套循环,不会给出错的答案。

Q:我的生产环境还在 5.6,怎么办?

Hash Join 从来不是依赖项。类型一致 + 索引齐全,5.6 一样进秒级------上表 2.8 秒那行就是 5.6 跑出来的。升级大版本是锦上添花,不是解毒药。

结尾:把雷提前炸出来

最后送一条自查 SQL。类型不匹配这种雷,EXPLAIN 是事后验尸,不如事前体检------把库里所有"长得像外键的字符串列"拉出来对一遍:

sql 复制代码
SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'your_db'
  AND DATA_TYPE IN ('varchar', 'char')
  AND (COLUMN_NAME LIKE '%id%' OR COLUMN_NAME LIKE '%_no');
-- 逐个对照它们要 join 的主键列类型:varchar join bigint,就是待爆的雷

join 列类型一致,是一切 join 优化的第一前提。索引和 Hash Join 都只是"不扫全表"的手段------而隐式类型转换,能让所有手段同时失效。

相关推荐
敲代码的嘎仔2 小时前
自己设计了一个兑换码算法:自增ID + Base32转码 + 按位加权签名 + 异或混淆,面试被追问细节时终于不用慌了
java·数据库·mysql·算法·微服务·面试·职场和发展
shirsl3 小时前
数据开发实时项目问题整理
数据库·sql·big data
—Miss. Z—3 小时前
计算机三级数据库技术—应用题2️⃣
数据库·mysql
这个DBA有点耶3 小时前
临时表从4.7秒到0.12秒:不是所有Using temporary都需要优化
数据库·mysql·代码规范
天衍四九-3 小时前
排查慢SQL用explain分析执行计划,主要关注哪些字段?
数据库·sql
润乾软件5 小时前
SQLazy:多表按 ID 合并为单行
sql·sqlazy
天衍四九-6 小时前
MySQL有哪些索引
数据库·mysql
天天喝旺仔7 小时前
PostgreSQL 慢查询治理实战:读懂 EXPLAIN ANALYZE 执行计划,落地复合索引、部分索引与覆盖索引
sql·postgresql·性能优化·数据库开发
kyrie_sakura8 小时前
MySQL学习笔记4 -- select的7大子句,子查询
笔记·学习·mysql