Skip to content

MySQL 存储引擎层

MySQL 中常见的存储引擎有 InnoDB 和 MyISAM,MySQL 5.5 后默认的存储引擎是 InnoDB

一、InnoDB 存储引擎介绍

MySQL 存储引擎相关命令

sql
-- 查看所有支持的存储引擎
SHOW ENGINES;

-- 查看默认存储引擎
SHOW VARIABLES LIKE 'default_storage_engine';

-- 建表时指定存储引擎
CREATE TABLE t1 (id INT PRIMARY KEY) ENGINE = InnoDB;

-- 修改已有表的存储引擎
ALTER TABLE t1 ENGINE = InnoDB;

InnoDB vs MyISAM 核心差异对比

特性InnoDBMyISAM
事务支持✅ 支持 ACID❌ 不支持
锁粒度行级锁(Row Lock)表级锁(Table Lock)
MVCC✅ 支持❌ 不支持
外键✅ 支持❌ 不支持
崩溃恢复✅ 通过 Redo Log❌ 需要手动修复
索引结构聚簇索引非聚簇索引
全文索引✅ MySQL 5.6+ 支持✅ 原生支持
COUNT(*)需遍历索引计数直接存储行数
存储空间.ibd(数据+索引).MYD(数据)+ .MYI(索引)
并发写性能高(行锁)低(表锁)
适合场景高并发 OLTP读多写少、不需要事务

InnoDB 成为默认存储引擎的原因

InnoDB 每一个特性都支撑了它成为默认存储引擎:

  • 行级锁 + 支持 MVCC 的特性让 InnoDB 支持高并发读写
  • 聚簇索引的特性让 InnoDB 的数据检索速度更快
  • 支持事务的特性让 InnoDB 保证了数据的安全修改
  • Redo Log 的特性让 InnoDB 支持服务崩溃后安全恢复数据

InnoDB 存储引擎层架构总览

二、InnoDB 页结构详情

InnoDB 数据存储的逻辑结构

InnoDB 中处理的数据并不是一个字节一个字节处理,而是以一定的逻辑结构为单位,下面是 InnoDB 数据存储的逻辑结构

我们需要重点关注 Extent 和 Page,1 个 Extent 包含了 64 个 Page,Page 是 InnoDB 存储引擎磁盘管理的最小单元,每个页的大小默认为 16KB


InnoDB 页内结构

InnoDB 的每个页由以下部分组成:

组成部分大小作用
File Header38 字节页的通用信息,如页号、上一页/下一页指针、所属表空间
Page Header56 字节页的状态信息,如记录数、空闲空间偏移、目录槽数
Infimum 记录固定比页中任何记录都小的虚拟记录,最小记录
Supremum 记录固定比页中任何记录都大的虚拟记录,最大记录
User Records动态实际存储的行记录,按主键大小排序
Free Space动态页中尚未使用的空间
Page Directory动态页目录,存储若干槽位,用于二分查找
File Trailer8 字节用于检测页是否完整(与 File Header 配合做校验)

Page Directory 与二分查找

Page Directory 的作用是加速页内查找

  1. 页中的记录被分成若干,每个组的最后一条记录的偏移量存入一个槽(Slot)
  2. 查找时,先通过 Page Directory 做二分查找,定位到具体的组
  3. 再在组内通过单链表顺序遍历找到目标记录
  4. 时间复杂度:O(log 组数 + 组内记录数)

InnoDB 的页内查找利用 Page Directory 实现了二分查找。Page Directory 是一个槽位数组,每个槽指向页内一组记录的最后一条。查找时先二分定位到组,再在组内顺序遍历,整体效率很高。

三、Buffer Pool 引入

在 MySQL 中,存储引擎负责数据存储、数据读取、数据更新,但是数据实际上存在磁盘中。因此无论是哪一款存储引擎,当客户端需要对数据进行修改时,都需要先把数据从磁盘中加载到内存中,数据操作完成后,把内存中的数据再写回到磁盘中。

显然这种做法是不太合理的,多个更新操作都直接操作磁盘,由于磁盘 IO 效率不高,导致性能低下

MySQL 的设计者自然也考虑到了这个问题,所以在客户端与磁盘文件中间设计了一个 Buffer Pool ,专门用于更新数据使用的缓冲池,毕竟操作内存的效率还是要比操作磁盘更高

  • 读取数据:如果数据存在 Buffer Pool,则直接从 Buffer Pool 中读取;如果不存在,则从磁盘读取数据,再写入到 Buffer Pool 中。

  • 写入数据:数据先写入 Buffer Pool,然后根据配置的持久化策略,把数据写入到磁盘文件中。

