InnoDB 存储引擎
字数: 0 字 时长: 0 分钟
在 MySQL 5.5 之前 MyISAM 是默认引擎,而之后 InnoDB 替代了 MyISAM 作为默认引擎。 如今,MyISAM 的重要性已经大幅下降,因此我们主要研究 InnoDB 存储引擎。
特性 | MyISAM | InnoDB |
---|---|---|
事务 | 不支持 | 支持 ACID |
锁粒度 | 表锁 | 行锁 |
崩溃恢复 | 不支持 | 支持(Redo Log + Doublewrite ) |
MVCC | 不支持 | 支持 |
为什么 MySQL 默认引擎由 MyISAM 换为 InnoDB?
InnoDB 支持事务、行锁、崩溃恢复、MVCC,更适合现代高并发事务型应用。
InnoDB 存储结构
InnoDB 将数据划分为若干 页
,每页的大小默认为 16KB
。
页
是磁盘和内存之间交互的基本单位,也就是说 InnoDB 一次最少把 16KB
内容从磁盘读到内存中(或从内存刷新到磁盘)
页结构
- 每个数据页中的记录会安装主键值从小到大的顺序组成一个单向链表。
- 每个数据页都会为存储的记录生成一个页目录,可以使用二分法快速定位到对应的槽,然后遍历该槽进行查询
- 页与页之间在物理上不一定是连续的,在逻辑上通过双向链表进行关联
页的上层结构
页
是数据库 I/O 操作的最小单位,页上存放着多行
数据。
- 区
区
是比页
大一级的存储结构,我们不能保证所有页在物理上是连续的,但尽量保证每一批页是连续的,这一批连续的页称为 区
。
一个区会分配最多 64
个连续的页,因此一个区的大小是 64 * 16KB = 1MB
。
- 段 Segment
段
的出现是为了把 B+ 树的叶子节点
和非叶子节点
的区
进行区别对待,也就是叶子节点有自己的区的集合,非叶子节点有自己的区的集合。
这些不同的区的集合就是一个 段
,常见的段有 数据段
、索引段
、回滚段
,段由引擎自身管理,DBA不能也不需要对其管理。
- 表空间 Tablespace
表空间
是一个逻辑容器,一个表空间中可以包含多个段,一个段只能属于一个表空间,数据库由一个或多个表空间组成。
每张表有一个独立表空间(由段、区、页组成),也就是数据和索引都会保存在自己的表空间之中。
整个 MySQL 进程只有一个系统表空间,系统表空间记录元数据,由系统内部表存储,也称为数据字典。
页的内部结构
数据页的 16KB
存储空间被划分为 7
个部分:
- 文件头(File Header)
文件头存储文件的元数据信息,包含 页类型
、前后页指针
、Checksum(校验和)
、LSN
(页面被修改后对应的日志序列位置,用于崩溃恢复)
- 页头 (Page Header)
页头存储页的状态信息,如记录数
、槽数量
- 最小最大记录
记录该页的记录边界
- 用户记录 (User Records)
用户记录中的这些数据按照指定的行格式一条一条存储,相互之间形成单链表
- 空闲空间 (FreeSpace)
我们存储的记录会按照指定的行格式
存储到User Records
中。
但是在一开始生成页的时候,其实并没有 User Records
这个部分,当我们插入一条记录时就把空闲空间的一部分划分给 User Records
。 直到空闲空间不足时,还有新的记录插入就需要去申请新的页了。
- 页目录 (Page Directory)
当前页的记录进行分组,每个组中的最后一条记录的头信息会记录该组一共有多少条记录,作为 n_owned
字段
页目录用来存储每组最后一条记录的地址偏移量,这些地址偏移量按照先后顺序存储,每组的地址偏移量也成为 槽
,每个槽相当于指针指向了每组的最后一条记录
- 文件尾(File Trailer)
存储 Checksum
数值,与文件头中的 Checksum
值对比,确保页的完整性
compact 行格式
用户记录实际是一行一行的存储在数据页当中的,MySQL 5.7 之前默认行格式是 compact
行格式,MySQL 5.7+ 变更为 dynamic
。
变长字段长度列表
VARCHAR(M)
、TEXT
这类支持长度变化的字段会存储在 变长字段长度列表 中。
变长字段的存储是倒序的
变长字段的长度存储顺序是倒序的,比如数据顺序是 a(10)
b(15)
,变长字段长度列表存储顺序是 15 10
NULL值列表
Mysql 中的数据都是需要对齐的,NULL 值列表用来存储 NULL 值情况(使用比特位 0 / 1
表示是否为null
值情况)
# 字段 a,b,c ,其中 a 为主键
# 行1
a = 1,b = null,c = 2
# 那么空值列表为 01 ,因为 a 是主键明确非空,就不考虑记录 a 是否为 null 了
记录头信息
假设存储 4 条记录
insert into page_demo
values
(1,100,'song'),
(2,200,'tong'),
(3,300,'zhan'),
(4,400,'li')
delete_mask
删除标志,如果该位为 1,则表示该记录已经被删除。
这也意味着被删除的记录实际还存储在磁盘上,只是被标记为删除。这些为了实际性能的考虑,防止删除一条数据导致所有其他之后的数据都要移动在磁盘上的存储位置。
如果删除多条记录,那么所有被删除的记录会组成一个 垃圾链表
,后续有新数据插入时,可能会复用这些空间,把已删除记录覆盖
掉。
min_rec_mask
B+ 树的每层非叶子节点的最小记录都会添加该标记,min_rec_msk
值为 1
我们自己插入的 4 条记录,每条记录的 min_rec_msk
值为 0,意味着它们都不是非叶子节点的最小记录。
record_type
:表示当前记录类型- 0 表示普通记录
- 1 表示B+ 树非叶子节点记录
- 2 表示最小记录
- 3 表示最大记录
heap_no
表示该记录在当前页中的位置,我们插入的 4 条记录在本页的位置分别为 2 3 4 5
为什么没有 0 和 1 呢?
因为 MySQL 会隐式地自动为每个页添加两个记录,一个代表最小记录
,一个代表最大记录
,占据了 0 和 1 位置。
next_record
记录下一条记录的地址偏移量,组成单向链表
n_owned
页目录
会将所有数据分为若干个组,每个组中的最后一条记录的 n_owned
值存储该组一共有多少条记录
Dynamic 行格式
什么是行溢出
?
MySQL 一个页的大小是 16KB
(也就是 16384
字节),而一个 VARCHAR(M)
列最多可以存储 65533
个字节。
这样就有可能一个页存放不了一行记录,这种现象称为行溢出
MySQL 5.7 之后采用 Dynamic 作为默认的行格式,它和 Compact 的区别是对 行溢出
的处理
- Compact 处理行溢出,会存储部分数据在当前页,然后存储剩余数据在
溢出页
中
- Dynamic 处理行溢出,当前页只存储溢出页地址,数据全部存储在溢出页中
事务
数据库事务是指数据库管理系统 (DBMS)中的一个操作序列,这些操作必须作为一个不可分割的单元执行,要么全部执行成功,要么全部失败回滚。
事务有 ACID 四个特性:
- 原子性(Atomicity):事务是一个原子操作,要么全部提交,要么全部回滚。使用
Undo Log
日志实现。 - 一致性(Consistency):事务执行结束后,数据必须保持一致性(不可以处于中间状态)。使用
约束 + Undo/Redo Log
实现。 - 隔离性(Isolation):并发事务互不干扰。使用
锁
+MVCC
实现。 - 持久性(Durability):提交后修改永久保存。使用
Redo Log
+Doublewrite Buffer
实现。
事务的终极目标是实现一致性,原子性、隔离性和持久性是保证一致性的手段。
日志类型
接下来我们具体介绍上面提到的几个实现事务的技术术语,先从 MySQL 的日志类型开始。
Redo Log
当 MySQL 修改(Update
、Insert
、Delete
)实际的数据文件页之前,会先将相应的修改日志记录到 Redo Log
中。
Redo Log
(重做日志)的核心目标是保证事务的持久性,作用原理如下:
- 当事务执行数据修改时,首先修改 Buffer Pool(内存中的缓存页)
- 这些修改不会立即写入磁盘上的数据文件(性能太差)
- 同时,InnoDB 会生成描述这些物理变更的
Redo Log
记录(比如在某页的某个偏移量处修改了什么数据) - 在事务提交时,必须先将该事务对应的
Redo Log
记录刷写到磁盘上的Redo Log File
(此时数据文件本身可能还没写盘) - 之后才返回给客户都安提交成功的响应
这里提到了物理变更的概念,Redo Log
在 InnoDB 中是物理级别的日志,它记录的是某个 Page 底层物理存储结构的变更记录(某个位置被修改了什么值)
它是数据库崩溃恢复的核心:
- 当数据库实例崩溃重启时,会首先检查数据文件页和
Redo Log
- 如果发现数据文件页的 LSN 小于
Redo Log
的 LSN,说明该页的修改还没刷盘就崩溃了 - 那么 InnoDB 会重放
Redo Log
,将数据文件页恢复到崩溃前的状态,保证提交的数据不丢失
Undo Log
Undo Log
回滚日志,用于保证事务的原子性和多版本并发控制(MVCC),作用原理如下:
- 当事务对数据进行
Insert
时,Undo Log
会记录新增数据的主键,回滚时使用主键删除数据 - 当事务对数据进行
Update
时,Undo Log
会记录修改前的旧值(字段或整行),回滚时将新值改为旧值 - 当事务对数据进行
Delete
时,Undo Log
会记录旧行的完整内容,回滚时Insert
回去
Undo Log
与 Redo Log
不同的一点是,它记录的是逻辑日志,用以逆向操作进行回滚。
Binlog
MySQL 除了有 Redo Log
和 Undo Log
外,还存在第三种日志类型,也就是Binlog
(Binary Log,二进制日志)
Binlog
与存储引擎无关,用于记录 MySQL 服务器上的所有修改操作,它可以记录所有的 DDL 和 DML 操作,包括对表结构的更改、数据的插入、修改和删除等。
Binlog
记录的是逻辑日志(SQL 语句本身),在事务提交后才落盘,用于主从复制和数据恢复/归档
锁
MySQL 锁机制是 MVCC (并发控制)和事务隔离性的核心,解决并发事务访问同一份数据时可能引发的数据不一致问题:
- 脏读:读到未提交的数据
- 不可重复读:同一事务内多次读同一数据结果不同(数据被修改)
- 幻读:同一事务内多次按相同条件查询,结果集记录数不同(新插入或删除数据)
共享锁和排他锁
共享锁(S锁)和排他锁(X锁)都是行级锁,他们的区别如下:
- 共享锁允许多个事务读取同一行数据,互相之间不会冲突。
- 排他锁只允许一个事务对同一行数据进行修改,其他事务需要阻塞等待。
元数据锁
一般情况下我们使用行级锁(锁粒度更低),不会使用表级锁。除非当我们进行 DDL 操作,才会使用表级锁锁定整个表,防止查询和修改。 MySQL 对此提供了一个叫 MDL 的锁,即 Metadata Locks
(元数据锁)。
元数据锁分为读锁(MDL_SHARED)和写锁(MDL_EXCLUSIVE)。
- 读锁是共享锁,允许多个事务同时读取一个表的元数据
- 写锁是排他锁,当一个事务修改表的元数据是时,其他事务需要阻塞等待元数据修改完毕
意向锁
如果真使用到了表级锁,那么表锁和行锁之间肯定会冲突,当 InnoDB 加表锁的时候,如何判断表里面是否有行锁呢?
答案是意向锁:
- IS(Intention Shared Lock):共享意向锁
- IX(Intention Exclusive Lock):排他意向锁
这两个锁是表级锁,当需要对表中的某条记录上 S 锁的时候,先在表上加个 IS 锁,表明此时表内有 S 锁; 当需要对表中某条记录上 X 锁时,先在表上加个 IX 锁,表名此时表内有 X 锁。
因此 IS 和 IX 的作用就是上表级锁时,可以快速判断是否可以上锁,而不需要遍历表中所有记录去查看是否有行级锁。
记录锁
记录锁是行级锁的一种实现方式,它通过锁住索引来达到锁住当前记录的目的。(InnoDB 肯定是有索引的,即使没有主键会隐式创建)。
比如:
- 事务 A 执行
SELECT * FROM t WHERE name = 'xx' FOR UPDATE
,那么name=xx
这条记录旧被锁定了,其他事务无法插入、修改、删除这条记录。 - 事务 B 执行
insert into t (name) values ('xx')
时会被阻塞
那么 事务 C 执行 insert into t (name) values ('aa')
时会被阻塞吗?
- 如果
name
没有索引,事务 C 会被阻塞,因为只能一一遍历聚簇索引去找name=aa
,但此时聚簇索引name=xx
被锁住了。 - 如果
name
有索引,则不会阻塞
间隙锁
记录锁需要加到记录上,但是如果要给此时还未存在的记录加锁怎么办,也就是预防幻读的出现。
比如此时有 1、3、5、10 这四条记录,数据页中有两条虚拟记录分别是 Infimum
和 Supremum
(详情需要看 MySQL 索引相关知识)
可以看到记录之间都有间隙,间隙锁锁的就是这个间隙,比如把 3
和 5
的间隙锁上,那么插入 id = 4
这条数据就无法插入了。
乐观锁和悲观锁
乐观锁和悲观锁是两个相对概念,他们不是 MySQL 的内置锁机制,属于常见的并发控制策略:
- 悲观锁:在操作的时候就加上锁,牺牲性能保证强一致性
- 乐观锁:假设不会发送冲突,操作数据时不加锁,在更新数据时进行版本控制或校验,适合读多写少的场景
死锁是什么,如何解决?
死锁是指多个事务在执行过程中,因争抢资源而造成的一种相互等待的现象,若无外力干预,他们都无法继续执行下去。
MySQL 自带死锁检测机制(innodb_deadlock_detect
),检测到死锁时,会自动回滚其中一个持有资源最少的事务,以解除死锁
避免或降低死锁的手段:
- 避免大事务,减少锁持有时间
- 合理设计索引
- 如果业务允许,降低隔离级别
- 保持一致的访问顺序
事务隔离级别
MySQL 事务隔离级别由低到高有以下四种:
- 读未提交(Read Uncommitted)
最低的隔离级别,一个事务可以读取到其他未提交的事务修改的数据,即脏读,实际使用场景极少。
- 读已提交(Read Committed)
每次普通 select
(快照读)都会生成一个新的读取视图,这个视图只包含到该 select
语句开始时已提交的数据。 因此只要两次 select
之间有其他事务提交了修改,就一定会出现不可重复读问题(同一事务两次读取的结果不一致)
在 RC 隔离级别下,普通 select
不会造成幻读问题;但当前读(比如 select ... for UPDATE
)也可能读取到其他事务最新提交的数据,造成幻读
- 可重复读(Repeatable Read)
MySQL 默认事务隔离级别,在 RR 级别下,第一个 select
会创建一个整个事务可见的读视图,该事务内后续的所有普通 select
都基于这个视图,保证了可重复读。
至于幻读问题,RR 引入了间隙锁阻止其他事务在这些可能产生幻影行的间隙中插入新数据,造成幻读。
- 串行化(Serializable)
最高的事务隔离级别,相当于事务操作是一个按顺序执行的单线程操作,避免了所有并发问题,但大大降低了性能。
由此看来事务的隔离级别没有最完美的选择,只能根据实际业务场景,在性能和数据一致性之间找到平衡。
为什么互联网大厂把事务隔离级别设为 RC ?
首先 Oracle、PostgreSQL 等数据库的默认隔离级别是 RC,而 MySQL 的默认隔离级别是 RR 是历史遗留问题,为了兼容早期 binlog 的 statement 格式问题(有可能导致主从备份不一致)
大公司将隔离级别设为 RC 是因为 RR 为了解决幻读问题引入了间隙锁,并发性能更低,死锁概率更高。而在很多场景下,幻读是可以容忍的,因此大厂选择 RC 来获得更高的并发性能
MVCC
MVCC (Multi-Version Concurrency Control,多版本并发控制)是一种并发控制机制,允许多个事务同时读取和写入数据库,而无需互相等待,提高了数据库的并发性能。
在 MVCC 中,数据库为每个事务创建一个数据快照。每当数据被修改时,MySQL 不会立即覆盖原有数据,而是生成新版本的记录。每个记录都保留了对应的版本号或时间戳。
这样不同事务可以无锁地获得不同版本的数据,此时读(普通读)写操作不会阻塞。
实际上 MVCC 并不是真的存储了多个版本的数据,索引上的记录其实只有一个版本(最新版本)。只是借助 Undo Log
记录每次写操作的反向操作,可以根据 Undo Log
记录得到数据的历史版本,看起来是多个版本。
Doublewrite Buffer
之前我们提到了 InnoDB 使用 Redo Log
+ Doublewrite Buffer
来保证数据持久性。
InnoDB 的数据页大小为 16KB
,但 Linux 系统的内存页大小为 4KB
,因此 InnoDB 的数据页被映射为 4
个 Linux 内存页。
所以 InnoDB 的一页数据要刷盘需要写四个系统页,如果只写了一个系统页就断电了,那么这一页 InnoDB 数据页就损坏了,无法保证刷盘这个操作的原子性。
于是 MySQL 在数据落盘前,先将页数据拷贝到 Doublewrite Buffer
中,然后再从 Doublewrite Buffer
刷盘到 Doublewrite Buffer Files
中备份,然后再将数据页真正刷盘到磁盘中。
在崩溃恢复的时候,如果发现页损坏,从 Doublewrite Buffer Files
里找到页副本恢复即可。
这个过程写了两次磁盘,所以叫 double write
。虽然是两次写,但因为数据拷贝到 Doublewrite Buffer
(双写缓冲区) 这个过程是内存操作,Doublewrite Buffer Files
刷盘是批量操作,所以性能影响不大。