InnoDB:存储引擎架构

11.1 概述

MySQL 的 InnoDB 存储引擎架构,包括了内存架构和磁盘架构两部分。其中,内存架构部分包括:缓冲池(Buffer Pool)、修改缓冲区(Change Buffer)、自适应 hash 索引(Adaptive Hash Index)、日志缓冲区(Log Buffer)。磁盘架构包括:表、索引、表空间、双写缓冲区(Doublewrite Buffer)、重做日志(Redo Log)、撤销日志(Undo Logs)。

11.2 内存架构

11.2.1 Buffer Pool三大特性之一

  1. Buffer Pool 组成

在 MySQL 服务启动的时候就向操作系统申请了一片连续的内存,这片内存就是 Buffer Pool。Buffer Pool 中默认的缓存页大小和在磁盘上默认的页大小是一样的,都是 16KB。为了更好的管理这些在 Buffer Pool 中的缓存页,InnoDB 为每一个缓存页都创建了一些所谓的控制信息,这些控制信息包括该页所属的表空间编号、页号、缓存页在 Buffer Pool 中的地址、链表节点信息、一些锁信息以及 LSN 信息等。

每个缓存页对应的控制信息占用的内存大小是相同的,我们把每个页对应的控制信息占用的一块内存称为一个控制块,控制块和缓存页是一一对应的,它们都被存放到 Buffer Pool 中,其中控制块被存放到 Buffer Pool 的前边,缓存页被存放到 Buffer Pool 后边,所以整个 Buffer Pool 对应的内存空间看起来就是这样的:

每个控制块大约占用缓存页大小的 5%,而我们设置的 innodb_buffer_pool_size 并不包含这部分控制块占用的内存空间大小,也就是说 InnoDB 在为 Buffer Pool 向操作系统申请连续的内存空间时,这片连续的内存空间一般会比 innodb_buffer_pool_size 的值大5%左右。

控制块和缓存页之间的那个碎片是因为,每一个控制块都对应一个缓存页,那在分配足够多的控制块和缓存页后,可能剩余的空间不够一对控制块和缓存页的大小,所以就被称为碎片了。

:::color1

默认情况下 Buffer Pool 只有 128M 大小,可以在 my.cnf 中配置 innodb_buffer_pool_size 参数设置 Buffer Pool 的大小,默认单位是字节,即 268435456B,也可以设置为 256M。需要注意的是,Buffer Pool 也不能太小,最小值为 5M(当小于该值时会自动设置成 5M)。

数据库专用服务器推荐 InnoDB Buffer Pool 大小= 内存 * 75% 【其最接近 128 整数倍的值】

:::

  1. Free 链表

当我们最初启动 MySQL 服务的时候,需要完成对 Buffer Pool 的初始化过程,就是先向操作系统申请 Buffer Pool 的内存空间,然后把它划分成若干对控制块和缓存页。但是此时并没有真实的磁盘页被缓存到 Buffer Pool 中,当执行读写的时候磁盘的数据页会加载到 Buffer pool 的数据页中,当 BufferPool 中间有的页数据持久化到硬盘后,这些数据页又会被空闲出来。

以上的过程中会有一个问题,如何知道那些数据页是空的,那些是有数据的,只有找到空的数据页,才能吧数据写进去。Free 链表(空闲链表)就是干这个事的,它是一个双向链表,由一个基础节点和若干个子节点组成,记录空闲的数据页对应的控制块信息。

Free 链表定义了一个基节点,包含了链表的头节点地址,尾节点地址,以及当前链表中节点的数量等信息。这里需要注意的是,链表的基节点占用的内存空间并不包含在为 Buffer Pool 申请的一大片连续内存空间之内,而是单独申请的一块内存空间。

每当需要从磁盘中加载一个页到 Buffer Pool 中时,就从 Free 链表中取一个空闲的缓存页,并且把该缓存页对应的控制块的信息填上(该页所在的表空间、页号之类的信息),然后把该缓存页对应的 Free 链表节点从链表中移除,表示该缓存页已经被使用了。

  1. 缓存哈希表

