MySQL Inner Join 执行流程分析

本文基于 MySQL 5.7.35 在代码层面对内连接的 SQL 语句进行框架性分析,主要是介绍在连接执行过程中,涉及到哪些比较关键的环节,在这些环节干了什么事情,不会涉及到很细节的代码逻辑层面,不然整篇文章就会非常长了

查询执行阶段的缓冲区相关的逻辑,都是基于 BNL 类型的缓冲区,至于 BKA、BKA_UNIQUE 类型的缓冲区,还没有研究过,以后有时间再补充吧

1. 概述

为了先对执行过程有个整体印象,我们先来看一下搜索过程中调用的各层级的方法以及关键的代码行组成的树状结构:

由于执行过程中存储循环调用、嵌套调用,下面的树状结构,只是尽可能的还原连接在执行过程中的调用层级,而不是精确的的描述调用过程的层级(如果要精确描述调用过程的层级,需要基于具体 SQL 语句),大体的执行流程走向是没问题的,想要弄清楚代码执行细节的朋友,以本文为基础进行代码调试,相信可以节省一些时间

 1| SELECT_LEX::optimize()
 2|-- JOIN *const join_local= new JOIN(thd, this)
 3|-- -- JOIN(THD *thd_arg, SELECT_LEX *select): first_select(sub_select)
 4|-- JOIN::optimize()
 5|-- -- setup_join_buffering()
 6|-- -- make_join_readinfo()
 7|-- -- -- qep_tab->next_select= sub_select
 8|-- -- -- QEP_TAB::init_join_cache()
 9|-- -- -- -- QEP_TAB::op = new JOIN_CACHE_BNL(JOIN, this, NULL)
10|-- -- -- -- JOIN_CACHE_BNL::init()
11|-- -- -- -- -- JOIN_CACHE::calc_record_fields()
12|-- -- -- -- -- -- calc_used_field_length()
13|-- -- -- -- -- JOIN_CACHE::alloc_fields()
14|-- -- -- -- -- JOIN_CACHE::create_flag_fields()
15|-- -- -- -- -- -- add_flag_field_to_join_cache()
16|-- -- -- -- -- JOIN_CACHE::create_remaining_fields
17|-- -- -- -- -- -- add_table_data_fields_to_join_cache()
18|-- -- -- -- -- -- Field::fill_cache_field()
19|-- -- -- -- -- JOIN_CACHE::set_constants()
20|-- -- -- -- -- JOIN_CACHE::const_cond= make_cond_for_table()
21|-- -- -- -- this[-1].next_select= sub_select_op
22|-- -- -- QEP_TAB::pick_table_access_method()
23|-- -- JOIN::make_tmp_tables_info()
24|-- -- -- qep_tab[primary_tables + tmp_tables - 1].next_select = get_end_select_func()
25|-- -- -- -- JOIN::get_end_select_func()
26| JOIN::exec()
27|-- do_select()
28|-- -- sub_select(join, qep_tab, 0)
29|-- -- -- join_init_read_record()
30|-- -- -- -- init_read_record()
31|-- -- -- -- -- QEP_TAB::read_record->read_record= rr_sequential、rr_quick ...
32|-- -- -- -- QEP_TAB::read_record->read_record()
33|-- -- -- evaluate_join_record()
34|-- -- -- -- sub_select_op()
35|-- -- -- -- -- JOIN_CACHE::put_record()
36|-- -- -- -- -- -- JOIN_CACHE::put_record_in_cache()
37|-- -- -- -- -- -- -- JOIN_CACHE::write_record_data()
38|-- -- -- -- -- -- JOIN_CACHE::join_records()
39|-- -- -- -- -- -- -- JOIN_CACHE_BNL::join_matching_records()
40|-- -- -- -- -- -- -- -- join_init_read_record()
41|-- -- -- -- -- -- -- -- -- init_read_record()
42|-- -- -- -- -- -- -- -- -- -- QEP_TAB::read_record->read_record= rr_sequential、rr_quick ...
43|-- -- -- -- -- -- -- -- QEP_TAB::read_record->read_record()
44|-- -- -- -- -- -- -- -- const bool consider_record= const_cond->val_int() != FALSE;
45|-- -- -- -- -- -- -- -- JOIN_CACHE::get_record()
46|-- -- -- -- -- -- -- -- -- JOIN_CACHE::get_size_of_rec_offset()
47|-- -- -- -- -- -- -- -- -- JOIN_CACHE::get_rec_ref()
48|-- -- -- -- -- -- -- -- -- JOIN_CACHE::read_some_record_fields()
49|-- -- -- -- -- -- -- -- -- -- JOIN_CACHE::read_some_flag_fields()
50|-- -- -- -- -- -- -- -- -- -- JOIN_CACHE::blob_data_is_in_rec_buff()
51|-- -- -- -- -- -- -- -- -- -- JOIN_CACHE::read_record_fields()
52|-- -- -- -- -- -- -- -- -- JOIN_CACHE::get_record_by_pos()
53|-- -- -- -- -- -- -- -- JOIN_CACHE::generate_full_extensions()
54|-- -- -- -- -- -- -- -- -- JOIN_CACHE::check_match()
55|-- -- -- -- -- -- -- -- -- -- QEP_TAB::skip_record()
56|-- -- -- -- -- -- -- -- -- JOIN_CACHE::set_curr_rec_link()
57|-- -- -- -- -- -- -- -- --(qep_tab->next_select)(join, qep_tab+1, 0) => sub_select_op(..., qep_tab+1, ...) // 读取下一个表的记录

