MySQL JOIN性能优化实践:驱动表选择与连接算法
大家好,我是数据库小学妹👋 我踩过的坑,你别再踩。
上周一条报表 JOIN 从两百毫秒变成两秒,我第一反应是索引掉了。看了一遍,两边连接列都有索引,一个都没少。再跑 EXPLAIN,驱动表从订单表换成了用户表。订单表只过滤两万行,用户表却按城市筛出四万多行。同一条 SQL,换了个开车的人,路就堵死了。
一、 索引都在,为什么还是慢
这条 SQL 不复杂。订单表连用户表,按订单时间和用户城市过滤。订单表的 created_at 有索引,用户表的 city 有索引。连接列一个走主键,一个走普通索引。单看索引,找不出毛病。
问题在顺序。优化器先扫用户表,按城市拿到一批用户。再拿每个用户去订单表查订单。用户表估算只有三百行,看着很划算。实际跑出来四万八千行,内表就被执行了四万八千次。单次查找几十微秒,乘起来正好两秒。
慢日志里只有这一条 SQL,看不出其它异常。CPU 不高,磁盘也不忙。EXPLAIN 里每张表都在走索引,没有 ALL。这类慢最容易误判,因为每个局部都对,错的是整体。
二、三条连接路径
JOIN 的执行路径只有三种。第一种是嵌套循环,简称 NLJ。外表每读一行,就拿连接键去内表查一次。内表连接列有索引,这一步就是索引查找。没有索引,就退化成第二种,块嵌套循环,也就是 BNL。它把外表的一批行放进 join_buffer,再整表扫一遍内表。
8.0.18 引入哈希连接,8.0.20 之后取代了 BNL。等值连接、内表没有可用索引,才会走哈希连接。优化器把小的一侧建成哈希表。然后扫另一侧,逐行去哈希表里碰。不等值连接用不上它,比如 BETWEEN、LIKE 前缀模糊。执行计划里会写 Using join buffer,括号里注明是 hash join 还是 BNL。
三条路径的代价差得很远。NLJ 的成本是外表行数乘以内表单次查找。BNL 的成本是外表行数乘以内表扫描,再加内存开销。哈希连接建表一次,探测每行一次。它适合两侧都不小、又没有索引的场景。
三、 驱动表不是越小越好
驱动表越小越好,这是一个流传很广的误会。优化器算的是代价,不是行数。代价大致等于外表的行数,乘以每行去内表查一次的成本。外表再小,内表没有索引,每行都要全表扫,代价也会爆。反过来,外表大一点,内表走主键,循环成本很低,反而更快。
所以真正要看两件事。被驱动表的连接列,有没有能用的索引。驱动表过滤之后,还剩多少行。这两件事都取决于估算,估算来自统计信息。统计信息偏了,驱动表就会选错。索引都在,计划照样能翻车。
优化器搜连接顺序时,不会把所有排列都算一遍。表一多,组合是阶乘级的。它用贪心加剪枝,靠 optimizer_search_depth 控制搜索深度。代价模型本身没问题,问题在输入。输入就是每张表的行数和选择率。
四、join_buffer 不是万能药
join_buffer 是给 BNL 和哈希连接用的内存。它按连接分配,不是按连接池连接分配。一条 SQL 里有三个 JOIN,就可能开三块。默认 256KB,很多人一看到 JOIN 慢就调大。调大只影响没有索引的那条路,改不了驱动表选错。并发一高,每块都按上限分配,内存会被吃穿。
更麻烦的是,调大以后计划可能不变。因为优化器估的代价没变,它还是选同一条路。内存只是让那条慢路稍微快一点,掩盖了问题。看起来有效,过一阵负载上来又慢。参数能缓解症状,改不了选择。
五、EXPLAIN 要看估算和实际
先看 EXPLAIN 的逐表输出。type 告诉你访问方式,ALL 是全表扫,ref 和 eq_ref 是索引查找。rows 是估算行数,filtered 是过滤后剩下的比例。Extra 里出现 Using join buffer,说明内表没走索引。出现 Using filesort 或 Using temporary,是另一类问题,别混在一起看。
估算和实际差多少,要靠 EXPLAIN ANALYZE。8.0.18 之后支持,它会真跑一遍,给出实际行数和实际耗时。看 loops,那是这一层被执行的次数。外表四万多行,内表这一层就会跑四万多次。单次只要几十微秒,乘起来就是秒级。
EXPLAIN ANALYZE
SELECT o.order_no, o.amount, u.nickname
FROM t_order o
JOIN t_user u ON o.user_id = u.id
WHERE o.created_at >= '2026-10-20'
AND u.city = '杭州';
-> Nested loop inner join (cost=662937 rows=1043) (actual time=0.297..2013 rows=1852 loops=1)
-> Index lookup on u using idx_city (city='杭州')
(cost=10435 rows=300) (actual time=0.043..71.5 rows=48210 loops=1)
-> Index lookup on o using idx_user_created (user_id=u.id, created_at>='2026-10-20')
(cost=2.11 rows=1) (actual time=0.019..0.039 rows=1 loops=48210)
这张图里有两个数最关键。用户表这一层,估算三百行,实际跑出四万八。内表这一层 loops 是四万八,单次耗时看着很低。两者一乘,就是两秒。换成订单表驱动,外层两万行,内表走主键,总耗时回到两百毫秒。
六、计划漂移怎么修
那次的原因是统计信息采样偏了。InnoDB 默认只采 20 个页,innodb_stats_persistent_sample_pages 控制这个数。杭州用户集中在新写入的页里,采样没扫到。估算出来三百行,实际四万八。优化器按三百行算代价,自然觉得用户表适合当驱动表。
修复分三层。第一层是 ANALYZE TABLE,重新采样。分布偏斜的列,加直方图,让优化器看到真实分布。第二层是索引。检查被驱动表的连接列和过滤列,能不能合并成一个联合索引。第三层才是强制顺序,用 STRAIGHT_JOIN 或 JOIN_ORDER。
-- 1. 重新采样
ANALYZE TABLE t_user;
-- 2. 给偏斜列建直方图(8.0 支持)
ANALYZE TABLE t_user UPDATE HISTOGRAM ON city WITH 64 BUCKETS;
-- 3. 临时止血:把订单表固定在驱动位
SELECT STRAIGHT_JOIN o.order_no, o.amount, u.nickname
FROM t_order o
JOIN t_user u ON o.user_id = u.id
WHERE o.created_at >= '2026-10-20'
AND u.city = '杭州';
STRAIGHT_JOIN 是止血,不是根治。它把顺序写死,数据一变,可能从最优变成最差。我一般只在发版窗口或报表临时用。用完记一笔,下次数据分布变了要复查。直方图也不是一劳永逸,数据偏斜变了要重采。
还有一点,别把 Using join buffer 当成必死信号。小表连大表,哈希连接往往比索引查找还快。要看 loops 和实际行数,不能只看出没出这个提示。真正的坏信号,是外层行数大,内层还在全表扫。
七、 避坑清单
JOIN 慢,先看驱动表是谁,再看被驱动表的连接列有没有索引。索引都在还慢,去比估算行数和实际行数。差值大,问题在统计信息,不在 SQL 写法。别一上来就改 SQL,改写法可能把计划带得更偏。
join_buffer_size 不是 JOIN 的万能药。它只服务没有索引的连接路径。驱动表选错,调多大都没用。并发高的库,调大还可能把内存打满。先把索引和统计信息弄对,再谈这块内存。
最后一条是我自己踩的。有次报表慢,我加了 STRAIGHT_JOIN,当天确实快了。过了半年,订单量翻了几倍,这个固定顺序成了最差的一条路。报表又慢,我查了半天 SQL,才想起那个提示还在。现在我加这类提示,一定在注释里写日期和原因。到期复查,比凭记忆可靠。
写在最后
JOIN 的代价,不在 SQL 长什么样,在优化器怎么估。估得准,索引对,计划就稳。估偏了,索引全在也能翻车。驱动表换了,往往不是 SQL 变了,是数据变了。
下次遇到 JOIN 突然变慢,先跑 EXPLAIN ANALYZE。看驱动表,看估算和实际,看 loops。这三眼看完,方向基本就有了。参数和提示词都放后面,它们是工具,不是答案。
你们遇到过同一条 JOIN 突然变慢吗?最后是统计信息、索引还是驱动表的问题?当时是怎么发现驱动表换人的?评论区聊聊。
我是数据库小学妹,帮你少走弯路少踩坑,咱们下篇见👋
- 点赞
- 收藏
- 关注作者
评论(0)