mysql的查询优化“小表驱动大表“的原理

104 阅读9分钟

MySQL 小表驱动大表的原理:本质是最小化低效循环次数

小表驱动大表(Small Table Drives Large Table)是 MySQL 连表查询的核心优化原则,其底层逻辑完全服务于 减少 “高开销操作” 的执行次数—— 核心结论:用数据量少的 “小表” 做外层循环(驱动表),数据量多的 “大表” 做内层循环(被驱动表),能最大程度降低总开销

要理解这个原理,必须结合之前提到的连表查询核心算法(尤其是最常用的嵌套循环连接 NLJ),从 “循环次数” 和 “单次循环开销” 两个维度拆解。

一、先明确:什么是 “驱动表” 和 “被驱动表”?

在连表查询中,MySQL 会将参与连接的表分为两类:

  • 驱动表(Outer Table) :外层循环遍历的表,先被全量扫描(或通过 WHERE 条件过滤后扫描),每一条记录都会触发一次对 “被驱动表” 的匹配。
  • 被驱动表(Inner Table) :内层循环遍历的表,需要为驱动表的每一条记录做 “匹配查询”(可能是索引查找,也可能是全表扫描)。

关键区别:

  • 驱动表的开销:仅需扫描一次(无论大小,扫描次数固定为 1);
  • 被驱动表的开销:扫描次数 = 驱动表过滤后的记录数(每一条驱动表记录都要匹配一次被驱动表)。

这是小表驱动大表的核心前提:被驱动表的扫描次数由驱动表的记录数决定,而单次扫描的开销由被驱动表的大小决定

二、核心原理:为什么小表适合做驱动表?

我们用 “嵌套循环” 的逻辑(MySQL 最常用的 INLJ/BNL 算法都基于此),通过对比 “小表驱动大表” 和 “大表驱动小表” 的总开销,就能直观理解。

假设场景

  • 小表 T1:过滤后剩余 100 行(记为 M=100);
  • 大表 T2:过滤后剩余 100 万行(记为 N=1000000);
  • 核心前提:被驱动表的 “单次匹配开销” 远大于驱动表的 “单次遍历开销”(大表的扫描 / 索引查找开销,远高于小表的一行数据遍历开销)。

两种方案的开销对比

方案 1:小表 T1 做驱动表,大表 T2 做被驱动表
  • 外层循环:遍历 T1,执行 100 次(M=100);
  • 内层循环:对 T1 的每一行,去 T2 中匹配(若 T2 连接列有索引,单次匹配开销是 O (log N);若无索引,单次是 O (N));
  • 总开销 ≈ 驱动表扫描开销(可忽略,因仅 1 次扫描) + 驱动表行数 × 被驱动表单次匹配开销;
  • 代入数值(假设 T2 有索引,单次匹配 O (log 1e6)≈20):总开销 ≈ 100 × 20 = 2000。
方案 2:大表 T2 做驱动表,小表 T1 做被驱动表
  • 外层循环:遍历 T2,执行 100 万次(N=1e6);
  • 内层循环:对 T2 的每一行,去 T1 中匹配(T1 是小表,即使无索引,单次匹配 O (M)=100);
  • 总开销 ≈ 1e6 × 100 = 100,000,000(1 亿)。

结论:差距源于 “高开销操作的次数”

  • 被驱动表的 “单次匹配” 是高开销操作(尤其是大表无索引时,单次是全表扫描);
  • 小表驱动大表:高开销操作执行次数 = 小表行数(少);
  • 大表驱动小表:高开销操作执行次数 = 大表行数(多);
  • 两者的总开销差距可能达 百倍、千倍—— 这就是小表驱动大表的核心逻辑:用 “少次数” 的外层循环,承载 “高开销” 的内层循环

三、结合具体算法,理解原理落地

小表驱动大表的原则,在 MySQL 主流连接算法中都有明确体现,并非抽象概念:

1. 嵌套循环连接(NLJ/INLJ,最常用)

INLJ 是最优的连接算法(被驱动表有索引),此时小表驱动大表的优势更明显:

  • 驱动表(小表):扫描 1 次,得到 M 条记录(M 小);
  • 被驱动表(大表):每条驱动表记录触发 1 次索引查找(O (log N)),总开销 M×log N;
  • 若反过来用大表做驱动表,总开销 N×log M—— 因 N>>M,即使 log N>log M,N×log M 仍远大于 M×log N。

例:T1(100 行)、T2(1e6 行,索引 log N≈20):

  • 小驱大:100×20=2000;
  • 大驱小:1e6×log 100≈1e6×7=7e6(差距 3500 倍)。

2. 块嵌套循环(BNL,无索引场景)

若被驱动表无索引,MySQL 会用 BNL 优化(批量加载驱动表记录到 join buffer),但小表驱动大表的原则依然成立:

  • 驱动表(小表):批量加载到 join buffer,加载次数 = ceil (M / 缓冲区行数)(M 小,加载次数少);
  • 被驱动表(大表):每加载一批驱动表记录,扫描 1 次大表(单次扫描开销固定为 O (N));
  • 总开销 ≈ 加载次数 × O (N)—— 小表的加载次数少,自然减少大表的扫描次数。