接下来的各小节就是对上面树状结构中的各个关键环节进行说明了,由于树状结构嵌套较深,所以接下来会使用相对扁平化的结构来说明各各个关键环节都干了什么事情

2. 查询优化阶段

2.1 SELECT_LEX::optimize()

本方法要介绍的有两个关键逻辑:JOIN 类实例化、调用 JOIN::optimize() 对 SQL 语句进行优化

代码如下:

1// sql/sql_select.cc SELECT_LEX::optimize()
2
3// 1010 行
4JOIN *const join_local= new JOIN(thd, this);
5
6// 1016 ~ 1017 行
7// join = join_local,即用上面实例化的 JOIN 对象调用 optimize() 方法
8if (join->optimize())
9  DBUG_RETURN(true);

在 new JOIN() 创建 JOIN 类的实例时,JOIN::first_select 函数指针被初始化设置为 sub_select,另个一赋值的地方是在 JOIN::make_tmp_tables_info() 中,当需要为查询创建临时表时,并且是所有表的查询计划都是 const 时,会把 JOIN::first_select 赋值为 sub_select_op(这个地方的逻辑还没有明白,按理说如果所有表的查询计划都是 const 时,每个表都最多只能查询出一条记录,这种情况下排序、distinct、group 都是不需要的,为啥还会创建临时表?等后面搞明白了再补充吧)

2.2 JOIN::optimize()

setup_join_buffering() 的逻辑是确定使用什么类型的连接缓冲区:BKA(Batched Key Access)、BKA_UNIQUE(Batched Key Access UNIQUE)、BNL(Blocked Nested Loop),确定缓冲区类型后,调用 JOIN_TAB::set_use_join_cache() 方法把缓冲区类型保存到 JOIN_TAB::m_use_join_cache 属性中,后面 JOIN_CACHE_BNL::init() 中会用到

make_join_readinfo() 在接下来的 2.3 小节介绍

JOIN::make_tmp_tables_info() 的逻辑是临时表,以及为连接操作的最后一个表的执行计划(QEP_TAB)设置 next_select 属性,用于结束连接过程中一个完整的行的操作,并把找到的行发送给客户端(只发送客户端需要的字段),代码如下:

1// sql/sql_select.cc JOIN::make_tmp_tables_info()
2
3// 3928 ~ 2932 行
4if (qep_tab)
5{
6  qep_tab[primary_tables + tmp_tables - 1].next_select=
7  get_end_select_func();
8}

2.3 make_join_readinfo()

make_join_readinfo() 中首先会为连接操作的每个表的执行计划(QEP_TAB)的 next_select 属性设置一个默认值,从当前表读取一行或多行(使用连接缓冲区同是要读取多行,到缓冲区满)符合 WHERRE 条件和 ON 连接条件的数据之后,会执行 QEP_TAB::next_select(),把多个表连接形成的行发送到客户端,或者再从存储引擎读取下一个表的记录,来和当前表的记录进行连接,代码如下:

1// sql/sql_select.cc make_join_readinfo()
2
3// 2175 行
4// 这是为每个 qep_tab->next_select 设置个默认值
5// 对于连接操作中最后一个表的,在 JOIN::make_tmp_tables_info() 再次赋值
6// 详情见 2.2 JOIN::optimize() 小节
7qep_tab->next_select=sub_select;

接下来会调用 QEP_TAB::init_join_cache() 对连接缓冲区进行初始化,这个留到 2.4 小节再说

QEP_TAB::pick_table_access_method() 方法的逻辑比较简单,是根据每个执行计划访问表的方法(ref、ref_or_null、const、range、all ...)来确定代码中调用哪个方法从存储引擎读取数据,主要是初始化 QEP_TAB 的 read_first_record、read_record.read_record、read_record.unlock_row 属性,这些属性都是函数指针

如果 QEP_TAB::read_first_record 被赋值为 join_init_read_record(),在执行 join_init_read_record() 时,会为 read_record.read_record、read_record.unlock_row 赋值

2.4 QEP_TAB::init_join_cache()

初始化连接缓冲区,首先得知道要使用哪种类型的缓冲区,所以会先根据 JOIN_TAB::m_use_join_cache 属性判断使用哪种类型的缓冲区,然后对相应的类型的缓冲区类进行实例化,代码如下:

 1// sql/sql_select.cc QEP_TAB::init_join_cache()
 2
 3// 2058 ~ 2072 行
 4  switch (join_tab->use_join_cache())
 5  {
 6  case JOIN_CACHE::ALG_BNL:
 7    op= new JOIN_CACHE_BNL(join_, this, prev_cache);
 8    break;
 9  case JOIN_CACHE::ALG_BKA:
10    op= new JOIN_CACHE_BKA(join_, this, join_tab->join_cache_flags, prev_cache);
11    break;
12  case JOIN_CACHE::ALG_BKA_UNIQUE:
13    op= new JOIN_CACHE_BKA_UNIQUE(join_, this, join_tab->join_cache_flags, prev_cache);
14    break;
15  default:
16    assert(0);
17
18  }

