第5章 InnoDB 存储引擎
在前面的章节里,我们得知MySQL 采用插件式设计,其底层的存储引擎具备可插拔性。在编译过程中,一旦指定了相应的存储引擎,启动MySQL 后便能使用,在创建表时指定对应存储引擎即可。MyISAM 和InnoDB 是两种常用的存储引擎,不过当下MyISAM 已逐渐淡出历史舞台。早在2010 年发布的MySQL 5.5.5 版本里,InnoDB 存储引擎便取代了MyISAM,成为MySQL 默认的存储引擎。这归因于InnoDB 引擎所具备的一系列强大特性,诸如支持事务并且支持多种隔离级别、多版本快照、行锁等。InnoDB 的这些特性使MySQL 能够处理联机事务处理(On-Line Transaction Processing,OLTP)过程,而这正是 MySQL 现今在各行各业系统中被大规模运用的缘由。这些特性在后续的章节中都会详细介绍。
那么,为拥有这些特性,InnoDB 存储引擎自身的架构是如何设计的呢?它无疑是一个由众多组件构成的庞大系统,接下来让我们一同来瞧瞧InnoDB 存储引擎的整体架构以及各个组件的详细情况。
5.1 整体架构
MySQL InnoDB 存储引擎以其众多强大的特性而著称,这些特性源于其高效处理读写数据的能力,并同时能确保事务的原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability),简称ACID。由于这些复杂的功能需求,InnoDB 的内部设计显得尤为复杂。第2 章介绍了InnoDB 的整体架构。在初步了解各组件的基本功能与角色定位后,本章将聚焦于InnoDB 存储引擎内存部分的相关组件,进行详尽的阐述。第6 章将全面解析InnoDB 存储引擎的文件系统组成,以便读者获得更为全面深入的理解。
5.2 缓冲池
在MySQL 的InnoDB 存储引擎中,缓冲池扮演着至关重要的角色。该缓冲池负责在内存中管理并缓存一部分用户数据,确保所有对数据库的操作均通过此缓冲池进行。除了初次操作需将数据从磁盘加载至缓存中之外,随后的操作都能直接在内存环境中高效执行。对于需要频繁访问的数据而言,此机制能将操作性能显著提升至一个更高的量级。
然而,将数据缓存至内存亦伴随着一系列挑战,包括但不限于数据的持久化问题,如何确保缓冲池中存储的均为热点数据,以及当缓冲池空间耗尽时应采取的应对措施。针对这些挑战,本节将深入剖析缓冲池如何有效管理其缓存的数据,以确保数据库操作的流畅性与高效性。
5.2.1 总体架构
首先来看InnoDB 缓冲池的结构,如图5-1 所示。
图5-1 InnoDB 缓冲池的结构(原始图)
索引页、回滚页、插入缓冲、自适应哈希 —— 这些是缓存页的不同类型. 链表中包含:空闲链表、LRU 链表、flush 链表.
可以看出,缓冲池主要包括两部分:缓存页和链表。
1. 缓存页
从图5-1 中可以清晰地观察到缓冲池中包含4 种不同类型的页,每种类型均服务于特定数据的存储需求。在缓冲池的管理体系中,页作为最小的管理单元,无论是数据从磁盘加载至内存,还是自内存持久化回磁盘,均遵循以页为单位的原则。值得注意的是,无论页的具体类型如何,其默认大小均设定为16KB。接下来,我们将对各类型页的具体作用进行简要阐述。
(1) 索引页
在缓冲池架构中,索引页扮演着至关重要的角色,它们被设计用于存储表的具体数据项,这些索引页与数据文件中的相应索引页形成直接映射关系。数据文件被精心划分为多个独立的索引页单元,每个索引页内部则有序地容纳了多条用户记录。关于索引页内容的详尽阐述,参见6.1 节。
(2) 回滚页
回滚页主要用于存储回滚数据,也就是数据修改前的内容,它对应回滚文件中的一个回滚页。这部分内容将在6.3 节详细介绍。
(3) 插入缓冲
插入缓冲机制实质上是在系统数据文件中设立一个隐藏表,用于存储对二级索引操作的相关记录。这些记录本质上也是数据页的一部分,但出于便于区分和管理的目的,我们特别将这部分内容称为“插入缓冲”。
(4) 自适应哈希
自适应哈希技术主要致力于将频繁访问的数据通过哈希表的形式进行存储,以提升后续访问的效率。值得注意的是,自适应哈希在内存管理方面并未采取独立分配的策略,而是依赖于缓冲池进行资源的动态申请。具体而言,它会在缓冲池中请求连续的页面,以形成一块连续的内存区域供其使用。
2. 链表
如前所述,缓冲池以页为基本单位进行组织与管理。为实现高效管理,缓冲池构建了一系列链表,用以根据页的不同状态进行分类管理。具体而言:新初始化的页会被分配至空闲链表,以备后续使用;一旦页被使用,将会从空闲链表中移除,并转至LRU 链表,以遵循最近最少使用原则进行调度;若页内容发生修改,则进一步将该页转至flush 链表,以便适时进行数据的持久化操作。在了解缓冲池的基本构成后,接下来将逐一深入阐述其各个组成部分的详细情况。
3. 缓冲池的管理
了解了缓冲池中主要存储的内容后,来看看如何管理缓冲池。首先介绍一个缓冲池的全局管理对象,也就是buf_pool_t 结构体,buf_pool_t 结构体及描述如表5-1 所示。
表5-1 buf_pool_t 结构体及描述
| 名称 | 描述 |
|---|---|
mutex | 互斥锁,用于保护buf_pool_t 结构体中的相关字段 |
instance_no | 在MySQL 中,可以有多个缓冲区实例,这里表示实例的序号 |
curr_pool_size | 当前缓冲区的大小,单位是B |
n_chunks | 一个缓冲区实例被划分为多个chunk,默认情况下每个chunk 128MB |
chunks | chunks 链表,被划分的chunk 被插入该链表中 |
curr_size | 当前缓冲区的大小,单位是页,指的是当前缓冲区实例包含多少个页 |
page_hash | 存放缓存页的哈希表,哈希表元素的key 由表空间ID 和页号生成,元素的value 则对应内存页的指针,在查找页的时候首先会去该哈希表中查询,如果存在则说明数据页已经被加载到内存中。如果不存在则需要从数据文件中读取到内存中,然后再将对应的内存页插入该哈希表中 |
zip_hash | 存放压缩缓存页的哈希表 |
flush_list | 被修改后的缓存页被插入flush 列表中 |
free | 空闲的缓存页被插入空闲链表中 |
LRU | 被使用过的缓存页被插入LRU 链表中 |
unzip_LRU | 被使用过的解压缩缓存页被插入解压缩LRU 链表中 |
然后介绍缓冲池中的最小管理单位—块,块结构体及描述如表5-2 所示。
表5-2 块结构体及描述
| 名称 | 描述 |
|---|---|
page | buf_page_t 类型,存储page 相关信息 |
frame | 指向页存储的数据 |
最后是页,它跟块差不多,页结构体及描述如表5-3 所示。
表5-3 页结构体及描述
| 名称 | 描述 |
|---|---|
id | 页的ID |
size | 页的大小,单位为B |
state | 页的状态,例如页是否被使用,是否包含一个干净的压缩页等 |
hash | 哈希表节点,作为值存储到上述缓冲区实例中维护的哈希表中 |
in_flush_list | 当前缓存页是否在flush 链表中 |
in_free_list | 当前缓存页是否在空闲链表中 |
in_LRU_list | 当前缓存页是否在LRU 链表中 |
newest_modification | 保存当前缓存页最近被修改的日志序列号 |
分析块和页的结构体构成,可以观察到块与页之间存在一种相互转换的可能性,这源于块内部包含页作为其首个字段。此设计的目的在于实现块与页之间的快速转换机制。进一步审视块与页各自的相关字段,可以明确:块主要聚焦于记录的物理层面信息,如具体数据的存储;而页则侧重于记录逻辑层面的信息,如页的标识符、大小、状态等。
5.2.2 缓冲池初始化
前面介绍了缓冲池的管理结构,下面来看缓冲池的内存是如何初始化的。
缓冲池内部将内存分为多个chunk 进行管理,每个chunk 默认为128MB。在MySQL 启动的时候就以chunk 的粒度进行初始化,初始化的主要流程如下:
- 将
innodb_buffer_pool_size设置的缓冲池的大小分割成innodb_buffer_pool_chunk_size,默认128MB。 - 每个chunk 再初始化块,块的数量用chunk 的大小除以页的大小计算。页的大小默认为16KB,所以块的数量就是
(128×1024)/16 = 8192。初始化完成相当于分配一批空闲的内存块。 - 将这些空闲块加入空闲链表中。
- 直到所有的chunk 被初始化成块,缓冲池初始化完成。
为了提升性能,MySQL 将缓冲池划分为多个实例进行管理,上面只是一个实例的流程,在初始化的时候将循环为每个实例完成这一流程。
完成初始化流程后,缓冲池的所有内存均被初始化为空闲块,每个块对应一个空闲的页。这些初始化完成的块随后被加入空闲链表中,以便后续进行内存分配操作。当需要从缓冲池中分配内存时,系统会从空闲链表中申请空闲的页。
注意
尽管缓冲池在初始化阶段已设定,但实际的内存分配并未立即进行。内存分配发生在每次申请空闲页时,此时会调用
memset函数对内存进行初始化,并真正从操作系统请求内存资源。因此,随着MySQL 服务的启动及业务负载的增加,内存使用量会持续上升。当缓冲池接近满负荷时,内存增长的速度将逐渐减缓。
1. 空闲链表
空闲链表由上述buf_pool_t 结构中的free 字段进行维护,该free 字段采用链表数据结构,其节点类型为buf_page_t,即页。空闲链表专门用于管理一组处于空闲状态的页。在初始化chunk 的过程中,会同时初始化其中的块和页,并在初始化完成后,将相应的页加入空闲链表中。当需要使用时,系统会从该链表中检索并获取相应的节点,随后将该节点从空闲链表中移除,并加入LRU 链表中。当空闲链表中已无空闲页可供分配,且新的请求需要申请页时,系统将不得不从LRU 链表中申请,从而触发LRU 链表的淘汰机制,以释放部分页资源。
2. LRU 链表
buf_pool_t 的LRU 字段负责维护一个链表结构,该链表类型同样为buf_page_t,即页。LRU 链表用于追踪一组已被使用的页面,当页面从空闲链表中分配后,它们会被加入LRU 链表中,以表示这些页面已被激活。在缓冲池管理中,LRU 链表扮演着至关重要的角色。鉴于缓冲池的内存资源有限,且其大小通常被预先设定,当空闲链表中的页面被耗尽时,LRU 链表便成为获取页面的主要来源。
在有限的内存资源下,如何优化性能成为一个关键问题,这正是引入LRU 链表的目的所在。面对庞大的数据集,无法将所有数据一次性加载到内存中,因此,需要一种机制来确保最近频繁访问的页面保留在内存中,而将长时间未被访问的页面适时淘汰。
LRU 链表进一步细分为old list 和young list 两部分。默认情况下,链表的前5/8 部分作为young list,后3/8 部分则作为old list。新加入LRU 链表的页面默认被放置在old list 的头部,在满足特定条件后,这些页面才能被迁移到young list 中。若页面长时间停留在old list 中,则它们更有可能被后续淘汰。这种old list 与young list 的划分机制旨在应对如全表扫描等需要大量读取页面但后续使用频率较低的场景。此机制可以有效防止全表扫描等操作对LRU 链表的污染,避免常用数据页被意外淘汰,从而确保系统在实际生产环境中稳定运行。
3. flush 链表
由buf_pool_t 结构中的flush 字段进行维护,该flush 字段被设计为链表类型,其节点类型为buf_page_t,即页。flush 链表负责追踪并维护一组已被修改的页。每当页的数据内容发生变动时,该页即被加入flush 链表中。在MySQL 数据库执行脏页刷新操作时,系统会从flush 链表中检索出这些被标记为脏页的页(即已修改的页),随后将这些脏页的内容持久化保存到相应的数据文件中。
5.2.3 缓存及淘汰
缓冲池最核心的功能就是缓存和淘汰数据,在有限的内存中缓存频繁使用的数据将直接影响到MySQL 的性能,本小节将详细介绍缓冲池是如何缓存和淘汰数据的。
1. 数据页缓存
实际上,关于数据缓存的概念,我们在前面的几个小节中已经进行了初步的探讨。在此,我们再次进行简要的总结。数据缓存是以页为基本单位进行的,且其操作是响应式的,即缓存动作是被动触发的。当用户执行一条操作指令时,系统首先需要定位到所需数据在数据文件中所对应的数据页。一旦找到对应的数据页,系统便会执行加载操作,将这一数据页读取至内存中。加载过程具体可细分为以下几个步骤:
- 从
page_hash哈希表中查找该数据页是否已经缓存到缓冲池了,如果有则直接返回。 - 如果没有则需要向缓冲池中的空闲链表申请一个空闲页,用来保存从数据文件中读取的数据页,它们都是相同的大小,默认为16KB。
- 从空闲链表请求到空闲的内存页后,会将该内存页从空闲链表中移除并且插入LRU 链表中。
- 将从数据文件中读取到的数据页内容复制到刚刚申请的内存页中。
- 在数据操作的时候直接操作内存页即可。
完成上述步骤后,数据文件的数据页就被缓存到缓冲池中了。
2. 数据页淘汰
我们深知内存资源的有限性,因此通常会通过调整innodb_buffer_pool_size 参数来合理控制其使用。一般而言,将innodb_buffer_pool_size 设置为操作系统内存的60% 左右是一个较为适宜的选择。若在同一台计算机上并行运行多个数据库实例,则需确保所有实例的内存使用总量亦维持在60% 左右的水平,以避免因内存配置过高(如设置为100% 或更高)而引发内存溢出,进而触发操作系统终止MySQL 进程的情况。
在缓冲池初始化的过程中,已明确说明所有缓冲池内存将被初始化为内存页,并随后被插入空闲链表中,以供后续缓存数据页时从中提取。然而,随着数据页的不断缓存,空闲链表中的资源终将耗尽。在此情境下,系统将采取策略,淘汰那些使用频率较低的页,以确保缓冲池的有效运作。
在申请空闲内存页时,首先会从空闲链表中申请,如果空闲链表为空则触发淘汰机制,淘汰机制如下:
- 从LRU 链表的末尾开始淘汰内存页。
- 淘汰的时候会判断数据页是否被修改,如果没有就可以进行淘汰,将内存页从LRU 链表中删除,然后初始化内存页,清理内存页中的数据并将其加入空闲链表中。
- 一般情况下,通过上述步骤就能淘汰出内存页。如果遇到系统繁忙的时候,上述步骤没有淘汰出内存页,则会触发刷新脏页机制。为了避免阻塞用户线程,每次只刷一个脏页,刷完的脏页就可以释放了,让其回到空闲链表中。
- 如果上述步骤还不能淘汰出内存页,则重复第2 步,不过这次是扫描全部LRU 链表,第一次是只扫描
innodb_lru_scan_depth个。 - 如果上述步骤还没有淘汰出内存页,那么再重复第2 步操作,每次间隔10ms。
- 如果超过20 次都没有淘汰出内存页,就会淘汰机制的基本流程如上所述,从该流程中可以明确观察到,LRU 链表与flush 链表的淘汰操作是串行执行的。在高度并发的环境下,这种串行处理可能导致由刷脏线程释放的空闲页面无法满足新申请空闲页面的需求。为了优化这一性能瓶颈,部分厂商在自己实现的MySQL 版本中采取了将LRU 链表与flush 链表分别交由不同的后台线程进行刷脏操作的策略,此举显著提升了刷脏操作的效率。
此外,当刷脏操作涉及压缩页时,其复杂性会进一步提高。建议对此类技术细节感兴趣的读者深入阅读相关源代码,特别是位于 storage/innobase/buf/buf0lru.cc 文件中的 buf_LRU_get_free_block 方法,该方法是实现上述优化策略的核心所在。
5.2.4 相关参数
在MySQL 中,针对缓冲池有一些重要的参数:
❑ innodb_buffer_pool_instances。该参数控制缓冲池的数量,默认为1 个,如果内存比较大,可以设置多个,设置多个的好处主要是互斥锁的粒度会减小,从而能提升性能。需要注意的是,最好保证每个缓冲池的大小大于1GB。
❑ innodb_lru_scan_depth。在对LRU 链表进行刷脏的时候,默认扫描 innodb_lru_scan_depth 个数据页,其默认值为1024。这个可以根据实际情况进行调整,不过一般保持默认即可。
❑ innodb_max_dirty_pages_pct。刷脏阈值,该值默认为75%,表示脏页占比。后台刷脏线程每次刷脏都会计算一个比例,如果脏页占比超过75%,那么后台线程刷脏会直接以100% 的比例进行。
❑ innodb_adaptive_flushing。设置了该参数后,MySQL 会根据负载情况来调整InnoDB 刷脏线程每次刷脏的比例。建议默认开启,可以应对负载突增的情况。
在了解完缓冲池的实现之后,接下来将详细介绍同样使用缓冲池内存资源的插入缓冲区和自适应哈希索引。
5.3 插入缓冲区
在之前的介绍中,我们已经知道插入缓冲区主要解决MySQL 二级索引随机I/O 的问题,通过插入缓冲区的机制来将二级索引随机I/O 合并,从而减少随机I/O。
为何MySQL 的二级索引会引发显著的随机I/O 现象?原因在于,二级索引往往被设计为非唯一索引,其插入操作通常遵循随机顺序,这在执行批量DML 操作时会不可避免地导致大量随机I/O 的产生。至于插入缓冲,它并非完全消除随机I/O 的解决方案。实际上,插入缓冲的作用是将对二级索引的操作暂存起来,随后批量进行处理。这一机制的优势在于,它能够将部分原本分散的随机I/O 合并为较少的操作,从而有效减少随机I/O 的总次数。
5.3.1 插入缓冲的流程
我们已经知道插入缓冲区的作用,下面介绍InnoDB 插入缓冲的工作流程,如图5-2 所示。
图5-2 InnoDB 插入缓冲的工作流程(原始图)
客户端请求 → 用户线程 → 生成插入缓冲记录(第1步) 插入缓冲记录持久化到系统数据文件(第2步) 后台master线程主动合并插入缓冲记录(第3步) 用户线程执行查询等操作时被动合并(第4步) 将更新后的数据页写入用户数据文件(第5步)
主要工作流程如下:
- 用户线程执行插入等操作时生成插入缓冲记录。
- 将插入缓冲记录持久化到系统数据文件上。
- 后台master 线程主动合并插入缓冲记录,合并之前需要从系统数据文件中将插入缓冲记录读取到缓冲区中。
- 用户线程执行查询等操作时需要读取对应的数据页,而该数据页存在插入缓冲记录中,这个时候需要被动执行插入缓冲记录的合并,合并之前同样需要将插入缓冲记录读取到缓冲区中。
- 如果该数据页对应多条插入缓冲记录,会将插入缓冲记录更新到该数据页中,最终将该数据页写入对应的用户数据文件中。
现在我们知道了插入缓冲的主要工作流程,下面来详细看看插入缓冲是如何生成的,包含什么内容,以及又是怎么进行合并的。
1. 生成插入缓冲记录
MySQL 为插入缓冲区定义了如下几种类型:
/* 可能的操作会缓存在插入/其他缓冲区中.参见 ibuf_insert() 函数.不要更改这些值,它们是存储在磁盘上的.*/
typedef enum {
IBUF_OP_INSERT = 0,
IBUF_OP_DELETE_MARK = 1,
IBUF_OP_DELETE = 2,
/* 不同操作类型的数量. */
IBUF_OP_COUNT = 3
} ibuf_op_t;插入语句会生成 IBUF_OP_INSERT 类型,由用户线程在插入数据的时候生成对应的插入缓冲记录。更新语句会生成 IBUF_OP_DELETE_MARK 和 IBUF_OP_DELETE 类型,后台purge 线程在彻底删除数据的时候也会生成 IBUF_OP_DELETE 插入缓存记录。
由于篇幅有限,本小节还是以更新语句为例进行详细介绍。
2. 插入缓冲记录生成条件
条件如下:
❑ 配置了 innodb_change_buffer 参数,默认开启。
❑ 需要是DML 操作语句。
❑ 需要是对二级索引的操作,如果二级索引是唯一索引的话,则只能缓冲删除操作。
❑ 只能缓存二级索引的叶子节点,不能缓存根节点。
❑ 如果二级索引叶子节点对应的数据页在缓冲池中,则不能进行缓存操作。
3. 插入缓冲记录
前面我们知道了插入缓冲记录是如何生成的,那么插入缓冲记录包含什么内容?保存在哪里?
执行更新语句
mysql> update sbtest1 set k=3306 where id = 3000;对应的表结构如下:
CREATE TABLE `sbtest1` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`k` int(10) unsigned NOT NULL DEFAULT '0',
`c` char(120) NOT NULL DEFAULT '',
`pad` char(60) NOT NULL DEFAULT '',
PRIMARY KEY (`id`),
KEY `k` (`k`)
) ENGINE=InnoDB在执行上述MySQL 更新语句时,系统首先会对涉及的二级索引记录进行逻辑删除标记,而非直接在数据页中执行物理删除。此时,系统会生成一个标记为删除的插入缓冲记录,以替代直接的数据页操作。随后,当新记录被插入时,会相应生成类型为 IBUF_OP_INSERT 的插入缓冲记录。
对于先前被标记为删除的数据,其物理删除过程将由后台的purge 线程触发。在此过程中,可能会生成类型为 IBUF_OP_DELETE 的插入缓冲记录。这一可能源于系统操作的并发性:在生成上述两种插入缓冲记录后,后台的master 线程可能恰好执行合并操作,该操作涉及将相关二级索引的数据页加载到缓冲区中。由于插入缓冲记录的生成条件之一是对应的二级索引数据页不在缓冲区中,因此,在合并操作发生时,可能会触发生成 IBUF_OP_DELETE 类型的插入缓冲记录。
我们已经知道针对update 语句会生成2 条插入缓冲记录,分别为 IBUF_OP_DELETE_MARK 和 IBUF_OP_INSERT 类型。无论是哪种类型,最终的记录内容格式都一致。InnoDB 插入缓冲记录如图5-3 所示。
图5-3 InnoDB 插入缓冲记录(原始图)
记录包含:表空间ID | 标记位 | 数据页序号 | 列数据 | 列类型数据 | 元数据信息 其中列数据包含多个列的精确数据类型、是否为老版本、操作类型、记录类型、字符集、列的长度等.
根据图5-3,我们可以得知一条插入缓冲记录主要包含如下数据:
❑ space id。表空间ID。
❑ marker。标记位,默认为0。
❑ page number。数据页序号。
❑ column data。二级索引对应更新的字段内容,后面在合并的时候会通过这些字段再生成索引记录插入对应的数据页中。
❑ column info。存储所有更新列的类型信息。包含如下几个字段:列的数据类型、列的精确数据类型、列的长度、字符集。
❑ metadata info。元数据信息,包含如下几个字段:counter(布尔类型,判断存储是否为老版本,老版本需要判断记录是否为compact 类型,如果不是compact 类型则不支持delete 类型操作)、operation type(存储操作类型,例如insert 类型)、record type(存储记录类型,标识是否为compact 类型)。
现在,我们已经知道一条插入缓冲记录包含什么内容,那么它最终保存到哪里呢?其实在MySQL 中,缓冲区的一条记录对应表中的一行数据,MySQL 是用一张表来保存所有插入缓冲记录的,这个表存储在系统表空间上,采用共享表空间方式单独在系统表空间中存储了一张表,名称为 innodb_change_buffer,该表为系统隐藏表,不对外暴露,所以我们也查询不到。
4. 合并流程
MySQL 中插入缓冲记录的合并主要分为两种情况:
❑ 主动合并。主动合并是由后台master 线程主动触发,后台master 线程会定期扫描是否有插入缓冲记录进行合并。
❑ 被动合并。在二级索引数据页被加载到缓冲区中的时候,如果有对应的插入缓冲记录,则需要进行合并。
主动合并和被动合并的过程有些细微区别,但是总体原理一致,这里我们主要介绍如何合并。
合并的主要流程涉及将插入缓冲区中存储的数据转化为相应的索引记录,并插入对应的二级索引数据页中。在阐述插入缓冲记录内容时,我们已提及其中包含了二级索引所需更新的字段,这些字段正是用于生成相应的索引记录。
在合并过程中,一个关键环节是,若某数据页需要进行合并,则需要扫描并更新该数据页对应的所有插入缓冲记录。此举旨在将原本可能需要多次I/O 操作的任务合并为单次I/O 操作,从而最大限度地减少I/O 消耗。待合并完成后,后续操作将简化为一次异步I/O 刷盘处理。
在数据读取过程中,此处采用了异步I/O 技术来插入缓冲。同时,对于异步I/O 返回的结果处理,也专门设立了独立的插入缓冲I/O 线程,以区分于常规的读I/O 操作,从而避免对读取性能造成不利影响。
对于主动合并流程可能存在的疑问是,当MySQL 从磁盘读取数据页时,如何判断该数据页是否包含插入缓冲记录?值得注意的是,MySQL 并未采取低效的方式,如遍历系统数据文件中的插入缓冲区来检查,而是在每个数据文件中维护了 ibuf bitmap 页。此页通过位来标记每个数据页是否包含插入缓冲记录,从而实现了高效判断。若数据页被标记为包含插入缓冲记录,则进一步在插入缓冲区进行扫描。
在了解插入缓冲的原理及其带来的优势后,需明确其并非完美无缺。尽管在高并发场景下,插入缓冲能有效减少随机I/O 操作,但也可能对MySQL 性能产生负面影响,具体取决于应用场景。以下两点为主要考虑因素:
❑ 如果后台插入缓冲记录合并的速度远慢于插入缓冲记录生成的速度,就会造成大量的插入缓冲记录没有合并,这时访问这些插入缓冲记录对应的数据页,性能就会变慢,因为这个过程首先需要将对应的插入缓冲记录进行合并。
❑ 插入缓冲区使用的是缓冲区的内存,如果缓冲区本身不够用,那么开启插入缓冲可能会将其他活跃的数据页淘汰出去,造成I/O 增加。
5.3.2 相关参数
这里的主要参数有两个:
❑ innodb_change_buffer_max_size。指定插入缓冲区的最大使用空间,因为插入缓冲区共用缓冲池的空间,所以这个参数其实是设置的一个比例,默认占缓冲池中的25%,这里可以根据实际情况进行调整。
❑ innodb_change_buffering。设置是否开启插入缓冲功能,或者只开启insert、delete、change、purges,默认为all,表示开启所有操作的插入缓冲,这里建议采用默认设置。
总结一下,我们看到一项技术的引入会带来好处也可能影响到其他方面,不过在了解它的原理后,合理去使用就能最大限度地发挥它的作用。
5.4 自适应哈希索引
我们深知 MySQL 的索引机制是基于 B+ 树结构的,其中聚簇索引的叶子节点负责存储表内的具体数据。然而,B+ 树在应对大规模数据时面临挑战,其层级可能显著增加,且各层宽度亦会扩大。具体而言,小规模数据可能仅需两级 B+ 树即可满足需求,而大规模数据则可能需扩展至四级,进而降低数据检索效率。
为解决此问题,MySQL 引入了一种高效的数据定位结构 — 哈希表。哈希表以其独特的键值映射特性著称,能够迅速根据键定位到相应的值。MySQL 巧妙地利用这一特性,将频繁访问的数据项纳入哈希表,从而实现对这些数据的快速访问。此举在理论上能够显著提升数据读取性能,尤其是在涉及高频访问数据的场景中。
然而,值得注意的是,自适应哈希并非万能解决方案,其应用受到诸多条件限制。尽管在特定场景下能够显著提升操作性能,但并不能全面替代或优化所有数据库操作。接下来我们将深入剖析自适应哈希的工作流程及其背后的详细原理。
InnoDB 自适应哈希索引的流程图如图 5-4 所示。

图 5-4 描述了一个架构图:左侧是客户端请求通过用户线程(读取)访问缓冲池;缓冲池内包含哈希桶(键:值)、空闲链表、LRU 链表、flush 链表,并维护着自适应哈希索引;缓冲池从系统数据文件和用户数据文件读取索引页。自适应哈希索引包含多个哈希桶,每个桶内包含键:值对,指向具体记录。
可以看到,自适应哈希索引其实维护了多个哈希表,每个哈希表中保存了多条记录,每条记录指向一条具体的数据。在建立好哈希表之后,客户端就可以直接从哈希表中进行访问。
自适应哈希索引的建立存在很多限制条件,这里用一个具体的例子来说明。在这之前我们先来看表结构:
mysql> show create table sbtest1\G
*************************** 1. row ***************************
Table: sbtest1
Create Table: CREATE TABLE `sbtest1` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`k` int(10) unsigned NOT NULL DEFAULT '0',
`c` char(120) NOT NULL DEFAULT '',
`pad` char(60) NOT NULL DEFAULT '',
PRIMARY KEY (`id`),
KEY `k` (`k`)
) ENGINE=InnoDB AUTO_INCREMENT=83332 DEFAULT CHARSET=utf8 MAX_ROWS=1000000
1 row in set (0.00 sec)然后执行这条 SQL 语句:
mysql> select * from sbtest1 where id = 2000;
+------+------+------------------------------------------------------------------------------------------------------------------------+------------------------------------------------------+
| id | k | c | pad |
+------+------+------------------------------------------------------------------------------------------------------------------------+------------------------------------------------------+
| 2000 | 5003 | 13491936175-81303443798-58326593529-71690937750-60292280702-32163773055-14720427361-27660097466-35567728491-37426483064 | 29731284887-80115182500-39568897329-67431008266-60060913902 |
+------+------+------------------------------------------------------------------------------------------------------------------------+------------------------------------------------------+
1 row in set (20.04 sec)
mysql> explain select * from sbtest1 where id = 2000;
+----+-------------+---------+------------+-------+---------------+---------+---------+-------+------+----------+-------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+---------+------------+-------+---------------+---------+---------+-------+------+----------+-------+
| 1 | SIMPLE | sbtest1 | NULL | const | PRIMARY | PRIMARY | 4 | const | 1 | 100.00 | NULL |
+----+-------------+---------+------------+-------+---------------+---------+---------+-------+------+----------+-------+这条 SQL 语句要触发 MySQL 创建哈希索引,需要满足什么条件呢?答案如下:
- 首先,该条 SQL 对应使用的索引需要被查询 17 次,在 MySQL 内部由
BTR_SEARCH_HASH_ANALYSIS变量控制。 - 其次,MySQL 会根据检索条件来计算出一个 key,使用该 key 命中同一个数据页的次数大于该数据页中的记录数(16)。
- 最后,上面根据查询条件计算出来的 key 被成功使用超过 100 次,由
BTR_SEARCH_BUILD_LIMIT变量控制。
满足上述三个条件之后才能创建哈希索引,我们可以看到,同样的 SQL 语句至少需要执行 100 次才可能创建哈希索引,所以哈希索引的建立是针对高频执行并且查询条件没有变化的 SQL 语句的。
创建哈希索引的具体流程
- MySQL 默认初始化了 8 个哈希表用于存储哈希索引,创建哈希索引的时候根据当前数据页的索引 id 和表空间 id 计算出对应存储的哈希表。
- 获取该数据页的总记录数量,为该数据页中的每条记录创建哈希索引。
- 计算索引 ID + 匹配列的数据 + 不匹配列中前面匹配的数据,共同得出对应的 key。这三个数据是在上述查询的时候生成的,MySQL 在查找和对比数据的时候可以知道匹配的列数量和不匹配列中匹配的字节数量。value 则是具体的数据记录,对应表中的一行数据。
- 将 key 和 value 插入对应的哈希表中。
至此,哈希索引创建成功。可以看到,MySQL 其实就是把经常访问的数据页中的所有记录都插入哈希表中,key 则是根据查询条件生成的,这样后续相同条件的查询就直接从哈希索引中获取数据,而不再遍历之前的 B+ 树索引。
那么,下面这条 SQL 语句创建哈希索引的流程是怎么样的呢?
select * from sbtest1 where id = 2000;答案如下:
- MySQL 会根据查询条件选择聚簇索引,然后去聚簇索引的根节点和叶子节点查找数据。
- 在查找的过程中,会生成 2 个数据作为哈希索引供后续使用。第一个是查询条件匹配的列数量,可以看到这里查询条件只有一列,所以这里为 1。第二个是如果列的数据不完全相等,那么就记录前面匹配的字节数量。这里是等值查询,列是完全匹配的,所以这里为 0。
- 满足建立哈希索引的条件后,就查找
id=2000在哪个数据页中。 - 为这个数据页中的每条记录建立哈希索引,key 的生成通过计算索引 ID + 匹配列的数据 + 不匹配列中前面匹配的数据得出。索引 ID 就不说了,匹配列的数据就是根据上述匹配列的数量从索引的每条记录中查找的,例如这里匹配列数量为 1,那么就是第一列的数据。然后不匹配列中匹配的数据为 0,这里就不用计算了。
- 生成 key 之后,value 就是具体的一条索引记录对应一行数据。
- 最终将 key 和 value 插入对应的哈希表中。
5.4.1 使用自适应哈希索引查询
下面介绍哈希索引是如何使用的。在哈希索引创建成功后,后续再次执行该语句时,会从哈希表中查询数据,大体流程如下:
- 通过索引 ID 和表空间 ID 计算出对应的哈希表。
- 通过索引 ID + 匹配列的数据 + 不匹配列中前面匹配的数据来共同计算出对应的 key。
- 从该哈希表中获取该 key 对应的 value。
- 若 value 不为空,则返回。value 对应一条记录,就是保存之前聚簇索引的叶子节点对应的具体数据。
可以看到,在未创建哈希索引之前需要遍历 B+ 树索引,找到对应的叶子节点,返回具体的数据。在建立哈希索引之后,就直接在哈希索引中获取具体的数据并返回,这大幅提升了查询的效率。
5.4.2 自适应哈希索引的维护
我们已明确哈希索引的构建机制,其核心在于将聚簇索引的叶子节点中的具体数据映射至哈希表中,其中哈希表的键由检索条件生成。因此,当对应的数据发生更新或删除操作时,相应的哈希索引也需进行必要的调整。
在某些情况下,如在数据更新极为频繁的环境中,哈希索引的维护成本可能相对较高,因为数据的快速变动可能导致索引频繁重建与失效。因此,建议在实际应用中,根据业务特点进行充分的测试与评估,以确定哈希索引的适用性。同时,深入理解哈希索引的实现原理将有助于我们更有效地发挥其优势。
鉴于篇幅限制,此处不再深入阐述具体细节,有兴趣的读者可自行查阅相关源代码,特别是位于 storage/innobase/btr/btr0sea.cc 文件中的相关逻辑部分。
5.5 重做日志缓冲区
在 MySQL 数据库中,数据的写入操作严格遵循 WAL原则(Write-Ahead Logging)。此原则要求,在数据实际被写入数据文件之前,必须确保相应的日志信息已被先行写入日志文件中。此机制的核心目的在于确保数据的持久性与一致性,即便在系统发生故障时,也能通过日志将数据恢复至最近的一致状态。
当数据库中的数据发生变更时,会自动生成相应的日志记录。这些日志记录首先被保存在内存中的一个特定区域,即重做日志缓冲区。随后,系统会将这些缓存在内存中的重做日志按照一定的流程写入磁盘上的日志文件中,以确保数据的持久存储。
接下来,我们将深入探讨 MySQL 中重做日志缓冲区的具体设计细节,以及日志写入磁盘时遵循的详细流程。
5.5.1 整体架构
与缓冲池相似,重做日志缓冲区是在系统内存中分配的一块区域,专门用于暂存重做日志记录。其设计相对简洁,核心功能在于临时存储并缓存这些日志记录。InnoDB 重做日志缓冲区的架构如图 5-5 所示。

图 5-5 展示了重做日志缓冲区的结构:缓冲区被分为两个大小相同的区域(由 innodb_log_buffer_size 控制),每个区域内包含多个重做日志块(每个块固定 512B)。每个重做日志块由块头(包含 LOG_BLOCK_HDR_NO, LOG_BLOCK_HDR_DATA_LEN, LOG_BLOCK_FIRST_REC_GROUP, LOG_BLOCK_CHECKPOINT_NO 等字段)和块数据组成。块数据内包含多条重做日志记录,以及页校验数据。
在图 5-5 中,重做日志缓冲区被划分为两个大小相同的区域,其大小由 innodb_log_buffer_size 进行控制。在 MySQL 5.7.6 及后续版本中,该参数的默认值被设定为 16MB。在常规操作中,写入操作仅涉及其中一个缓冲区,而在执行刷盘操作时,会触发缓冲区的切换机制,即图 5-5 中的两个缓冲区将交替进行使用,具体细节将在后续部分详细阐述。
在缓冲区内部,维护了多个重做日志块,这些块构成了重做日志刷盘操作的最小单位。每个重做日志块的大小固定为 512B,与重做日志文件中一一对应。每个重做日志块由两部分组成:重做日志块头和重做日志块数据。其中,重做日志块数据进一步包含多个重做日志记录,这些记录具有不同的类型,将在后续关于重做日志的章节中深入进行介绍。
5.5.2 管理结构
上面介绍了重做日志缓冲区里面的内容,下面来看 MySQL 是如何管理重做日志缓冲区的。在 MySQL 内部用 log_sys 来管理重做日志缓冲区,log_sys 的类型为 log_t 结构体,它的字段及描述如表 5-4 所示。
表 5-4 log_t 结构体字段及描述
| 名称 | 描述 |
|---|---|
lsn | 重做日志序列号 |
buf_free | 重做日志缓冲区第一个空闲的 offset,复制重做日志到重做缓冲区就从当前位置开始写入 |
buf_ptr | 指向重做日志缓冲区,默认情况下重做日志缓冲区会分配 2 块 innodb_log_file_size 大小的内存 |
buf | buf 指向 buf_ptr 指向的其中一块内存,循环切换 |
log_groups | 用于管理重做日志文件 |
write_lsn | 上一次写完重做日志的 lsn 号,这里还没有刷盘 |
current_flush_lsn | 正在执行 flush 操作的 lsn |
flushed_to_disk_lsn | 上一次刷盘的 lsn |
log_t 中字段较多,表 5-4 中只列举了主要的字段,这里重点介绍 buf_ptr 和 buf,其他字段在后续的内容中也会有涉及。
在 MySQL 数据库中,通过调整 innodb_log_buffer_size 参数,我们能够精确控制重做日志缓冲区的容量。值得注意的是,在 MySQL 初始化这一缓冲区时,实际上会分配两倍于 innodb_log_buffer_size 指定大小的内存空间。这一内存分配过程通过内部调用 calloc 方法实现,与 malloc 相比,calloc 在分配内存的同时会将其初始化为零。
重做日志缓冲区与缓冲池在内存管理上存在显著差异。重做日志缓冲区在初始化阶段即由操作系统分配并初始化内存,而缓冲池虽然申请了内存空间,但并未立即进行初始化,其内存的真正分配与初始化发生在后续的使用过程中。
表 5-4 中提及的 buf_ptr 指针指向整个重做日志缓冲区的起始位置,而 buf 指针则用于指向并循环切换至缓冲区中的特定部分。这种设计旨在优化 MySQL 的重做日志处理机制。
在 MySQL 中,重做日志的基本单位是日志记录,但刷盘操作的最小单位是重做日志块,每个重做日志块固定为 512B,这一设计与磁盘扇区的大小相匹配,确保了重做日志的原子性写入,避免了数据不一致的风险。在将重做日志写入磁盘时,MySQL 可能一次性写入多个重做日志块,但最后一个重做日志块可能并未完全填满。为了处理这种情况,MySQL 采用了双缓冲机制:首先,所有重做日志记录被写入第一个重做日志缓冲区 A;当执行刷盘操作时,将最后一个未填满的重做日志块复制到另一个重做日志缓冲区 B 中;随后,新的重做日志记录将继续写入缓冲区 B 中上次未写满的重做日志块,直至其再次被填满;之后,随着更多重做日志块的写入,缓冲区 B 也将被刷盘,其未填满的重做日志块再次被复制到缓冲区 A 中,如此循环往复。
前面我们已对重做日志缓冲区的管理方式有了初步了解。接下来,我们将深入解析重做日志中的一个核心概念 — MTR(Mini-Transaction)。MTR 在 MySQL 执行物理层面的操作时扮演着确保操作原子性的关键角色。具体而言,一个 MySQL 事务通常涵盖多个 MTR 事务,而每个 MTR 事务则进一步包含多条重做日志记录。
在 MTR 事务的执行过程中,若涉及对页或索引的修改,MTR 将自动对这些资源进行锁定,以确保数据的一致性和完整性。一旦操作完成,相应的锁将被释放,以允许其他事务操作或访问这些资源。
值得注意的是,在 MTR 事务的生命周期内,所产生的所有重做日志记录会首先被临时存储在 MTR 缓冲区中。这一设计旨在提高日志记录的效率和性能。随后,在 MTR 事务提交时,这些暂存的重做日志记录将被批量复制到重做日志缓冲区中,以便后续的恢复和复制操作使用。
综上所述,MTR 作为 MySQL 事务处理中的一个重要组件,通过其精细的锁管理和日志记录机制,为数据库的物理操作提供了强有力的原子性保障。
在 MySQL 内部,MTR 的使用方式如下:
mtr_start(&mtr);
write redo log record to mtr buf
write redo log record to mtr buf
write redo log record to mtr buf
write redo log record to mtr buf
......
mtr_commit(&mtr);5.5.3 更新语句的流程
现在我们已对重做日志缓冲区的管理机制有了清晰的认识。接下来,我们需要关注的是,在执行更新语句的具体场景下,重做日志记录是如何被精确地写入到这一关键缓冲区的。以下,我们将详细阐述执行更新语句时,重做日志记录写入流程5. 在 mtr 事务提交的时候,同时也可能触发将脏的数据页加入缓冲池的 flush 链表中。
-
将重做日志记录复制到重做日志缓冲区后,会进行重做日志缓冲区切换,就是图 5-6 中重做日志缓冲区 A 和重做日志缓冲区 B 切换。重做日志缓冲区 A 的最后一个未写满的块会被复制到重做日志缓冲区 B,后续新的写入就复制到重做日志缓冲区 B 中,接着这个块里面空闲的区域继续写入。
-
InnoDB 会将重做日志缓冲区进行刷盘,刷盘的动作可以主动或者被动触发,后续会详细介绍。
5.5.4 重做日志刷盘
前面我们已经了解了重做日志记录是如何写入重做日志缓冲区的,那么重做日志缓冲区又是怎样刷盘的呢?
重做日志刷盘主要在以下 3 个地方触发:
- 事务提交阶段。在 MySQL 事务提交的时候会将数据写入数据文件中,在提交的第一个阶段就会将重做日志刷盘,保证当前数据产生的重做日志都已经刷盘后才能进行数据文件的刷盘。
- 后台刷脏线程。由于需要保证 WAL 的原则,在将数据页写入磁盘之前,一定要先写入该数据页之前产生的所有重做日志记录,这主要是通过重做日志刷盘的 lsn 要大于该数据页的 lsn 来保证的。
- 后台 master 线程。它会将当前时间和上一次时间做对比,如果大于 1s,则进行日志刷盘,刷盘前会比较当前的 flush lsn 是否小于重做日志 lsn,并查看后台是否有正在刷盘的操作,满足这两个条件之后才能进行重做日志刷盘操作。master 线程在检查点的时候也会触发重做日志刷盘,需要保证当前检查点之前的重做日志都已经刷盘。
刷盘流程
在 MySQL 中,重做日志缓冲区刷盘是同步 I/O,而检查点的写入重做日志文件是异步 I/O。一次将一个或者多个日志块写入到重做日志文件中,未写满的日志块则复制到另一个日志缓冲区中。在 Linux 下,同步 I/O 最终是调用 pwrite 方法进行写入的,写入文件中的 offset 是由 lsn 计算出来的。
刷盘这里还涉及三个参数:
-
innodb_flush_log_at_trx_commit。该参数用来控制重做日志刷盘的频率,默认为 1,表示每次事务提交都需要将重做日志刷盘,这样能完全保证 ACID 特性。还可以设置为 0 或者 2。设置为 0 时,每秒将重做日志缓冲区的内容写入磁盘并刷盘;设置为 2 时,每个事务都将重做日志缓冲区的内容写入磁盘,然后每秒将写入的内容进行刷盘操作。这里的 1s 并不能保证准确,因为这个操作由 MySQL 后台线程控制,线程需要执行其他逻辑和进行调度,不能保证准确的 1s,不过相差也不会太大。前面介绍了 MySQL 重做日志刷盘主要在 3 个地方触发,其实就是对应参数的配置项:设置为 1 就是在事务提交阶段触发刷盘;设置为 0 或者 2 就是在后台线程中触发。 -
innodb_log_write_ahead_size。该参数主要是为了解决 read-on-write 问题。read-on-write 指的是在写入数据到操作系统文件上的时候,写入的数据量大小可能跟文件系统的块大小不一致,最终导致需要把要写的那块区域先读取到内存中,在内存中写完再写入到文件系统中。每次写重做日志块只需要 512B,可能需要从文件系统读取 4KB 大小的文件块到操作系统内存中,然后在内存写入其中的 512B,再写入文件系统中。如果写入大小是 4KB,则不用读取到操作系统内存中,直接写入文件系统即可。MySQL 引入了innodb_log_write_ahead_size参数,该参数的原理是:一个重做日志块的大小为 512B,我们要进行写入磁盘操作,但是文件系统或者操作系统的一个块的大小为 4KB,那么我们将innodb_log_write_ahead_size设置为 4KB,这样在写入的时候每次写入 4KB,512B 后的数据用 0 进行填充,这样就可以避免 read-on-write;如果一次写入的重做日志块为 8 个,总共 4KB,则不需要进行填充。 -
innodb_log_compressed_pages。当使用 InnoDB 表压缩特性的时候,默认会将整个压缩页复制到重做日志文件中,这会增加重做日志量。其主要原因是为了防止 MySQL 在崩溃恢复的时候,zlib 的版本不一致导致压缩页损坏。zlib 版本通常情况下不会变化,所以可以把这个参数关闭,在压缩场景下它能节省一半左右的重做日志,并且性能也有小幅提升。
5.6 双写机制
鉴于磁盘存储的基本单位是 512B,而 MySQL 数据库管理系统在进行数据读写操作时,其最小单位通常是一个数据页,该数据页的大小一般为 16KB。因此,在数据写入过程中,若遭遇断电或其他异常情况,可能导致数据页未能完整写入,进而引发数据页损坏及数据不一致的问题。
为解决数据页在写入过程中可能发生的部分失败问题,MySQL 引入了双写机制。具体而言,数据首先被写入双写缓冲区,随后再被写入用户数据文件。这一流程有效避免了直接写入用户数据文件可能导致的数据页损坏且无法恢复的风险。即便在数据页写入过程中发生损坏,通过双写区,也能在恢复过程中从该区域复制回完整的数据页。
该机制的核心思想在于,先将待修改的数据页写入到一个独立的、专用于此目的的文件中。一旦该写入操作成功完成,MySQL 随后会将这部分数据从该临时文件中分别复制到其对应的数据文件中。本节将深入阐述双写机制的详细流程及其背后的原理。
InnoDB 双写缓冲区的写入流程如图 5-7 所示。

图 5-7 展示了双写机制的流程:缓冲池包含空闲链表、LRU 链表和 flush 链表;后台刷脏线程从 flush 链表获取脏页,将其复制到内存中的双写缓冲区,然后通过同步 I/O 写入系统数据文件(双写区);写入成功后,再通过异步 I/O 将脏页写入对应的用户数据文件。
其主要工作流程如下:
- 客户端发送更新请求,如果更新的数据在缓冲区没有,就会去数据文件中读取相应的数据页。
- 读取后放入到缓冲区的 LRU 链表中,然后修改该数据页,再将该数据页放入 flush 链表中。
- 由后台刷脏线程主动将该修改的数据页写入数据文件。
- 将该脏页复制到内存中的双写缓冲区,再同步将双写缓冲区中的内容写入系统数据文件中的双写区中。
- 写入成功后再将该脏页以异步 I/O 的方式写入对应的用户数据文件。
5.6.1 双写缓冲区管理
这里主要包括双写缓冲区初始化、MySQL 启动初始化以及写入流程。
1. 双写缓冲区初始化
在MySQL数据目录的初始化过程中,双写缓冲区的初始化是一个关键步骤。此流程主要涉及在系统数据文件中分配一块逻辑上连续的空间,该空间的大小固定为2MB。为实现这一目标,MySQL会分配两个连续的区,每个区默认负责管理1MB的连续页。随后,双写区的相关元数据信息会被记录到事务页中,以确保在MySQL后续启动时,能够准确地识别并定位双写区的存储位置。这一过程确保了数据的一致性和完整性,是MySQL数据库稳定运行的重要基础。
上述双写区的元数据会存储到事务页中,那么具体存储的是什么数据呢?InnoDB双写缓冲区如表5-5所示。
表5-5 InnoDB双写缓冲区
| 名称 | 偏移 | 描述 |
|---|---|---|
TRX_SYS_DOUBLEWRITE_FSEG | 0 | 存储文件段,用于后续给缓冲区分配数据页 |
TRX_SYS_DOUBLEWRITE_MAGIC | 10 | 双写缓冲区magic number |
TRX_SYS_DOUBLEWRITE_BLOCK1 | 14 | 双写缓冲区分为两块,指向第一个块的第一个页 |
TRX_SYS_DOUBLEWRITE_BLOCK2 | 18 | 双写缓冲区分为两块,指向第二个块的第一个页 |
TRX_SYS_DOUBLEWRITE_REPEAT | 12 | 重复存储TRX_SYS_DOUBLEWRITE_MAGIC、TRX_SYS_DOUBLEWRITE_BLOCK1、TRX_SYS_DOUBLEWRITE_BLOCK2字段的值,在事务页写入到磁盘上异常的情况下,还是可以恢复这些元数据信息 |
双写缓冲区初始化的关键点是分配2MB连续空间,并将元数据存储到事务页中,确保后续启动时能正确识别。
2. MySQL 启动初始化
在MySQL数据目录的初始化过程中,双写缓冲区已被配置并分配了连续2MB的空闲空间于系统数据文件中。除了双写缓冲区的初始化之外,MySQL服务启动之际还需执行一系列必要的初始化步骤。具体来说,关于双写缓冲区的进一步操作涉及读取相关的元数据,并为其在内存中分配相应的空间。
在MySQL启动流程中,针对双写区的具体处理包括:将先前存储于事务页中的双写区元数据信息提取出来,随后将这些信息加载至内存中一个特定的数据结构中以供后续使用。此数据结构在MySQL内部被定义为buf_dblwr_t结构体,它负责维护双写区的元数据信息。buf_dblwr_t结构体中关键字段的具体描述如表5-6所示,它们共同协作以确保双写缓冲区在MySQL运行过程中的有效性和一致性。
表5-6 buf_dblwr_t结构体中关键字段的具体描述
| 名称 | 描述 |
|---|---|
block1 | 指向双写区第一块的第一个页,对应双写区元数据中的TRX_SYS_DOUBLEWRITE_BLOCK1 |
block2 | 指向双写区第二块的第一个页,对应双写区元数据中的TRX_SYS_DOUBLEWRITE_BLOCK2 |
first_free | 在双写缓冲区第一个空闲的位置,以页为单位 |
write_buf | 指向双写缓冲区 |
buf_dblwr_t结构体维护了一个write_buf字段,指向双写缓冲区。在MySQL启动的时候会申请一块大小为2MB的内存作为双写缓冲区,该内存直接从操作系统申请。
3. 写入流程
在数据写入的时候,先将脏页复制到双写缓冲区中,然后将双写缓冲区写入系统数据文件,最后再将双写区对应的脏页写入其对应的数据文件。写入的大体流程前面已经介绍过,这里还需要注意两点:
- 双写缓冲区每次最多写入1MB,如果超出1MB,剩下的第二次再写入,一次刷脏流程最多写入2MB数据。
- 这里双写缓冲区写入到系统数据文件是同步I/O,因为需要马上知道双写缓冲区是否写入成功,后续脏页写入到对应的数据文件需要依赖前面的操作。脏页写入到对应的数据文件中是异步I/O。
5.6.2 数据的可靠性保证
上述为正常写入的流程,如果写入的时候发生了异常,双写机制如何保证数据页写入的可靠性呢?
可以分为以下两种情况:
- 写入双写缓冲区失败。这会导致MySQL直接崩溃,在下次启动的时候会进行崩溃恢复,此时数据页并未损坏,崩溃恢复会利用重做日志记录前滚数据库,例如插入语句会从重做记录中解析再插入对应的数据页中,然后通过事务的状态来确定是否需要提交或者回滚,最终保证数据一致。
- 写入双写缓冲区成功,但是写入到数据文件失败。脏页写入数据文件过程中MySQL崩溃,在下次启动的时候MySQL会进行崩溃恢复,此时当时写入的数据页可能已经损坏,崩溃恢复的一个阶段就是从双写缓冲区中读出对应完整的数据页然后复制到数据文件对应的位置来覆盖数据文件中损坏的页,这样损坏的页就修复了,然后再通过事务状态确认是否需要提交和回滚,最终保证数据一致。
至此,双写机制的介绍全部完成。尽管双写机制确保了数据页写入的可靠性,但它也伴随着一定的性能损耗。
尽管数据被写入了两份,但由于双写缓冲区采用合并数据页刷盘的方式,且为顺序写入,因此性能损耗并非简单的减半。根据广泛的测试数据,引入双写缓冲区后,性能损耗在5%左右。
此外,随着存储技术的不断发展,部分现代存储设备已支持16KB的原子写操作。在此情况下,双写机制实际上可被视为冗余,因为原子写操作本身已能确保数据页写入的可靠性。因此,MySQL内部在检测到底层存储设备为Fusion-IO等支持此类特性的设备时,会默认关闭双写缓冲区,以优化性能并减少不必要的资源消耗。
5.7 后台线程
在2.2节中,我们了解到InnoDB有多达26个线程,由于篇幅有限,这里我们只详细介绍4个重要的线程。
- master线程
- I/O线程
- 刷脏线程
- 清理线程
5.7.1 master线程
在MySQL数据库中,master线程扮演着至关重要的后台处理角色,其职责涵盖了多项核心任务,诸如后台表删除操作、重做日志的磁盘写入以及执行检查点等。以下是对master线程工作流程的详尽阐述。
master线程作为MySQL的一个核心后台进程,在数据库系统启动时即被激活,并持续以每秒一次的频率循环执行任务。其工作范畴可明确划分为两个主要阶段:空闲时段处理任务、活跃时段处理任务。
尽管这两个阶段在任务性质上大致相同,但在任务执行的频率上却存在显著差异。具体而言,当MySQL执行DML操作时,master线程即被触发进入活跃状态。
接下来,我们将对这两个阶段master线程所执行的具体任务进行更为细致的说明。
在master线程中,主要有如下工作任务:后台删除表、检查重做日志空闲是否够用、插入缓冲区合并、驱逐表缓存中的表、将重做日志刷盘、进行一次检查点。
1. 后台删除表
当我们删除表的时候,如果表正在被外键或其他地方引用,这时不会立即删除,会直接向用户返回成功,然后将该表加入链表中。后台master线程就会扫描这个链表,满足条件后即可进行删除。
2. 检查重做日志空闲是否够用
用户线程在每次将重做记录复制到重做缓冲区的时候,会有如下检查:
log_sys->lsn - log_sys->last_checkpoint_lsn + margin > log_sys->log_group_capacity
上述公式指的是如果当前lsn减去上一次进行检查点的lsn再加上当前写入重做日志的长度大于重做日志总长度,就说明重做日志不够用了,必须触发检查点。这时候会标记check_flush_or_checkpoint为true,后台master线程发现该标记为true就会触发重做日志刷盘和检查点。
3. 插入缓冲区合并
在5.3节中,我们已经提到插入缓冲记录的主动合并是由后台线程负责的,合并的大致流程就是从系统数据文件中读取插入缓冲表中的插入缓冲记录,然后将其记录更新到对应的数据页中,可能多条记录对应一个数据页,更新完成之后进行刷盘。那这样原本需要多次I/O的操作最终合并成一次I/O,这就是插入缓冲合并。
4. 驱逐表缓存中的表
在MySQL中打开的表都会将表定义存放到表定义缓存中,但是数量是受table_definition_cache参数控制的。在后台线程中会检查当前缓存的表的数量是否大于table_definition_cache参数,如果大于就开始驱逐表,直到缓存的表数量小于或等于table_definition_cache参数值。
5. 将重做日志刷盘
会将当前时间和上一次时间做对比,如果大于1s,则进行日志刷盘,刷盘前会比较当前的flush lsn是否小于重做日志lsn,并查看后台是否有正在刷盘的操作,满足这两个条件之后才能进行重做日志刷盘操作。
6. 进行一次检查点
这里进行检查点不触发刷脏,只是将缓冲池脏页中最小的lsn写入重做日志文件。如果没有脏页则写入当前系统的lsn。
5.7.2 I/O线程
I/O线程在MySQL中主要是配合异步I/O做一些收尾工作,在将对应的缓存数据写入对应的文件中后,I/O线程负责处理后续收尾的事情,其中I/O线程又分为以下4种类型:
- 读I/O线程。异步从数据文件中读取数据页后,读I/O线程负责接收异步处理结果并处理后续收尾工作。
- 写I/O线程。将缓冲区的数据页异步写入数据文件后,写I/O线程负责接收异步处理结果并处理后续收尾工作。
- 日志I/O线程。在进行checkpoint的时候,将信息异步I/O写入重做日志文件,日志I/O线程负责接收异步处理结果并处理后续收尾工作。
- 插入缓冲I/O线程。插入缓冲合并的时候采用异步I/O读取数据页,插入缓冲I/O线程负责接收异步结果并做后续的收尾处理。
这里我们重点介绍读写I/O线程,它在MySQL日常的读写操作中有着至关重要的作用,并且也是决定MySQL读写性能的关键因素。在MySQL 5.7中默认开启4个读I/O和4个写I/O线程,并且最多支持开启64个线程。这里大家不要简单地认为把读写I/O线程调大就能提升MySQL的性能。至于为什么,下面我们来看一下。
首先我们来看InnoDB的读写I/O流程如图5-8所示。
1. 写入流程
写入流程主要包括:
- 刷脏线程在缓冲池的flush链表或LRU链表中将对应的数据页进行刷脏,将其复制到双写缓冲区。
- 双写缓冲区的内容会调用同步I/O进行写入,在Linux上一般对应
pwrite方法。 - 写入成功后会将数据页进行异步写入,在Linux上调用
io_submit方法进行异步写入,该方法会封装具体的操作类型、操作文件的fd、对应的偏移量、写入的数据及大小等。 io_submit会直接返回结果,不用等写入成功,这时候刷脏线程会进行下一个数据页的写入。- 多个写I/O线程会调用Linux的
io_getevents方法来获取异步I/O处理的结果。 - 拿到异步处理的结果后,写I/O线程还会进行一些收尾处理,例如从缓冲池的flush链表中移除对应的数据页,并且更新双写缓冲区。移除的数据页会清空并放入到缓冲区中的空闲链表供后续使用。
图5-8 InnoDB的读写I/O流程(参见原文插图,展示了刷脏调度线程、刷脏工作线程、io_submit、io_getevents、pread等组件以及双写缓冲区、缓冲池、系统数据文件、用户数据文件之间的交互)
2. 读取流程
读取流程包含同步I/O和异步I/O,正常的读取数据页是同步I/O,在预读数据页的时候用的是异步I/O。
同步I/O的流程如下:
- 用户线程发起SQL语句请求查询对应的数据,最终会去缓冲池中找对应的数据页,如果不存在就去数据文件读取。
- 这里读取是同步I/O,在Linux下一般调用
pread方法。 - 发送请求前会初始化一个内存页,然后将这个内存页的地址传入同步I/O的方法中。
- 从数据文件中读取的数据页会赋值给刚刚初始化的内存页。
- 然后会进行一系列的操作,例如进行解压、判断页是否损坏等。
异步I/O(预读)的流程如下:
在MySQL中为了提高读的性能,提供了预读的机制,分为线性预读和随机预读两种方式,无论哪种方式,总体思想就是在同步读取当前数据页的时候,会触发读取该数据页后续的页,甚至后续整个区。并且读取的方式是异步I/O,这里用异步I/O是为了更快地返回,而不影响当前用户线程的执行效率,异步I/O的具体流程如下:
- 用户线程执行SQL语句请求对应的数据,会触发预读。
- 预读会发送异步I/O请求,在Linux上会调用
io_submit方法,同上述写入流程一样,这里只是操作类型不一样,上述是写入而这里是读取。 - 发送请求前会初始化一个内存页,然后将这个内存页的地址传入到异步I/O的方法中。
- 多个读I/O线程会调用Linux的
io_getevents方法来获取异步I/O处理的结果。 - 拿到异步处理的结果后,会将从数据页中读取的数据页自动赋值给刚刚初始化的内存页。
- 检查页是否损坏,看是不是在双写中(新读出来的页不应该在双写中),如果是压缩页还要尝试解压,看是否成功,以及做插入缓冲区合并等。
现在我们知道了同步I/O和异步I/O的流程,那么在MySQL中什么情况使用同步I/O,什么情况使用异步I/O呢?
同步I/O的使用场景包括:
- 重做日志缓冲区写入重做日志文件。
- 双写缓冲区写入系统数据文件。
- 将数据页读取到缓冲区。
异步I/O的使用场景包括:
- 进行checkpoint的时候,采用异步I/O将checkpoint信息写入重做日志文件。
- 将缓冲池中的脏页写入对应的数据文件。
- 随机或线性预读的时候,将数据页读取到缓冲池。
其实本小节介绍的I/O线程都是为异步I/O服务的,主要做一些收尾的工作。异步I/O又分为MySQL自身实现的Simulated aio和使用操作系统的Linux native aio,现在默认都使用操作系统的,这里面其实还有很多细节,感兴趣的读者可以自行研究。
另外,这里的主要参数有innodb_use_native_aio。该参数用于设置是否使用Linux异步I/O子系统,默认开启,这里也建议开启,依赖Linux的异步I/O子系统性能会有所提升。
5.7.3 刷脏线程
在上述整体架构的阐述中,我们明确了刷脏线程的核心职责在于将脏页数据刷新至磁盘,此过程进一步细化为调度线程与工作线程的协同作业。接下来,我们深入剖析刷脏线程的脏页刷新流程。
在上一小节关于I/O线程流程图的解析中,已初步勾勒出刷脏线程的基本运作框架。基于这一基础,我们将详细展开刷脏流程的各个环节。
- 遍历缓冲区的flush链表,拿到脏页。
- 在把该脏3. 将脏页进行初始化,主要将
lsn和页校验数据写入到脏页中。 - 判断是否有开启双写缓冲区,如果没有开启就直接进行脏页刷盘。
- 如果开启就将脏页复制到双写缓冲区,默认情况下每次刷20个脏页。
- 将双写缓冲区同步的写入到系统数据文件中对应的双写区。
- 遍历双写区的脏页,依次将脏页异步的写入到数据文件中,这里就跟上一小节中介绍的I/O线程能对应上,写完之后,对应的I/O线程就做收尾的处理。
这里只是大体介绍了刷脏页的流程,里面其实还有很多细节,感兴趣的读者可以自行阅读其相关源码逻辑,主要逻辑在storage/innobase/buf/buf0flu.cc方法中。
无论是调度线程还是工作线程,在执行脏页刷新任务时,均会遵循相同的流程框架,即各自认领一个缓冲区实例进行后续操作。
这里相关参数主要有innodb_flush_method、innodb_io_capacity和innodb_flush_neighbors。
innodb_flush_method 参数设置数据页和重做日志刷盘的方式,可选值包括:
fsync,这是默认值,表示每次写数据文件和重做日志的时候都先写入到操作系统缓冲区,再调用fsync方法将操作系统缓冲区的内容刷到磁盘中。这里建议生产环境采用默认值。O_DSYNC,对于重做日志采用O_DSYNC方式写入,每次写入的时候强制将缓冲区刷盘,对于数据页还是采用先写操作系统缓冲区再用fsync进行刷盘。O_DIRECT,对于数据页采用O_DIRECT方式写入,每次写入的时候会绕过操作系统的缓冲区,直接写入磁盘中。对于重做日志则先写缓冲,再调用fsync进行刷盘。
innodb_io_capacity 参数设置每秒最大的I/O操作次数,单位为页。该参数是所有缓冲池共用的,在好的磁盘上可以适当调整该值来提升性能。
innodb_flush_neighbors 参数设置在刷脏时是否将相邻的脏页也进行刷盘,可选值包括:
0,表示不将相邻的页进行刷盘。1,表示将同一个区相邻的脏页进行刷盘。2,表示将同一个区所有的脏页进行刷盘。
对于机械硬盘来说,刷相邻的页可能减少磁盘寻址的时间,可以开启该参数。不过对于SSD磁盘来说,寻址不是影响速度的关键因素,可以关掉该参数。
5.7.4 清理线程
在InnoDB存储引擎中,数据删除后只是标记删除,并不会马上从数据文件中删除。后台清理线程的主要工作就是删除这些数据,清理线程也分为调度线程和工作线程,调度线程将删除的任务分发给工作线程,工作线程来进行具体的删除操作。
InnoDB清理线程的流程如图5-9所示。
图5-9 InnoDB清理线程的流程(参见原文插图,展示了回滚段、历史链表、回滚头、回滚记录、清理调度线程、清理工作线程、系统数据文件、用户数据文件等组件之间的交互)
具体而言,调度线程的主要处理流程为:
- 遍历所有的回滚段,默认为96个,然后针对每个回滚段遍历其维护的历史链表,在历史链表保存指向回滚头的指针,每个回滚头维护一批回滚记录。
- 每次拿到一条回滚记录后,都会插入对应的工作线程维护的集合中,依次轮询插入,这样每个工作线程处理的回滚记录就比较均匀。
- 重复上述步骤直到处理超过300条回滚记录,调度线程就会退出,等待下一次被唤醒执行。
- 定期删除历史链表中已经做完清理的回滚,由
innodb_purge_rseg_truncate_frequency参数控制频率,默认清理调度线程每执行128次就删除一次回滚记录,主要是将对应的undo header从历史链表中移除。
工作线程的主要处理流程为:
- 当调度线程将回滚记录插入工作线程维护的集合中后,工作线程就从该集合中取出回滚记录依次进行处理。
- 处理流程首先需要解析回滚日志,得到对应的table id、索引信息以及主键或者唯一键的值,然后根据这些信息就能到数据文件中找到对应的记录。
- 找到对应的记录后就开始删除,删除主要分删除聚簇索引和二级索引,先删除二级索引记录再删除聚簇索引记录。
- 这里的删除其实只是将记录从数据页的记录链表中移除,然后挂到对应的
PAGE_FREE链表中。
上述就是调度线程和工作线程的大致处理流程。
这里历史链表中的回滚记录其实指的是一组记录,表示一个事务对应的回滚记录,由一个回滚日志记录头和多个回滚日志组成,这在第6章中会详细介绍。
这里的主要参数为以下两项:
innodb_purge_batch_size:该参数设置清理线程每次从历史链表中读取多少个回滚日志记录,默认为300个。该参数可根据实际情况进行调整。innodb_purge_rseg_truncate_frequency:该参数主要用于控制从历史链表中删除回滚记录的频率,默认为调度线程执行128次后删除一次回滚记录。可以适当将该参数调小来提高删除回滚记录的频率。
5.8 总结
至此,InnoDB存储引擎的介绍已全部完成。在本章中,我们深入探讨了InnoDB存储引擎内存中的各组件及对应的后台线程。这些组件的设立均旨在解决特定的性能或数据完整性问题,具体表现为:
- 缓冲池旨在加速数据访问速度,减少磁盘I/O操作。
- 重做日志缓冲用于管理重做日志记录的顺序刷盘过程,确保数据恢复时的完整性和一致性。
- 插入缓冲机制则针对二级索引的随机I/O问题进行了优化,提高了索引构建和更新的效率。
- 双写机制通过双重写入数据页的方式,有效解决了写入过程中可能发生的页损坏问题。
- 自适应哈希索引的引入,进一步提升了高频查询操作的性能,减少了查询响应时间。
- 不同的后台线程各司其职,协同工作,共同维护着InnoDB存储引擎的高效运行。
这些组件与后台线程的组合,犹如一条精密的流水线,将用户操作的数据准确无误地写入磁盘文件中。
在介绍完InnoDB存储引擎内存中的各组件及工作机制后,我们将进入第6章,探讨InnoDB存储引擎文件的组织结构。