磁盘页是通过 Free 加载到 Buffer Pool 的缓存页,不能所有的数据都去磁盘读取然后通过 Free 链表写入缓存页中,有可能在缓存页中已经有了这个数据页了。数据库提供了一个数据页缓存哈希表,以 表空间号+数据页号作为 key,缓存页控制块的地址作为 value。当使用数据页时,会先在数据页缓存哈希表中查找,如果找到了,则直接根据 value 定位控制块,然后根据控制块找到缓存页,如果没有找到,则读取磁盘数据页写入缓存,最后写入数据页缓存哈希表。

  1. Flush 链表

    如果我们修改了 Buffer Pool 中某个缓存页的数据,那它就和磁盘上的页不一致了,这样的缓存页也被称为脏页。当然,最简单的做法就是每发生一次修改就立即同步到磁盘上对应的页上,但是频繁的往磁盘中写数据会严重的影响程序的性能。所以每次修改缓存页后,然后在通过后台线程将脏页写入到磁盘,持久化到磁盘中,称之为脏刷

但是如果不立即同步到磁盘的话,那之后再同步的时候我们怎么知道 Buffer Pool 中哪些页是脏页,哪些页从来没被修改过呢?总不能把所有的缓存页都同步到磁盘上吧,假如 Buffer Pool 被设置的很大,比方说 300G,那一次性同步这么多数据岂不是要慢死!所以,我们不得不再创建一个存储脏页的链表,凡是修改过的缓存页对应的控制块都会作为一个节点加入到一个链表中,因为这个链表节点对应的缓存页都是需要被刷新到磁盘上的,所以也叫 Flush 链表。

  1. LRU 算法

Buffer pool 作为一个 InnoDB 自带的一个缓存池,数据的读写都是 Buffer Pool 中进行的,操作的都是 Buffer Pool 中的数据页,但是 Buffer Pool 的大小是有限的,所以对于一些频繁访问的数据是希望能够一直留在 Buffer Pool 中,而一些访问比较少的数据,我们希望能将它够释放掉,空出数据页缓存其他数据。基于此,InnoBD 采用了 LRU(最近最少使用) 算法,将频繁访问的数据放在链表头部,而不怎么访问的数据链表末尾,空间不够的时候就从尾部开始淘汰,从而腾出空间。但是这种实现存在两种比较尴尬的情况让基础的 LRU 算法不是很符合要求:

复制代码
- **预读**

线性预读:InnoDB 提供了一个系统变量 innodb_read_ahead_threshold(默认 56,取值范围 1~64),如果顺序访问了某个区的页面超过这个系统变量的值,就会触发一次异步读取下一个区中全部的页面到 Buffer Pool 的请求。

随机预读:InnoDB 同时提供了一个系统变量 innodb_random_read_ahead(默认 OFF),如果 Buffer Pool 中已经缓存了某个区的 13 个连续的页面,不论这些页面是不是顺序读取的,都会触发一次异步读取本区中所有其他的页面到 Buffer Pool 的请求。、

:::color1

根据通过执行 show engine innodb status 命令显示的三个参数判断 read-ahead 算法的有效性(read_ahead:预读页数、read_ahead_evicted:未使用就被驱除的页数、read_ahead_rnd:触发随机预读次数),如果通过监控发现,这个预读功能长期有效性很低,可以考虑关闭这个预读功能。

:::

复制代码
- **全表扫描**

扫描全表意味着将该表所有页统统都加载到 Buffer Pool 中,这也就意味着 Buffer Pool 中的所有页都被换了一次血,其他查询语句在执行时又得执行一次从磁盘加载到 Buffer Pool 的操作。而这种全表扫描的语句执行的频率也不高,每次执行都要把 Buffer Pool 中的缓存页换一次血,这严重的影响到其他查询对 Buffer Pool 的使用,从而大大降低了缓存命中率。