代码中一会使用 JOIN_TAB,一会使用 QEP_TAB,让人比较晕,实际上 QEP_TAB 是从 JOIN_TAB 初始化而来的,在优化阶段结束之后,JOIN_TAB 就会被释放了,在执行过程中只会使用 QEP_TAB,所以我们可以看到 JOIN_TAB 和 QEP_TAB 时,暂且可以认为这两个类是同一个东西,先不用那么细的区分

实例化了相应类型的连接缓冲区对象之后,会通过 op->init() 对该类型的连接缓冲区进行初始化,这个留到 2.5 小节再说

接下会是对 QEP_TAB::next_select 进行赋值,在 2.3 make_join_readinfo() 中一开始就对 QEP_TAB::next_select 进行了初始化,默认设置为 sub_select,不使用连接缓冲区就是调用 sub_select() 读取连接操作的下一个表的记录,而对于使用连接缓冲区的连接查询来说,需要调用 sub_select_op() 来读取连接操作的下一个表的记录,因此需要再次赋值,代码如下:

1// sql/sql_select.cc QEP_TAB::init_join_cache()
2
3// 2118 行
4this[-1].next_select= sub_select_op;

sub_select 我目前(2022 年 1 月 16 日)只发现了 SELECT 语句执行第一个表的查询时会用到,后面有新发现再补充

到这里,QEP_TAB::next_select 的赋值逻辑已经出现了 3 次了,并且因为我们采用了扁平化的方式来计划各层级逻辑调用,到这里容易搞懵了,所以在这里总结一下 3 次赋值的执行顺序:

  • 第 1 次:默认赋值(sub_select),在 sql/sql_select.cc make_join_readinfo() 的 2175 行
  • 第 2 次:赋值为 sub_select_op,在 sql/sql_select.cc QEP_TAB::init_join_cache() 2118 行,也就是本小节所讲的赋值逻辑,用于从存储引擎读取连接操作的下一个表的数据
  • 第 3 次:调用 get_end_select_func() 方法获取相应的函数指针,赋值为 end_send 或者 end_send_group,在 sql/sql_select.cc JOIN::make_tmp_tables_info() 的 3930 行,用于把连接操作中多表记录连接成的行发送到客户端

2.5 JOIN_CACHE_BNL::init()

先调用 JOIN_CACHE::calc_record_fields() 主要是计算要写入缓冲区的字段数量、这些字段中包含的 blob 字段的数量,这里指的字段包含标记字段、数据字段(即表中的字段),其计算过程主要是在 calc_used_field_length() 方法里完成的

接着调用 JOIN_CACHE::alloc_fields() 为缓冲区字段描述符(即字段结构 CACHE_FIELD)分配内存,以及指向 blob 字段描述符的指针,这个指针在往连接缓冲区中写入记录时会使用到,主要作用是为了每次连接缓冲区写满时,能够减少一次从存储引擎中复制 blob 字段的内容到连接缓冲区中

接着调用 JOIN_CACHE::create_flag_fields() 初始化标记字段的 CACHE_FIELD 对象,标记字段有 3 个,缓冲区中的记录有可能包含 0 ~ 3 个标记字段,取决于不同的情况,可以参照另一篇文章:MySQL 数据在连接缓冲区是怎么存储的?

JOIN_CACHE::create_flag_fields() 调用 add_flag_field_to_join_cache() 对 CACHE_FIELD 对象进行初始化,根据连接缓冲区中标记字段的量,可能会调用 0 ~ 3 次:

  • 初始化匹配标记 CACHE_FIELD 对象:关键参数为 QEP_TAB::found 属性,标识记录是否匹配
  • 初始化字段 NULL 标记 CACHE_FIELD 对象:关键参数为 TABLE::null_flags 属性,标记写入缓冲区中的字段是否可能为 NULL(创建表结构时,没有 NOT NULL 的字段都是可能为 NULL 的)
  • 初始化行 NULL 标记 CACHE_FIELD 对象:关键参数为 TABLE::null_row 属性,标记表的整行都为 NULL,这个只有外连接查询的第一个内表、以及首次匹配半连接的第 2 个及以后的内表才会有这个标记字段

接着调用 JOIN_CACHE::create_remaining_fields() 初始化数据字段的 CACHE_FIELD 对象,这主要是通过在该方法再调用 add_table_data_fields_to_join_cache() => Field::fill_cache_field() 实现的

接着调用 JOIN_CACHE::set_constants() 最主要的是干了两件事:

  • 缓冲区的大小:可通过 join_buffer_size 系统变量指定(默认是 256K),但是有一个前提,不管系统变量指定的是多大,必须要够存入 2 条记录,如果不够的话,那缓冲区大小就是要存入缓冲区的一条记录的总长度 * 2(这个计算结果是有可能大于 join_buffer_size 指定的值的)
  • 存储到缓冲区中的一条记录的长度(占用字节数):对于 blob 字段,实际内容的长度要在写入记录到缓冲区才会计算,而对于像 varchar、varbinary 这样的变长字段来说,在调用 add_table_data_fields_to_join_cache() 时,会以创建表结构时指定的字段长度算出来写满该字段时最大需要占用多少字段(不过实际写入过程中一般不会写满,所以实际写入内容时一行的长度要小于这里算出来的长度)

