InnoDB存储引擎底层原理

1.InnoDB体系结构


InnoDB的底层结构主要由2部分组成:内存结构和磁盘结构。

内存结构主要有如下几个部分:

  • Buffer Pool 缓冲区,放经常使用的数据页和索引页
  • Change Buffer 修改缓冲区,对数据进行增删改操作时,先把数据放到这里
  • Adaptive Hash Index
  • Log Buffer 重做日志缓冲区,redo log, 保证数据库不丢失数据的重要环节,只要数据写入redo log成功,则认为更新成功。

磁盘结构有如下几个部分:

  • SystemTablespace 系统表空间,ibdata1,记录系统相关的数据
      InnoDB Data Dictionary:系统表空间的数据字典
      Doublewrite Buffer:双写缓冲区
      Change Buffer:修改的数据
  • File-Per-Table Tablespaces 用户表空间,每一个表对应一个表空间(*.ibd文件)存储用户表的数据以及索引
  • General Tablespaces 通用表空间
  • Temporary Tablespaces 临时表空间
  • Undo Tablespaces 回滚日志,可以配置为回滚表空间独立出来
  • Redo Log 重做日志 ib_logfile0,ib_logfile1

2.内存结构

2.1 自适应哈希索引

InnoDB会根据访问的频率和模式,为热点页建立哈希索引,来提高查询效率。

InnoDB存储引擎会监控对表上各索引页的查询。如果观察到建立哈希索引可以带来速度提升,则建立哈希索引,称之为自适应哈希索引(Adaptive Hash Index,AHI) 。AHI是通过缓冲池的B+树页构造而来,因此建立的速度很快,而且不需要对整张表构建哈希索引。 InnoDB存储引擎会自动根据访问的频率和模式来自动地为某些热点页建立哈希索引。

2.2 change buffer

主要针对二级索引的数据插入存在的问题而设计。

在InnoDB引擎上进行插入操作时,一般需要按照主键顺序进行插入,这样才能获得较高的插入性能。当一张表中存在二级索引(除主键以外的索引)时,在插入时,数据页的存放还是按照主键进行顺序存放,但是对于二级索引叶节点的插入不再是顺序的了,这时就需要离散的访问次要索引页,由于随机读取的存在导致插入操作性能下降。

InnoDB为此设计了Change Buffer来进行插入优化。对于二级索引的插入或者更新操作,不是每一次都直接插入到索引页中,而是先判断插入的非主键索引是否在缓冲池中,若在,则直接插入;若不在,则先放入到一个Change Buffer中。看似数据库这个非主键的索引已经插到叶节点,而实际没有,这时存放在另外一个位置。然后再以一定的频率和情况进行Change Buffer和非聚簇索引页子节点的合并操作。这时通常能够将多个插入合并到一个操作中,这样就大大提高了对于非聚簇索引的插入性能。

2.3 Log Buffer

InnoDB在缓冲池中变更数据时,会首先将相关变更写入重做日志缓冲中,然后再按时或者当事务提交时写入磁盘,这符合Force-log-at-commit原则;

当重做日志写入磁盘后,缓冲池中的变更数据才会依据checkpoint机制择时写入到磁盘中,这符合WAL原则。


2.4 Buffer Pool

当需要访问某个页的数据时,就会把完整的页的数据全部加载到内存中,也就是说即使我们只需要访问一个页的一条记录,那也需要先把整个页的数据加载到内存中。将整个页加载到内存中后就可以进行读写访问了,在进行完读写访问之后并不着急把该页对应的内存空间释放掉,而是将其缓存起来,这样将来有请求再次访问该页面时,就可以省去磁盘IO的开销了。

InnoDB为了缓存磁盘中的页,在MySQL服务器启动的时候就向操作系统申请了一片连续的内存,他们给这片内存起了个名,叫做Buffer Pool(中文名是缓冲池)。

2.4.1 大小设置

默认只有128M。可以在启动服务器的时候配置innodb_buffer_pool_size参数的值。

更大的缓冲池只需更少的磁盘 I/O 来多次访问相同的表数据。在专用数据库服务器上,可以将缓冲池大小设置为机器物理内存大小的 80%。

InnoDB 为缓冲区和控制结构保留了额外的内存,因此分配的总空间比指定的缓冲池大小大约大 10%。

总的来说,没有专人管理和实时监控的情况下,可以设置为60%较为稳妥,有专人管理和实时监控的情况下,可以设置为75%,并根据业务情况适度增大或者缩小。

2.4.2 Buffer Pool内部组成

