Mysql数据库,想必大家都不陌生,下面以innodb引擎为例从多个维度聊一下在一条insert语句中,Mysql数据库都做了什么。
首先,我们要知道,mysql数据在innodb中是以大小为16KB的数据页为单位进行存储的。
通常来说,一条普通的mysql行数据,是不会占满一个页面大小的,那也就是说,一个页面中会存在一张表的多条数据。
这些数据,都是根据主键的从小到大的排列,以链表的方式来关联起来,可以根据上一条记录的某个属性,找到下一条数据在数据页中的具体位置。
明白了innodb中关于行数据的存储方式,那么如何插入新数据,就很容易知道了,无非就是在链表中根据主键的位置,找一个合适的位置插入链表即可。
这个操作是完全没有问题的,但是在实际的操作中,会遇到很多问题:
首先,这个主键ID有可能不是自增的最大ID,而是需要在链表中间插入,那么随之而来就产生了新问题,innodb中数据页的大小是固定的16KB,如果中途插入数据,代表需要在指定的位置,把原本已经保存好的数据往后挪动一段距离。那么所有的数据页的结构和数据都要做变动吗?
是也不是。
这里又需要引入一个关于innodb删除数据的概念。通常我们的delete语句,其实并不会把innodb中的数据页直接删除,而是会打上一个删除标记,便于以后重用。
所以,如果恰好在删除的地方,重新插入数据的话,如果插入的数据大小小于以前删除数据的大小,那么可以直接覆盖使用以前的数据空间。
如果新插入的数据大小大于原来的数据,那么就需要开辟新的数据页来进行存储。
其次,如果插入的数据的主键是最大的,那么加入链表最末尾就行,如果最末尾的数据页空间够用,直接存,如果不够用,则需要新开辟一个数据页来存放数据。
最后考虑一种特殊情形,如果目前的数据刚好把所有数据页全部填满,且插入的数据刚好需要插在倒数第二个,那么就需要涉及到页分裂的使用了。具体的步骤就是,先新建一个新的数据页, 把最后一条数据放到过去,然后把需要插入的数据放在原来最后一条数据的位置。
在很多资料中,都会说mysql的innodb是基于B+树的聚簇索引。这里对B+树不做深入讲解,可以理解为对简单的平衡二叉树进行了改进,通常的平衡二叉树是一个父节点挂左右两个字节的,B+树的子节点,有两个,也可能有三个,所以也简称二、三树。
众所周知,把数据构造成一颗二叉树,最主要的作用就是可以通过二分查找的方式,快速找到指定数据。
那么聚簇索引主要也是利用了这个原理,且由于它子节点比较多,会减少这颗二叉树的深度,从而加快查询速度。
那么到底什么是聚簇索引呢?
其主要是针对innodb的主键而言,innodb引擎会为每一张表默认生成一颗以主键值为节点的B+树,如果没有设置主键,会使用隐藏列row_id作为主键。
那么其实这也只是生成了一颗B+树而已,为什么叫聚簇索引呢?
是因为在这颗B+树的最底层的叶子节点,就不是只存主键了,而是直接指向了数据页中对应主键的真实数据。
这样当聚簇索引遍历到叶子节点的时候,就可以直接获取到主键对应的所有数据,不需要额外的利用主键去回表查询,这样就可以大大加快查询效率。
这也是innodb中聚簇索引最鲜明的特点:(叶子节点)索引即数据,数据即(叶子节点)索引。
在innodb的数据页数据更新以后,聚簇索引的叶子节点就自动更新完成了。
那么主要就是需要重新更新一下B+树的对应的根节点就可以了,这个工作量就需要根据B+树的层级和数据量级具体评估了。
由于innodb的数据页是存储在硬盘中,为了加快数据的处理速度,mysql引入了内存缓存——Buffer Pool。
为了更好的缓存innodb中的数据,Buffer Pool也引入了类似数据页的概念,其大小与数据页的大小保持一致,都是16KB。为了与innodb中的数据页区分开来,我们称Buffer Pool中的页面为缓冲页。
缓冲页的大小毕竟还是太小,为了更好的利用Buffer Pool缓存,mysql通过链表的形式,构造了多个不同功能的数据结构,来更快的满足业务需求。
下面我们分别一一介绍。
通常我们会通过参数innodb_buffer_pool_size来设置mysql中Buffer Pool的大小。在mysql服务器初始化后,会向操作系统申请一片连续的内存空间,用来构造所有的缓冲页。
在刚刚初始化完成的Buffer Pool中,所有的缓冲页都会加入free链表中,注意这里的free链表只是放置缓冲页的一些控制信息,并不需要存放缓冲页中的完整数据。
当mysql的查询事务开始执行的时候,如果需要从硬盘中加载一个数据页的数据到Buffer Pool中,首先需要从free链表节点中获取一个空闲缓冲页的控制信息,然后在控制信息中填入硬盘数据页对应的表空间、页号之类的数据,其次根据free链表节点中的控制信息,可以找到Buffer Pool中对应的缓冲页的信息,再把硬盘中的数据页信息写入到对应的缓冲页中,最后再移出free链表中对应的节点,表示该缓冲页已经被使用。
flush链表的构造基本和free链表保持一致,主要是保存Buffer Pool中缓冲页的一些控制信息。
当我们修改了Buffer Pool中某个缓冲页的数据,导致其与硬盘上保存的对应数据页的信息不一致,那么我们就称被修改的缓冲页为脏页。
既然修改了数据,肯定是要同步保存到硬盘中才稳妥,但是也不可能每修改一条数据就刷盘一次,这样会严重影响程序性能,理想的办法是把脏页保存在内存中的一个链表中,等脏页积累到一定的量级,一次性刷入硬盘,效率最高,这个的链表被我们称作flush链表。
mysql后台线程会定时刷新flush链表到硬盘,来最终实现数据的持久化。
注意flush链表和free链表是互斥的关系,Buffer Pool中的同一页缓冲页信息,要么只能在free链表,要么只能在flush链表。
有了free链表和flush链表与Buffer Pool的配合,一切就都结束了吗?当然不是,由于实际的业务情况千变万化,会遇到各种各样的异常情况。最常见便是,内存空间是有限的,假如free链表要用完了,也就是说Buffer Pool中可用的缓冲页不多了,怎么办?
既然是内存空间不够用,解决办法无非是加大Buffer Pool的存储空间,或者淘汰掉一些使用频率不太高的缓冲页了。
加大存储空间就不说了,再大的空间都有极限,最终都会需要使用淘汰算法来淘汰一部分使用频率较少的数据,来保证我们Buffer Pool的可用性。
一个比较经典的算法便是LRU(最近最少使用算法),当然这个算法具体的实现有很多的变种,也有很多细节,甚至很多别的中间件也经常使用,这里就不过多赘述。
这里简要介绍其原理,主要是构造一个链表,把使用频率较高的热点数据放在链表头部,使用频率较低的数据放在链表尾部,当Buffer Pool空间不够的时候,删除LRU链表尾部的数据,来释放Buffer Pool对应的缓冲页的内存空间。
当然,mysql在具体的实现中并不是这么的简单直接,也还有很多的其他因素的考量,以及一些异常情况的处理,但是其大体的实现逻辑便是这样。
LRU链表包含哪些数据
LRU链表会同时包含未修改的缓冲页和已修改的脏页,也就是说一个缓冲页脱离free链表后,就必然会加入LRU链表,同理flush链表中的脏页也会加入LRU链表统一管理。
如何删除LRU链表数据
准确来说,LRU的链表并不是只被单纯的从链表删除,这样没有任何意义。重要的是把删除的冷数据刷新到硬盘中。这里要注意这些冷数据可能包含已修改和未修改的缓冲页数据,所以需要只刷入修改过的缓冲页即可。
考虑一种特殊情况,在insert语句执行的过程中,如果innodb的数据页数据确实在Buffer Pool保存上了,但是数据还没写入到磁盘中,由于各种不可预知的异常,导致mysql服务器突然死机,那么这些插入的数据是不是就丢了。
丢一部分也可以理解,关键如果全丢了呢?那么原本执行成功的业务数据是不是就彻底消失了?
作为一款成熟且广泛使用的数据库系统,mysql当然提供了对应的解决办法——redo日志,顾名思义,就是对各种异常进行相应的数据保存。
那可能有人会说,这太麻烦了,为什么不等到innodb数据页的数据全部写入硬盘以后再结束事务?
首先存在的问题就是,硬盘写入和内存写入的效率存在指数级的差距,除非是硬盘顺序写入。但是面对实际的业务场景,假如同时插入多条id完全随机的数据到innodb,那么就必然无法进行顺序写盘,而且在这种情况下,还涉及到对已存入硬盘数据的属性修改,这样耗费的时间是用户完全难以接受的。
所以就有了redo日志,redo日志的核心便是顺序写入硬盘和只写入必须数据。
顺序写盘就不说了,下面我们说下只写入必须数据,比如一条数据不是插入,而只是更新部分数据,那么完全可以只保存主键id和更改过的数据,这样就可以大大减少写入redo日志数据。
和innodb的数据页需要借助Buffer Pool来在内存缓存数据一样,redo日志也借助了内存来缓存数据,我们称之为log buffer。
这样,在插入数据的时候,log buffer可以和innodb数据页中的数据同步写入,最后在事务提交的时候把对应事务的redo日志刷入磁盘即可。
注意在这时候,其实Buffer Pool中的数据都可以不需要刷入磁盘,主要原因还是因为Buffer Pool需要包含完整数据,且写入存在随机IO。
所以,在事务提交的时候,只需要保证对应的log buffer数据刷入磁盘即可,这样可以保证事务数据完整性的基础上,加快事务的提交速度。
即使发生系统崩溃,也可以通过redo日志结合硬盘存储的数据库记录,对数据进行还原。
在一个事务中,如果插入的数据需要被回滚删除,那么无论是上面的Buffer Pool页面缓存还是redo日志,就都不起作用了。
所以mysql还需要引入undo日志,来对需要回滚的数据做记录。
所谓的undo日志,可以理解为是和普通数据页是一样的,只是被定义了不同的类型和写入了不同的数据内容,它们一起被存放在当前事务所属的表空间之下。
innodb的undo日志,主要是通过innodb记录的行数据中的事务id字段trx_id和指向上一条undo日志的指针roll_point来完成回滚。
这里我们介绍的是insert类型的undo日志。
在实际的insert插入中,除了对聚簇索引的子节点进行数据插入,还会对其对应的根节点进行修改。由于undo日志会记录插入的主键id,所以对于回滚的数据直接根据主键id进行删除即可。
对于二级索引,在数据插入的过程中,肯定也会更新对应的索引树。当需要回滚的时候,由于二级索引和聚簇索引是通过主键id进行一对一映射的,根据主键id去二级索引删除指定数据即可。
在一个事务中,可能有多个语句进行插入,单个64KB的页面可能放不下所有的insert类型的undo日志,所以需要把这些insert类型的页面,通过链表组合起来,形成insert类型的undo链表,一起集中保存。
Undo 链表一共两个类型,insert undo链表和undate undo链表。
关于insert类型的undo日志,在当前事务提交后,就没有作用了,要么删除,要么可以被下一个事务重用。
多版本并发控制的主要原理,就是借助了数据页中两个的隐藏字段——trx_id(事务id)和roll_pointer(版本链指针),使得在查询同一条数据的时候,可以获取到指定事务在各个时期不同的数据版本。
版本链是由innodb数据页中的指定数据记录和该记录的undo日志,通过roll_pointer指针串联而成的一个链表,我们称之为版本链。简单来说就是同一条数据,在不同的事务中的数据,通过隐藏字段roll_pointer串起来,根据事务id的大小排列,形成一个有序链表。
为了判断版本链中,哪个版本的数据是当前事务可见的,mysql引入了ReadView的概念,可以简单的理解为数据在某个时刻的快照。其主要是针对隔离级别中的读已提交和可重复读两个级别。
每次的ReadView读取,都需要通过版本链和当前的事务id来判断,在当前时刻可以获取到版本链上的哪一条数据,有一个重要的前提便是,获取到的ReadView的事务id,一定要小于当前事务id。
在同一个事务中,每次的select查询,都会更新其ReadView快照,这就会导致在同一条数据,在同一个事务中,每次查询,有可能每次查询到的同一条数据内容并不相同。其底层原因是由于读已提交这个隔离级别,缺少贯穿整个事务的读锁,导致当前事务正在查询的数据,被别的事务获取到了写锁,直接改变了当前查询数据的内容。
在同一个事务中,只有第一次的select查询,才会更新其ReadView快照,这样在后续的查询中,每次都会使用同一个ReadView,保证了数据多次读取的一致性,所以也叫可重复读。和读已提交不同,它由于存在贯穿整个事务的读锁,可以保证别的事务无法修改当前数据,只有当前事务完成,释放读锁后,才可以对数据进行修改。理所当然,这个隔离级别由于长时间的存在读锁,其并发能力会弱于读已提交。