JOIN_CACHE_BNL::init() 中最后一段逻辑,是把连接缓冲区所属表(假设表名为 t)上的查询条件(包含 WHERE 条件和 ON 连接条件)中的,只属于 t 表的字段的那些 WHERE 条件,或者 t 表和其它常数表的 ON 连接条件,都存放到 JOIN_CACHE_BNL::const_cond 属性中,在执行连接操作时,这个属性中保存的单表查询条件以及和常数表的连接条件用来过滤掉从存储引擎读取出来的记录,避免了和连接操作中其它的非常数表进行连接操作浪费时间

常数表,指的表中没有记录、只有 1 条记录,或者是通过 WHERE 条件只能查出一条记录,在查询优化阶段,就会读取该表的记录,并把 SELECT 语句中该表对应的字段都替换为实际的值,在执行阶段,就不需要再去读取该表了,SELECT 语句中这样的表就是常数表

2.6 JOIN_CACHE_BKA::init()

还没有研究过 BKA 类型的缓冲区,等后面研究过了再补充吧

3. 查询执行阶段

3.1 do_select()

do_select() 是连接查询(单表查询也是连接查询,是连接查询的特例)的入口函数,会执行 2 次 join->first_select():

  • 第 1 次:调用 join->first_select(join,qep_tab,0) 开始执行连接查询的第一个表的查询计划
  • 第 2 次:当第 1 次调用 join->first_select(join,qep_tab,0) 的返回值大于等于 NESTED_LOOP_OK 时,会再次调用 join->first_select(join,qep_tab,1) 结束查询

代码如下:

1// sql/sql_executor.cc do_select()
2
3// 955 ~ 959 行
4QEP_TAB *qep_tab= join->qep_tab + join->const_tables;
5assert(join->primary_tables);
6error= join->first_select(join,qep_tab,0);
7if (error >= NESTED_LOOP_OK)
8  error= join->first_select(join,qep_tab,1);

JOIN::first_select 属性是一个函数指针,可能的值为 sub_select、sub_select_op,本文只讲 JOIN::first_select = sub_select 这种情况

3.2 sub_select()

sub_select() 循环从存储引擎读取表中的记录,代码如下:

 1// sql/sql_executor.cc sub_select()
 2
 3// 1278 ~ 1306 行
 4while (rc == NESTED_LOOP_OK && join->return_tab >= qep_tab_idx)
 5  {
 6    int error;
 7    if (in_first_read) // 读取第 1 行
 8    {
 9      in_first_read= false;
10      error= (*qep_tab->read_first_record)(qep_tab);
11    }
12    else // 读取第 2 行及后面的行
13      error= info->read_record(info);
14
15    DBUG_EXECUTE_IF("bug13822652_1", join->thd->killed= THD::KILL_QUERY;);
16
17    if (error > 0 || (join->thd->is_error()))   // Fatal error
18      rc= NESTED_LOOP_ERROR;
19    else if (error < 0)
20      break;
21    else if (join->thd->killed)
22    {
23      join->thd->send_kill_message();
24      rc= NESTED_LOOP_KILLED;
25    }
26    else
27    {
28      if (qep_tab->keep_current_rowid)
29        qep_tab->table()->file->position(qep_tab->table()->record[0]);
30      // 计算查询出来的记录是否符合 WHERE 条件
31      rc= evaluate_join_record(join, qep_tab);
32    }
33  }

上面的代码,error= info->read_record(info); 中的 info 是 QEP_TAB::read_record,所以这行代码是调用 QEP_TAB::read_record 对象的 read_record 方法(这是个函数指针,可能在 QEP_TAB::pick_table_access_method() 中赋值,也可能在 join_init_read_record() 中赋值)

从上面代码可见,读取第 1 行调用的方法(qep_tab->read_first_record())和读取第 2 行及后面的行调用的方法(info->read_record)是不一样的,这是因为某些情况下,qep_tab->read_first_record() 还肩负着设置从存储引擎读取数据调用的方法的使命,比如:当 qep_tab->read_first_record = join_init_read_record 时,在执行join_init_read_record() 过程中,会设置 info->read_record= rr_sequential 或者 rr_quick 等等

读取一条数据之后,调用 evaluate_join_record() 来判断该记录是否符合 WHERE 条件

3.3 join_init_read_record()

先上代码:

1// sql/sql_executor.cc join_init_read_record()
2
3// 2500 ~ 2504 行
4// 这里的 tab 是 QEP_TAB 的实例
5if (init_read_record(&tab->read_record, tab->join()->thd, NULL, tab,
6                       1, 1, FALSE))
7    return 1;
8
9  return (*tab->read_record.read_record)(&tab->read_record);

在 join_init_read_record() 中,会先初始化 QEP_TAB::read_record 对象中的各属性,初始化完成后,再调用 (*tab->read_record.read_record)(&tab->read_record) 读取第 1 条记录