控制块:

  • 包括该页所属的表空间编号、页号、缓存页在Buffer Pool中的地址、链表节点信息、一些锁信息以及LSN信息,当然还有一些别的控制信息。
  • 每个缓存页对应的控制信息占用的内存大小是相同的
  • 控制块被存放到 Buffer Pool 的前边
  • 每个控制块大约占用缓存页大小的5%,而我们设置的innodb_buffer_pool_size并不包含这部分控制块占用的内存空间大小

缓存块:

  • 存放到 Buffer Pool 后边
  • 控制块和缓存页是一一对应的

2.4.3 缓存页的哈希处理

怎么知道该页在不在Buffer Pool中呢?难不成需要依次遍历Buffer Pool中各个缓存页么?

用表空间号 + 页号作为key,缓存页作为value创建一个哈希表,在需要访问某个页的数据时,先从哈希表中根据表空间号 + 页号看看有没有对应的缓存页,如果有,直接使用该缓存页就好,如果没有,那就从free链表中选一个空闲的缓存页,然后把磁盘中对应的页加载到该缓存页的位置。

2.4.4 三个链表

free链表:

  • 把所有空闲的缓存页对应的控制块作为一个节点放到一个链表中,这个链表也可以被称作free链表(或者说空闲链表)。刚刚完成初始化的Buffer Pool中所有的缓存页都是空闲的,所以每一个缓存页对应的控制块都会被加入到free链表中
  • 有了这个free链表之后,每当需要从磁盘中加载一个页到Buffer Pool中时,就从free链表中取一个空闲的缓存页,并且把该缓存页对应的控制块的信息填上(就是该页所在的表空间、页号之类的信息),然后把该缓存页对应的free链表节点从链表中移除,表示该缓存页已经被使用了。

flush链表:

  • 如果我们修改了Buffer Pool中某个缓存页的数据,那它就和磁盘上的页不一致了,这样的缓存页也被称为脏页(英文名:dirty page)。
  • 存储脏页的链表,凡是修改过的缓存页对应的控制块都会作为一个节点加入到一个链表中,因为这个链表节点对应的缓存页都是需要被刷新到磁盘上的,所以也叫flush链表。
  • 刷新脏页到磁盘
    1、从LRU链表的冷数据中刷新一部分页面到磁盘。
    2、从flush链表中刷新一部分页面到磁盘。

LRU链表:

  • 按照最近最少使用的原则去淘汰缓存页的
  • 一部分存储使用频率非常高的缓存页,所以这一部分链表也叫做热数据,或者称young区域。
  • 另一部分存储使用频率不是很高的缓存页,所以这一部分链表也叫做冷数据,或者称old区域。
  • 只有被访问的缓存页位于young区域的1/4的后边,才会被移动到LRU链表头部,这样就可以降低调整LRU链表的频率,从而提升性能比如只有被访问的缓存页位于young区域的1/4的后边,才会被移动到LRU链表头部,这样就可以降低调整LRU链表的频率,从而提升性能

3.磁盘结构

3.1 行格式

Compact格式:


Dynamic和Compressed行格式和Compact行格式挺像,只不过在处理行溢出数据时有所不同。Compressed行格式和Dynamic不同的一点是,Compressed行格式会采用压缩算法对页面进行压缩,以节省空间。

数据溢出:

  • 一个页的大小一般是16KB,也就是16384字节,而一个VARCHAR(M)类型的列就最多可以存储65532个字节,这样就可能造成一个页存放不了一条记录的情况。
  • 在Compact和Redundant行格式中,对于占用存储空间非常大的列,在记录的真实数据处只会存储该列的该列的前768个字节的数据,然后把剩余的数据分散存储在几个其他的页中,记录的真实数据处用20个字节存储指向这些页的地址。这个过程也叫做行溢出,存储超出768字节的那些页面也被称为溢出页。
  • Dynamic和Compressed行格式,不会在记录的真实数据处存储字段真实数据的前768个字节,而是把所有的字节都存储到其他页面中,只在记录的真实数据处存储其他页面的地址。

3.2 页

将数据划分为若干个页,以页作为磁盘和内存之间交互的基本单位,InnoDB中页的大小一般为 16 KB。也就是在一般情况下,一次最少从磁盘中读取16KB的内容到内存中,一次最少把内存中的16KB内容刷新到磁盘中。

InnoDB为了不同的目的而设计了许多种不同类型的页,存放我们表中记录的那种类型的页自然也是其中的一员,官方称这种存放记录的页为索引(INDEX)页。