因为有这两种情况的存在,所以 InnoDB 把 LRU 链表按照一定比例分成两截,一部分存储使用频率非常高的缓存页,所以这一部分链表也叫做热数据,或者称 young 区域(默认占比 73%)。另一部分存储使用频率不是很高的缓存页,所以这一部分链表也叫做冷数据,或者称 old 区域(默认占比 37%)。有了这个被划分成 young 和 old 区域的 LRU 链表之后,InnoDB 就可以针对我们上边提到的两种可能降低缓存命中率的情况进行优化了:

复制代码
- **针对预读的页面可能不进行后续访问情况的优化**

InnoDB 规定,当磁盘上的某个页面在初次加载到 Buffer Pool 中的某个缓存页时,该缓存页对应的控制块会被放到 old 区域的头部。这样针对预读到 Buffer Pool 却不进行后续访问的页面就会被逐渐从 old 区域逐出,而不会影响 young 区域中被使用比较频繁的缓存页。

复制代码
- **针对全表扫描时,短时间内访问大量使用频率非常低的页面情况的优化**

在进行全表扫描时,虽然首次被加载到 Buffer Pool的 页被放到了 old 区域的头部,但是后续会被马上访问到,每次进行访问的时候又会把该页放到 young 区域的头部,这样仍然会把那些使用频率比较高的页面给顶下去(InnoDB 规定每次去页面中读取一条记录时,都算是访问一次页面)。所以在对处在 old 区域的缓存页进行第一次访问时就在它对应的控制块中记录下来这个访问时间,如果后续的访问时间与第一次访问的时间在某个时间间隔内,那么该页面就不会被从 old 区域移动到 young 区域的头部。上述的这个间隔时间是由系统变量 innodb_old_blocks_time 控制的(默认 1000ms)。

11.2.2 Change Buffer

变更缓冲区是一种特殊的数据结构,当要修改的二级索引页不在 Buffer Pool 中时,用来 cache 对二级索引页的修改。等到相关的索引页被读入 Buffer Pool 中后,才会使用 Change Buffer 中的内容对辅助索引页进行修改(即 merge 操作),从而达到减少磁盘 I/O 的目的。

:::color1

与聚簇索引不同,二级索引通常是不唯一的,并且二级索引的插入以相对随机的顺序发生。同样,删除和更新可能会影响索引树中并不相邻的二级索引页。随着其他操作将受影响的页读入 Buffer Pool 后,再合并 Change Buffer 中的更改可以避免从磁盘将二级索引页读入 Buffer Pool 所需的大量随机访问 I/O。

:::

11.2.3 Adaptive Hash Index三大特性之一

自适应哈希索引即:当 InnoDB 注意到某些索引值被使用得非常频繁时,它会在内存中基于 B+Tree 索引之上再创建一个哈希索引,这样就让 B+Tree 索引也具有哈希索引的一些优点,比如快速的哈希查找。这是一个完全自动的、内部的行为,用户无法控制或者配置,不过如果有必要,完全可以关闭该功能。

11.2.4 Log Buffer

日志缓冲区是内存存储区域,用于保存要写入磁盘上的 Redo Log 数据,日志缓冲区的内容会定期刷新到磁盘。提供变量 innodb_log_buffer_size 来控制日志缓冲区大小(默认 16MB)。大的日志缓冲区能够在事务提交前无需写入 Redo Log 数据到磁盘的情况下执行大事务。因此,如果有更新、插入、删除很多行记录的事务,可以通过增加日志缓冲区的大小来减少磁盘 I/O。

为了保证事务的持久性,用户线程在事务提交时需要将该事务执行过程中产生的所有 redo 日志都刷新到磁盘上。这个操作很明显会降低数据库性能。如果对事务的持久性要求不是那么强烈的话,可以选择修改一个称为innodb_flush_log_at_trx_commit 的系统变量的值,该变量有 3 个可选的值:

  • 0:表示在事务提交时不立即向磁盘中同步 redo 日志,这个任务是交给后台线程做的。这样很明显会加快请求处理速度,但是如果事务提交后服务器挂了,后台线程没有及时将 redo 日志刷新到磁盘,那么该事务对页面的修改会丢失。
  • 1:【默认值】表示在事务提交时需要将 redo日志同步到磁盘,可以保证事务的持久性。
  • 2:表示在事务提交时需要将 redo 日志写到操作系统的缓冲区中,但并不需要保证将日志真正的刷新到磁盘。这种情况下如果数据库挂了,操作系统没挂的话,事务的持久性还是可以保证的,但是操作系统也挂了的话,那就不能保证持久性了。

