本文基于MySQL5.7版本。
前面几篇MySQL系列的文章介绍了索引,事务和锁相关知识,那么今天就让我们来看看当我们执行一条select语句和一条update语句的时候,MySQL要经过哪些步骤,才能返回我们想要的数据。
MySQL从大方向来说,可以分为 Server 层和存储引擎层。而Server层包括连接器、查询缓存、解析器、预处理器、优化器、执行器等,最后Server层再通过API接口形式调用对应的存储引擎层提供的接口。如下图所示(图片来源于《高性能MySQL》):
根据流程图,一条select查询大致经过以下六个步骤:
1、客户端发起一个请求时,首先会建立一个连接
2、服务端会检查缓存,如果命中则直接返回,否则继续之后后面步骤
3、服务器端根据收到的sql语句进行解析,然后对其进行词法分析,语法分析以及预处理
4、由优化器生成执行计划
5、调用存储引擎层API来执行查询
6、返回查询到的结果
查询流程也可以通过如下图表示(图片来源于丁奇MySQL45讲):
第一步建立连接,这个很容易理解,需要特别指出的是MySQL服务端和客户端的通信方式采用的是半双工协议。
半双工协议让MySQL通信简单快速,但是也在一定程度上限制了MySQL的性能,因为一旦从一端开始发送数据,另一端必须要接收完全部数据才能做出响应。所以说我们批量插入的时候尽量拆分成多次插入而不要一次插入太大数据,同样的查询语句最好也带上limit限制条数,避免一次返回过多数据。
MySQL单次传输数据包的大小可以通过参数max_allowed_packet控制,默认大小为4MB
SHOW VARIABLES LIKE 'max_allowed_packet';
连接上了之后,如果缓存是打开的,那么就会进入查询缓存阶段,可以通过如下命令查看缓存是否开启:
SHOW VARIABLES LIKE 'query_cache_type';
我们可以看到,缓存默认是关闭的。这是因为MySQL的缓存使用条件非常苛刻,是通过一个大小写敏感的哈希值去匹配的,这样就是说一条查询语句哪怕只是有一个空格不一致,都会导致无法使用缓存。而且一旦表里面有一行数据变动了,那么关于这种表的所有缓存都会失效。所以一般我们都是不建议使用缓存,MySQL最新的8.0版本已经将缓存模块去掉了。
跳过了缓存模块之后,查询语句会进入解析器进行解析。
这一步主要的工作就是检查sql语句的语法对不对,在这里,首先会把我们整个SQL语句打碎,比如:select name from test where id=1,就会被打散成select,name,from,test,where,id,=,1 这8个字符,并且能识别出关键字和非关键字,然后根据sql语句生成一个数据结构,也叫做解析树(select_lex),如下图:
经过了前面的词法和语法解析,那么至少我们一条sql语句的语法格式是满足要求了,接下来我们还需要做什么呢?自然是检查表名,列名以及其他一些信息等是不是真实存在的,预处理就是做一个表名和字段名等相关信息合法性的检测。
经过上面的步骤,到这里就得到了一句有效的sql语句了。而对一个查询语句,尤其是复杂的多表查询语句,我们可以有很多种执行方式,每种执行方式的效率也不一样,所以这时候就需要查询优化器去选择一种它认为最高效的执行方式。
查询优化器的目的就是根据解析树生成不同的执行计划(Execution Plan),然后选择一种最优的执行计划,MySQL 里面使用的是基于开销(cost)的优化器,哪种执行计划开销最小,就选择哪种。
我们可以通过变量Last_query_cost来查询开销:
SELECT * FROM test;
show status like 'Last_query_cost';
上图中展示的结果就表示MySQL认为SELECT * FROM test 查询语句需要做至少2个数据页的随机查找才能完成上面的查询。
这个结果是通过一系列复杂的运算得到的,包括每个表或者索引的页面个数,索引的基数,索引和数据行的长度,索引分布的情况。
优化器在评估成本的时候,不会考虑任何缓存的作用,而是假设读取任何数据都需要经过一次IO操作。
优化器可以替我们做很多优化,下面列举一些常用的优化:
实际当中优化器能做的优化远远比上面列举的更多,所以有时候我们不要觉得比优化器更聪明,所以大部分情况下我们都可以让优化器做出优化就可以了,如果有些我们确定优化器没有选择最优的查询方案,我们也可以在查询中通过添加hint提示告知到优化器,比如通过force index强制使用索引或者straight_join语句强制优化器按我们想要的表顺序进行关联。
MySQL优化器也并不是万能的,并不是总能把我们写的糟糕的sql语句优化成一个高效的查询语句,而且也有很多种原因会导致优化器做出错误的选择:
优化器听起来比较抽象,给人一种看不见摸不着的感觉,但是实际上我们也可以通过参数打开优化器追踪,优化器追踪默认是关闭的,因为开启后会影响性能,所以建议是在需要定位问题的时候开启,并及时关闭。
SHOW VARIABLES LIKE 'optimizer_trace';
set optimizer_trace='enabled=on';
接下来执行一句查询语句:
SELECT t1.name AS name1,t2.name AS name2 FROM test t1 INNER JOIN test2 t2 ON t1.id=t2.id
这时候优化器的分析过程已经被记录下来了,可以通过下面语句查询:
SELECT * FROM information_schema.optimizer_trace;
得到如下结果:
上面的图是为了看数据效果,如果需要自己操作的话,需要用shelll命令窗口去执行,sqlyog工具中直接查询出来TRACE列是空的,shell中返回的TRACE列信息如下:
从截图中的轮廓可以看出来这是一个json数据格式。
跟踪信息主要分为以下三部分(上图并未将全部内容展示出来,感兴趣的可以自己去尝试一下,开启之后记得及时关闭哦):
considered_execution_plans中的执行计划我们也可以通过explain关键字去查看,想要详细了解explain的可以点击这里。
当Server层得到了一条sql语句的执行计划后,这时候就会去调用存储引擎层对应的API,执行查询了。因为MySQL的存储引擎是插件式的,所以每种存储引擎都会对Server提供了一些对应的API调用。
想要了解MySQL存储引擎相关知识的,可以点击这里。
最后,将查询出得到的结果返回Server层,如果开启了缓存,Server层返回数据的同时还会写入缓存。
MySQL将查询结果返回是一个增量的逐步返回过程。例如:当我们处理完所有查询逻辑并开始执行查询并且生成第一条结果数据的时候,MySQL就可以开始逐步的向客户端传输数据了。这么做的好处是服务端无需存储太多结果,从而较少内存消耗(这个操作可以通过sql _buffer_result来提示优化器,和上文说的force index,straight_join一样都是人为强制优化器执行我们想要的操作)。
一条更新语句,其实是增,删,查的综合体,查询语句需要经过的流程,更新语句全部需要执行一次,因为更新之前必须要先拿到(查询)需要更新的数据。
InnnoDB的数据都是放在磁盘上的,而磁盘的速度和CPU的速度之间有难以逾越的鸿沟,为了提升效率,就引入了缓冲池技术,在InnoDB中称之为Buffer Pool。
从磁盘中读取数据的时候,会先将从磁盘中读取到的页放在缓冲池中,这样下次读相同的页的时候,就可以直接从Buffer Pool中获取。
更新数据的时候首先会看数据在不在缓冲池中,在的话就直接修改缓冲池中的数据,注意,前提是我们不需要对这条数据进行唯一性检查(因为如果要进行唯一性检查就必须加载磁盘中的数据来判断是否唯一了)
如果只修改了Buffer Pool中的数据而不修改磁盘中数据,这时候就会造成内存和磁盘中数据不一致,这种也叫做脏页。InnoDB 里面有专门的后台线程把 Buffer Pool 的数据写入到磁盘, 每隔一段时间就一次性地把多个修改写入磁盘,这个动作就叫做刷脏。
那么现在有一个问题,假如我们更新都需要把数据写入数据磁盘,然后磁盘也要找到对应的那条记录,然后再更新,整个过程 IO 成本、查找成本都很高。为了解决这个问题,InnoDB就有了redo log,并且采用了Write-Ahead Logging(WAL)方案实现。
redo log,即重做日志,是InnoDB引擎所特有,主要用于崩溃修复(crash-safe)。
Write-Ahead Logging,即先写日志,也就是说我们执行一个操作的时候会先将操作写入日志,然后再写入数据磁盘,那么有人就会问了,写入数据表是磁盘操作,写入redo log也是磁盘操作,同样都是写入磁盘,为什么不直接写入数据,而要先写入日志呢?这不是多次一举吗?
设想一下,假如我们所需要的数据是随机分散在不同页的不同扇区中,那么我们去找数据的时候就是随机IO操作,而redo log是循环写入的,也就是顺序IO。一句话:
刷盘是随机 I/O,而记录日志是顺序 I/O,顺序 I/O 效率更高。因此先把修改写入日 志,可以延迟刷盘时机,进而提升系统吞吐
InnoDB中的 redo log是固定大小的,也就是说redo log并不是随着文件写入慢慢变大,而是一开始就分配好了空间,空间一旦写满了,前面的空间就会被覆盖掉,刷盘的操作是通过Checkpoint实现的。如下图:
check point 是当前要覆盖的位置。write pos是当前写入日志的位置。写日志的时候是循环写的,覆盖旧记录前要把记录更新到数据文件。如果write pos和 check point 重叠,说明redo log 已经写满,这时候需要同步redo log刷到磁盘中。
MySQL整体来看,其实就有两块:一块是 Server 层,它主要做的是 MySQL功能层面的事情;还有一块是引擎层,负责存储相关的具体事宜。上面讲的redo log是InnoDB 引擎特有的日志,而Server 层也有自己的日志,称为 binlog(归档日志),也叫做二进制日志。
可能有人会问,为什么会有两份日志呢?
因为最开始 MySQL 里并没有 InnoDB 引擎。MySQL 自带的引擎是 MyISAM,但是 MyISAM是不支持事物的,也没有崩溃恢复(crash-safe)的能力,binlog日志只能用于归档。那么既然InnoDB是需要支持事务的,那么就必须要有崩溃恢复(crash-safe)能力,所以就使用另外一套自己的日志系统,也就是基于redo log 来实现 crash-safe 能力。
1、redo log 是 InnoDB 引擎特有的;binlog 是 MySQL 的Server层实现的,所有引擎都可以使用。
2、redo log 是物理日志,记录的是“在某个数据页上做了什么修改”;binlog 是逻辑日志,记录的是这个语句的原始逻辑,比如“给id=2 这一行的c字段加 1 ”。
3、redo log 是循环写的,空间固定会用完;binlog 是可以追加写入的。“追加写”是指 binlog 文件写到一定大小后会切换到下一个,并不会覆盖以前的日志。
前面铺垫了这么多,主要是想让大家先理解redo log和big log这两个概念,因为更新操作离不开这两个文件,接下来我们正式回到正题,一条update语句到底是如何执行的,可以通过下图表示:
上图可以大概概括为以下几步:
1、先根据更新语句的条件,查询出对应的记录,如果有缓存,也会用到缓存
2、Server端调用InnoDB引擎API接口,InnoDB引擎将这条数据写到内存,同时写入redo log,并将redo log状态设置为prepare
3、通知Server层,可以正式提交数据了
4、Server层收到通知后立刻写入bin log,然后调用InnoD对应接口发出commit请求
5、InnoDB收到commit请求后将数据设置为commit状态
上面的步骤中,我们注意到,redo log会经过两次提交,这就是两阶段提交。
两阶段提交是分布式事务的设计思想,就是首先会有请求方发出请求到各个服务器,然后等其他各个服务器都准备好之后再通知请求方可以提交了,请求方收到请求后再发出指令,通知所有服务器一起提交。
而我们这里redo log是属于存储引擎层的日志,bin log是属于Server层日志,属于两个独立的日志文件,采用两阶段提交就是为了使两个日志文件逻辑上保持一致
假如有一条语句id=1,age=18,我们现在要把这条数据的age更新为19:
通过以上的两个假设我们就会发现,假如不采用两阶段提交法就会出现数据不一致的情况,尤其是在有主从库的时候,因为主从复制是基于binlog实现的,如果redo log和bin log不一致,就会导致主从库数据不一致。
1、如果 redo log 里面的事务是完整的,也就是已经有了 commit 标识,则直接提交;
2、如果 redo log 里面的事务只有完整的 prepare,则判断对应的事务 binlog 是否存在并完整:如果是,则提交事务;否则,回滚事务。
本文主要分析了select和update语句的执行过程,而在分析update语句执行过程中,又简单介绍了redo log和bin log相关概念,这一部分内容在本文中没有过多深入的讲解,仅仅只是为了让大家去理解更新流程而做了简单的介绍,像redo log和其对应的缓存之间的关系,redo log刷盘策略,bin log写入策略,有了bin log为何还需要redo log等等问题本文中并没有给出明确的解释,因为本文篇幅有限,深入之后就会涉及到InnoDB引擎的存储结构以及更底层的一些知识,关于这些内容将在《MySQL系列》的后续文章中都会进行一一介绍。请关注我,和孤狼一起学习