MySQL 简单查询语句执行过程分析(二)查询准备阶段

本文是 MySQL 简单查询语句执行过程分析 6 篇中的第 2 篇,第 1 篇请看这里:
MySQL 简单查询语句执行过程分析(一)词法分析 & 语法分析

这一篇主要讲的内容是一条简单查询语句,在查询准备阶段会干哪些事情?分 3 个部分:

  • 打开表
  • select * 替换为表字段
  • 填充 where 条件

示例表及 SQL 如下:

 1-- 表结构
 2CREATE TABLE `t_recbuf` (
 3  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
 4  `i1` int(10) unsigned DEFAULT '0',
 5  `str1` varchar(32) DEFAULT '',
 6  `str2` varchar(255) DEFAULT '',
 7  `c1` char(11) DEFAULT '',
 8  `e1` enum('北京','上海','广州','深圳','天津','杭州','成都','重庆','苏州','南京','洽尔滨','沈阳','长春','厦门','福州','南昌','泉州','德清','长沙','武汉') DEFAULT '北京',
 9  `s1` set('吃','喝','玩','乐','衣','食','住','行','前后','左右','上下','里外','远近','长短','黑白','水星','金星','地球','火星','木星','土星','天王星','海王星','冥王星') DEFAULT '',
10  `bit1` bit(8) DEFAULT b'0',
11  `bit2` bit(17) DEFAULT b'0',
12  `blob1` blob,
13  `d1` decimal(10,2) DEFAULT NULL,
14  PRIMARY KEY (`id`)
15) ENGINE=InnoDB AUTO_INCREMENT=2001 DEFAULT CHARSET=utf8;
16
17-- 查询语句
18select * from t_recbuf where i1 > 49276

接下来,我们进入正题,展开讲讲这 3 部分内容。

1. 打开表

从存储引擎读取数据之前,MySQL 需要把 SQL 中涉及的所有表的信息读取出来。研究源码之前,我想像中的打开表就是读取 frm 文件中的信息,构造出来一个对象啥的,然后就没有然后了,不知道正在看文章的你想象中的打开表的过程是什么样的呢?

MySQL 打开表的过程比较复杂,读取 frm 文件并进行处理的一个方法就有 1700+ 行代码,还不包括调用的其它方法。

正是因为打开表的过程复杂,而代码复杂意味着执行效率下降,这对于 MySQL 来说是不能接受的,所以必定要有优化手段。

每次执行 SQL 的时候,不管是增、删、改、查,还是修改表结构,都要打开表,可见打开表是个非常频繁的操作,对于这种复杂而又频繁的操作,能用什么优化手段呢?聪明如你,一定能够想到,当然是用缓存了。

没错,MySQL 中就是用缓存的思想实现的,而且是本机内存缓存,效率极高。天下武功,唯快不破,为了快的极致,MySQL 还不只用了一级缓存,而是用了两级缓存。

一级缓存

一级缓存是TABLE 类实例缓存,顾名思义,该缓存中保存的就是已经创建好的 TABLE 类实例,是之前的连接中使用过的 TABLE 类实例,用完之后又放回到缓存中了,所以从这个缓存里拿到的 TABLE 类实例,可以直接使用。打开表时,第一步就是从这个缓存中去拿 TABLE 类实例

TABLE 类实例中保存的是表结构信息。

从缓存中查找 TABLE 类实例,需要一个 key,这个 key 是由数据库名和表名组成的,key 的形式是 dbname_tablename

TABLE 类实例缓存实际上并不是只有 1 个,而是有多个,数量由系统变量 table_open_cache_instances 控制,默认为 16。总共可以缓存多少个实例由系统变量 table_open_cache 控制,默认为 2000,所以默认情况下,每个 TABLE 类实例缓存可以缓存 2000 / 16 = 125 个 TABLE 类实例。

同一个表的 TABLE 类实例,在缓存中可以存在很多个,理论上限是 table_open_cache,就是缓存的全都是同一个表的 TABLE 类实例。

那么,怎么确定要从哪个 TABLE 类实例缓存读取 TABLE 类实例呢?是这样的:每个连接都会有一个线程 ID,用线程 ID % table_open_cache_instances 得到一个序号,通过序号找到对应的 TABLE 类实例缓存

既然是缓存,就会涉及到缓存满了怎么办的问题,对于 TABLE 类实例缓存,当往缓存中放入 TABLE 类实例时,会判断缓存是否已满,如果满了,则按照最近最少使用原则,把多出来的 TABLE 类实例释放掉。还有另一种方式就是手动了,执行 FLUSH TABLES 命令可以清空缓存。

如果从一级缓存中没有读取到 TABLE 类实例,就要进入二级缓存的处理流程了,二级缓存逻辑比一级缓存复杂,所以执行效率要低一些。

二级缓存

二级缓存是 TABLE_SHARE 类实例缓存,可以缓存的 TABLE_SHARE 类实例数量由系统变量 table_definition_cache 控制,默认为 1400,每个表只对应一个 TABLE_SHARE 实例,从这个缓存中读取到 TABLE_SHARE 类实例以后,用该实例中的各个属性去创建并初始化一个 TABLE 类实例,然后就可以使用 TABLE 类实例进行后续的操作了。