前面讲 sub_select() 的小节中 info->read_record() 和 tab->read_record.read_record 这行调用的是一样的,因为 info = tab->read_record = QEP_TAB::read_record

3.4 evaluate_join_record()

evaluate_join_record() 主要是计算读取出来的记录是否符合 WHERE 条件,如果符合的话,对于单表查询来说,就会把记录发送给客户端;对于多表连接查询来说,会调用 qep_tab->next_select 触发对下一个表的读取

使用连接缓冲区的时候是调用 sub_select_op(),要等等到写满缓冲区,才触发读取下一个表; 不使用连接缓冲区时,调用 sub_select() 直接读取下一个表的数据

1// sql/sql_executor.cc evaluate_join_record()
2
3// 1499 行
4found= MY_TEST(condition->val_int());

上面的代码片段,就是判断记录是否符合 WHERE 条件的,是不是非常简单?(我开始看到的时候也很吃惊,竟然只有一行代码)

实际上是因为做了很多前期的准备工作,所以这在里只体现为一行代码,而且在调用这一行代码时会展开,val_int() 里面会形成嵌套的调用

接下来就是连接查询读取一个表的入口了,代码如下:

1// sql/sql_executor.cc evaluate_join_record()
2
3// 1652 行
4rc= (*qep_tab->next_select)(join, qep_tab+1, 0);

上面代码的 qep_tab->next_select 是个函数指针,有 3 次赋值,在前面讲 QEP_TAB::init_join_cache() 的那节讲过,该函数指针的值可能为:sub_select、sub_select_op、end_send、end_send_group,本小节会讲 sub_select_op、end_send() 这两个

在连接操作中第 2 ~ n-1 个表执行查询时调用 sub_select_op(),会往连接缓冲区写数据,写满再读取连接操作中下一个表的数据,来和连接缓冲区中的数据进行连接操作

最后一个表执行查询时调用 end_send(),把多表连接组成的行发送给客户端

end_send() 指的是全局的方法 end_send(JOIN *join, QEP_TAB *qep_tab, bool end_of_records),在 sql/sql_executor.cc 中定义,注间和 JOIN_CACHE::end_send() 方法区分开

3.5 sub_select_op()

sub_select_op() 的主要功能是:调用 JOIN_CACHE::put_records() 往连接缓冲区写数据,写满后再调用 JOIN_CACHE::join_records() 读取下一个表的记录来和连接缓冲区中的数据进行连接操作

sub_select_op() 的部分代码如下:

 1// sql/sql_executor.cc sub_select_op()
 2
 3// 1074 ~ 1090 行
 4  if (end_of_records)
 5  {
 6    rc= op->end_send();
 7    if (rc >= NESTED_LOOP_OK)
 8      rc= sub_select(join, qep_tab, end_of_records);
 9    DBUG_RETURN(rc);
10  }
11  if (qep_tab->prepare_scan())
12    DBUG_RETURN(NESTED_LOOP_ERROR);
13
14  /*
15    setup_join_buffering() disables join buffering if QS_DYNAMIC_RANGE is
16    enabled.
17  */
18  assert(!qep_tab->dynamic_range());
19
20  rc= op->put_record();

if (end_of_records),在 do_select() 中第二次调用 join->first_select(join, qep_tab, 1) 时,其中的 1 就对应的是 end_of_records,也就是说只有 do_select() 调用 sub_select_op() 时才会触发

在 do_select() 中第一次调用 join->first_select(join, qep_tab, 0) 时,会把从存储引擎读取出来的记录写入到连接缓冲区中,等到写满之后,会触发读取下一个表的记录,来和连接缓冲区中的记录进行连接操作,而如果往缓冲区中写入一些记录之后,还没有写满,就读完了表里的数据,那怎么触发读取下一个表的记录呢?

上面代码片段中的 if (end_of_records) 这一块代码就是实现这个逻辑的:缓冲区还没有写满时,就读完了表里的记录,if 代码块中的代码会调用 opt->end_send() => JOIN_CACHE::join_records() 来触发读取下一个表的记录

rc= op->put_record(); 就是把从存储引擎读取到的符合条件的记录写入连接缓冲区

3.6 JOIN_CACHE::put_records()

 1//sql/sql_join_buffer.h put_record()
 2
 3// 433 ~ 438 行
 4virtual enum_nested_loop_state put_record()
 5{
 6  if (put_record_in_cache())
 7    // 缓冲区已满,调用 join_records() 触发读取下一个表的记录
 8    return join_records(false);
 9  return NESTED_LOOP_OK;
10}

put_record() 里的逻辑很简单,就是调用 put_record_in_cache() 把记录写入连接缓冲区,如果 put_record_in_cache() 返回值为 true,就说明缓冲区写满了,就调用 JOIN_CACHE::records() 方法读取下一个表的记录,来和连接缓冲区中的记录进行连接操作

 1// sql/sql_join_buffer.cc JOIN_CACHE::put_record_in_cache()
 2
 3// 1469 ~ 1477 行
 4bool JOIN_CACHE::put_record_in_cache()
 5{
 6  bool is_full;
 7  uchar *link= 0;
 8  if (prev_cache)
 9    link= prev_cache->get_curr_rec_link();
10  write_record_data(link, &is_full);
11  return (is_full);
12}