11.3 磁盘架构

省略 表空间、表、索引

11.3.1 Doublewrite Buffer三大特性之一

双写缓冲区/双写机制是一种特殊文件 flush 技术。它的作用是,在把页写到数据文件之前,InnoDB 先把它们写到一个叫 Doublewrite Buffer(双写缓冲区) 的连续区域内,在写 Doublewrite Buffer 完成后,InnoDB 才会把页写到数据文件的适当的位置。如果在写页的过程中发生意外崩溃,InnoDB 在稍后的恢复过程中在 Doublewrite Buffer 中找到完好的 page 副本用于恢复。所以,虽然叫双写缓冲区,但是这个缓冲区不仅在内存中有,更多的是属于 MySQL 的系统表空间,属于磁盘文件的一部分。

InnoDB的页大小一般是 16KB,其数据校验也是针对这 16KB 来计算的,将数据写入到磁盘是以页为单位进行操作的。而操作系统写文件是以 4KB 作为单位的,那么每写一个 InnoDB 的页到磁盘上,操作系统需要写 4 个块。而计算机硬件和操作系统,在极端情况下(比如断电)往往并不能保证这一操作的原子性,16K 的数据,写入 4K 时,发生了系统断电或系统崩溃,只有一部分写是成功的,这种情况下会产生 partial page write(部分页写入)问题。这时页数据出现不一样的情形,从而形成一个"断裂"的页,使数据产生混乱。

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

所以在正常的情况下, MySQL 写数据页时,会写两遍到磁盘上,第一遍是写到 Doublewrite Buffer,第二遍是写到真正的数据文件中。如果发生了极端情况,InnoDB 再次启动后,发现了一个页数据已经损坏,那么此时就可以从 Doublewrite Buffer 中进行数据恢复了。

位于系统表空间上的 Doublewrite Buffer 实际上也是一个文件,写系统表空间会导致系统有更多的 fsync 操作, 而硬盘的 fsync 性能因素会降低 MySQL 的整体性能。不过在存储上,doublewrite 是在一个连续的存储空间, 所以硬盘在写数据的时候是顺序写,而不是随机写,这样性能影响不大,相比不双写,降低了大概 5-10% 左右。

在数据库异常关闭的情况下启动时,都会做数据库恢复(redo)操作,恢复的过程中,数据库都会检查页面是不是合法,如果发现一个页面校验结果不一致,则此时会用到 doublewrite 这个功能(Redo Log 记录的是对页的物理操作,而不是页面的全量记录,而如果发生 partial page write 问题时,出现问题的是未修改过的数据,此时 Redo Log 无能为力。只能依靠 doublewrite)。

如果是写 Doublewrite Buffer 本身失败,那么这些数据不会被写到磁盘,InnoDB 此时会从磁盘载入原始的数据,然后通过 InnoDB 的 Redo Log 来计算出正确的数据,重新写入到 Doublewrite Buffer,这个速度就比较慢了。如果 Doublewrite Buffer 写成功,但是写数据文件失败,InnoDB 就不用通过 Redo Log 来计算了,而是直接用 Doublewrite Buffer 的数据再写一遍,速度上会快很多。

总体来说,Doublewrite Buffer 的作用有两个:一个是提高 InnoDB 把缓存的数据写到硬盘这个过程的安全性;一个是 InnoDB 的 Redo Log 不需要包含所有数据的前后映像,而是二进制变化量,这可以节省大量的 I/O。

11.3.2 Undo Log