Page Directory主要是解决记录链表的查找问题。如果我们想根据主键值查找页中的某条记录该咋办?按链表查找的办法:从Infimum记录(最小记录)开始,沿着链表一直往后找,总会找到或者找不到。但是时间复杂度不低。


3.3 系统表空间

系统表空间的结构和独立表空间基本类似,只不过由于整个MySQL进程只有一个系统表空间,在系统表空间中会额外记录一些有关整个系统信息的页面,所以会比独立表空间多出一些记录这些信息的页面,相当于是表空间之首,所以它的表空间 ID(Space ID)是0。

3.3.1 双写缓冲区

系统表空间有extent 1和extent两个区(2M),也就是页号从64~191这128个页面被称为Doublewrite buffer,也就是双写缓冲区。

doublewrite buffer是InnoDB在表空间上的128个页(2个区,extend1和extend2),大小是2MB。为了解决部分页写入问题,当MySQL将脏数据flush到数据文件的时候, 先使用memcopy将脏数据复制到内存中的一个区域(也是2M),之后通过这个内存区域再分2次,每次写入1MB到系统表空间,然后马上调用fsync函数,同步到磁盘上。在这个过程中是顺序写,开销并不大,在完成doublewrite写入后,再将数据写入各数据文件文件,这时是离散写入。

所以在正常的情况下, MySQL写数据页时,会写两遍到磁盘上,第一遍是写到doublewrite buffer,第二遍是写到真正的数据文件中。

不过在存储上,doublewrite是在一个连续的存储空间, 所以硬盘在写数据的时候是顺序写,而不是随机写,这样性能影响不大,相比不双写,降低了大概5-10%左右。

3.3.2 InnoDB数据字典(Data Dictionary Header)

InnoDB存储引擎特意定义了一些列的内部系统表(internal system table)来记录元数据:

  • SYS_TABLES 整个InnoDB存储引擎中所有的表的信息
  • SYS_COLUMNS 整个InnoDB存储引擎中所有的列的信息
  • SYS_INDEXES 整个InnoDB存储引擎中所有的索引的信息
  • SYS_FIELDS 整个InnoDB存储引擎中所有的索引对应的列的信息
  • SYS_FOREIGN 整个InnoDB存储引擎中所有的外键的信息
  • SYS_FOREIGN_COLS 整个InnoDB存储引擎中所有的外键对应列的信息
  • SYS_TABLESPACES 整个InnoDB存储引擎中所有的表空间信息
  • SYS_DATAFILES 整个InnoDB存储引擎中所有的表空间对应文件系统的文件路径信息
  • SYS_VIRTUAL 整个InnoDB存储引擎中所有的虚拟生成列的信息

3.4 用户表空间

对于每个独立表空间来说,对应着文件系统中一个名为表名.ibd的实际文件。

3.4.1 区(extent)

对于16KB的页来说,连续的64个页就是一个区,也就是说一个区默认占用1MB空间大小。

不论是系统表空间还是独立表空间,都可以看成是由若干个区组成的,每256个区又被划分成一个组。

每向表中插入一条记录,本质上就是向该表的聚簇索引以及所有二级索引代表的B+树的节点中插入数据。而B+树的每一层中的页都会形成一个双向链表,如果是以页为单位来分配存储空间的话,双向链表相邻的两个页之间的物理位置可能离得非常远。

范围查询只需要定位到最左边的记录和最右边的记录,然后沿着双向链表一直扫描就可以了,而如果链表中相邻的两个页物理位置离得非常远,就是所谓的随机I/O。

所以我们应该尽量让链表中相邻的页的物理位置也相邻,这样进行范围查询的时候才可以使用所谓的顺序I/O。

一个区就是在物理位置上连续的64个页。在表中数据量大的时候,为某个索引分配空间的时候就不再按照页为单位分配了,而是按照区为单位分配,甚至在表中的数据十分非常特别多的时候,可以一次性分配多个连续的区,从性能角度看,可以消除很多的随机I/O。

3.4.2 段(segment)

其实是对B+树叶子节点中的记录进行顺序扫描,而如果不区分叶子节点和非叶子节点,统统把节点代表的页面放到申请到的区中的话,进行范围扫描的效果就大打折扣了。所以InnoDB对B+树的叶子节点和非叶子节点进行了区别对待,也就是说叶子节点有自己独有的区,非叶子节点也有自己独有的区。存放叶子节点的区的集合就算是一个段(segment),存放非叶子节点的区的集合也算是一个段。也就是说一个索引会生成2个段,一个叶子节点段,一个非叶子节点段。