TABLE_SHARE 类实例中保存的也是表结构信息,TABLE 类实例中的数据就是从 TABLE_SHARE 类实例中复制过来的。

如果从二级缓存中没有读取到可以用于初始化 TABLE 类实例的表结构信息,就只能从表 frm 文件中读取了。

读取 frm 文件

到这一步,要从 frm 文件中读取表名、表注释、字段名、字段类型、字段注释、索引等所有信息,并且进行一大堆各种检查,然后创建 TABLE_SHARE 类实例,再用 TABLE_SHARE 类实例创建 TABLE 类实例。

从 frm 文件中读取信息构建 TABLE_SHARE 类实例这个过程,逻辑太复杂,执行效率就更低了。

然而不管怎样,只要表存在,并且服务器没问题,最多执行完上面 3 个步骤,就能拿到 TABLE 类实例了,然后就可以赋值给昨天说的 TABLE_LIST 的 table 属性了,从此,TABLE_LIST 就完整了。

2. select * 替换为表字段

我们在写 select 语句的过程中,经常会用到星号(*),表示查询表中所有字段,但是表中并没有一个星号字段用来表示所有字段,所以在查询准备阶段,会把星号替换为表中的所有字段。

这个替换过程比较简单,直接遍历表中的所有字段,为每个字段创建一个 Item_field 类实例,并且由于是直接遍历表中的 Field 子类实例列表,在创建 Item_field 类实例的时候就关联上了 Field 子类实例,不需要进行小蝌蚪找妈妈的过程了。

遍历完表中所有字段之后,形成一个 Item_field 列表,替换掉星号(*)对应的 Item_field 列表就行了,至此,就完成了 select 语句中星号替换为表字段的过程了。

3. 填充 where 条件

示例 SQL 的 where 中只有一个条件(i1 > 49276),条件中的 i1 字段也是一个 Item_field 类实例,需要找到对应表中的字段,并且关联上该字段的 Field 子类实例。

where 条件中的字段找到对应 Field 子类实例的过程,是这样的: 遍历 SQL 中使用到的表,在遍历每个表的过程中,根据字段名查询表中有没有这个字段,如果没有,继续去下一个表找。如果找到了呢?那也不是就万事大吉了,像 i1 > 49276 中的 i1 字段,前面没有限定数据库名和表名,也还要继续遍历下一个表查找字段。

只有像 where 数据库名.表名.字段名 > 49276 这样,字段前面带有限定的数据库名和表名时,找到一个字段之后,才能立马结束查找过程,而不用遍历整个查询语句中使用到的所有表。

为什么在某个表中找到了字段之后不停止查找,还要继续遍历下一个表呢?

这是为了判断字段名是不是存在冲突,如果同一个字段名可以在大于 1 个表中找到对应的字段,说明字段名冲突了,就会报错:1052 - Column 'i1' in field list is ambiguous

在这个过程中,为了提升根据字段名查找对应 Field 子类实例的性能,也使用了两级缓存。

一级缓存

一级缓存在 Item_field 类实例中保存字段在表中的序号,通过这个序号可以直接找到 Field 子类实例,就能一步到位了。

不过可惜的是,一级缓存是给 PREPARE Statement 使用的,本文中的示例 SQL 用不上。

二级缓存

二级缓存是一个 hash,key 是字段名,value 是字段 Field 子类实例。

前面说过查找字段的过程是遍历表,然后在遍历的当前表中查找字段,二级缓存中的 hash 是挂靠在表(TABLE_SHARE 类实例)上的,所以可以只用字段名作为 key。

又要可惜了,本文示例 SQL 中的 i1 字段是用不上 hash 查找了,因为只有当表中的字段数量大于等于 32时,才会为该表创建 hash,用于字段查找。

既然字段名 hash 是挂靠在 TABLE_SHARE 类实例上的,那么就是共享的,可以一次创建,无限次使用,边际成本为 0,为什么不是每个表都使用 hash 来进行字段查找?这点我也没想明白。

如果上面说的两级缓存都用不上,那就剩一条路了,就是:遍历。 遍历表中的每一个字段,然后比较该字段名和要查找的字段名是不是一样,如果一样那就是找到了,如果不一样,再接着遍历,直到遍历完表中的所有字段。

到这里,就把我们上一篇留下的小蝌蚪怎么找妈妈的故事讲完了。

然而,还有一点要补充的,就是 i1 字段和常数 49276 比较时执行的比较函数也是在填充 where 条件这一步中确定下来的,因为 Item_field 类实例找到对应的 Field 子类实例之后,i1 字段的类型就确定了,也就知道这两个值怎么比较了。

以上,就是本文的全部内容了,今天的文章有点长,感谢大家花时间阅读,如果觉得有用,还请帮忙转发朋友圈,让更多的人看到,大家一起进步,谢谢 ^_^

预告一下,下一篇要写的内容是 MySQL 简单查询语句执行过程分析(三)从存储引擎读数据,敬请关注!




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