undo 顾名思义,就是没有做,没发生的意思。undo log 就是没有发生事情(原本是什么)的一些日志。用于记录数据被修改前的样子。

  1. Undo Log 的作用

在准备更新一条语句的时候,该条记录已经被加载到 Buffer pool 中了,实际上这里还有这样的操作,就是在将该条记录加载到 Buffer Pool 中的时候同时会往 undo 日志文件中插入一条日志,将这条记录的原来的值记录下来。Innodb 存储引擎的最大特点就是支持事务,如果本次事务失败,那么该事务中的所有的操作都必须回滚到执行前的样子。

Undo Log 日志里面不仅存放着数据更新前的记录,还记录着 RowID、事务 ID、回滚指针。其中事务 ID 每次递增,回滚指针第一次如果是 insert 语句的话,回滚指针为 NULL,第二次 update 之后的 Undo Log 的回滚指针就会指向刚刚上一条 Undo Log 日志,依次类推,就会形成一条 Undo Log 的回滚链,方便找到该条记录的历史版本。

在更新数据之前,MySQL 会提前生成 Undo Log 日志,当事务提交的时候,并不会立即删除 Undo Log,因为后面可能需要进行回滚操作,要执行回滚操作时,从缓存中读取数据。Undo Log 日志的删除是通过通过后台 purge 线程进行回收处理的。

  1. 事务 id
  • 分配事务 id

对于只读事务来说,只有在它第一次对某个用户创建的临时表执行增、删、改操作时才会为这个事务分配一个事务 id,否则不分配事务 id。

对于读写事务来说,只有在它第一次对某个表(包括用户创建的临时表)执行增、删、改操作时才会为这个事务分配一个事务 id,否则不分配事务id。

  • 事务 id 生成机制

服务器会在内存中维护一个全局变量,每当需要为某个事务分配一个事务 id 时,就会把该变量的值当作事务 id 分配给该事务,并且把该变量自增 1。每当这个变量的值为 256 的倍数时,就会将该变量的值刷新到系统表空间的页号为 5 的页面中一个称之为 Max Trx ID 的属性处(占用8个字节的存储空间)。当系统下一次重新启动时,会将 Max Trx ID 属性值加载到内存中,将该值加上 256 后赋值给内存中的全局变量。这样就可以保证整个系统中分配的事务 id 值是一个递增的数字。

  1. INSERT 对应的 Undo Log

当我们向表中插入一条记录时,最终的结果就是这条记录被放到了一个数据页中。如果希望回滚这个插入操作,那么把这条记录删除就好了,也就是说在写对应的 undo 日志时,主要是把这条记录的主键信息记上。

当我们向某个表中插入一条记录时,实际上需要向聚簇索引和所有的二级索引都插入一条记录。不过记录 undo 日志时,我们只需要考虑向聚簇索引插入记录时的情况就好了,因为其实聚簇索引记录和二级索引记录是一一对应的。

  1. UPDATE 对应的 Undo Log
  • 不更新主键
    • 就地更新

更新记录时,对于被更新的每个列来说,如果更新后的列和更新前的列占用的存储空间都一样大,那么就可以进行就地更新,也就是直接在原记录的基础上修改对应列的值。

复制代码
- 先删除旧纪录,然后插入新纪录

如果有任何一个被更新的列更新前和更新后占用的存储空间大小不一致,那么就需要先把这条旧的记录从聚簇索引页面中删除掉,然后再根据更新后列的值创建一条新的记录插入到页面中。需要注意的是:这里的删除并不是 delete mark 标记,而是真正的删除掉,也就是把这条记录从正常记录链表中移除并加入到垃圾链表中,并且修改页面中相应的统计信息。如果新创建的记录占用的存储空间大小不超过旧记录占用的空间,那么可以直接重用被加入到垃圾链表中的旧记录所占用的存储空间,否则的话需要在页面中新申请一段空间以供新记录使用,如果本页面内已经没有可用的空间的话,那就需要进行页面分裂操作,然后再插入新记录。

  • 更新主键

在 UPDATE 语句所在的事务提交前,对旧记录只做一个 delete mark 操作,在事务提交后才由专门的线程做 purge 操作,把它加入到垃圾链表中。之所以只对旧记录做 delete mark 操作,是因为别的事务同时也可能访问这条记录,如果把它真正的删除加入到垃圾链表后,别的事务就访问不到了。然后根据更新后各列的值创建一条新记录,并将其插入到聚簇索引中。由于更新后的记录主键值发生了改变,所以需要重新从聚簇索引中定位这条记录所在的位置,然后把它插进去。

11.3.3 Redo Log

redo 日志文件是 InnoDB 特有的,是存储引擎级别的,不是 service 级别的。用来记录数据被修改后的样子。

  1. Redo Log 的作用

除了从磁盘中加载文件和将操作前的记录保存到 undo 日志文件中,其他的操作是在内存中完成的,内存中的数据的特点就是:断电丢失。如果此时 MySQL 所在的服务器宕机了,那么 Buffer Pool 中的数据会全部丢失的。这个时候 redo 日志文件就需要来大显神通了。通用结构如下所示:

redo 记录的是数据修改之后的值,不管事务是否提交都会记录下来,例如,此时将要做的是 update student set name='zs' where id=1; 那么这条操作就会被记录到 Log Buffer 中(MySQL 为了提高效率,所以将这些操作都先放在内存中去完成,然后会在某个时机将其持久化到磁盘中)。

InnoDB 执行 UPDATE 大致做了以下操作

复制代码
- InnoDB 会先去 Buffer Pool 中去查找这条数据,没找到就会去磁盘中查找,如果查找到就会将这条数据加载到 Buffer Pool 中
- 在加载到 Buffer Pool 的同时,会将这条数据的原始记录保存到 undo 日志文件中
- InnoDB 会在 Buffer Pool 中执行更新操作
- 更新后的数据会记录在 Log Buffer 中
- MySQL 提交事务的时候,会将 Log Buffer 中的数据写入到 Redo Log 中
  1. 数据恢复

如果 MySQL 宕机了,那么 MySQL 会认为本次事务是失败的,所以数据依旧是更新前的样子,并不会有任何的影响。如果语句更新好了,那么需要将更新的值提交,也就是需要提交本次的事务,因为只要事务成功提交了,才会将最后的变更保存到数据库,在提交事务前仍然会具有相关的其他操作。即:将 Log Buffer 中的数据持久化到磁盘中,就是将 Log Buffer 中的数据写入到 Redo Log 磁盘文件中,一般情况下,Log Buffer 数据写入磁盘的策略是立即刷入磁盘。

如果 Log Buffer 刷入磁盘后,数据库服务器宕机了,那我们更新的数据怎么办?此时数据是在内存中,数据岂不是丢失了?不,这次数据就不会丢失了,因为 Log Buffer 中的数据已经被写入到磁盘了,已经被持久化了,就算数据库宕机了,在下次重启的时候 MySQL 也会将 redo 日志文件内容恢复到 Buffer Pool 中。

相关推荐
p***c9494 小时前
微服务展望
微服务·云原生·架构
Xの哲學6 小时前
Linux slab分配器深度剖析:从原理到实践
linux·服务器·算法·架构·边缘计算
稚辉君.MCA_P8_Java8 小时前
Sqoop 实现的功能是什么
java·服务器·架构·kubernetes·sqoop
狼爷11 小时前
Go 重试机制终极指南:基于 go-retry 打造可靠容错系统
架构·go
Starduster11 小时前
一次数据库权限小改动,如何拖垮半个互联网?——Cloudflare 2025-11-18 大故障复盘
数据库·后端·架构
梁萌14 小时前
缓存高可用架构-读缓存
redis·缓存·架构·高可用架构·读缓存
2501_9411481515 小时前
云计算与容器技术在企业IT架构优化与高可用系统建设中的创新应用研究
架构·云计算
一只会写代码的猫15 小时前
当分布式协同成为主流应用架构时系统可信计算将面临的新挑战与革新方向
分布式·架构