MySQL深入学习(2)——InnoDB存储引擎

1. InnoDB存储引擎体系架构

     innoDB的存储引擎主要体系结构如上图所示

    首先是工作线程:默认7个后台线程,分别是4个io thread(insert buffer、log、read、write),1个master thread(优先级最高),1个锁(lock)监控线程,1个错误监控线程。可以通过show engine innodb status来查看。新版本已对默认的read thread和write thread分别增大到4个,可通过show variables like 'innodb_io_thread%'查看。

    每个线程操作的同一个块大内存池:该内存池中会被分为多个区域,分别是缓冲池(buffer pool)、重做日志缓冲池(redo log buffer)以及额外的内存池(additional memory pool)。内存池所负责也就是维护线程需要访问的数据结构、缓存磁盘文件的数据,方便快速读取,同时在对磁盘文件的数据修改之前在这里缓存,以及重做日志缓存等。具体配置可由show variables like 'innodb_buffer_pool_size'show variables like 'innodb_log_buffer_size'show variables like 'innodb_additional_mem_pool_size'三条指令查看来查看。

  第三部分则是最基础的InnoDB存储引擎对应的数据库表磁盘文件、日志文件。

2. InnoDB存储引擎的工作线程 

2.1 Master Thread

    该线程是InnoDB的核心后台线程,大部分工作都在这里完成,负责将缓冲池中的数据异步刷新到磁盘,保证数据的一致性,包括脏页的刷新、合并插入缓冲、undo页的回收等。

    Master Thread具有最高的线程优先级别,其内部有多个循环组成,包括主循环(Loop)、后台循环(background loop)、刷新循环(flush loop)以及暂停循环(suspend loop)。主线程会依据数据库运行状态在四个循环中切换。

    1. 主循环Loop

    主循环包括了大多数操作,其中主要分为两部分,分别是每秒进行的操作和每10秒进行的操作。伪代码如下

可以看到,在loop循环内,首先是一个for循环,该循环总共为10次,其中通过线程的sleep方法实现所谓的每秒和每10秒执行一次的操作,这种方式肯定是不精确地,肯定会有延迟现象,最多也就是大概维持在这个频率。

for循环内的操作(也就是每秒一次的操作)包括:

(1)日志缓冲刷新到磁盘(即使事务没有提交,一定会发生)

(2)合并插入缓冲(不一定)

(3)至多刷新100个InnoDB缓冲池中的脏页到磁盘(可能发生)

(4)判断当前是否有用户活动,如果没有则切换到后台循环执行。

for循环外的操作(每10秒一次执行的操作)包括:

(1)刷新100个脏页的数据到磁盘文件中(可能)

(2)合并至多5个插入缓冲(一定)

(3)将日志缓冲刷新到磁盘中(一定)

(4)删除无用的undo页(一定)

(5)刷新100个或者10个脏页到磁盘文件中(一定)

    2. 后台循环(background)

    若当前没有用户活动,也就是没有客户端连接或者用户登陆时,就会切换到这个循环,主要执行操作如下:

(1)删除无用的undo页(一定)

(2)合并20个插入缓冲(一定)
(3)跳回到主循环(一定)

(4)不断刷新100个页知道符合条件(可能,跳转到flush loop中完成)
如果flush loop中也没有什么事情可做了,InnoDB就会切换到suspend loop中,将主线程挂起,等待时间的发生,如果用户启用了InnoDB引擎,却没有任何基于InnoDB的数据库表,那么主线程将总是处于挂起状态。

2.2 IO Thread

    在MySQL中,InnoDB使用了大量的AIO来处理写IO请求,这样可以很大的提高数据库性能,而IO Thread的任务就是负责处理这些IO请求的回调(AIO基于回调来实现异步加非阻塞),主要有四类IO Thread,分别是write、read、insert buffer和log。可通过innodb_read_io_threads和innodb_write_io_threads参数设置。

2.3 Purge Thread

    事务被提交后,其所使用的undolog可能不再需要,因此需要purge Thread来回收已经使用并分配的undo页,该线程只能存在1个,在innoDB1.1版本之前原本该线程的任务是放在主线程中执行的,但是从1.1版本开始,purge操作有独立线程执行完成。

 

