InnoDB逻辑存储结构
前言
本文主要介绍了 MySQL 中 InnoDB 的逻辑存储结构,包括两层架构、多种存储引擎(如 MyISAM 和 InnoDB)的特点与对比,InnoDB 将数据划分为页以提升性能,其逻辑存储结构包含表空间、段、区、页、行等。页又有文件头部、页面头部等部分,行有隐藏字段和不同行格式,还讲解了记录在页中的存储、页目录等相关内容。
InnoDB逻辑存储结构
MySQL 的架构共分为两层:Server层和存储引擎层
Server层: 负责建立连接、分析和执行 SQL。MySQL 大多数的核心功能模块都在这实现,主要包括连接池,执行器、优化器、解析器、预处理器、查询缓存等。另外,所有的内置函数(如日期、时间、数学和加密函数等)和所有跨存储引擎的功能(如存储过程、触发器、视图等)都在 Server 层实现;
存储引擎层: 负责数据的存储和提取。支持 InnoDB、MyISAM、Memory 等多个存储引擎,不同的存储引擎共用一个 Server 层。现在最常用的存储引擎是 InnoDB,从 MySQL 5.5 版本开始, InnoDB 成为了 MySQL 的默认存储引擎。我们常说的索引数据结构,就是由存储引擎层实现的。
MySQL 提供了多种存储引擎,每种存储引擎都有各自的优缺点与使用场景,选择合适的存储引擎取决于具体的业务需求和数据结构,还要考虑数据的大小、访问模式、事务性能、并发性能等因素。
执行 SHOW ENGINES;
可以查看系统所支持的引擎类型以及默认引擎,输出结果中 DEFAULT
对应的引擎就是当前默认的存储引擎。
mysql> show engines;
+--------------------+---------+----------------------------------------------------------------+--------------+------+------------+
| Engine | Support | Comment | Transactions | XA | Savepoints |
+--------------------+---------+----------------------------------------------------------------+--------------+------+------------+
| ndbcluster | NO | Clustered, fault-tolerant tables | NULL | NULL | NULL |
| FEDERATED | NO | Federated MySQL storage engine | NULL | NULL | NULL |
| MEMORY | YES | Hash based, stored in memory, useful for temporary tables | NO | NO | NO |
| InnoDB | DEFAULT | Supports transactions, row-level locking, and foreign keys | YES | YES | YES |
| PERFORMANCE_SCHEMA | YES | Performance Schema | NO | NO | NO |
| MyISAM | YES | MyISAM storage engine | NO | NO | NO |
| ndbinfo | NO | MySQL Cluster system information storage engine | NULL | NULL | NULL |
| MRG_MYISAM | YES | Collection of identical MyISAM tables | NO | NO | NO |
| BLACKHOLE | YES | /dev/null storage engine (anything you write to it disappears) | NO | NO | NO |
| CSV | YES | CSV storage engine | NO | NO | NO |
| ARCHIVE | YES | Archive storage engine | NO | NO | NO |
+--------------------+---------+----------------------------------------------------------------+--------------+------+------------+
11 rows in set (0.09 sec)
MyISAM 与 InnoDB
MyISAM
-
在设计之时就考虑到数据库被查询的次数要远大于更新的次数。因此
MyISAM
执行读取操作的速度很快,而且不占用大量的内存和存储资源。由于数据索引和存储数据分离,MyISAM
引擎的索引结构是B+Tree
,其中B+Tree
的数据域存储的内容为实际数据的地址,也就是说它的索引和实际数据是分开的,通过索引指向实际的数据,这种索引也就是非聚合索引。因此,MyISAM
中索引检索的算法就是按照B+Tree
搜索算法搜索索引,如果指定的Key
存在则取出其data域的值,然后以data域的值为地址,读取相应数据记录。 -
没有提供对数据库事务的支持,是表级锁(插入修改都会锁表),因此当
INSERT()
或UPDATE()
数据时即写操作需要锁定整个表,效率便会低一些。 -
MyIASM中存储了表的行数,
SELECT COUNT(*) FROM TABLE
时只需要直接读取已经保存好的值而不需要进行全表扫描。
InnoDB
-
和
MyISAM
一样,InnoDB
也是采用B+Tree
作为索引结构。 -
InnoDB
表完全支持ACID
和事务。它们也是性能的最佳选择。InnoDB
表支持外键,提交,回滚,前滚操作。InnoDB
表的大小最高可达64TB
。 -
特殊的索引存放方式,可以减少
IO
,提高查询效率。 -
主索引的区别:
MyISAM
的索引文件和数据文件是分离的,索引文件仅保存数据记录的地址,叶子结点的data
域存放的是数据记录的地址; 而InnoDB
存放的是数据,因为InnoDB
的数据文件本身就是主索引。 -
辅助索引的区别:
MyISAM
的辅助索引和主索引在结构上没有任何区别,只是主索引要求key
是唯一的,而辅助索引的key
可以重复; 而InnoDB
辅助索引的data
域存储的是主键的值而不是地址,所以检索辅助索引需要先从辅助索引获得主键,再用主键到主索引中获得记录(也就是回表)。 -
InnoDB
的聚集索引效率:主键 > 第一个唯一非空索引 > 生成一个隐藏的主键。 -
InnoDB
采用MVCC
来支持高并发,并且实现了4个标准的隔离级别,默认级别是REPEATABLE READ
(可重复读),并且使用间隙锁next-key
locking
策略防止幻读的出现,使InnoDB
不仅仅锁定查询的行,还会对索引中的间隙进行锁定,以防止幻影行的插入。 -
InnoDB
是基于聚簇索引建立的,聚簇索引对主键查询有很大作用,二级索引中包含的列(InnoDB
采用B+Tree
,树的叶子节点就是主键索引),这也就导致了如果主键索引比较大,其他所有索引都会很大,所以表中索引较多时,主键应该尽可能的小。
👉 回滚(
Rollback
)是一个事务的撤销操作,它将事务中的所有操作全部撤销到初始状态。在MySQL中,你可以使用ROLLBACK语句来回滚事务。
前滚(或称为回滚点)是事务管理中的一个概念,它允许在事务过程中设置一个点,之后可以撤销到这个点,而不是撤销整个事务。在MySQL中,你可以使用SAVEPOINT语句来设置一个前滚点,然后使用ROLLBACK TO SAVEPOINT
语句来回滚到指定的前滚点。
MyISAM与InnoDB对比
对比项 | MyISAM | InnoDB |
---|---|---|
文件格式 | .frm(存储表定义) .MYD(MYData,存储数据) .MYI(MYIndex,存储索引) | .frm(存储表定义) .ibd(数据和索引文件) |
版本支持 | mysql 5.5之前的默认引擎 | mysql 5.5及以后的默认存储引擎 |
优点 | 查询速度快、占用空间小 | 支持事务、行级锁、外键等,因此数据完整性及一致性高 |
优点 | 支持全文索引、压缩表(压缩后数据不可修改但空间小性能高) | 并发性能好(采用MVVC多版本并发控制),写不阻塞读(基于快照读) |
优点 | 存储了表的行数(count速度更快) | 数据存储在表空间中、支持热备份 |
缺点 | 不支持事务、行级锁、外键等,容易出现数据损坏 | 占用的空间比较大,对于只读操作的性能不如 MyISAM |
缺点 | 插入和更新会锁表,效率低 | 不支持全文索引 |
可以禁用MyISAM引擎吗
随着MySQL 8.0的推出,系统表已经全面采用InnoDB
引擎,不再需要MyISAM
引擎。另外,MGR中也不支持MyISAM引擎。因此,基本上可以考虑全面禁止使用MyISAM引擎了。
从MySQL 5.7.8开始,新增一个选项 disabled_storage_engines
,只需要设置下即可:
disabled_storage_engines = MyISAM
这就完美地实现禁用MyISAM的目的了。另外,这么设置的话,是不会影响MySQL实例初始化的。即便是在MySQL 5.7版本中,系统表要使用MyISAM引擎,也不会影响生成MyISAM引擎的系统表。
disabled_storage_engines is disabled and has no effect if the server is started with any of these options: --bootstrap, --initialize, --initialize-insecure, --skip-grant-tables.
不过,设置该选项后可能会影响mysql_upgrade升级:
Setting disabled_storage_engines might cause an issue with mysql_upgrade. For details, see Section 4.4.7, "mysql_upgrade —Check and Upgrade MySQL Tables".
执行mysql_upgrade
进行升级时可能会报错mysql_upgrade: [ERROR] 3161: Storage engine MyISAM is disabled (Table creation is disallowed)
。
这时候需要临时关闭该选项,等待升级完成后再重新启用即可。
InnoDB简介
大家都知道mysql中数据是存储在物理磁盘上的,而真正的数据处理又是在内存中执行的。由于磁盘的读写速度非常慢,如果每次操作都对磁盘进行频繁读写的话,那么性能一定非常差。为了解决上述问题,InnoDB
将数据划分为若干页,以页作为磁盘与内存交互的基本单位,一般页的大小为16KB。这样的话,一次性至少读取1页数据到内存中或者将1页数据写入磁盘。通过减少内存与磁盘的交互次数,从而提升性能。
其实,这本质上就是一种典型的缓存设计思想,一般缓存的设计基本都是从时间维度或者空间维度进行考量的:
时间维度: 如果一条数据正在被使用,那么在接下来一段时间内大概率还会再被使用。可以认为热点数据缓存都属于这种思路的实现。
空间维度: 如果一条数据正在被使用,那么存储在它附近的数据大概率也会很快被使用。InnoDB的数据页和操作系统的页缓存则是这种思路的体现。
InnoDB的逻辑存储结构
表空间
表空间是InnoDB
存储引擎逻辑结构的最高层, 如果用户启用了参数innodb_file_per_table
(在8.0版本中默认开启) ,则每张表都会有一个表空间(xxx.ibd
),一个mysq
l实例可以对应多个表空间,用于存储记录、索引等数据。在默认情况下,InnoDB存储引擎都有一个共享表空间ibdata1
,即所有数据都存放在这个表空间内。如回滚信息、插入缓冲索引页、系统事务信息等还是存放在原来的共享表空间内。
段
分为数据段(Leaf node segment)、索引段(Non-leaf node segment)、回滚段(Rollback segment),InnoDB是索引组织表,数据段就是B+树的叶子节点, 索引段即为B+树的非叶子节点。段用来管理多个Extent(区)。
区
表空间的单元结构,每个区的大小为1M。 默认情况下, InnoDB存储引擎页大小为16K, 即一个区中一共有64个连续的页。为了保证区中页的连续性,InonoDB存储引擎一次从磁盘申请4-5个区。
页
常见的页类型有:
- 数据页(B-tree Node)
- undo页(undo Log Page)
- 系统页(System Page)
- 事务数据页(Transaction system Page)
- 插入缓冲位图页(Insert Buffer Bitmap)
- 插入缓冲空闲列表页(Insert Buffer Free List)
- 未压缩的二进制大对象页(Uncompressed BLOB Page)
- 压缩的二进制大对象页(compressed BLOB Page)
记录是按照行来存储的,但是数据库的读取并不以「行」为单位,否则一次读取(也就是一次 I/O 操作)只能处理一行数据,效率会非常低。因此,InnoDB
的数据是按Page
为单位来读写的。也就是说,当需要读一条记录的时候,并不是将这个记录本身从磁盘读出来,而是以页为单位,将其整体读入内存。意味着数据库每次读写都是以16K
为单位的,一次最少从磁盘中读取16K
的内容到内存中,一次最少把内存中的16K
内容刷新到磁盘中。
数据页包括七个部分,结构如下图:
名称 | 中文名 | 占用空间大小 | 简单描述 |
---|---|---|---|
File Header | 文件头部 | 38字节 | 页的一些通用信息,如:该页的偏移量、上下页相对位置、LSN等 |
Page Header | 页面头部 | 56字节 | 数据页专有的一些信息,页的状态信息,如:页在索引中的位置、最大事务ID、slots数等 |
Infimum + Supremum | 最小记录和最大记录 | 26字节 | 两个虚拟的行记录 1. 虚拟的行,用来限定User Records的边界; 2. Infimum记录比该页中任何主键值都要小的值;Supremum记录比该页中任何主键值都要大的值; 3. 虚拟记录,在页被创建时被建立,任何情况下无法被删除。 |
User Records | 用户记录 | 不确定 | 实际存储的行记录内容 |
Free Space | 空闲空间 | 不确定 | 1. 空闲空间,用链表数据结构; 2. 当一个记录被删除后,则该空间会被加入到空闲链表。 |
Page Directory | 页面目录 | 不确定 | 页中的某些记录的相对位置 1. 存放记录的相对位置(页中的相对位置,不是偏移量); 2. 有些记录指针称为槽(Slots) 或目录槽(Directory Slots) ; 3. 稀疏目录(sparse directory) :一个槽中可能包含多个记录; 4. Slots 中的记录按索引键值顺序存放,二叉查找法找到记录指针。 |
File Trailer | 文件尾部 | 8字节 | 校验页是否完整 1. 检测页是否完整的写入磁盘(如:磁盘损坏、宕机等); 2. 默认情况下,InnoDB引擎每次从磁盘读取页时,就会检测该页的完整性,由innodb_checksums 参数,默认开启ON; 3.innodb_checksum_algorithm 控制测试checksum函数的算法,默认crc32。 |
在File Header中有两个指针,分别指向上一个数据页和下一个数据页,连接起来的页相当于一个双向的链表,如下图所示:
💡 采用链表的结构是让数据页之间不需要是物理上的连续的,而是逻辑上的连续。
记录在页中的存储
用户存储的数据会按照对应的行格式存在User Records
中。实际上,新生成的页是没有User Records
的,只有当我们第一次插入数据时,才会从Free Space
划一个记录大小的空间给User Records
。当Free Space
用完之后,就意味着当前的数据页也使用完了。
File Header(文件头部)
File Header
是用来描述各种页都适用的一些通用信息的,由以下内容组成:
名称 | 占用空间大小 | 描述 |
---|---|---|
FIL_PAGE_SPACE_OR_CHKSUM | 4字节 | 页的校验和(checksum值),Mysql4.0.14之前是0,之后的版本是页的checksum值 |
FIL_PAGE_OFFSET | 4字节 | 表空间中页的偏移值(页号)。比如某个独立表空间a.idb的大小是1GB,页的大小是16K,那么总共就有65536个页,该值表示该页在所有页中的位置。如果这个表空间的ID为1,那么搜索页(1,3)就表示查找表空间a中的第四页 |
FIL_PAGE_PREV | 4字节 | 当前页上一个页的页号,B+Tree的特性决定了叶子节点是双向链表 |
FIL_PAGE_NEXT | 4字节 | 当前页下一个页的页号,B+Tree的特性决定了叶子节点是双向链表 |
FIL_PAGE_LSN | 8字节 | 页被最后修改的日志序列位置(Log Sequence Number) |
FIL_PAGE_TYPE | 2字节 | 页的类型 |
FIL_PAGE_FILE_FLUSH_LSN | 8字节 | 仅在系统表空间的一个页中定义,代表文件至少被刷新到了对应的LSN值。对于独立表空间该值是0 |
FIL_PAGE_ARCH_LOG_NO_OR_SPACE_ID | 4字节 | 页属于哪个表空间 |
FIL_PAGE_SPACE_OR_CHKSUM
当前页面的校验和(checksum)。对于一个很长的字节串来说,我们可以通过某种算法来计算一个比较短的值来代表这个很长的字节串,这个比较短的值就称为校验和。通过校验和可以大幅度提升字符串等值比较的效率。
FIL_PAGE_OFFSET
每一个页都有一个唯一的页偏移值(页号),InnoDB通过页偏移值(页号)来可以定位一个页所在的位置。
FIL_PAGE_PREV 和 FIL_PAGE_NEXT
表示本页的上一个和下一个页的页号,各个页通过FIL_PAGE_PREV
和FIL_PAGE_NEXT
形成双向链表。
FIL_PAGE_TYPE
代表当前页的类型,InnoDB
为了不同的目的而把页分为不同的类型。
类型名称 | 十六进制 | 描述 |
---|---|---|
FIL_PAGE_TYPE_ALLOCATED | 0x0000 | 最新分配,还没使用 |
FIL_PAGE_UNDO_LOG | 0x0002 | Undo日志页 |
FIL_PAGE_INODE | 0x0003 | 索引节点 |
FIL_PAGE_IBUF_FREE_LIST | 0x0004 | Insert Buffer空闲列表 |
FIL_PAGE_IBUF_BITMAP | 0x0005 | Insert Buffer位图 |
FIL_PAGE_TYPE_SYS | 0x0006 | 系统页 |
FIL_PAGE_TYPE_TRX_SYS | 0x0007 | 事务系统数据 |
FIL_PAGE_TYPE_FSP_HDR | 0x0008 | File Space头部信息 |
FIL_PAGE_TYPE_XDES | 0x0009 | 扩展描述页 |
FIL_PAGE_TYPE_BLOB | 0x000A | Blob页 |
FIL_PAGE_INDEX | 0x45BF | 索引页,也就是我们所说的数据页 |
Page Header(页面头部)
专门用来存储数据页相关的状态信息。固定占用56个字节,各部分字节属性含义如下:
名称 | 占用空间大小 | 描述 |
---|---|---|
PAGE_N_DIR_SLOTS | 2字节 | 在页目录中的槽数量 |
PAGE_HEAP_TOP | 2字节 | 还未使用的空间最小地址,也就是说从该地址之后就是Free Space |
PAGE_N_HEAP | 2字节 | 本页中的记录的数量(包括最小和最大记录以及标记为删除的记录) |
PAGE_FREE | 2字节 | 第一个已经标记为删除的记录地址(各个已删除的记录通过next_record也会组成一个单链表,这个单链表中的记录可以被重新利用) |
PAGE_GARBAGE | 2字节 | 已删除记录占用的字节数 |
PAGE_LAST_INSERT | 2字节 | 最后插入记录的位置 |
PAGE_DIRECTION | 2字节 | 最后一条记录插入的方向 |
PAGE_N_DIRECTION | 2字节 | 一个方向连续插入的记录数量,如果最后一条记录的插入方向改变了的话,这个状态的值会被清零重新统计。 |
PAGE_N_RECS | 2字节 | 该页中记录的数量(不包括最小和最大记录以及被标记为删除的记录) |
PAGE_MAX_TRX_ID | 8字节 | 修改当前页的最大事务ID,该值仅在二级索引中定义 |
PAGE_LEVEL | 2字节 | 当前页在B+树中所处的层级 |
PAGE_INDEX_ID | 8字节 | 索引ID,表示当前页属于哪个索引 |
PAGE_BTR_SEG_LEAF | 10字节 | B+树叶子段的头部信息,仅在B+树的Root页定义 |
PAGE_BTR_SEG_TOP | 10字节 | B+树非叶子段的头部信息,仅在B+树的Root页定义 |
Infimum + Supremum
InnoDB
引擎中,每个数据页都有虚拟的行记录,用来限定User Records
(行记录)的边界。
Infimum
记录比该页中任何主键值都要小的值;Supremum记录比该页中任何主键值都要大的值。
注意:这两个虚拟记录,在页被创建时被建立,任何情况下无法被删除。
User Records
数据页中的记录按照「主键」顺序组成单向链表,单向链表的特点就是插入、删除非常方便,但是检索效率不高,最差的情况下需要遍历链表上的所有节点才能完成检索。因此,数据页中有一个页目录,起到记录的索引作用。就好比一本书,书中内容的每个章节都会有一个目录,想看某个章节的时候,可以直接查看目录,快速找到对应的章节的所在页数,而数据页中的页目录就是为了能快速找到记录。
页目录与记录的关系如下图:
那么InnoDB
是如何给记录创建页目录的呢:
- 将所有的记录划分成几个组,这些记录包括最小记录和最大记录,但不包括标记为 “已删除” 的记录
每个记录组的最后一条记录就是组内最大的那条记录,并且最后一条记录的头信息中会存储该组一共有多少条记录,作为n_owned
字段 - 页目录用来存储每组最后一条记录的地址偏移量,这些地址偏移量会按照先后顺序存储起来,每组的地址偏移量也被称之为槽(slot),每个槽相当于指针指向了不同组的最后一个记录。
从上图可以看到,页目录就是由多个槽组成的,槽相当于分组记录的索引。因为记录是按照「主键值」从小到大排序的,所以我们通过槽查找记录时,可以使用二分法快速定位要查询的记录在哪个槽(哪个记录分组),定位到槽后,再遍历槽内的所有记录,找到对应的记录,无需从最小记录开始遍历整个页中的记录链表。
理解记录头信息
先简单介绍一下记录头信息各属性描述:
名称 | 大小(单位: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 | 表示下一条记录的相对位置 |
接下来以page_demo
表为例,通过实际操作来详细介绍记录头信息。
mysql> create table page_demo (p1 int , p2 int , p3 varchar(100), primary key(p1));
Query OK, 0 rows affected (0.10 sec)
mysql> insert into page_demo values(1, 100, "p1") ,(2, 200, "p2"),(3, 300, "p3");
Query OK, 3 rows affected (0.06 sec)
Records: 3 Duplicates: 0 Warnings: 0
这3条记录在InnoDB
中的行格式如下(只展示记录头和真实数据),列中数据均用十进制表示:
我们对照着这个图来重点介绍几个属性的详细信息:
delete_mask
:标记着当前记录是否被删除,0表示未删除,1表示删除。被删除的记录不会立即从磁盘上移除,而是先打上删除标记,所有被删除的记录会组成一个垃圾链表。之后新插入的记录可能会重用垃圾链表占用的空间,因此垃圾链表占用的存储空间也被成为可重用空间。heap_no
:表示当前记录在本页中的位置,比如上边3条记录在本页中的位置分别是2、3、4。实际上,InnoDB
会自动为每页加上两条虚拟记录,一条是最小记录,另一条是最大记录。这两条记录的构造十分简单,都是由5字节大小的记录头信息和8字节大小的固定部分(其实内容就是infimum或者supremum)组成的。这两条记录被单独放在Infimum + Supremum
的部分。从图中我们可以看出来,最小记录和最大记录的heap_no值分别是0和1,也就是说它们的位置最靠前。
next_record
:表示从当前记录的真实数据到下一条记录的真实数据的地址偏移量。可以简单理解为是一个单向链表,最小记录的下一个是第一条记录,最后一条记录的下一个是最大记录。
从图中可以看出来,用户记录实际上按照主键大小正序排序行成一个单向链表。如果这时候我们删除第二条记录,那么这个链表也是会跟着变化的:
我们发现第二条记录并没有从存储空间中移除,而是把该条记录的delete_mask
值设置为1。next_record
值变为了0,意味着该记录没有下一条记录了。第一条记录的next_record
指向了第三条记录。
Free Space(空闲空间)
指空闲的空间,也是链表数据结构。当一个记录被删除后,则该空间会被加入到空闲链表。
Page Directory(页目录)
我们已经知道,记录在页中按照主键大小正序串联成了一个单链表。那我们要根据主键查找具体的某条记录该怎么办呢?简单的方式就是根据链表遍历,但是在数据量比较大的情况下,这种方式显然效率太差了。因此mysql
使用了Page Directory
来解决这个问题。其大致的原理如下:
- 将所有正常的记录(包括最大和最小记录,不包括标记为已删除的记录)划分为几个组。怎么划分先不关注。
- 每个组的最后一条记录(也就是组内最大的那条记录)的头信息中的
n_owned
属性表示该组内共有几条记录。 - 将每个组的最后一条记录的地址偏移量单独提取出来按顺序存储到靠近页尾部的地方,这个地方就是所谓的
Page Directory
。
mysql规定对于最小记录所在的分组只能有 1 条记录,最大记录所在的分组拥有的记录条数只能在 1-8 条之间,剩下的分组中记录的条数范围只能在是 4-8 条之间。比如现在page_demo
表中正常的记录共有14条,InnoDB
会把它们分成5组,第一组中只有一个最小记录,如下所示:
通过Page Directory
在一个数据页中查找指定主键值的记录的过程分为两步:
- 通过二分法确定该记录所在的槽,并找到该槽所在分组中主键值最小的那条记录。
- 通过记录的
next_record
属性遍历该槽所在的组中的各个记录。
💡 对于链表的查询性能优化,思想上基本上都是通过二分法实现的。上面介绍的Page Directory,跳跃表和查找树都是如此。
File Trailer
Mysql中内存和磁盘的基本交互单位是页。如果内存中页被修改了,那么某个时刻一定会将内存页同步到磁盘中。如果在同步的过程中,系统出现问题,就可能导致磁盘中的页数据没能完全同步,也就是发生了脏页的情况。为了避免发生这种问题,mysql在每个页的尾部加上了File Trailer来校验页的完整性。由8个字节组成:
- 前4个字节代表页的校验和,这个部分是和
File Header
中的校验和相对应的。简单理解,就是File Header
和File Trailer
都有校验和,如果两者一致则表示数据页是完整的。否则,则表示数据页是脏页。 - 后4个字节代表页面被最后修改时对应的日志序列位置(LSN)。
行
InnoDB
存储引擎数据是按行进行存放的。在行中,默认有两个隐藏字段:
Trx_id
:每次对某条记录进行改动时,都会把对应的事务id赋值给trx_id
隐藏列。Roll_pointer
:每次对某条记录进行改动时,都会把旧的版本写入到undo
日志中,然后这个隐藏列就相当于一个指针,可以通过它来找到该记录修改前的信息。
Mysql
是以记录为单位向数据表中插入数据的,这些记录在磁盘上的存放方式称为行格式。Mysql
支持4种不同类型的行格式:Redundant
(旧格式)、Compact
、Dynamic
、Compressed
。
-
REDUNDANT
(冗余),被淘汰了。表会将变长列值的前768字节存储在B树节点的索引记录中,其余的存储在溢出页上。对于大于等于786字节的固定长度字段InnoDB
会转换为变长字段,以便能够在页外存储。 -
COMPACT
(紧凑),经常会使用。与REDUNDANT
行格式相比,COMPACT
行格式减少了约20%的行存储空间,但代价是增加了某些操作的CPU使用量。如果系统负载是受缓存命中率和磁盘速度限制,那么COMPACT
格式可能更快。如果系统负载受到CPU速度的限制,那么COMPACT格式可能会慢一些。 -
DYNAMIC
(动态),与COMPACT类似,可以进行数据压缩,默认使用DYNAMIC
行格式。InnoDB
会将表中可变长度的列值完全存储在页外,而索引记录只包含指向溢出页的20字节指针。大于或等于768字节的固定长度字段编码为可变长度字段。DYNAMIC
行格式支持大索引前缀,最多可以为3072字节,可通过innodb_large_prefix
参数控制。 -
COMPRESSED
(压缩),算法不同,更加节省空间。COMPRESSED
行格式提供与DYNAMIC
行格式相同的存储特性和功能,但增加了对表和索引数据压缩的支持。
行格式 | 紧凑存储特性 | 变长列增强存储 | 大索引键前缀支持 | 压缩支持 | 支持的表空间类型 |
---|---|---|---|---|---|
REDUNDANT | 否 | 否 | 否 | 否 | system, file-per-table, general |
COMPACT | 是 | 否 | 否 | 否 | system, file-per-table, general |
DYNAMIC | 是 | 是 | 是 | 否 | system, file-per-table, general |
COMPRESSED | 是 | 是 | 是 | 是 | file-per-table, general |
查看默认行格式:
mysql> SELECT @@innodb_default_row_format;
+-----------------------------+
| @@innodb_default_row_format |
+-----------------------------+
| dynamic |
+-----------------------------+
1 row in set (0.04 sec)
mysql> show variables like '%innodb_default_row_format%';
+---------------------------+---------+
| Variable_name | Value |
+---------------------------+---------+
| innodb_default_row_format | dynamic |
+---------------------------+---------+
1 row in set (0.04 sec)
同时我们也可以在创建或修改表语句时指定行格式:
CREATE TABLE 表名 (列的信息) ROW_FORMAT=行格式名称
ALTER TABLE 表名 ROW_FORMAT=行格式名称
mysql> CREATE TABLE row_format_demo (r1 VARCHAR(10), r2 VARCHAR(10) NOT NULL, r3 CHAR(10), r4 VARCHAR(10)) ROW_FORMAT=COMPACT;
Query OK, 0 rows affected (0.03 sec)
mysql> SELECT * FROM row_format_demo;
+------+-----+------+------+
| r1 | r2 | r3 | r4 |
+------+-----+------+------+
| aa | bbb | cccc |ddddd |
| aa2 | bbb2| cccc2| NULL |
+------+-----+------+------+
2 rows in set (0.00 sec)
Compact
一条完整的记录包含记录的额外信息和记录的真实数据两大部分。
记录的额外信息
变长字段长度列表
Mysql
中支持一些变长数据类型(VARCHAR(N)
、TEXT
等),它们存储数据占用的存储空间不是固定的,而是会随着存储内容的变化而变化。其长度为∶
- 若列的长度小于255字节,用1字节表示每列;
- 若大于255个字节,用2字节表示没列。
变长字段的长度最大不可以超过2字节,因为MySQL数据库中VARCHAR
类型的最大长度限制为65535字节。这种变长字段占用的存储空间要同时包含:
- 真正的数据内容
- 占用的字节数
在Compact
行格式中,把所有变长字段的真实数据占用的字节长度都存放在记录的开头部位,从而形成一个变长字段长度列表,各变长字段数据占用的字节数按照列的顺序逆序存放。
列名 | 存储内容 | 内容长度(十进制) | 内容长度(十六进制) |
---|---|---|---|
r1 | aa | 2 | 0x02 |
r2 | bbb | 3 | 0x03 |
r4 | ddddd | 5 | 0x05 |
我们以row_format_demo
第一行数据为例。r1
、r2
和r4
都是变成数据类型VARCHAR(10)
,因此要将这3列值得长度保存在记录的开头处。
需要注意的是,变长字段长度列表中只存储值为非NULL的列内容占用的长度,值为NULL的列的长度是不储存的。也就是说对于第二条记录来说,因为r4列的值为NULL,所以第二条记录的变长字段长度列表只需要存储r1和r2列的长度即可。
NULL值列表
对于可为NULL的列,为了节约存储空间,Mysql
不会将NULL
值保存在记录的真实数据部分。而是会将其保存在记录的额外信息里面的NULL值列表中。
具体的做法是先统计表中允许存储NULL值的列,然后将每个允许存储NULL值的列对应一个二进制位(1:值为NULL,0:值不为NULL)用来表示是否存储NULL值,并按照逆序排列。MySQL规定NULL值列表必须用整数个字节的位表示,如果使用的二进制位个数不是整数个字节,则在字节的高位补0。
对应row_format_demo
表中,r1、r3、r4都是允许存储NULL值的。前两条记录在填充了NULL值列表后的示意图就是这样:
记录头信息
记录头信息是由固定的5个字节(40位)组成, 不同的位代表不同的含义。这里我们不再赘述,前面页中User Records部分已经讲过。
记录的真实数据
记录的真实数据除了包含各列具体的数据外,还会自动添加一些隐藏列数据。
列名 | 是否必须 | 占用空间 | 描述 |
---|---|---|---|
DB_ROW_ID | 否 | 6字节 | 行ID,唯一标识一条记录 |
DB_TRX_ID | 是 | 6字节 | 事务ID |
DB_ROLL_PTR | 是 | 7字节 | 回滚指针 |
只有当数据库没有定义主键或者唯一键时,隐藏列row_id
才会存在,并且将其作为数据表主键。
因为表row_format_demo
并没有定义主键,所以MySQL会为每条记录增加上述的3个列。现在看一下加上记录的真实数据的两个记录的数据结构:
CHAR(N)列的存储格式
对于CHAR(N)
类型的列来说,当列采用的是定长字符集时,该列占用的字节数不会被加到变长字段长度列表,而如果采用变长字符集时,该列占用的字节数也会被加到变长字段长度列表。另外有一点还需要注意,变长字符集的CHAR(N)
类型的列要求至少占用N个字节,而VARCHAR(N)
却没有这个要求。比方说对于使用utf8字符集的CHAR(10)
的列来说,该列存储的数据字节长度的范围是10~30个字节,即使我们向该列中存储一个空字符串也会占用10个字节。
行溢出数据
即VARCHAR(N)
最多能存储的数据。Mysql对一条记录占用的最大存储空间是有限制的,除了BLOB
或者TEXT
类型的列之外,其他所有的列(不包括隐藏列和记录头信息)占用的字节长度加起来不能超过65535个字节。可以不严谨的认为,Mysql
一行记录占用的存储空间不能超过65535个字节。这个65535个字节除了列本身的数据之外,还包括一些其他的数据。比如说我们为了存储一个VARCHAR(N)
类型的列,其实需要占用3部分存储空间:
- 真实数据
- 真实数据占用字节的长度
NULL
值标识,如果该列有NOT NULL
属性则可以没有这部分存储空间
假如表row_format_demo
中只有r1字段,该字段最大占用的65532个字节。因为真实数据的长度可能占用2个字节,NULL
值标识需要占用1个字节。如果该VARCHAR类型的列没有NOT NULL
属性,那最多只能存储65532个字节的数据。如果该列是ascii字符集,对应的最大字符数最大为65532;如果是utf8字符集,则对应的最大字符数为21844。
记录中的数据太多产生的溢出
mysql> CREATE TABLE varchar_size_demo (v VARCHAR(65532) ) CHARSET=ascii ROW_FORMAT=Compact;
Query OK, 0 rows affected (0.01 sec)
mysql> INSERT INTO varchar_size_demo(c) VALUES(REPEAT('a', 65532));
Query OK, 1 row affected (0.00 sec)
Mysql中磁盘与内存交互的基本单位是页,一般为16KB,16384个字节,而一行记录最大可以占用65535个字节,这就造成了一页存不下一行数据的情况。在Compact和Redundant行格式中,对于占用存储空间非常大的列,在记录的真实数据处只会存储该列的一部分数据,把剩余的数据分散存储在几个其他的页中,然后记录的真实数据处用20个字节存储指向这些页的地址,从而可以找到剩余数据所在的页,如图所示:
这种在记录的真实数据处只会存储该列的前768个字节的数据和一个指向其他页的地址,然后把剩下的数据存放到其他页中的情况就叫做行溢出
,存储超出768字节的那些页面也被称为溢出页。
行溢出的临界点
MySQL中规定一个页中至少存放两行记录。表varchar_size_demo
只有一个列v,我们往这个表中插入两条记录,每条记录最少插入多少字节的数据才会行溢出的现象呢?这得分析一下页中的空间都是如何利用的。
- 每个页除了存放我们的记录以外,也需要存储一些额外的信息,大概132个字节。
- 每个记录需要的额外信息是27字节。
假设一个列中存储的数据字节数为n,如要要保证该列不发生溢出,则需要满足:
132 + 2×(27 + n) < 16384
结果是n < 8099
。也就是说如果一个列中存储的数据小于8099个字节,那么该列就不会成为溢出列。如果表中有多个列,那么这个值更小。
Dynamic和Compressed行格式
Mysql
中默认的行格式就是Dynamic
。Dynamic
和Compressed
行格式和Compact
行格式很像,只是在处理行溢出数据上有差异。Dynamic
和Compressed
行格式不会在记录的真实数据出存放前768个字节,而是将所有字节都存储在其它页面中。Compressed
行格式会采用压缩算法对页面进行压缩,以节省空间,在数据页只存储一个指向溢出页的地址,所有的实际数据都存放在溢出页中。