put_record_in_cache() 的逻辑也比较简单,主要是判断了连接缓冲区有没有前一个缓冲区,如果有的话,当前要写入缓冲区的这条记录,就会关联前一个缓冲区的一条记录,把前一个缓冲区中的这条记录在地址传给 write_record_data() 方法

write_record_data() 方法才是真正往连接缓冲区写入数据的,关于这个方法的详情的代码分析会单独写一篇文章,后面写完了会把连接补在这里

3.7 JOIN_CACHE::join_records()

在内连接操作中,JOIN_CACHE::join_records() 的关键代码比较少,如下:

 1// sql/sql_join_buffer.cc JOIN_CACHE::join_records()
 2
 3// 1937 ~ 1938 行
 4/* Find all records from join_tab that match records from join buffer */
 5rc= join_matching_records(skip_last);
 6
 7// 1987 ~ 1998 行
 8if(next_cache)
 9{
10  /* 
11    When using linked caches we must ensure the records in the next caches
12    that refer to the records in the join buffer are fully extended.
13    Otherwise we could have references to the records that have been
14    already erased from the join buffer and replaced for new records. 
15  */
16  rc= next_cache->join_records(skip_last);
17    if (rc != NESTED_LOOP_OK)
18      goto finish;
19}

调用 join_matching_records() 从存储引擎读取记录,每读取一条记录,都会遍历一遍连接缓冲区中的记录,每读取一条连接缓冲区中的记录,都会和存储引擎读取出来的记录进行连接操作,具体在 JOIN_CACHE_BNL::join_matching_records() 和 JOIN_CACHE::generate_full_extensions() 相关的小节中说明

join_matching_records() 返回之后,会判断当前缓冲区有没有下一个缓冲区,如果有的话,要调用下一个缓冲区的对象的 join_records() 方法(next_cache->join_records())把当前表的记录和下一个表的记录进行连接操作,不展开说了

3.8 JOIN_CACHE_BNL::join_matching_records()

JOIN_CACHE_BNL::join_matching_records() 的关键代码比较多,是个比较大的 do ... while 循环,代码如下:

 1// sql/sql_join_buffer.cc JOIN_CACHE_BNL::join_matching_records()
 2
 3// 2096 ~ 2148 行
 4// 读取第 1 条记录
 5if ((error= (*qep_tab->read_first_record)(qep_tab)))
 6  return error < 0 ? NESTED_LOOP_OK : NESTED_LOOP_ERROR;
 7
 8READ_RECORD *info= &qep_tab->read_record;
 9do
10{
11  if (qep_tab->keep_current_rowid)
12    qep_tab->table()->file->position(qep_tab->table()->record[0]);
13
14  if (join->thd->killed)
15  {
16    /* The user has aborted the execution of the query */
17    join->thd->send_kill_message();
18    return NESTED_LOOP_KILLED;
19  }
20  
21  /* 
22    Do not look for matches if the last read record of the joined table
23    does not meet the conditions that have been pushed to this table
24  */
25  if (rc == NESTED_LOOP_OK)
26  {
27    join->examined_rows++;
28    if (const_cond)
29    {
30      // const_cond 中保存着当前连接缓冲区上所属的表的单表 WHERE 条件
31      // 以及和常数表的 ON 连接条件,用于先过滤一些不符合条件的记录
32      // 避免不符合条件的记录和连接缓冲区中的记录进行不必要的连接操作(因为根本匹配不上)
33      // 这里的也是通过调用 const_cond->val_int() != FALSE 就判断了记录是否符合条件
34      // 和 evaluate_join_record() 中的逻辑是一样的
35      const bool consider_record= const_cond->val_int() != FALSE;
36      if (join->thd->is_error())              // error in condition evaluation
37        return NESTED_LOOP_ERROR;
38      if (!consider_record)
39        continue;
40    }
41    {
42      /* Prepare to read records from the join buffer */
43      reset_cache(false);
44
45      // 这个 for 循环就是整个使用连接缓冲区的精华部分了,要多来点说明
46      // 为了好描述,现在假设有两个表进行连接操作:t1 表、t2 表
47      // 此刻,连接缓冲区中的是 t1 表的记录,从存储引擎读取出来的是 t2 表的记录
48      // 从连接缓冲区中循环调用 get_record() 读取 t1 表的记录
49      // 从连接缓冲区读取 t1 的记录之后,再调用 generate_full_extensions()
50      // generate_full_extensions() 会判断 t2 表的记录是否符合所有的 WHERE 条件和 ON 连接条件
51      // 如果符合,且 t2 表后面还有个 t3 表,则会再去存储引擎读取 t3 表的记录进行连接操作
52      /* Read each record from the join buffer and look for matches */
53      for (uint cnt= records - MY_TEST(skip_last) ; cnt; cnt--)
54      {
55        /* 
56          If only the first match is needed and it has been already found for
57          the next record read from the join buffer then the record is skipped.
58        */
59        if (!check_only_first_match || !skip_record_if_match())
60        {
61          get_record();
62          rc= generate_full_extensions(get_curr_rec());
63          if (rc != NESTED_LOOP_OK)
64            return rc;
65        }
66      }
67    }
68  }
69} while (!(error= info->read_record(info))); // 循环读取第 2 条及以后的记录

