Innodb是Mysql5.5版本以后默认的数据库引擎,相比较于MyISAM, 它最大的特点是支持了事务处理,它的设计目标是: 最有效的使用内存和CPU,可以说Innodb就是Mysql的灵魂。经过不断的优化,Mysql8.0之后Innodb引擎的读写性能进一步得到了提升,学习Innodb引擎的工作原理,对企业开发人员的业务架构设计非常有帮助,但是Innodb引擎的架构设计可谓庞大而复杂,故笔者制定了一个2020年前学习计划,第一个阶段先从基础出发,庖丁解牛的方式学习Innodb引擎的模块(基础模块篇);第二阶段将这些模块之间协同工作的原理给梳理出来,总结一些调优经验(模块协同工作篇);第三阶段将结合Mysql8.0.20的源码对Innodb引擎新增的一些新特性和一些优化细节做探索(新特性源码探索篇)。写博文有两个目的,首先立下的flag接受监督(其实是怕自己坚持不下来~);第二是将学习笔记记录下来,有错误或者遗漏重点的地方还希望读者在评论区里批评指正。
本节主要来说一下Buffer Pool,在此之前会先简单描述一下数据页和数据记录结构,然后根据官网的架构图概述一下Innodb引擎体系结构,此后的基础模块篇章节将根据这张图中涉及的模块展开。
1. 数据记录和数据页的基本结构
Innodb引擎中磁盘和内存之间数据交互的基本单位是数据页,数据页的默认大小是16KB,数据页中存储页的一些基本信息和数据记录;数据记录是按照行格式在磁盘上存储的。下边是数据页的基本结构和最通用的Compact行格式的用户记录结构图:
先来说说用户记录吧,Compact格式的用户记录分为两部分:记录的额外信息和真实数据部分,记录的额外信息存放记录中的变长字段列表、记录NULL标志位列表还有记录头信息,记录头信息使用40个标志位记录一些有用的标示信息;真实数据部分存储的就是业务中的数据记录还有一些隐藏列信息了。下边表格给出了记录头中40个标志位包含的信息标示,不理解具体含义的读者不要灰心,只需要混个脸熟就好了,之后有用得到的地方会再进行讲解的。
| 名称 | 占用bit数 | 描述 |
|---|---|---|
| 预留位1 | 1 | 暂时未使用到 |
| 预留位2 | 1 | 暂时未使用到 |
| delete_mask | 1 | 标记该记录是否被删除 |
| min_rec_mask | 1 | B+树的每层非叶子节点中的最小记录都会添加该标记 |
| n_owned | 4 | 当前槽拥有的记录数 |
| heap_no | 13 | 当前记录在记录堆的位置 |
| record_type | 3 | 表示当前记录的类型,0表示普通记录,1表示B+树非叶子节点记录,2表示最小记录,3表示最大记录 |
| next_record | 16 | 下一条记录的相对位置 |
我们再来看看数据页,下表列出了数据页基本结构各部分的描述信息:
| 名称 | 中文名 | 占用空间大小 | 简单描述 |
|---|---|---|---|
| File Header | 文件头部 | 38字节 | 页的一些通用信息 |
| Page Header | 页面头部 | 56字节 | 数据页专有的一些信息 |
| Infimum + Supremum | 最小记录和最大记录 | 26字节 | 两个虚拟的行记录 |
| User Records | 用户记录 | 不定 | 实际存储的行记录内容 |
| Free Space | 空闲空间 | 不定 | 页中尚未使用的空间 |
| Page Directory | 页面目录 | 不定 | 页中的某些记录的相对位置 |
| File Trailer | 文件尾部 | 8字节 | 校验页是否完整 |
其中File Header、Page Header、Infimum + Supermum记录、File Tailer每一部分占用的空间大小都是固定的;User Records、Free Space、Page Directory部分占用的空间总大小是固定的,但是每一部分又是动态变化的,像弹簧一样,当User Records部分新增用户记录时,Free Space部分就随着被压缩了,Page Directory记录的信息随之也做变更。每个数据页都有两个固定的最小记录和最大记录,用户记录在User Records部分是通过头信息中的next_record作为逻辑指针形成链表紧邻存储的,每个数据页中能存储很多用户记录。
FileHeader部分存储了页的一些通用信息,有些信息对我们本节分析问题很关键,先单独列出来,再说一下重要部分信息代表的含义:
| 名称 | 占用空间大小 | 描述 |
|---|---|---|
| FIL_PAGE_SPACE_OR_CHKSUM | 4字节 | 页的校验和(checksum值) |
| FIL_PAGE_OFFSET | 4字节 | 页号 |
| FIL_PAGE_PREV | 4字节 | 上一个页的页号 |
| FIL_PAGE_NEXT | 4字节 | 下一个页的页号 |
| FIL_PAGE_LSN | 8字节 | 页面被最后修改时对应的日志序列位置(英文名是:Log Sequence Number) |
| FIL_PAGE_TYPE | 2字节 | 该页的类型 |
| FIL_PAGE_FILE_FLUSH_LSN | 8字节 | 仅在系统表空间的一个页中定义,代表文件至少被刷新到了对应的LSN值 |
| FIL_PAGE_ARCH_LOG_NO_OR_SPACE_ID | 8字节 | 页属于哪个表空间 |
Innodb引擎使用B+树结构存储数据,树的叶子节点存储数据页,非叶子节点存储数据页的目录(目录页), 为了方便数据范围查找,数据页之间使用双向链表链接,双向链表的前后指针就是表格中说的 FIL_PAGE_OFFSET 和 FIL_PAGE_PREV 。下边是一个聚簇索引的B+树结构示意图:
每一个数据页在表空间都有一个页号, FIL_PAGE_OFFSET 存储的就是该信息,FIL_PAGE_ARCH_LOG_NO_OR_SPACE_ID 记录数据页属于哪个表空间,在数据库中,通过表空间 + 页号能够唯一标示一个数据页。Innodb引擎有很多种页,除了存储用户记录的数据页外,还有索引页,Undo页、Inode页,系统页、BloB页等等,页的类型使用 FIL_PAGE_TYPE 标示;FIL_PAGE_SPACE_OR_CHKSUM 是用来校验数据页的完整性的,我们不用管它,FIL_PAGE_FILE_FLUSH_LSN 也可以先不了解。但是FIL_PAGE_LSN非常非常重要!!!英文名叫Log Sequence Number,后边会有专门的一篇文章来讲解它。
对数据记录和数据页有了一个基本的了解后,留给读者一个思考问题:为什么Innodb引擎要以数据页作为磁盘和内存交互的基本单位?
相信读者心中已经有了自己的答案,不过我还是希望读者能了解一下局部性原理的相关概念,基于它也能对这个问题做一些解释。
程序的局部性原理是指程序在执行时呈现出局部性规律,分为时间局部性和空间局部性。时间局部性指的是如果某数据被访问,则不久之后该数据可能再次被访问;空间局部性是指一旦程序访问了某个存储单元数据,则不久之后,其附近的存储单元也将被访问。
2. Innodb引擎架构概述
Innodb引擎的架构设计可谓复杂,但是读者也不要被它给吓到,只要我们将其中的模块分解开,逐个突破,再将他们协同工作的原理给弄清楚了,再回过头来看这张图时就是另一种心情了💢(这是笔者的内心的真实感受)。
下边是官网给出的Innodb总体架构图:
从架构图上可以看到Innodb的架构可以划分为“内存架构(In-Memory Structures)”和“磁盘架构(On-Disk Structures)”。内存架构比较简单, 由Buffer Pool和Log Buffer组成,磁盘架构稍微复杂些,由Redo Log日志文件、Undo Log日志文件、系统表空间(System Tablespace)下的文件、独立表空间(General Tablespaces)下的文件;还有临时表空间(Temporary Tablespaces)下的一些文件和双写缓冲区(Doublewrite Buffer)下的文件等等。
Innodb引擎使用数据页完成磁盘和内存之间的交互,CPU直接操作的都是内存中的数据,而Mysql为了实现数据持久化,都是将用户数据存储到表空间下的磁盘文件上。CPU每次对数据进行读写,如果都从磁盘上加载数据页到内存,那成本就太高了,所以Innodb引擎专门申请了一大片内存来缓存磁盘上的数据页,取名叫“Buffer Pool”。下边我们就来介绍一下它。
3. Buffer Pool体系结构
前边我们提到在Innodb引擎的磁盘结构中,数据页在B+树的叶子节点上是通过双链表管理的,这样有助于根据条件进行范围查找。可是数据页加载到Buffer Pool中以后就是一些零零散散的数据页了。
3.1 Buffer Pool的内部组成
为了更加方便灵活的管理这些数据页,Innodb引擎为每一个缓存页都创建了一个对应的块结构,块结构中存有该页面的表空间编号、页号、缓存页在Buffer Pool中的地址、链表节点信息、锁信息和LSN等信息(还记得LSN吗?就是我们刚描述数据页的时候提到的Log Sequence Number,这个信息在控制块中也有,大家可以先留意一下这个概念 ,后边笔者会专门整理一个章节来介绍它的)。Buffer Pool的内存空间是类似于下边这样:
缓存页的控制块被放到了Buffer Pool的前边,缓存页存放到后边,它们之间是一一对应的。当控制块和缓存页填充满Buffer Pool的内存空间时,可能会存在一块不足以存放下一个缓存页的内存碎片。
3.2 Buffer Pool中的free链表
Mysql服务器刚启动时,Innodb引擎需要完成Buffer Pool的初始化,首先向操作系统申请需要使用的内存空间,然后将它划分成很多控制块和缓存页。因为此时还没有真正的数据页被缓存到Buffer Pool中,为了记录哪些缓存页是可以使用的,Innodb引擎维护了一个free链表:
Innodb引擎特意为这个链表定义了一个基节点,其中包含了指向链表的头节点、指向链表的尾节点和链表中节点数量等信息。
有了free链表之后,每当需要从磁盘加载一个页到Buffer Pool中时,就直接从free链表中取一个空闲的缓存页就好了,并且将该缓存页对应的控制块填充上缓存页的元信息(就是我们前边提到的,页面的表空间编号、页号、缓存页在Buffer Pool中的地址、链表节点信息、锁信息和LSN等),并将该控制块从free链表中移除,表示该缓存页已经被使用了。
3.3 设置Buffer Pool的相关参数与初始化流程
通过上边的介绍,我们对Buffer Pool的基本结构已经有了一个初步认识,下边再来看一下设置Buffer Pool的相关参数吧:
- innodb_buffer_pool_size : Buffer Pool的总大小
- innodb_buffer_pool_instances : Buffer Pool中instance的数量
- innodb_buffer_pool_chunk_size : Buffer Pool中chunk的大小,默认为128M
为了减轻高并发下锁争抢的压力(并发情况下,访问Buffer Pool中的各种链表都需要加锁处理),Buffer Pool分为多个instances,具体的数量由innodb_buffer_pool_instances参数控制。
从上边的配置参数中我们得知,innodb_buffer_pool_size是Buffer Pool的总大小,innodb_buffer_pool_instances是Buffer Pool实例的个数,那么每个Buffer Pool实际占用的空间就可以通过下边的公式计算出来(总大小除以实例的个数):
innodb_buffer_pool_size/innodb_buffer_pool_instances
不过Buffer Pool的实例并不是创建的越多越好,管理Buffer Pool本身也需要额外的开销。并且当innodb_buffer_pool_size的值小于1G的时候设置多个实例是没有效果的,这个时候Innodb引擎会默认把innodb_buffer_pool_instances的值修改为1。当Buffer Pool大于或等于1G的时候还是设置多个Buffer Pool实例比较好。
在Mysql5.7.5版本之前,Buffer Pool的大小只能在服务器启动时通过配置innodb_buffer_pool_size启动参数来调整大小,在服务器的运行过程中是不允许调整该值的。从Mysql5.7.5版本开始,Innodb引擎支持了在服务运行过程中调整Buffer Pool的大小。这个功能确实牛逼,但是按照之前的实现方式,每次调整Buffer Pool的大小时都需要向操作系统申请一块连续的内存空间,再将旧的Buffer Pool的内容复制到这一块新的空间,这个操作是极其耗时的。所以Mysql5.7.5版本后,Innodb引擎初始化Buffer Pool时不再是一次性的申请一大片的连续内存空间了,而是引入了一个chunk结构,每次以chunk为单位向操作系统申请空间,这样一个Buffer Pool实例就由若干个chunk组成,chunk中存放我们前边提到的缓存页的控制块和缓存页,还有管理这些缓存页的链表信息等。通过上边的描述我们就可以大概勾勒出Buffer Pool的组成结构了:
Buffer Pool中的chunk的大小可以自定义设置,默认是128M。整个Buffer Pool的内存大小分配关系有这样一个公式,假设n为chunk的数量。
innodb_buffer_pool_size = n * innodb_buffer_pool_chunk_size * innodb_buffer_pool_instances
其中n值会在Buffer Pool初始化阶段计算得出,加入计算得到的N不是正整数,Innodb引擎会自动调整innodb_buffer_pool_size的大小。
下边我们再用简单的回顾一下Buffer Pool的初始化过程。上边我们一直提到Buffer Pool的结构中保存着一个个的缓存页,每个缓存页对应一个控制块。其实在Buffer Pool中就是以buf_block_t这样一个数据结构作为基本单元的:
//代码路径(mysql8.0.20):storage/innobase/include/buf0buf.h
struct buf_block_t {
buf_page_t page; /* Page的元信息,id, size之类 */
byte *frame; /* Page数据 */
}
buffer Pool初始化时以buf_pool_init()方法作为入口,然后调用buf_pool_create()方法初始化每个instance;初始化链表,创建相关的mutex,创建chunk等。再然后才是调用buf_chunk_init()方法真正的进行初始化,也分为几个步骤:
- chunk初始化,使用os_mem_alloc_large()函数分配内存
- 初始化buf_block_t, 每一个Block包含block->frame数据页和block->page页信息管理
- 将每一个Block的Page结构即block->page加入buf_pool->free链表
以上通过代码片段描述的Buffer Pool的初始化过程,和我们之前的分析是一致的,只不过引入了多实例和chunk的结构以后,申请内存的方式和粒度由原来的申请大块内存变成了以chunk为单位向操作系统申请内存了。
3.4 缓存页的hash表
前边我们提到的free链表管理着Buffer Pool中没有使用的缓存页;当数据页从磁盘加载到Buffer Pool中以后,缓存到Buffer Pool中的缓存页就从free链表中移除了。该页已经在Buffer Pool中了,当我们下次访问这个数据页中的数据时直接从Buffer pool中取就可以了。问题是:我们怎么确定数据页在不在Buffer Pool中呢?
再来回顾一下我们讲过的数据页结构的内容,在数据页的FileHeader部分,存有数据页所在的表空间号和页号,通过表空间号+页号可以在数据库中唯一标示一个数据页。我们就可以使用表空间号 + 页号作为key,缓存页作为value创建一个hash表,在需要访问页面数据时,先通过表空间号 + 页号确定Buffer Pool中有没有缓存页,有就直接使用,没有就从free链表中取一个空闲的缓存页,然后从磁盘中将对应的数据页加载进去再访问。
缓存页的hash表比较好理解,下边我们再来看一下Buffer Pool中另外一个比较重要的链表:flush链表。
3.5 Buffer Pool中的flush链表
我们经常会使用增、删、改等DML语句对数据记录进行更新,当我们修改了Buffer Pool缓存页中的数据时,它就和磁盘上的数据页不一致了,这样的数据页被称为dirty page(脏页)。Mysql数据库是强一致性的,为了实现数据的一致性,我们能想到的最简单的方法就是,每次当有缓存页被修改时就马上同步到对应的磁盘页上;Innodb引擎以高性能著称,肯定不会这么干的!脏页同步到磁盘肯定是迟早的事,但Innodb引擎并不着急每次都同步,具体什么时候同步、怎样同步我们先不用关心,后边会详细讲解这一块。我们再来思考接下来的问题:dirty page不立即同步到磁盘的话,当我们将来要同步时,该如何判断哪些数据页是dirty page,哪些页从来没有被修改过呢?我们前边有讲到Buffer Pool中的free list,相信聪明的你一定想到了,Innodb引擎维护了另外一种链表——flush list(脏页链表),当有缓存页被修改的时候,就添加到里边,将来脏页同步到磁盘以后,就从flush list中移除,flush list的构造也是和free list差不多的。
3.6 Buffer Pool中LRU链表的管理
Buffer Pool的大小是有限的,用户数据是不断增长的,总有free链表拿不出可用的缓存页的时候。那么问题来了,真的到了那个时候Innodb引擎应该怎么办?这个问题很简单,Innodb引擎不得不从Buffer Pool中淘汰一部分缓存页了。
问题是淘汰哪一些缓存页呢?从flush链表中刷新一部分脏页到磁盘可以吗?方案是可行的,但是读者不要认为free链表拿不出缓存页的时候flush链表一定是有内容的,我们平时查询一张大表的数据时,假如将这张大表的数据页都加载到了Buffer Pool并占满了内存空间,这个时候我们只是查询数据,并没有更改任何缓存页呢,flush链表是空的。
面对问题不能总是想当然了,要有一定的理论依据,还记得我们之前提到的局部性原理吗?其中时间局部性提到:如果某数据被访问,则不久之后该数据可能再次被访问。基于此很多软件开发者都会使用LRU(全称是 Least Recently Used:最近最少使用)算法管理淘汰内存数据,熟悉redis的读者应该了解这一点。
Innodb引擎也在Buffer Pool中创建了自己的LRU链表,设计的原则就是:尽量提高Buffer Pool的缓存命中率。我们先顺着自己的思路考虑一下这个LRU链表如何实现吧,我们前边讲过free list和 flush list,像它们一样,我们也创建一个LRU链表的基节点,当访问某个页面时,我们这样来处理:
-
如果数据页不在
Buffer Pool中,在把该页从磁盘加载到Buffer Pool中的缓存页时,将该缓存页对应的控制块作为节点塞到链表的头部。 -
如果该页已经缓存在Buffer Pool中,则直接把该页对应的控制块移动到LRU链表的头部。
我们想象中的LRU链表长这个样子:
如果你真的以为Innodb引擎就是这样实现的!沉下心来仔细思考我们想象中的LRU链表,还真的存在很多问题。还拿上边的例子来讲,我们对一张大表进行全表扫描,大量新的缓存页进入到Buffer Pool,Buffer Pool中原来热的缓存页被淘汰掉了(热的缓存页是指最近经常被使用的缓存页),而对一张大表进行扫描这种操作通常很少发生,这直接导致的就是Buffer Pool的缓存命中率降低,需要程序很长时间的运行才会恢复过来。Innodb引擎在设计LRU链表之初就考虑到了这个问题,我们来看一下Innodb引擎的LRU链表是怎样实现的:
Innodb引擎将LRU链表按照比例一分为二,前边是存储热缓存页数据的New Sublist;后边是存储冷缓存页数据的Old Sublist。New Sublist和Old Sublist的可以通过下边的参数调控:
mysql> show variables like 'innodb_old_blocks_pct';
+-----------------------+-------+
| Variable_name | Value |
+-----------------------+-------+
| innodb_old_blocks_pct | 37 |
+-----------------------+-------+
1 row in set (0.01 sec)
默认情况下,Old Sublist占LRU链表的37%;也就是3/8。我们再回过头来看刚才那个问题,当我们对一张大表进行全表扫描的时候,首次被加载进Buffer Pool的缓存页被添加到了LRU链表的Old区域,缓存页的控制块中会记录下来它首次被加载进Buffer Pool的时间,下次再次访问该缓存页时,判断访问时间与第一次访问的时间在某个时间间隔内,那么该页面就不会被从Old区域移动到New区域的头部,否则将它移动到New区域的头部。
我们上边描述的这个间隔时间也是可以通过参数配置的:
mysql> show variables like 'innodb_old_blocks_time';
+------------------------+-------+
| Variable_name | Value |
+------------------------+-------+
| innodb_old_blocks_time | 1000 |
+------------------------+-------+
1 row in set (0.00 sec)
innodb_old_blocks_time的默认值是1000,它的单位是毫秒,意味着从磁盘上被加载到LRU链表的Old区域的某个页,如果第一次和最后一次访问该页面的时间间隔小于1s,那么该页是不会被加入到New Sublist头部的~ ;我们还可以将innodb_old_blocks_time设置为0,这样的话,当我们每次访问一个缓存页时,都会将该缓存页放到LRU的New Sublist头部。
Innodb引擎设计的这种LRU链表结构还有很多其他的考量,比如说预读策略。其中针对如何提高Buffer Pool的缓存命中率,还制定了其他的一些优化策略,笔者在这里就不一一赘述了,感兴趣的读者可以深入研究一下。
3.7 Buffer Pool的其他一些数据结构
Buffer Pool为了更方便的管理内存中的缓存页,还设计了其他的一些数据结构,例如:zip_free(压缩空闲链表),是一个指针数组。他们组成伙伴系统来为压缩页提供内存空间,伙伴系统的精髓就在于按照2的倍数进行紧邻内存块的合并和拆分,进而达到高效管理、代码复杂度低的效果。这个指针数组按照块大小实际包含4层,1024,2048,4096和8192,每一层基结点只管理同类大小的块。还有一些其他的unzip LRU链表、zip clean链表;看不懂这段描述没有关系,这不是我们本文的重点,读者只要知道他们都是Buffer Pool创建出来用来更好的管理缓存页就好了。
通过前边几个章节的描述,相信读者对buffer pool的体系结构有了一个比较全面的认识了,这对我们后边分析Innodb引擎的工作原理很有帮助。后边的章节我们还会提到它。
4. 小结与预告
本节简单介绍了一下数据页和数据记录的结构,然后概述了一下Innodb引擎的架构,此后的基础模块部分的章节也将据此展开;本节主要总结了一下Buffer Pool的结构,分析了其中一些重要链表的设计,有心的读者可能发现了在Innodb引擎架构的图中,Buffer Pool还有一块内容叫“Change Buffer”,本节并没有提到是因为笔者认为它很关键,不想将文章写的特别长,所以下一篇文章会重点学习它。