MySQL 8.0 Semi-Join 源码揭秘:FirstMatch 与 Duplicate Weedout 物理算子实现
在关系型数据库查询优化中,半连接(Semi-Join)是优化器用来消除IN (SELECT ...)和EXISTS (SELECT ...)低效相关子查询的最强武器。
在传统的全连接(Inner Join)中,如果左表的某一行在右表中匹配到了 5 条记录,左表的这一行就会在结果集中重复出现 5 次(产生数据扇出,Fanout)。
而 Semi-Join 的代数语义是:只要在右表中找到了至少一条匹配记录,立即返回左表的这一行,且左表在结果集中绝不重复出现。
在 MySQL 8.0 的执行引擎中,优化器在将子查询扁平化上拉为 Semi-Join 之后,提供了5 种底层物理执行策略(FirstMatch, MaterializeLookup, MaterializeScan, LooseScan, DuplicateWeedout)。
其中,FirstMatch(短路匹配)与Duplicate Weedout(临时表去重)是最为通用且最核心的两大物理执行算子。
深入 MySQL 8.0 源码(sql/sql_executor.cc与sql/item_subselect.cc),看懂这两个算子的内存与指针流转,才能真正掌握复杂关联查询的极致调优。
// MySQL 8.0 Semi-Join 算子执行伪代码 (sql/sql_executor.cc) // 1. FirstMatch 物理算子核心循环 enum_nested_loop_code sub_select_firstmatch(JOIN *join, QEP_TAB *qep_tab, bool end_of_records) { int error = (*qep_tab->read_record)(qep_tab); if (error > 0) return NESTED_LOOP_ERROR; if (error < 0) return NESTED_LOOP_NO_MORE_ROWS; // 关键点: 只要内表找到第一条匹配行 (First Match Found) if (qep_tab->condition()->val_int()) { // 核心源码动作: 立即跳转回外层驱动表,短路跳过内表剩余所有匹配行! // 彻底杜绝了内表重复扫描与结果集扇出膨胀 return evaluate_join_record(join, qep_tab); } return NESTED_LOOP_OK; }算子一:FirstMatch(短路匹配算子)——以极速短路消灭内表扫描
FirstMatch 类似于编程语言中的break语句:
假设我们查询:
SELECT c.customer_name FROM t_customer c WHERE c.id IN (SELECT o.customer_id FROM t_order o WHERE o.amount > 1000);[FirstMatch 物理短路扫描时序图] 外表 t_customer 扫描到 customer_id = 1001 │ ▼ [进入内表 t_order 索引树扫描 (idx_customer_amount)] - 扫描第 1 行: (1001, 1200元) ──▶ 【满足条件! 命中!】 ──▶ 【FirstMatch 立即触发短路返回外表行!】 - 内表剩余的第 2 行 (1001, 1500元)、第 3 行 (1001, 2000元) ──▶ 【完全不读! 物理跳过!】- 执行机制:外表读取一行,驱动内表 B+ 树索引扫描。一旦内表找到第 1 行满足条件的记录,算子立即中断当前内表的遍历,直接向上层返回该客户名称,并跳转回外表读取下一个客户!
- 适用场景:内表在关联字段上建有高效索引。此时 FirstMatch 的单次短路成本极低,性能达到巅峰。
算子二:Duplicate Weedout(去重算子)——在内存中斩断扇出
如果内表没有索引,或者优化器决定选择内表作为驱动表(Join 顺序被调换为内表驱动外表),FirstMatch 无法生效,此时 MySQL 8.0 会激活Duplicate Weedout(去重算子):
// Duplicate Weedout 去重哈希表检查逻辑 (sql/sql_executor.cc) bool do_sj_dups_weedout(THD *thd, SJ_TMP_TABLE *sjtbl) { // 获取外表当前行在物理存储引擎中的唯一 RowID (主键句柄) uchar *rowid_buf = sjtbl->rowid_buffer; sjtbl->file->position(sjtbl->table->record[0]); memcpy(rowid_buf, sjtbl->table->file->ref, sjtbl->table->file->ref_length); // 将 RowID 插入内存临时去重表 (基于内存哈希表) int error = sjtbl->file->ha_write_row(sjtbl->table->record[0]); if (error == HA_ERR_FOUND_DUPP_KEY) { // 发现重复 RowID: 说明该外表行在之前已经输出过了,无情丢弃 (Weedout)! return true; } // 首次遇到该 RowID: 放行输出 return false; }[Duplicate Weedout 内存哈希去重时序图] 外表 t_customer 与 内表 t_order 正常做常规全连接 (产生 3 行包含 customer_id = 1001 的结果) │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ Duplicate Weedout 内存哈希过滤器 (SJ_TMP_TABLE): │ │ - 处理第 1 行 (RowID: 1001) ──▶ 插入哈希表成功 ──▶ 【放行输出!】│ │ - 处理第 2 行 (RowID: 1001) ──▶ 命中唯一键冲突 ──▶ 【丢弃!】 │ │ - 处理第 3 行 (RowID: 1001) ──▶ 命中唯一键冲突 ──▶ 【丢弃!】 │ └─────────────────────────────────────────────────────────────┘- 执行机制:允许底层执行引擎像普通 Inner Join 一样产生临时的数据扇出(Fanout);但在最终将数据发送给客户端之前,内核开辟了一块临时的内存哈希表,记录外表每一行的物理 RowID。只有第一次出现的 RowID 允许输出,后续重复的记录全部在内存中被物理剔除(Weedout);
- 适用场景:外表数据量较小,内表无索引或多表复杂交错关联。
两大算子的 EXPLAIN 特征与调优
在EXPLAIN执行计划中:
- FirstMatch:会在内表的
Extra字段中显式打印FirstMatch(outer_table); - Duplicate Weedout:会在外表和内表之间用
Start temporary与End temporary将参与去重的表范围包裹起来。
-- 生产级优化:通过 Optimizer Hints 精准控制 Semi-Join 算子选型 -- 1. 强制走 FirstMatch 短路匹配 SELECT /*+ SEMIJOIN(FIRSTMATCH) */ customer_name FROM t_customer WHERE id IN (SELECT customer_id FROM t_order); -- 2. 强制走 Duplicate Weedout 内存去重 SELECT /*+ SEMIJOIN(DUPSWEEDOUT) */ customer_name FROM t_customer WHERE id IN (SELECT customer_id FROM t_order);理解 FirstMatch 的短路机制与 Duplicate Weedout 的内存 RowID 去重原理,才能在面对复杂的子查询性能瓶颈时,精准利用 Hints 纠正优化器的算子裁决,榨干数据库内核的每一滴计算潜能。