一条 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_time、channel、amount;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) |
三个信号:
type=ALL:两张表都在全表扫描;possible_keys=NULL:注意,这不是"优化器没用索引",而是"压根没有索引可选"。possible_keys列出的是候选,NULL 是没有候选;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 都只是"不扫全表"的手段------而隐式类型转换,能让所有手段同时失效。