时光荏苒啊!在过两个月我就工作满三年了,大学毕业的情景还历历在目,而我已经默默的向油腻中年大叔进发了。作为一名苦逼的后端工程师,我搞过一段时间python,现在靠java糊口,但后来才发现,始终不弃我的是数据库啊。从大学到工作,她始终陪伴左右,从sql service到mysql,她一直在身边,而今是时候深入了解总结下数据库的一些基础知识了。
(一)mysql逻辑架构
1.数据库与数据库实例
在介绍mysql逻辑架构前,首先需要理解两个概念:数据库和数据库实例。
- 数据库: 数据库是文件的集合,是依照某种数据模型组织起里并存放于二级存储器中的数据集合。
- 数据库实例: 数据库实例是程序,是位于用户和操作系统之间的一层数据管理软件,用来管理数据库数据。
在日常开发中,我们经常将两者的概念混淆,但实际两者概念完全不同。mysql有点类似我们的java应用,是一个单进程多线程架构的数据库,mysql数据库实例在系统上的表现就是一个进程。
2.mysql逻辑结构及分层
接下来我们看下《高性能mysql》中的一张简单的mysql服务器逻辑架构图,我们可以在逻辑上将mysql服务器分为三层:客户端层、服务器层、存储引擎层。这个分层并不是官方定义的,是基于使用经验和习惯意义上的分层。
上面的图比较粗糙,主要用来让大家对逻辑架构和分层有个大致的了解,接下来我们通过另一张图来介绍各层的职责功能。
- 客户端层:负责连接处理、授权认证、安全服务等。
- 服务器层:这一层包含了mysql的所有核心服务功能,包括查询、解析、分析、优化、缓存及所有内置函数。所有跨存储引擎的功能都在这一层实现,例如存储过程、触发器及我们常常提到的binlog日志。
- 存储引擎层:负责mysql中数据的存储和提取。
插件式的表存储引擎是mysql数据库的特色,我们可以在不同的应用场景下可以使用不同的存储引擎,当然最常用的还是Innodb存储引擎,除此之外还有MyISAM、NDB、Memory等存储引擎。有心的童鞋能够看到存储引擎层的解释被我加了斜体,我们都知道mysql的大部分数据是存储在磁盘中的,所以这个解释还带有一层含义:存储引擎层负责和磁盘打交道,存储引擎读取数据的方式很大程度上决定了sql执行的快慢。实际上这也是我介绍mysql逻辑架构图的一个重要用意,我想让大家清楚:服务器层的很多操作是基于内存的,而存储引擎层主要和磁盘打交道,通过合适的sql语句优化减少存储引擎访问的数据量是mysql语句优化的一个基本思路。
通过上面的图,我们可以大致了解MySQL查询的执行过程,可以分为5个步骤:
- 客户端向mysql服务器发出请求
- (若mysql开启了查询缓存)服务器检查缓存,若存在缓存直接返回,否则进入下个阶段。
- 服务器解析sql语句,进行预处理,并由优化器生成相应的执行计划。
- mysql根据执行计划,调用存储引擎API来执行查询。
- 返回查询结果给客户端,(若mysql开启了查询缓存)并缓存查询结果。
这里有两点值得注意:
首先是: 客户端/服务器端的通信协议问题:mysql服务器和客户端间的通信是半双工的,不能双向同步数据,任意时刻只能由一方传输,一旦一端开始发送消息,另一端需要接收完消息才能响应。在mysql服务端返回查询数据时,需要等所有的数据都发送给客户端才能够释放这条查询所占用的资源。当我们需要进行大量的数据查询时,例如需要查询几万条或几十万条运单的商户信息,请只返回我们真正需要的字段,尽量避免select *,这样能够减少数据传输的开销,减轻服务器和客户端的压力。
其次是: mysql的缓存问题,缓存并不是什么场景都是好的,需要衡量缓存使用的开销和它能够给我们带来的收益。目前我们的mysql服务器缓存是默认关闭的,因为在缓存的设置,删除以及更新都需要比较多的系统开销,综合收益并不大,另外在客户端层mybatis的一级、二级缓存提供了非常相似的缓存功能,我个人感觉还是在客户端层进行缓存会更好些。
3.Innodb引擎索引实现
索引是用于快速查找记录的一种数据结构,在mysql中也别称为建(key)。在mysql中索引是由存储引擎层而不是服务器层实现的,不同存储引擎实现的索引工作方式也并不一样,这里我们只介绍Innodb的索引实现。在Innodb存储引擎中,主键索引 (或称聚簇索引)和辅助索引(或称二级索引)都是通过B+树实现的,B+树是一种平衡多叉树(注意是多叉树而不是二叉树)。
在Innodb存储引擎中数据存储结构被分为:表、段、区、页,页是Innodb磁盘管理的最小单位。 页的默认大小是16k,我们无法通过B+树索引直接找到给定键值的具体行,B+树索引能找到的只是数据行所在的页,然后通过把页读取到内存中,在内存中查找并读取相应的行数据。无论是主键索引还是辅助索引,他们的数据存储和读取都是基于页的,所以也经常将叶子节点称为数据页,将非叶子节点称为节点页。
主键索引和辅助索引的区别: 在主键索引的B+树中:非叶子节点存储的是索引列(即主键列)和相关指针,而叶子节点存储了行的全部数据。在辅助索引的B+树中:非叶子节点存储的同样是索引列(组成索引的所有列)和相关指针,而叶子节点除了存储索引列外,还存储了主键列。
B+树索引的本质是B+树在数据库中的实现,B+树索引在数据库中的一个特点就是高扇出性,在数据库中B+树的高度一般都是2-4层,也就是说查找某一主键键值对应的行记录仅需要2-4次IO,这也就保证了数据库数据查询的速度。B+树深度不大却能够保存数以亿计的数据,同时拥有自平衡的功能,能够确保数据查询时间的均衡性,我认为这两点就是数据库或其他直接存取辅助设备使用B+树保存数据的根本原因。
通过上面的小图,其实我们就可以简单的了解到数据库的查询方式了:通过辅助索引查询时,首先需要在辅助索引中定位到记录的主键值,再利用主键值在主键索引中查询到相应的数据页,通过主键值去主键索引中查询完整行数据的过程通常被我们称为“回表”。如果我们的查询只发生在辅助索引中,而不需要回表的话,那么毋庸置疑查询效率会很高,这种通过辅助索引就可以获取到所需数据而无需回表的查询方式被称为“覆盖索引”。熟练的使用“覆盖索引”是一名优秀的后端工程师应该具备的基本技能。
(二)sql查询过程及where条件的提取与应用
在上一节中我们已经介绍了索引结构及简单的查询过程,接下来我们通过一个复杂些的sql,来介绍下详细的查询过程及where条件语句的提取应用流程,同时也会介绍些在mysql5.6版本中使用的新技术。这里讲的知识很大程度上借鉴参考了何登成大神的文章和思想,借花献佛是最快乐的事了,哈哈哈,有兴趣的童鞋可以到大神的网站上学习:mysql大神的github 。
首先我们创建一张测试表,将字段a作为主键,同时建立字段b、c、d的联合索引idx_b_c_d,然后插入几条记录,具体语句如下:
create table test (a int primary key,b int,c int,d int,e varchar(20)
) ENGINE = InnoDB;alter table test add index idx_b_c_d (b,c,d);
insert into test values (4,3,1,1,'d');
insert into test values (1,1,1,1,'a');
insert into test values (8,8,8,8,'h');
insert into test values (2,2,2,2,'b');
insert into test values (5,2,3,5,'e');
insert into test values (3,3,2,2,'c');
insert into test values (7,4,5,5,'g');
insert into test values (6,6,4,4,'f');
生成的大致索引结构图如下(意思意思即可,不要太纠结细节):
接下来我们考虑以下sql的执行过程(我们假设语句走idx_b_c_d索引,实际上记录这么少估计就直接全表扫描了)
select * from test where b >= 2 and b < 8 and c > 1 and d != 4 and e != 'a';
在看到这条语句后,我们可以思考几个问题:
1.此SQL能够覆盖索引idx_b_c_d上的哪些记录?
起始范围:记录[2,2,2]是第一个需要检查的索引项,索引起始查找范围由b >= 2,c > 1决定。
终止范围:记录[8,8,8]是第一个不需要检查的记录,而之前的记录均需要判断。索引的终止查找范围由b < 8决定;
2.在确定了查询的起始、终止范围之后,SQL中还有哪些条件可以使用索引idx_b_c_d过滤?
固定了索引的查询范围[(2,2,2),(8,8,8))之后,此索引范围中并不是每条记录都是满足where查询条件的。例如:(3,1,1)不满足c > 1的约束;(6,4,4)不满足d != 4的约束。而c,d列均可在索引idx_b_c_d中过滤掉不满足条件的索引记录的。因此,SQL中还可以使用c > 1 and d != 4条件来进行索引记录的过滤。
3.在确定了索引中最终能够过滤掉的条件之后,还有哪些条件是索引无法过滤的?
显而易见,e != 'a’这个查询条件,无法在索引idx_b_c_d上进行过滤,因为索引并未包含e列。为了过滤此查询条件,必须将已经满足索引查询条件的记录回表,取出表中的e列,然后使用e列的查询条件e != ‘a’进行最终的过滤。
在理解以上的问题解答的基础上,做一个抽象,可总结出一套放置于所有SQL语句而皆准的where查询条件的提取规则:
可归纳为3大类:Index Key (First Key & Last Key),Index Filter,Table Filter。
(1)Index Key:
用于确定SQL查询在索引中的连续范围的查询条件,被称之为Index Key。一个范围包含一个起始与一个终止,因此Index Key也被拆分为Index First Key和Index Last Key,分别用于定位索引查找的起始,以及索引查询的终止条件。
Index First Key
提取规则:从索引的第一个键值开始,检查其在where条件中是否存在,若存在并且条件是=、>=,则将对应的条件加入Index First Key之中,继续读取索引的下一个键值,使用同样的提取规则;若存在并且条件是>,则将对应的条件加入Index First Key中,然后终止Index First Key的提取。针对上面的SQL,应用这个提取规则,提取出来的Index First Key为(b >= 2, c > 1)。由于c的条件为 >,提取结束,不包括d。
Index Last Key
与Index First Key正好相反,用于确定索引查询的终止范围。提取规则:从索引的第一个键值开始,检查其在where条件中是否存在,若存在并且条件是=、<=,则将对应条件加入到Index Last Key中,继续提取索引的下一个键值,使用同样的提取规则;若存在并且条件是 < ,则将条件加入到Index Last Key中,同时终止提取;若不存在,同样终止Index Last Key的提取。针对上面的SQL,应用这个提取规则,提取出来的Index Last Key为(b < 8),由于是 < 符号,因此提取b之后结束。
(2)Index Filter:
在Index Key的提取之后固定了索引的查询范围,但是此范围中的项,并不都是满足查询条件的项。在上面的SQL用例中,(3,1,1),(6,4,4)均属于范围中,但是均不满足SQL的查询条件。Index Filter的提取规则:同样从索引列的第一列开始,检查其在where条件中是否存在:
1 若存在并且where条件仅为 =,则跳过第一列继续检查索引下一列,下一索引列采取与索引第一列同样的提取规则;
2 若where条件为 >=、>、<、<= 其中的几种,则跳过索引第一列,将其余where条件中索引相关列全部加入到Index Filter之中;
3 若索引第一列的where条件包含 =、>=、>、<、<= 之外的条件,则将此条件以及其余where条件中索引相关列全部加入到Index Filter之中;
4 若第一列不包含查询条件,则将所有索引相关条件均加入到Index Filter之中。针对上面的用例SQL,索引第一列只包含 >=、< 两个条件,因此第一列可跳过,将余下的c、d两列加入到Index Filter中。因此获得的Index Filter为 c > 1 and d != 4 。
(3)Table Filter:
Table Filter是最简单,也是提取最为方便的。提取规则:所有不属于索引列的查询条件,均归为Table Filter之中。针对上面的用例SQL,Table Filter就为 e != ‘a’。
有了上面的“where条件的提取应用”流程后,对于数据库的查询逻辑我们就清楚很多了,我们这里以上面提到的sql语句为例,来讲解下具体的查询流程:
(1) 首先需要在辅助索引中定位起始索引记录,在索引第一次Search Path(沿着索引B+树的根节点一直遍历,到索引正确的叶节点位置)时使用,一次判断即可。
(2) 之后遍历起始索引记录后的每一条索引记录,判断是否为终止索引记录,是的话就结束查询 ,同时利用辅助索引中的条件列过滤掉不满足查询条件的索引记录。
讲到这里时,一些比较有经验的程序员可能会说:查询语句虽然使用了idx_b_c_d索引,但其中索引字段b和c进行的都是范围查询(b >= 2 and b < 8 and c > 1),这会导致其余的索引字段(字段d)无法使用的,很多人将这种情况称为“索引截断”。在《高性能mysql》中确实也明确的提过:“如果查询中有某个列进行范围查询,则其右边所有的列都无法使用索引进行优化查询”。我们的《高性能mysql》涵盖版本主要是mysql5.5及以前,在mysql5.5及以前确实是这样的,这主要是由于存储引擎API不支持使用过滤条件,服务器层没办法把过滤条件传到存储引擎层,而在5.6版本mysql推出了index condition pushdown(ICP)索引条件下推技术解决了这个问题。有兴趣的童鞋可以了解下:ICP技术。当使用了ICP时,在Explain执行计划的Extra列中会出现Using index condition的提示。
(3) 将查询得到的索引记录暂存在缓存中,然后根据主键键值进行排序,之后利用主键键值顺序地访问主键索引中存储的数据文件。 这个流程其实是mysql5.6推出的Multi-Range Read(MRR)技术,主要用于减少磁盘的随机IO。其具体内容可见于:MRR技术。
(4) 根据主键键值在主键索引中查询到完整的数据记录,并将数据记录返回给【服务器层】。
(5)【服务器层】根据where条件中非索引列进行过滤,并将过滤后的数据记录返回给【客户端层】。
这里有两点值得注意:
第一点: 在主键索引中不会进行条件过滤。我们知道字段e并不是索引idx_b_c_d包含的字段,所以在辅助索引idx_b_c_d中无法进行条件过滤,但当我们根据主键键值在主键索引中查找数据记录时仍不能进行过滤,只能当数据返回到服务器层后,在服务器层通过 e != 'a’进行条件过滤。之前已经提过存储引擎层是负责和磁盘交互的,当数据记录到达服务器层时,实际已经完成了从磁盘中读数据的操作,也就是说实际上我们从磁盘中读取了我们并不需要的数据,这无疑会增加数据库的查询时间,当出现这种情况时,我们会在Explain执行计划的Extra列中看到 Using where的提示。当然这种情况其实很常见,也很难避免,只要在辅助索引中能够有效的过滤掉大部分无效数据,即使出现using where也不会有很大的性能影响。
第二点: mysql结果集返回客户端是一个增量、逐步返回的过程。当开始生成第一条结果时。mysql就开始向客户端开始返回数据了,存储引擎层和服务器层也是一样的,查询到一条数据就会返回一条数据给客户端,这样做的好处主要是:可以减少mysql服务器存储的数据,也就不会因为要返回太多结果而消耗太多内存。
(三)EXPLAIN执行计划信息
上面讲了很多东西,但其实我最想讲的是explain,对的,就是大家都很熟悉的mysql explain命令。上面讲的一大堆其实都是为此铺垫,explain真的很难,需要你对mysql的基础有比较深入的了解,才能读懂,这也是我认为mysql值得吐槽的一点,explain的提供信息真的是非常不人性化,而且很容易误导人。在讲解explain命令的相关信息前,我们首先要明确一点:explain给出的很多统计数据都是近似值,并不是精确值。
我们继续使用上文中的sql语句进行分析(这里只考虑单表的,暂时不考虑多表联接的情况)。通过上图可以看到explain结果中有很多列,接下来我们介绍下explain结果中每一列的意义,其中我认为比较重要的列会放在后面讲解。在mysql官方文档中也有相关解释:https://dev.mysql.com/doc/refman/5.7/en/explain-output.html#explain_rows。
上面已经将explain中比较简单的列信息描述好了,接下来我们开始重头戏,也就是我们剩下的key_len列、type列、及Extra列。
(1)key_len列:
key_len其实是和key结合在一起使用的。在key确定后,也就是优化器选定好索引后,我们就可以通过key_len的值来查看我们的sql语句能够使用的索引长度,也就可以确定我们的sql语句能使用到索引的哪几个字段。但这里有一点需要重点说明的是:key_len无法统计ICP优化后辅助索引使用的列。也就是说虽然我们在5.6版本引入了ICP技术使得所有的索引列都能参与索引记录过滤,但key_len的统计逻辑没有做相应的优化,在联合索引中靠前的列出现范围查询时key_len的统计逻辑仍会认为发生了索引截断,认为这个列后的其余索引列无法使用。这其实就是上面的图中为什么key_len的值为10(字段a和b都为int类型,占用4个字节,同时因为列可为NULL,a和b都需要增加1个字节标识),key_len统计逻辑认为后面的索引字段d无法被使用,而实际上字段d能够被使用。下面列了简单的验证图
开启ICP:
关闭ICP:
select * from test force index(idx_b_c_d) where b >= 2 and b < 8 能查询出的记录为5条,
select * from test force index(idx_b_c_d) where b >= 2 and b < 8 and c > 1 and d != 4语句能查询出的记录为4条,
而开启ICP时实际只回表读了4条记录,说明辅助索引中确实使用了d字段。
其实通过简单的测试我们能够发现,其实在关闭ICP时,字段c也不能过滤索引记录,但是key_len的长度却包含了c字段,这有点让我费解。
(2)type列:
type列在mysql用户手册上被称为“关联类型”,其实更准确的说法应该是“访问类型”,即“mysql查找表中行的方式”,下面我们按照从最差到最优的顺序列出最重要的几种访问方式。
(3)Extra列:
这一列包含的是不适合在其他列中显示的额外信息,同时也是最复杂和比较容易让人产生误解的一列。接下来我们通过上面的test表及相关语句来演示下,各种extra值出现的场景,以便于大家理解(不含using temporary)。
using index
我们可以看到当查询的字段刚好是辅助索引包含的字段,且查询条件都是辅助索引包含的字段,并且是等值查询时,Extra的值为using index。
using where;using index
我们可以看到当查询的字段刚好是辅助索引包含的字段,且查询条件都是辅助索引包含的字段,但查询为范围查询时,Extra的值为using where; using index。
using index condition
我们可以看到当查询条件都是辅助索引包含的字段,且查询为范围查询时,但查询的数据包含非索引字段时,Extra的值为using index condition。
using index condition;using where
我们可以看到当增加过滤条件e != 'a’后,多了一个using where,上面我们其实已经讲过了,因为二级索引中不包含字段e,索引需要先通过b >= 2 and b < 8 and c > 1 and d != 4过滤出数据记录,然后将数据记录返回给服务器层再次利用e != 'a’进行过滤,所以会多出一个using where。
Using index condition; Using where; Using filesort
我们可以看到当使用order by e时出现了Using filesort,我们应该尽量避免Using filesort的出现,它可能会影响系统的性能。
(4)using filesort排序原理及可能的影响
上面我们已经将几种常见的Extra值出现的情况进行了列举,到这里整篇文章其实差不多要结束了。在最后我想说下using filesort的问题,这个问题大家平时关注的比较少,但它可能带来的影响确可能很大。当我们的查询中不能使用索引生成查询结果的时候,mysql需要自己进行排序,如果数据量小则在内存中排序,否则需要使用磁盘,不过在Extra中都只是展示using filesort。每个数据库connection在需要进行排序时mysql都会根据参数sort_buffer_size来为其分配内存(默认1m),当数据量小于sort_buffer_size时,mysql将所有数据放入内存中进行快速排序。当数据量大于sort_buffer_size时,mysql会先将数据分块,对每块分别排序并将结果存放在磁盘中,最后将排序好的块进行合并(其实就是归并排序算法)。这里其实存在一些隐患:首先用于排序的内存分配使用的是mmap()函数而不是malloc(),分配内存的效率比较低,其次当排序的并发connection非常多,可能占用很多内存资源,给系统带来不小的压力。
在mysql中还有一个参数max_length_for_sort_data(默认1k),这个参数决定了mysql使用的排序算法。当需要查询的所有列的总长度不超过max_length_for_sort_data时mysql使用单次传输排序,否者使用两次传输排序。
两次传输排序: 第一次只读取主键值和需要排序的字段,对其排序,然后在根据排序结果第二次读取数据。这种方式占用比较少的内存空间,但是会造成大量的随机IO。
上面说了很多,其实总结一下就一句话:使用order by时应注意利用索引的有序性,尽量避免出现using filesort或者尽可能避免对大量数据进行排序。