主要的逻辑在注释里都做了说明,没有其它要说的了

3.9 JOIN_CACHE::get_record()

JOIN_CACHE::get_record() 就是从连接缓冲区中读取一条记录,代码不多,全都贴出来:

 1// sql/sql_join_buffer.cc JOIN_CACHE::get_record()
 2
 3// 1505 ~ 1531 行
 4bool JOIN_CACHE::get_record()
 5{ 
 6  bool res;
 7  uchar *prev_rec_ptr= 0;
 8  if (with_length)
 9    // 如果连接缓冲区中存储了行的长度,读取时跳过行长度,从连接缓冲区中读取记录时,不需要读长度字段
10    pos+= size_of_rec_len;
11  if (prev_cache)
12  {
13    // 指针指向存储前一个缓冲区的记录的 offset 之后,也就是指向了当前缓冲区的记录的第一个标记字段
14    pos+= prev_cache->get_size_of_rec_offset();
15    prev_rec_ptr= prev_cache->get_rec_ref(pos);
16  }
17  // 指向当前缓冲区的记录的开头(第一个标记字段内容的首地址)
18  curr_rec_pos= pos;
19  // 读取记录的字段,包括标记字段和数据字段
20  res= (read_some_record_fields() == -1);
21  if (!res) 
22  { // There are more records to read
23    pos+= referenced_fields*size_of_fld_ofs;
24    if (prev_cache)
25    {
26      /*
27        read_some_record_fields() didn't read fields stored in previous
28        buffers, read them now:
29      */
30      // 根据前面获取到的指向关联的前一个缓冲区中的记录的指针,读取关联记录的内容
31      prev_cache->get_record_by_pos(prev_rec_ptr);
32    }
33  } 
34  return res; 
35}

接下来简单说一下 JOIN_CACHE::get_record() 中调用到的那些方法的逻辑

3.9.1 JOIN_CACHE::get_rec_ref()

根据当前缓冲区的当前记录中存储的获取前一个缓冲区中的记录的 Offset,获取关联的前一个缓冲区中的记录的指针

1// sql/sql_join_buffer.h get_rec_ref()
2
3// 316 ~ 319 行
4uchar *get_rec_ref(uchar *ptr)
5{
6  return buff+get_offset(size_of_rec_ofs, ptr-size_of_rec_ofs);
7}

因为是通过 prev_cache->get_rec_ref() 调用的 get_rec_ref(),所以代码中的 buff 是前一个缓冲区的开始处的地址

ptr 是当前缓冲区的指针,传到 get_rec_ref() 时,指向的是存储前一个缓冲区关联记录的 Offset 之后的位置,所以 ptr-size_of_rec_ofs 就是把 ptr 指针指回到存储前一个缓冲区关联记录的 Offset 之前的位置,再把这个指针传 get_offset() 方法,就可以读取到前一个缓冲区的 Offset 的值了,用 buff+get_offset(...) 就获取到了前前一个缓冲区的关联记录的 Offset 了

关于 ptr 指针的指向,不太好理解,画了一张图:

3.9.2 JOIN_CACHE::read_some_record_fields()

JOIN_CACHE::read_some_record_fields() 从连接缓冲区中读取记录的标记字段数据字段,代码如下:

 1// sql/sql_join_buffer.cc JOIN_CACHE::read_some_record_fields()
 2
 3// 1619 ~ 1637 行
 4int JOIN_CACHE::read_some_record_fields()
 5{
 6  uchar *init_pos= pos;
 7  
 8  if (pos > last_rec_pos || !records)
 9    return -1;
10
11  // First match flag, read null bitmaps and null_row flag
12  read_some_flag_fields();
13
14  /* Now read the remaining table fields if needed */
15  CACHE_FIELD *copy= field_descr+flag_fields;
16  CACHE_FIELD *copy_end= field_descr+fields;
17  bool blob_in_rec_buff= blob_data_is_in_rec_buff(init_pos);
18  for ( ; copy < copy_end; copy++)
19    read_record_field(copy, blob_in_rec_buff);
20
21  return (uint) (pos-init_pos);
22}

调用 JOIN_CACHE::read_some_flag_fields() 读取标记字段,逻辑比较简单,不展开说了

blob_data_is_in_rec_buff() 方法是判断记录是不是缓冲区中的最后一条记录,并且记录中的 blob 字段的内容留在了存储引擎给 blob 字段分配的内存中(row_prebuilt_t::blob_heap)

