多应用+插件架构,代码干净,二开方便,首家独创一键云编译技术,文档视频完善,免费商用码云13.8K 广告
## 疑问 数据存储在硬盘,InnoDB如何高效读写? 页 + 行格式 数据存储在硬盘如何高效的利用空间? 行格式 ## InnoDB页简介 InnoDB需要把数据存储到硬盘(持久性),但是真正处理数据的过程是发生在内存中的(内存速度快)。所以需要把磁盘中的数据加载到内存中,如果是处理写入或修改请求的话,还需要把内存中的内容刷新到磁盘上。磁盘的速度非常慢,和内存读写差了几个数量级,所以当我们想从表中获取某些记录时,`InnoDB`存储引擎不能一条一条的把去磁盘读取记录,需要更高效的方式。 `InnoDB`采取的方式是:将数据分为若干页,以 **页** 作为磁盘和内存之间交互的最小单位。InnoDB中页的大小一般为 16 KB。也就是一次最少从磁盘中读取16KB的内容到内存中,一次最少把内存中的16KB内容刷新到磁盘中。 ## InnoDB行格式 我们平时是以记录为单位来向表中插入数据的,这些记录在磁盘上的存放方式也被称为`行格式`或者`记录格式`。设计`InnoDB`存储引擎的大叔们到现在为止设计了4种不同类型的`行格式`,分别是`Compact`、`Redundant`、`Dynamic`和`Compressed`行格式,随着时间的推移,他们可能会设计出更多的行格式,但是不管怎么变,在原理上大体都是相同的。 Compact 行格式是MySQL5.1后的默认格式。但在 MySQL 5.7.9 及以后版本,默认行格式是innodb_default_row_format变量决定,**默认值是 Dynamic** ### 指定行格式的语法 我们可以在创建或修改表的语句中指定`行格式`: ``` CREATE TABLE 表名 (列的信息) ROW_FORMAT=行格式名称 ALTER TABLE 表名 ROW_FORMAT=行格式名称 ``` ### COMPACT行格式 ![](https://img.kancloud.cn/b3/b2/b3b24fdcb365c4ecef531bf79f70d0d2_783x250.png) 一条完整的记录其实可以被分为记录的额外信息和记录的真实数据两大部分 #### 记录的额外信息 这部分信息是服务器为了描述这条记录而不得不额外添加的一些信息,这些额外信息分为3类,分别是**变长字段长度列表、NULL值列表和记录头信息**。 ##### 变长字段长度列表 变长字段占用的存储空间分为两部分: 1. 真正的数据内容 2. 占用的字节数 在`Compact`行格式中,所有的变长字段数据占用的字节长度都放在记录的开头部位, 形成一个变长字段长度列表, 各变长字段数据占用的字节数按照列的顺序**逆序存放**。变长字段长度列表中只存储值为 非NULL 的列内容占用的长度,值为 NULL 的列的长度是不储存的 。 对于***CHAR(M)***类型的列来说,当列采用的是定长字符集时,该列占用的字节数不会被加到变长字段长度列表,而如果采用变长字符集时,该列占用的字节数也会被加到变长字段长度列表。 ### 行溢出数据 #### VARCHAR(M)最多能存储的数据 `VARCHAR(M)`类型的列最多可以占用`65535`个字节。其中的`M`代表该类型最多存储的字符数量,`MySQL`对一条记录占用的最大存储空间是有限制的,除了`BLOB`或者`TEXT`类型的列之外,**其他所有的列(不包括隐藏列和记录头信息)占用的字节长度加起来不能超过`65535`个字节。** 存储一个`VARCHAR(M)`类型的列,其实需要占用3部分存储空间: * 真实数据 * 真实数据占用字节的长度 * `NULL`值标识,如果该列有`NOT NULL`属性则可以没有这部分存储空间 如果该`VARCHAR`类型的列没有`NOT NULL`属性,那最多只能存储`65532`个字节的数据, 有则存储`65533`个字节的数据。 utf8字符集表示一个字符最多需要3个字节,那在该字符集下,M的最大取值就是21844(也就是:65532/3)个字符。 `MySQL`是以`页`为基本单位来管理存储空间的,页能存储16kb数据,也就是`16384`字节,而一个`VARCHAR(M)`类型的列就最多可以存储`65532`个字节,这样就可能造成一个页存放不了一条记录的尴尬情况。这种就会在`记录的真实数据`处只会存储该列的一部分数据,把剩余的数据分散存储在几个其他的页中,然后`记录的真实数据`处用20个字节存储指向这些页的地址。 ### Dynamic和Compressed行格式 如果某一列中的数据非常多的话,在本记录的真实数据处只会存储该列的前`768`个字节的数据和一个指向其他页的地址,然后把剩下的数据存放到其他页中,这个过程也叫做`行溢出`,存储超出`768`字节的那些页面也被称为`溢出页`。 `Dynamic`和`Compressed`行格式,我现在使用的`MySQL`版本是`5.7`,它的默认行格式就是`Dynamic`,这俩行格式和`Compact`行格式挺像,只不过在处理`行溢出`数据时有点儿分歧,它们不会在记录的真实数据处存储字段真实数据的前`768`个字节,而是把所有的字节都存储到其他页面中,只在记录的真实数据处存储其他页面的地址 `Compressed`行格式和`Dynamic`不同的一点是,`Compressed`行格式会采用压缩算法对页面进行压缩,以节省空间。 ## InnoDB数据页结构 | 名称 | 中文名 | 占用空间大小 | 简单描述 | | :-: | :-: | :-: | :-: | | `File Header` | 文件头部 | `38`字节 | 页的一些通用信息 | | `Page Header` | 页面头部 | `56`字节 | 数据页专有的一些信息 | | `Infimum + Supremum` | 最小记录和最大记录 | `26`字节 | 两个虚拟的行记录 | | `User Records` | 用户记录 | 不确定 | 实际存储的行记录内容 | | `Free Space` | 空闲空间 | 不确定 | 页中尚未使用的空间 | | `Page Directory` | 页面目录 | 不确定 | 页中的某些记录的相对位置 | | `File Trailer` | 文件尾部 | `8`字节 | 校验页是否完整 | ## 记录在页中的存储 一开始生成页的时候,其实并没有`User Records`这个部分,每当我们插入一条记录,都会从`Free Space`部分,也就是尚未使用的存储空间中申请一个记录大小的空间划分到`User Records`部分,当`Free Space`部分的空间全部被`User Records`部分替代掉之后,也就意味着这个页使用完了,如果还有新的记录插入的话,就需要去申请新的页了。 ### 记录头信息 | 名称 | 大小(单位: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` | 表示下一条记录的相对位置 | * delete_mask 这个属性标记着当前记录是否被删除,占用1个二进制位,值为`0`的时候代表记录并没有被删除,为`1`的时候代表记录被删除掉了。这些被删除的记录之所以不立即从磁盘上移除,是因为移除它们之后把其他的记录在磁盘上重新排列需要性能消耗,所以只是打一个删除标记而已,所有被删除掉的记录都会组成一个所谓的垃圾链表,在这个链表中的记录占用的空间称之为所谓的可重用空间,之后如果有新记录插入到表中的话,可能把这些被删除的记录占用的存储空间覆盖掉。 * min_rec_mask B+树的每层非叶子节点中的最小记录都会添加该标记 * n_owned 这个分组中的记录数 * heap_no 这个属性表示当前记录在本页中的位置,值从 2 开始, 0和1分别被自动插入的最小记录和最大记录占用了,也就是 Infimum + Supremum 的部分 * record_type 当前记录的类型,一共有4种类型的记录,`0`表示普通记录,`1`表示B+树非叶节点记录,`2`表示最小记录,`3`表示最大记录。 * next_record 非常重要,它表示从当前记录的真实数据到下一条记录的真实数据的地址偏移量 ## Page Directory(页目录) ![](https://img.kancloud.cn/37/93/3793d641bb414997bc18fafe604aba28_1011x540.png) 从这个图中我们需要注意这么几点: * 现在`页目录`部分中有两个槽,也就意味着我们的记录被分成了两个组,`槽1`中的值是`112`,代表最大记录的地址偏移量(就是从页面的0字节开始数,数112个字节);`槽0`中的值是`99`,代表最小记录的地址偏移量。 * 注意最小和最大记录的头信息中的`n_owned`属性 * 最小记录的`n_owned`值为`1`,这就代表着以最小记录结尾的这个分组中只有`1`条记录,也就是最小记录本身。 * 最大记录的`n_owned`值为`5`,这就代表着以最大记录结尾的这个分组中只有`5`条记录,包括最大记录本身还有我们自己插入的`4`条记录。 每个分组中的记录条数是有规定的:对于最小记录所在的分组只能有***1***条记录,最大记录所在的分组拥有的记录条数只能在***1~8***条之间,剩下的分组中记录的条数范围只能在是***4~8***条之间。 ## Page Header(页面头部) 比如本页中已经存储了多少条记录,第一条记录的地址是什么,页目录中存储了多少个槽等等 | 名称 | 占用空间大小 | 描述 | | :-: | :-: | :-: | | `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页定义 | * `PAGE_DIRECTION` 假如新插入的一条记录的主键值比上一条记录的主键值大,我们说这条记录的插入方向是右边,反之则是左边。用来表示最后一条记录插入方向的状态就是`PAGE_DIRECTION`。 * `PAGE_N_DIRECTION` 假设连续几次插入新记录的方向都是一致的,`InnoDB`会把沿着同一个方向插入记录的条数记下来,这个条数就用`PAGE_N_DIRECTION`这个状态表示。当然,如果最后一条记录的插入方向改变了的话,这个状态的值会被清零重新统计。 ## File Header(文件头部) `File Header`针对各种类型的页都通用,也就是说不同类型的页都会以`File Header`作为第一个组成部分,它描述了一些针对各种页都通用的一些信息,比方说这个页的编号是多少,它的上一个页、下一个页是谁 | 名称 | 占用空间大小 | 描述 | | :-: | :-: | :-: | | `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` | `4`字节 | 页属于哪个表空间 | * `FIL_PAGE_SPACE_OR_CHKSUM` 这个代表当前页面的校验和(checksum)。啥是个校验和?就是对于一个很长很长的字节串来说,我们会通过某种算法来计算一个比较短的值来代表这个很长的字节串,这个比较短的值就称为`校验和`。这样在比较两个很长的字节串之前先比较这两个长字节串的校验和,如果校验和都不一样两个长字节串肯定是不同的,所以省去了直接比较两个比较长的字节串的时间损耗。 * `FIL_PAGE_OFFSET` 每一个`页`都有一个单独的页号,就跟你的身份证号码一样,`InnoDB`通过页号来可以唯一定位一个`页`。 * `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 | 表空间头部信息 | | `FIL_PAGE_TYPE_XDES` | 0x0009 | 扩展描述页 | | `FIL_PAGE_TYPE_BLOB` | 0x000A | 溢出页 | | `FIL_PAGE_INDEX` | 0x45BF | 索引页,也就是我们所说的`数据页` | 我们存放记录的数据页的类型其实是`FIL_PAGE_INDEX`,也就是所谓的`索引页`。 * `FIL_PAGE_PREV`和`FIL_PAGE_NEXT` `InnoDB`可能不可以一次性为这么多数据分配一个非常大的存储空间,如果分散到多个不连续的页中存储的话需要把这些页关联起来,`FIL_PAGE_PREV`和`FIL_PAGE_NEXT`就分别代表本页的上一个和下一个页的页号。这样通过建立一个双向链表把许许多多的页就都串联起来了,而无需这些页在物理上真正连着。 ## File Trailer 每个页的尾部都加了一个`File Trailer`部分,这个部分由`8`个字节组成,校验页是否完整 * 前4个字节代表页的校验和 这个部分是和`File Header`中的校验和相对应的。每当一个页面在内存中修改了,在同步之前就要把它的校验和算出来,因为`File Header`在页面的前边,所以校验和会被首先同步到磁盘,当完全写完时,校验和也会被写到页的尾部,如果完全同步成功,则页的首部和尾部的校验和应该是一致的。二者不同则意味着同步中间出了错。 * 后4个字节代表页面被最后修改时对应的日志序列位置(LSN) 这个`File Trailer`与`File Header`类似,都是所有类型的页通用的。 ## 总结 1. InnoDB为了不同的目的而设计了不同类型的页,我们把用于存放记录的页叫做`数据页`。 2. 一个数据页可以被大致划分为7个部分,分别是 * `File Header`,表示页的一些通用信息,占固定的38字节。 * `Page Header`,表示数据页专有的一些信息,占固定的56个字节。 * `Infimum + Supremum`,两个虚拟的伪记录,分别表示页中的最小和最大记录,占固定的`26`个字节。 * `User Records`:真实存储我们插入的记录的部分,大小不固定。 * `Free Space`:页中尚未使用的部分,大小不确定。 * `Page Directory`:页中的某些记录相对位置,也就是各个槽在页面中的地址偏移量,大小不固定,插入的记录越多,这个部分占用的空间越多。 * `File Trailer`:用于检验页是否完整的部分,占用固定的8个字节。 3. 每个记录的头信息中都有一个`next_record`属性,从而使页中的所有记录串联成一个`单链表`。 4. `InnoDB`会把页中的记录划分为若干个组,每个组的最后一个记录的地址偏移量作为一个`槽`,存放在`Page Directory`中,所以在一个页中根据主键查找记录是非常快的,分为两步: * 通过二分法确定该记录所在的槽。 * 通过记录的next\_record属性遍历该槽所在的组中的各个记录。 5. 每个数据页的`File Header`部分都有上一个和下一个页的编号,所以所有的数据页会组成一个`双链表`。 6. 为保证从内存中同步到磁盘的页的完整性,在页的首部和尾部都会存储页中数据的校验和和页面最后修改时对应的`LSN`值,如果首部和尾部的校验和和`LSN`值校验不成功的话,就说明同步过程出现了问题。