例:join buffer 可存 50 条记录,T1(100 行)、T2(1e6 行):

  • 小驱大:加载次数 = 100/50=2 → 大表扫描 2 次(总开销 2×1e6=2e6);
  • 大驱小:加载次数 = 1e6/50=20000 → 大表(此时 T1 是被驱动表,虽小但扫描 2 万次):20000×100=2e6(看似相等?但实际大表扫描的 IO 开销远高于小表,2 次大表扫描的开销远小于 2 万次小表扫描)。

3. 哈希连接(HJ,8.0+ 大表无索引场景)

哈希连接中,“小表” 对应 “构建表”(构建哈希表),“大表” 对应 “探测表”(遍历匹配),本质也是小表驱动大表:

  • 构建表(小表):构建哈希表的开销是 O (M)(M 小,内存可容纳,无需分区,开销低);
  • 探测表(大表):遍历匹配的开销是 O (N)(N 大,但仅需遍历 1 次);
  • 若用大表做构建表:哈希表过大,内存存不下,需拆分到磁盘(分区哈希),构建和匹配开销会急剧上升(磁盘 IO 远慢于内存操作)。

四、为什么不能 “大表驱动小表”?

核心问题是  “高开销操作的次数被放大”

  • 大表做驱动表时,外层循环次数 = 大表行数(N 很大,比如 1e6 次);
  • 即使小表的单次匹配开销很低(比如 O (1) 索引查找),总开销 = N×O (1)(1e6×1=1e6);
  • 而小表驱动大表的总开销 = M×O (log N)(100×20=2000),差距依然巨大。

极端情况:若被驱动表无索引(单次匹配开销 O (小表行数)):

  • 大表驱动小表:总开销 = 大表行数 × 小表行数(1e6×100=1e8,1 亿次操作);
  • 小表驱动大表:总开销 = 小表行数 × 大表行数(100×1e6=1e8,看似相等?但实际大表全表扫描的单次开销远高于小表 —— 扫描 1 次 1e6 行的大表,IO 开销可能是扫描 1 次 100 行小表的 1 万倍,因此 100 次大表扫描的开销远大于 1e6 次小表扫描)。

简单说:大表驱动小表,相当于 “用多轮高开销操作,去匹配少轮低开销操作”,完全违背 “最小化高开销次数” 的优化逻辑

五、关键细节:如何定义 “小表”?(不是表的物理大小)

很多人误以为 “小表” 是指表的总行数少,这是错误的 ——MySQL 判定 “小表” 的核心是:表经过 WHERE 条件过滤后,最终参与连接的 “有效行数” 少(即驱动表的 “实际扫描行数” 少)。

例:

  • 表 T1 总行数 100 万(物理大表),但 WHERE 条件 status=1 过滤后仅 10 行(有效小表);
  • 表 T2 总行数 1 万(物理小表),但无 WHERE 条件过滤(有效大表);
  • 此时最优选择是 T1 做驱动表(有效行数 10),T2 做被驱动表 —— 因为驱动表的 “有效行数” 才决定内层循环次数。

MySQL 优化器的判断逻辑:优化器会通过 “统计信息”(如 information_schema.TABLES 中的 ROWS 字段、索引选择性)估算每张表过滤后的有效行数,选择有效行数最少的表作为驱动表,这就是 “小表驱动大表” 的自动化实现。

六、实际应用:如何落地 “小表驱动大表”?

  1. 写 SQL 时,主动让小表(有效行数少)在前?

    • 对于 INNER JOIN:MySQL 优化器会自动判断有效行数,选择小表做驱动表,和 SQL 中表的顺序无关(比如 T1 JOIN T2 和 T2 JOIN T1 可能生成相同的执行计划);
    • 对于 LEFT JOIN/RIGHT JOIN:表的顺序会强制驱动表(LEFT JOIN 左表是驱动表,RIGHT JOIN 右表是驱动表),此时需手动调整顺序,让小表作为驱动表(比如 小表 LEFT JOIN 大表,而非 大表 LEFT JOIN 小表)。
  2. 通过 WHERE 条件过滤驱动表,减少有效行数比如驱动表加 WHERE 条件 过滤无效数据,让有效行数更少,进一步减少内层循环次数:

    -- 推荐:T1 加 WHERE 过滤后有效行数少(小表),驱动 T2(大表)
    SELECT * FROM T1 
    WHERE T1.status = 1  -- 过滤后仅 10 行
    INNER JOIN T2 ON T1.id = T2.t1_id;
    
    -- 不推荐:T1 无过滤(有效行数 100 万),驱动 T2
    SELECT * FROM T1 
    INNER JOIN T2 ON T1.id = T2.t1_id;
    
  3. 给被驱动表的连接列建索引小表驱动大表的核心是 “减少内层循环次数”,而给被驱动表连接列建索引,能将 “单次内层循环开销” 从 O (N)(全表扫描)降到 O (log N)(索引查找)—— 两者结合,能实现连表查询的最优性能(INLJ 算法)。

总结

小表驱动大表的原理,本质是  “最小化高开销操作的执行次数”

  • 驱动表的核心成本是 “扫描次数”(固定 1 次,和大小无关);
  • 被驱动表的核心成本是 “扫描次数 × 单次扫描开销”(扫描次数 = 驱动表有效行数);
  • 用小表做驱动表,能让 “扫描次数” 最小化,再配合被驱动表的索引(降低单次开销),就能实现连表查询的高效执行。