3. InnoDB引擎的内存结构

3.1 缓冲池buffer pool

    缓冲池,实际上就是一块内存区域,由于磁盘的读写性能相较于CPU的处理速度来说相差较大,所以会通过内存(缓冲池)来提高数据库的整体性能,数据库中读取页中的的数据时,会首先将数据读取到缓冲池中,下次再读取这个页中的数据时,就会直接从缓冲池中获取,如果缓冲池中没有这部分数据,那么就去读取磁盘数据文件,缓存到缓冲池中;对于数据库中页的修改操作,首先是修改在缓冲池中的页,然后再以一定的频率将数据同步更新到磁盘上。

    缓冲池中缓存的数据主要包括:索引页、数据页、undo页、插入缓冲、自适应哈希索引、InnoDB存储的锁信息、数据字典信息等。其中的索引页和数据页占据较大部分。

    缓冲池允许有多个实例,每个页根据哈希值平均分配到不同的缓冲池实例中,这样做的好处是减少数据库内部的资源竞争,增加数据库的并发处理量。可以通过innodb_buffer_pool_instances来查看和配置,该值默认为1,还可以通过innodb_buffer_pool_stats表来查询各个缓冲池的状态。

    LRU List、Free List和Flush List

    缓冲池是一个很大的内存区域,其中存放各种类型页的数据,但是,内存区域是有限的,通常来说,我们不可能把所有的磁盘数据文件全部缓存到内存中,而是通过各种策略来管理内存中的数据。数据库缓冲池是通过LRU(最近最少使用)算法来进行管理的,在innoDB引擎中,缓冲池中页的大小默认为16kb,同样使用LRU算法对缓冲池进行管理,但对LRU算法进行了一些修改优化。

   在innoDB的存储引擎中,LRU列表中还加入了midpoint位置,新读取到得页,虽然是最新读取的数据,但不会放在LRU队列最前端,而是会放在midpoint的位置,默认配置下,midpoint被设置为队列长度的5/8处,但可以通过innodb_old_blocks_pct控制。在midpoint之后的的队列元素就是old队列,之前的就是new队列。

    之所以采用midpoint,而不是直接放在队列首部,是因为某些SQL操作可能会使缓冲池中的热点数据被挤出去,常见的这类操作为索引或者数据的扫描操作,这类操作会访问表中的许多页,甚至是全部的页,而这些页又仅仅只是本次操作中需要访问操作的,并不能算是热点数据,但终归还是要扫描到缓冲区中的,如果放在缓冲区队列首部,那么就可能会把大量的真正热点数据挤出去,导致这些热点数据在下次读取时又需要从磁盘中重新读取,严重影响性能。

    所以,innoDB用了两个参数来解决上面这个问题,除了midpoint外,还有另一个innodb_old_blocks_time,用于表示页读取到mid位置后需要多久才加入到LRU队列的热点数据端。因此当执行上述所说的SQL操作时,可以通过下面的方法尽可能使LRU列表中的热点数据不被刷出。

    首先,新读取的数据肯定会被放在mid位置上,如果该数据在mid位置之后(也就是old部分)存活的次数超过innodb_old_blocks_time次,那么该数据就会放在new队列端(这个操作就是Pages made young),而如果因为innodb_old_blocks_time参数的设置导致页数据并没有从old移动到new队列,该操作被称为Pages not  made young,这样就可以保证mid位置的前面作为热点数据尽量保持其存活,而非热点数据就会处于mid位置的后面,快速被淘汰。   

    通过show engine innodb status命令可以查看内存使用情况(该命令显示的肯定不是实时状态,而是过去的某个时间范围内的状态,比如前60秒内的状态):

    其中,Total large memory表示分配的最大内存空间,Buffer pool size就表示当前共有多少个页的数据,大小为512*16kb,而Free buffers表示Free 队列中页的数量,Database pages 表示LRU队列中页的数量。可能会存在Free buffers+Database pages不等于Buffer pool size,因为Buffer pool size中可能还会有自适应哈希索引、Lock信息等数据,这些数据不用LRU算法维护,因此不会存在于LRU队列中。

    Pages made young 0, not young 0:记录了LRU列表中页移动到new端的次数。

    0.00 youngs/s, 0.00 non-youngs/s:表示每秒这两类操作发生的次数。

    还有另一个非常重要的观察参数buffer pool hit rate,该参数表示缓存命中率,如果命中率为100%,则表示该缓冲池非常优秀,如果小于95%,那么就必须检查是否是由于全表扫描引起的LRU列表被污染的问题。

关于unzip_LRU

   在上图中的数据中,我们可以看到倒数第二行两个参数LRU len:256,unzip_LRU len:0,这两个参数分别表示由LRU管理的总页数,而在innoDB引擎从1.0版本之后支持压缩页的功能,会将原本16kb的页压缩为1kb、2kb、4kb和8kb,而这部分压缩后的页则是由unzip_LRU进行管理的,但是LRU len的数量是包含unzip_LRU的数量的。

关于Flush List

    当LRU中的某个页的数据被修改后,那么该页就被称为脏页,即缓冲池中该页的数据与磁盘上页的数据不一致,而这部分脏页就会复制一份于Flush List中,这时数据库会通过checkpoint机制将脏页的数据刷新到磁盘上,也就是说LRU List中和Flush LIst中各有一份脏页,LRU中保证该页的缓冲可用性,而Flush List则是用来讲脏页数据刷新到磁盘,两者互不影响。在上图的Modified db pages数据就是脏页的数量。

在innodb中定义三种page(页):

1) free page :此page未被使用(通常都是数据库刚刚启动的时候),此种类型page位于free List中,一旦某个页的数据被读取或者操作,那么该页就会从free List中移除,移动到LRU List中

2) clean page:此page被使用,对应数据文件中的一个页面,但是页面没有被修改(仅做读取操作),此种类型 page位于lru List中

3) dirty page:此page被使用,对应数据文件中的一个页面,但是页中的数据被修改过,此种类型page位于lru List和flush List中

 

3.2 重做日志缓冲(redo log buffer)

    重做日志缓冲,或者说日志缓冲,innoDB引擎首先将重做日志信息存放到这个缓冲区中,然后将按一定频率区刷新到重做日志文件中,重做日志缓冲区一般不需要设置到很大,因为一般都会每秒刷新一次日志缓冲区数据到日志文件中,因此只需要保证每秒产生的事务量在这个缓冲大小之内即可。该值由参数innodb_log_buffer_size控制,默认为8mb。

 

4. Checkpoint机制

    在上面的Flush List中说到了,在关于脏页数据刷新(写回)到磁盘中时,是通过Checkpoint机制实现。什么是checkpoint机制?可以先看看下面的问题,就可以知道什么是checkpoint机制。

    上面已经说过,缓冲区的目的就是为了降低CPU处理速度与磁盘IO速度的差距,因此对于数据页的操作都是现在缓冲区中完成,如果该页中的数据发生了改变,那么这个缓冲区中的页就变成了脏页,因为其与磁盘数据是不一致的,所以就需要将缓冲区最新的数据页刷新到磁盘中。

    (1)第一个问题来了,如果每次缓冲区的一个数据页发生了改变,就要刷新一次数据到磁盘上,那么这个开销将是非常可怕的,而且一旦在刷新到磁盘的过程中数据库宕机,那么这部分数据就无法恢复。

    对于这个问题,现在的支持事务机制的数据库普遍采用了write and log策略,也就是当事务提交时,先写重做日志,在修改页,即使数据库突然宕机也可以通过重做日志来进行数据恢复。

   做一个假设,如果我们的缓冲区和磁盘空间可以无限大,我们可以将数据库中所有的数据全部缓存到缓冲区中,而且日志文件可以无限呢增长,那么从某种程度上来说却是可以不需要数据脏页回写磁盘这个步骤了。但是,这种假设目前来说肯定是不可能的,现在一个大型系统的数据库基本都会有上TB的数据,但是内存TB级别的有几个见过?其次,磁盘空降某种程度上来说,却是可以做到无限增长,但是,增长是没问题,但是在进行数据恢复或者重启数据库的时候,那一定是崩溃的,如果日志文件达到几TB,估计恢复时长可能就得几个月了。

   (2)所以第二个问题,如何去减小日志文件的大小,一次减少数据库恢复时间,以及保证缓冲区的数据能够及时刷新到磁盘?

这个问题就是checkpoint机制所负责解决的问题了,也正是checkpoint机制的作用。

首先,如果数据库宕机或者其他原因,需要重新启动,那么数据库是不需要重做日志文件中的所有日志的,因为在checkpoint之前的页都已经刷回磁盘,所以只需要对checkpoint后的重做日志进行恢复即可,这样就大大缩短了恢复的时间(有点类似Redis中通过AOF日志恢复数据的方式)。

其次,如果缓冲区空间不足,依据LRU算法也会溢出最近最少使用的数据页,而如果该页是脏页,那么就会强制执行checkpoint,将该页数据刷回磁盘。

具体checkpoint是如何指定的,这个指定规则比较复杂,不做过多解释,但checkpoint发生时所做的工作无非就是将缓冲池的脏页刷新到磁盘中。比如上面介绍InnoDB的线程中,Master Thread就会进行脏页刷新操作,但不仅仅只有Master Thread中会进行脏页数据刷新。

 

5. InnoDB的关键特性

InnoDB的关键特性包括:

(1)插入缓冲

(2)两次写

(3)自适应哈希索引

(4)异步IO

(5)刷新邻接页

5.1 插入缓冲(Insert Buffer)

     插入缓冲这块建议去了解一下什么是聚集索引,什么是非聚集索引,二者之间的区别是什么,可以很快就理解这部分内容。不过后续讲解索引时,我也会详细说明。在《MySQL技术内幕》书中对这块的描述个人觉得很抽象,不太懂,我结合对于非聚集索引的理解(个人吐槽一下书的内容排版,为什么不把页、索引的概念和原理介绍放在前面),重新翻译了一下关于插入缓冲这部分的内容(个人理解,可能不太正确)。

    Insert Buffer

    首先,对于聚集索引(一般来说也就是基于主键建立的索引)其索引和数据是存储在一起的,而对于非聚集索引(辅助索引),其索引和数据时分开存储的,非聚集索引页下中并不保存数据,而是数据所在的物理存储位置。我们在通过非聚集索引查询数据时,依据索引所获取到的是目标数据在磁盘中的存储位置,然后依据这个位置前往数据页中进行读取。也即是说,非聚集索引是单独的一个页,而真正的数据在另一个数据页中,该数据页中是按照聚集(主键)索引进行存储的。(这也是为什么书中说插入缓冲也是物理页的一部分,实际上就是指的是非聚集索引页)

    前面也说过了,为了提高性能,降低CPU处理速度与磁盘IO速度之间的差距,引入了缓冲区。缓冲区会加载物理磁盘页中的数据到内存中,其中就包括非聚集索引页,和数据页缓冲作用一样,如果非聚集索引的数据发生了改变(插入或更新)并不是直接写入到物理磁盘页中,而是先写入到缓冲区中,然后以一定的频率将非聚集索引数据刷新到磁盘中,这样就可以将多次非聚集索引的插入或者更新操作合并,提高非聚集索引插入性能,这就是插入缓冲(Insert Buffer)。

    但InnoDB引擎对于Insert Buffer的使用有两个条件限制:1、索引必须是辅助索引,或者说是非聚集索引;2、索引不是唯一索引。

    第一个条件很容易理解为什么,但是第二个非唯一索引的的限制原因在于:如果是唯一索引,那么在插入数据的时候,就必须要判断一次插入的辅助索引字段的数据内容是否唯一,就必须要进行一次查找,也就意味着要进行一次离散读取(或者说随机读取,也就是上面所说的从非聚集索引中取到数据页的存储位置进行读取),这也就失去了Insert Buffer的意义了。

    同样的,可以通过命令行命令show engine innodb status来查看insert buffer缓冲区的情况。

    Change Buffer

    如果说上面的Insert Buffer只是针对插入缓冲,那么在InnoDB1.0版本之后引入了Change Buffer,可以将其视为Insert Buffer的升级版,可以支持DML操作(插入、删除、修改)都进行缓冲,分别是Insert Buffer、Delete Buffer和Purge Buffer。

    但Change Buffer的应用也会造成一些问题,如果说对于存在非聚集索引的表进行大量的DML数据操作,那么肯定会导致插入缓冲占用大量的缓冲区空间,进而对其他的数据操作产生影响。可以通过innodb_change_buffer_max_size来设置插入缓冲允许占用总缓冲区最大多少百分比的空间,取值范围0到50。

    Insert Buffer的内部实现

    Insert Buffer的数据结构是一颗B+树,而且整个InnoDB使用一颗B+树来维护Insert  Buffer。Insert Buffer会将辅助索引记录缓存起来,当缓存记录过多时,就会将记录合并回页,然后经行插入操作。这就需要一个空间来记录各个辅助索引页的剩余空间。
在InnoDB中,存在一个Insert Buffer bitmap页,一个Insert Buffer bitmap管理16384个页(256个区),每一个辅助索引页在Insert Buffer bitmap页中占据4位空间。


剩余空间用两位表示

0:表示无可用空间
1:表示剩余空间大于1/32页
2:表示剩余空间大于1/16页
3:表示剩余空间大于1/8页
当剩余空间小于1/32时,就会主动经行 Merge Insert Buffer 操作,这一步会在Master Thread线程中经行。
Insert Buffer的数据结构是一颗B+树,其中非叶子节点存放的是search key(键值),其的构成


space为表空间id;marker用来兼容老版本;offset表示页所在偏移量
叶子节点构成


其中,metadata 记录的每一列的类型,长度;secondary index record 记录的具体值,数据插入流程如下
1.一个辅助索引插入到页(space,offset)
2.检查这个页是否在缓冲池中
在:直接插入
不在:继续
3.构造一个search key
4.查询insert buffer树
5.生成逻辑记录并插入树中

5.2 两次写(doublewrite)

    如果说Insert Buffer带给InnoDB存储引擎的是性能的提升,那么doublewrite带给InnoDB存储引擎的是数据的可靠性。

    由于innodb page是16K,一般系统page是4k,当有个update语句需要对业内记录加1,当第一个4k中记录加1后,系统宕机,重启恢复时候,innodb 不知道从哪里给记录加1,如果给16k里所有记录都加1,就会导致第一个4k里面记录加2,必然导致数据不一致,这种情况就是部分写失效double write buffer就解决了这个问题。但前面说过,InnoDB还有一个重做日志不是可以还原恢复数据吗?不能恢复数据的原因如下

    因为Innodb中的日志是逻辑的,所谓逻辑就是比如插入一条记录时,它可能会在某一个页面(这条记录最终被插入的位置)的多个偏移位置写入某个长度的值,例如页头的记录数、槽数、页尾槽数据、页中的记录值等。这些本是一些物理操作,而Innodb为了节省日志量及其它原因,设计为逻辑处理的方式,即在一个页面上插入一条记录时,对应的日志内容包括表空间号、页面号、将被记录的各个列的值等内容,在真正物理插入的时候,才会将日志逻辑操作转换为前面的物理操作。

先有逻辑日志,再有物理操作,但是这样需要有一个前提,就是物理操作的页面是正确的。如果那个数据页本身是错误的,这种错误可能是上次的操作导致的写断裂(1个页面为16KB,分多次写入,后面的可能没有写成功,导致这个页面不完整)或者其它原因,那么这个逻辑操作就没办法完成了。因为如果这个页面不正确的话,里面的数据是无效的,就可能会产生各种不可预料的问题。

因此首先要保证这个页面是正确的,方法就是两次写。double write的流程如下

    

double write由两部分组成,一部分是InnoDB内存中的double write buffer,大小为2M,另一部分是物理磁盘上ibdata系统表空间中大小为2MB,共128个连续的Page,既2个分区。其中120个用于批量写脏,另外8个用于Single Page Flush。做区分的原因是批量刷脏是后台线程做的,不影响前台线程。而Single page flush是用户线程发起的,需要尽快的刷脏并替换出一个空闲页出来。

在对缓冲池的脏页进行刷新时,并不直接写磁盘,而是会通过 memcpy函数将脏页先复制到内存中的 doublewrite buffer,之后通过 doublewrite buffer再分两次,每次1MB顺序地写入共享表空间的物理磁盘上,然后马上调用 fsync函数,同步磁盘,避免缓冲写带来的问题。在这个过程中,因为 doublewrite页是连续的,因此这个过程是顺序写的,开销并不是很大。在完成 doublewrite页的写入后,再将 doublewrite buffer中的页写入各个表空间文件中,此时的写入则是离散的。