这种做法尽可能地避免了客户端直接和磁盘进行 IO,Buffer Pool 的加入提升了读写性能。


四、Buffer Pool 读取机制

缓存预读机制

根据局部性原理,往往一块需要使用的数据,它附近位置的数据可能也会马上被用到;为了进一步减少磁盘 IO 的次数,InnoDB 设计了预读机制,一次性读取多一点数据到 Buffer Pool 中,在数据交互时,可以尽量减少磁盘 IO 的次数,使用到了一个空间换时间的思想。

InnoDB 中数据的最小单位是页,所以从磁盘文件中读取数据写入 Buffer Pool 中也是以页为单位。缓存中的页会对应一份元数据,这个元数据包含数据页的编号,数据页在 Buffer Pool 中的地址等信息。

InnoDB 使用两种预读算法来提升 IO 的效率:线性预读和随机预读

线性预读

当我们读取一个 Extent 中的 Page 的数量达到了 innodb_read_ahead_threshold (默认56)个时,提前加载下一个 Extent 的数据到 Buffer Pool 中。方便后面数据访问时可以直接访问 Buffer Pool,不需要再访问磁盘。

随机预读

当 Buffer Pool 中同一个 Extent 缓存的 Page 的数量达到了 13 个时( innodb_random_read_ahead 参数控制此行为是否开启),提前把当前正在读取的 Extent 中剩下的所有 Page 读取到 Buffer Pool 中。方便访问后面的 Page 时可以直接访问 Buffer Pool。


缓存淘汰机制

有了预读机制后,Buffer Pool 的作用得到了进一步的发挥,通过减少数据交互时的磁盘 IO 次数,达到提升数据操作性能的目的。

此时我们同样需要考虑一个问题,Buffer Pool 默认大小是 128 M,虽然我们可以通过修改配置来增大这个空间,但是 Buffer Pool 的空间总是有限的,如果缓存页中加载了非常多的数据也会导致缓存空间耗尽

LRU 变体算法与冷热数据区

InnoDB 使用的是一种 LRU 的变体算法将缓冲池作为一个链表进行管理,当需要对 Buffer Pool 进行内存淘汰时,最近最少使用的缓存页将会被清除。使用 midpoint 把数据划分为冷数据区和热数据区。New Sublist:热数据区;Old Sublist:冷数据区。其中每一个数据区都分别有 HeadTail 指针

链表数据存放规则:

  • 无论是预读还是普通读取,所有数据页读取到 Buffer Pool 中时,都使用头插法插入到冷数据区中,更早的数据往 Tail 方向挪动,避免了预读机制的存在导致热数据区的数据被冲掉

  • 当冷数据区中的数据被访问,那么这份数据会移动到热数据区中;而热数据区长期未被访问的数据,会随着新数据进入热数据区而被逐渐推向冷数据区

  • 当实现内存淘汰时,会把冷数据区中 Tail 部分的数据淘汰掉

默认热数据区和冷数据区大小比例是 5:3,通过参数 innodb_old_blocks_pct 来控制。冷数据区的占比越小,会使得冷数据区没有被访问的数据的淘汰速度更快,在生产环境中,内存足够大时,会尽量把这个数值调低,尽量避免热数据被淘汰。


热数据转移规则

假设我们现在执行了一条不带索引不带 limit 的查询语句

sql
select * from account;

由于这条语句没有使用索引,所以会进行全表扫描,但是这种查询很多时候都是短时间内只访问一 次,后面基本上都不会用到的。

假设这些数据被访问一次就导致它从冷数据区转移到热数据区中,热数据区中的热点数据被转移到冷数据区中,当发生内存淘汰时,位于冷数据区中的“原来的热点数据”就被清除出内存,导致 Buffer Pool 中全是一些低频访问的冷数据,这大大降低了缓存的命中率,无法充分发挥缓存的作用。

这种情况很常见,同时 InnoDB 的设计者也考虑到了这个问题,所以 InnoDB 定义了冷数据转移到热数据的规则:如果这个数据页在 LRU 链表中冷数据区存在的时间超过了 1 秒,就把它移动到热数据区,这个时间可以通过参数 innodb_old_blocks_time 来进行控制。

因为通过预读机制和全表扫描加载到冷数据区中的数据,通常在 1 秒内会加载并且完成对他们的访问,它们会放在冷数据区等待清空,不会有太多机会进入热数据区;如果 1 秒后还有其他操作对它进行了访问,那说明确实有多个场景要对他进行操作,才会放入到热数据区的头部。

所以 Buffer Pool 中的 LRU 算法整体原理是这样的:


InnoDB 不采用传统 LRU 算法的原因