段其实不对应表空间中某一个连续的物理区域,而是一个逻辑上的概念。

3.5 redo log

事务的具体实现机制,Mysql采用WAL(Write-ahead logging,预写式日志)机制。

所有的修改都先被写入到日志中,然后再被应用到系统中。通常包含redo和undo两部分信息。

为什么WAL包含redo和undo信息呢?

  • redo log称为重做日志,每当有操作时,在数据变更之前将操作写入redo log,这样当发生掉电之类的情况时系统可以在重启后继续操作。
  • undo log称为撤销日志,当一些变更执行到一半无法完成时,可以根据撤销日志恢复到变更之间的状态。

3.5.1 redo log作用

redo日志中只需要记录一下在某个页面的某个偏移量处修改了几个字节的值,具体被修改的内容是啥就好了。

这样我们在事务提交时,把上述内容刷新到磁盘中,即使之后系统崩溃了,重启之后只要按照上述内容所记录的步骤重新更新一下数据页,那么该事务对数据库中所做的修改又可以被恢复出来,也就意味着满足持久性的要求。因为在系统崩溃重启时需要按照上述内容所记录的步骤重新更新数据页,所以上述内容也被称之为重做日志。

redo log优势:

  • 1、redo日志占用的空间非常小
  • 2、redo日志是顺序写入磁盘的

3.5.2 redo日志的写入过程

redo日志刷盘时机:

  • 1、log buffer空间不足时,InnoDB认为如果当前写入log buffer的redo日志量已经占满了log buffer总容量的大约一半左右,就需要把这些日志刷新到磁盘上。
  • 2、事务提交时
  • 3、后台有一个线程,大约每秒都会刷新一次log buffer中的redo日志到磁盘。
  • 4、正常关闭服务器时等等。

3.5.3 innodb_flush_log_at_trx_commit的用法

0:当该系统变量值为0时,表示在事务提交时不立即向磁盘中同步redo日志,这个任务是交给后台线程做的。但是如果事务提交后服务器挂了,后台线程没有及时将redo日志刷新到磁盘,那么该事务对页面的修改会丢失。

1:当该系统变量值为1时,表示在事务提交时需要将redo日志同步到磁盘,可以保证事务的持久性。1也是innodb_flush_log_at_trx_commit的默认值。

2:当该系统变量值为2时,表示在事务提交时需要将redo日志写到操作系统的缓冲区中,但并不需要保证将日志真正的刷新到磁盘。
这种情况下如果数据库挂了,操作系统没挂的话,事务的持久性还是可以保证的,但是操作系统也挂了的话,那就不能保证持久性了。

3.6 undo log

事务需要保证原子性,也就是事务中的操作要么全部完成,要么什么也不做。

会出现事务执行到一半的一些情况:

  • 情况一:事务执行过程中可能遇到各种错误,比如服务器本身的错误,操作系统错误,甚至是突然断电导致的错误。
  • 情况二:程序员可以在事务执行过程中手动输入ROLLBACK语句结束当前的事务的执行。

每当我们要对一条记录做改动时(这里的改动可以指INSERT、DELETE、UPDATE),都需要把回滚时所需的东西都给记下来。

你插入一条记录时,至少要把这条记录的主键值记下来,之后回滚的时候只需要把这个主键值对应的记录删掉。
你删除了一条记录,至少要把这条记录中的内容都记下来,这样之后回滚时再把由这些内容组成的记录插入到表中。
你修改了一条记录,至少要把修改这条记录前的旧值都记录下来,这样之后回滚时再把这条记录更新为旧值。

3.7 事务

3.7.1 事务的执行流程

MySQL在事务执行的过程中,会记录相应SQL语句的UndoLog 和 Redo Log,然后在内存中更新数据并形成数据脏页。接下来RedoLog会根据一定规则触发刷盘操作,Undo Log 和数据脏页则通过刷盘机制刷盘。事务提交时,会将当前事务相关的所有Redo Log刷盘,只有当前事务相关的所有Redo Log 刷盘成功,事务才算提交成功。

3.7.2 事务恢复流程

参考文章

  • https://dev.mysql.com/doc/refman/5.7/en/innodb-architecture.html
  • https://dev.mysql.com/doc/refman/8.0/en/innodb-architecture.html
  • https://zhuanlan.zhihu.com/p/502056224
  • https://developer.aliyun.com/article/1052535
  • 图灵笔记

你可能感兴趣的:(InnoDB存储引擎底层原理)