说白了,double write就是在写数据页之前,先把这个数据页写到一块独立的物理文件位置(ibdata),然后再写到数据页。这样在宕机重启时,如果出现数据页损坏,那么在应用redo log之前,需要通过该页的副本来还原该页,然后再进行redo log重做,这就是double write。

在数据库启动时(异常关闭的情况下),都会做数据库恢复(redo)操作,恢复的过程中,数据库都会检查页面是不是合法(校验等等),如果发现一个页面校验结果不一致,则此时会用到两次写这个功能,这个特点也正是为了处理这样的错误而设计的。此时的操作很明白了,将两次写的2个BLOCK都读出来,然后将所有这些页面写回到对应的页面中去,那么这时可以保证这些页面是正确的,并且是在写入前已经更新过的(最新数据)。在写回对应页面中去之后,那么就可以在这基础上继续做数据库恢复了,之后则不会再遇到这样的问题了,因为已经将最后有可能产生写断裂的数据页面都恢复了。redo流程如下

MySQL InnoDB特性:两次写(Double Write)

    那假如在将脏页数据刷新到Double Write Buffer中或者是从Double Write Buffer刷新到磁盘double write块中时,发生异常导致失败会怎么样呢?其实这个问题已经说过了,如果不使用double write机制,直接将缓冲区的脏页数据刷新到磁盘中,如果1个16kb的页在刷新过程中数据库宕机会导致磁盘上该页的数据结构发生异常,该页会失效,所以导致无法通过重做日志恢复数据,但是我们现在有了double write块来备份一份脏页,即使在这个备份的过程中异常宕机导致double write块的页异常,在通过重做日志进行数据恢复时是不影响的,因为我们只考虑的是数据页的合法性,而不考虑double write块中数据页的合法性。

5.3 自适应哈希索引

    哈希是一种非常快速的查找方法,在一般情况下这种查找的时间复杂度为O(1),而B+树的查找次数取决于树的高度。InnoDB会监控对表上各索引页的查询,如果观察到建立哈希索引可以提升查询速度,则自动建立哈希索引,这就是自适应哈希索引(AHI)。

   AHI通过对缓冲池的B+树页构造而来,因此建立的速度很快,而且不会对整张表构建哈希索引,而是会依据访问的频率和模式来自动的为某些热点数据页建立哈希索引,当然,哈希索引的建立还需要一些条件:

(1)对于页数据的连续查询模式相同:也就是where后的条件语句格式相同(条件值不用相同),而且必须是等于判断的条件语句,比如

where  name = 'xxx' 或者是 where  name = 'xxx' and age = 11

(2)同一个查询模式,必须连续查询次数大于100次,或者页中的数据被连续访问了至少N次,其中N=页中记录数量/16。

5.4 异步IO(AIO)

实际上就是指在进行磁盘操作时采用AIO进行,至于什么是AIO可以自行了解一下,在进行磁盘IO时我们完全不需要等待其完成后再进行其他操作,比如说扫描一个磁盘上的页数据到缓冲池中,只需要发出一条磁盘扫描也就是IO请求即可,发出之后我们可以立即执行下次IO操作,不需要等待上一个IO操作的完成。这样就可以大大提升IO性能,充分利用CPU资源,

当然,AIO的实现是需要操作系统支持的,Windows和Linux都对AIO进行了实现,但Mac并没有。

5.5 刷新邻接页

刷新邻接页实际上指的就是在将脏页数据刷新回磁盘时,会检查该页所在区中是否还包含脏页,如果包含,那么就会将整个区的页一起刷新回磁盘中,这样的好处是显而易见的,可以将多个IO操作合并为一个。但也会有两个问题产生:

(1)是否会有一些不太脏的数据页被刷新到磁盘,而且该数据页又很快变成了脏页。

(2)对于现在的一些高速读写(高IOPS)的固态硬盘,该特性实际上是否需要?

因此,在innodb1.2版本之后,对于刷新邻接页属性可以选择是否关闭。通过设置innodb_flush_neighbors控制是否开启此特性。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章