通过 midpoint 把数据区分割成冷、热数据区,默认比例是 5:3;避免了全表扫描读取了大量的数据进入 Buffer Pool,这些数据可能是只用一次的冷数据,但是 Buffer Pool 空间有限,如果全表扫描出来的冷数据太多,会挤走热数据,导致 Buffer Pool 的利用率下降。这个机制保证了冷数据可以自然淘汰、不挤占热数据的空间,只有再次被访问时才能升级到热数据区


五、Buffer Pool 写入机制

Buffer Pool 除了有提升数据读取性能的机制,还有修改机制。并且对于修改数据的请求,数据页的情况不同,Buffer Pool也有相应不同的行为

脏页

当内存中的数据和磁盘中的数据不一致时,我们称内存中的数据为脏数据,也称为脏页把内存中的数据写入到磁盘中的这个过程我们称为刷脏

从这方面也能看出,内存和磁盘,可靠的其实是磁盘数据

直接修改 (数据页已经在 Buffer Pool)

当需要更新一个数据页时,如果这个数据页已经缓存在 Buffer Pool 中,那么直接对这个缓存页进行修改,这个数据页会变成"脏页",等待刷脏

Change Buffer (数据页不在 Buffer Pool)

Change Buffer 是 Buffer Pool 中的一块特殊的区域,主要的作用是提高数据写入效率,在更新数据时可以把更新的值缓存下来,先不进行数据更新,减少磁盘 IO 次数

当需要更新一个数据页时,如果这个数据页不在 Buffer Pool 中,那么不去磁盘中读取数据页而产生随机读磁盘 IO,直接把需要修改成的值缓存到 Change Buffer 区域中。

等待下次需要访问这个数据页 / 预读机制把这个数据页加载到 Buffer Pool 中时,进行 merge 操作


merge 触发场景

  • 访问这个数据页,无论是主动读取还是预读机制,数据页被加载到 Buffer Pool 中了,自然进行 merge 操作
  • 后台线程定时把 Change Buffer merge 到对应的数据页中,这个机制是保证了假如一个二级索引页只有写入,从不被查询,也能定时更新数据,而不会一直把修改累积下去
  • Buffer Pool 空间不足时,为了保证热数据区的数据准确性,把数据的修改 merge 了腾出空间
  • 数据库正常关闭

使用限制

如果本次操作操作了唯一索引,那么这个操作就不能使用 Change Buffer 了,因为需要保证数据的唯一性,需要把数据都读到内存中进行比对看看是否违反了唯一索引的约束,如果把数据都已经读入到内存中了,那么 Change Buffer 自然也就没有意义了,所以唯一索引的更新不能使用 Change Buffer

其他情况下都可以正常使用 Change Buffer 来提升效率


Flush链表与刷脏机制

Flush 链表

Buffer Pool 中需要更新到磁盘中的数据是修改过的数据,Buffer Pool 中存在着预读机制加载进来的数据,全部更新同步一遍显然不合理,所以 Buffer Pool 使用了 Flush 链表来记录修改过的数据。

Flush 链表仅记录修改过的数据页(脏页),后台线程会按配置的策略把数据进行持久化到磁盘中(这一步也叫刷脏)。

刷脏触发场景

MySQL 在以下场景会触发刷脏动作

  • 系统内存不够,需要将一部分数据页淘汰,如果是干净页就直接淘汰;如果是脏页就需要全部同步到磁盘
  • MySQL 正常关闭 之前,由于不能导致内存中的数据丢失,在正常关闭前也会把脏页更新到磁盘中
  • Redo log 写满了,先停止所有更新操作,将 checkpoint 向前推进,推进那部分日志的脏页更新到磁盘

六、总结

MySQL InnoDB 存储引擎已经成为默认的存储引擎,离不开它对写入和读取的设计:

  • 读取方面
    • 考虑到直接随机磁盘 IO 效率低下,MySQL 在中间加入了一个 Buffer Pool
    • 考虑到减少主线程时间的磁盘 IO 次数,MySQL 加入了预读机制,预读机制基于 Page 和 Extent 结构又分为线性预读与随机预读
    • 考虑到预读机制带来的内存空间压力,MySQL 使用了内存淘汰算法,定义了冷热数据区的一系列规则
  • 写入方面
    • 考虑到尽可能减少写入、同步的数据,MySQL 使用了 Flush 链表来记录修改过的数据页,脏页会在特定的时候进行刷脏
    • 考虑尽可能降低每次更新时磁盘读取数据的次数,MySQL 在 Buffer Pool 中开辟了一块 Change Buffer 区域,暂时缓存修改的数值缓存下来,等待数据页进入 Buffer Pool 时进行 merge 操作

自测