调用 JOIN_CACHE::read_record_field() 读取数据字段,代码如下:

 1// sql/sql_join_buffer.cc JOIN_CACHE::read_record_field()
 2
 3// 1687 ~ 1740 行
 4// 下面代码片段中,删除了代码的地方加了说明,删除了注释的地方没有加说明
 5uint JOIN_CACHE::read_record_field(CACHE_FIELD *copy, bool blob_in_rec_buff)
 6{
 7  uint len;
 8  // 如果字段内容为 NULL,就不用读取了
 9  if (copy->field && copy->field->maybe_null() && copy->field->is_null())
10    return 0;           
11  if (copy->type == CACHE_BLOB)
12  {
13    Field_blob *blob_field= (Field_blob *) copy->field;
14    if (blob_in_rec_buff)
15    {
16      // 如果读取缓冲区中最后一条记录的 blob 字段
17      // 并且 blob 字段的内容留在了存储引擎给 blob 字段分配的内存中
18      // 此时连接缓冲区中存储的是内容的指针
19      // 只需要从连接缓冲区中读取内容的长度,以及内容的指针就可以了
20      blob_field->set_image(pos, copy->length+sizeof(char*),
21			    blob_field->charset());
22      len= copy->length+sizeof(char*);
23    }
24    else
25    {
26      // 如果连接缓冲区中存储的是 blob 字段的内容
27      // 则读取连接缓冲区中 blob 字段内容的地址,并保存到 Field_blob 字段的 ptr 属性中
28      blob_field->set_ptr(pos, pos+copy->length);
29      len= copy->length+blob_field->get_length();
30    }
31  }
32  else
33  {
34    switch (copy->type) {
35    // 此处省略了一些代码
36    // 对于 CHAR、BINARY 字段来说,因为在把 CHAR、BINARY 字段的内容存到缓冲区之前
37    // 为了节省存储空间,去掉了内容后面的空格,这里把空格给补回来
38    case CACHE_STRIPPED:
39      /* Pad the value by spaces that has been stripped off */
40      len= uint2korr(pos);
41      memcpy(copy->str, pos+2, len);
42      memset(copy->str+len, ' ', copy->length-len);
43      len+= 2;
44      break;
45    // 此处省略了一些代码
46  }
47  pos+= len;
48  return len;
49}

3.9.3 JOIN_CACHE::get_record_by_pos()

 1// sql/sql_join_buffer.cc JOIN_CACHE::get_record_by_pos()
 2
 3// 1552 ~ 1563 行
 4void JOIN_CACHE::get_record_by_pos(uchar *rec_ptr)
 5{
 6  uchar *save_pos= pos;
 7  pos= rec_ptr;
 8  // 根据 JOIN_CACHE::get_record() 中读取出来的关联的前一个缓冲区的记录的指针
 9  // 取关联记录的内容
10  read_some_record_fields();
11  pos= save_pos;
12  if (prev_cache)
13  {
14    uchar *prev_rec_ptr= prev_cache->get_rec_ref(rec_ptr);
15    // 递归调用,再获取更前面一个缓冲区的记录
16    // 这个递归调用会进行多次,直到某个缓冲区的 prev_cache = NULL
17    // 即没有前一个缓冲区时截止
18    prev_cache->get_record_by_pos(prev_rec_ptr);
19  }
20}

3.10 JOIN_CACHE::generate_full_extensions()

 1// sql/sql_join_buffer.cc JOIN_CACHE::generate_full_extensions()
 2
 3// 2244 ~ 2272 行
 4// rec_ptr 是连接缓冲区中的记录,不是当前从存储引擎读取出来的记录
 5enum_nested_loop_state JOIN_CACHE::generate_full_extensions(uchar *rec_ptr)
 6{
 7  enum_nested_loop_state rc= NESTED_LOOP_OK;
 8  // 判断从存储引擎读取出来的记录是否符合 WHERE 条件和 ON 连接条件
 9  if (check_match(rec_ptr))
10  {
11    int res= 0;
12    if (!qep_tab->check_weed_out_table ||
13        !(res= do_sj_dups_weedout(join->thd, qep_tab->check_weed_out_table)))
14{
15      // rec_ptr 是从连接缓冲区中读取出来的记录,保存到 JOIN_CACHE::curr_rec_link 属性中
16      // 把当前从存储引擎读取出来的记录,写入下一个连接缓冲区时
17      // 会在下一个连接缓冲区中的记录中保存一个 rec_ptr 记录在其所属缓冲区的 Offset
18      set_curr_rec_link(rec_ptr);
19      rc= (qep_tab->next_select)(join, qep_tab + 1, 0);
20      // 此处省略了代码
21    }
22    // 此处省略了代码
23  }
24  return rc;
25}

多个连接缓冲区会组成一个链表,感兴趣可以看一下这篇文章:MySQL 数据在连接缓冲区是怎么存储的?

rc= (qep_tab->next_select)(join, qep_tab + 1, 0) 这一行的调用最为关键,如果连接操作中还有下一个表的数据要读取,qep_tab->next_select 为 sub_select_op,调用 sub_select_op() 就会把当前从存储引擎读取的记录写入到下一个表的连接缓冲区中,后续的逻辑就重复本文前面的流程了

如果当前读取的表是连接操作中的最后一个表,qep_tab->next_select 为 end_send,调用 end_send() 会把符合 WHERE 条件和 ON 连接条件的多表连接而成的行发送给客户端

此处的 end_send 指是的全局的 end_send() 方法,不是 JOIN_CACHE::end_send()




欢迎扫码关注公众号,我们一起学习更多 MySQL 知识: