在数据库中, 不论读一行,还是读多行,都是将这些行所在的页进行加载。
也就是说存储空间的基本单位是页。
一个页就是一棵树B+树的节点,数据库I/O操作的最小单位是页,与数据库相关的内容都会存储在页的结构里。
1、InnoDB的物理文件
InnoDB是MySQL使用最为广泛的存储引擎。
我们每创建一个 database(数据库) 都会在 /var/lib/mysql/ 目录里面创建一个以库名为名称的目录,然后保存表结构和表数据的文件都会存放在这个目录里。
当我们创建一个table时, InnoDB会创建三个文件。
- .opt文件,存储当前库字符集;
- .frm文件,表结构定义文件;
- .ibd文件,数据实际存储文件, 并且所有的索引也将存放在这个文件中;
如果我们新建一个t_order的表,会有以下几个文件
- db.opt,用来存储当前数据库的默认字符集和字符校验规则。
- t_order.frm ,t_order 的表结构会保存在这个文件。每次建立一张表都会生成一个.frm 文件,该文件是用来保存每个表的元数据信息的,主要包含表结构定义。
- t_order.ibd,t_order的数据实际存储文件。每一张表的数据都存放在一个独立的 .ibd 文件,包含实际数据和索引。
2、数据文件的结构
数据文件由段(segment)、区(extent)、页(page)、行(row)组成,InnoDB存储引擎的逻辑存储结构大致如下图:
下面我们从下往上一个个看看。
行(row)
数据库表中的记录都是按行(row)进行存放的,每行记录根据不同的行格式,有不同的存储结构。
后面我们详细介绍 InnoDB 存储引擎的行格式,也是本文重点介绍的内容。
页(page)
记录是按照行来存储的,但是数据库的读取并不以「行」为单位,否则一次读取(也就是一次 I/O 操作)只能处理一行数据,效率会非常低。
因此,InnoDB 的数据是按「页」为单位来读写的,也就是说,当需要读一条记录的时候,并不是将这个行记录从磁盘读出来,而是以页为单位,将其整体读入内存。
默认每个页的大小为 16KB,也就是最多能保证 16KB 的连续存储空间。
页是 InnoDB 存储引擎磁盘管理的最小单元,意味着数据库每次读写都是以 16KB 为单位的,一次最少从磁盘中读取 16K 的内容到内存中,一次最少把内存中的 16K 内容刷新到磁盘中。
页的类型有很多,常见的有数据页、undo 日志页、溢出页等等。数据表中的行记录是用「数据页」来管理的总之知道表中的记录存储在「数据页」里面就行。
区(extent)
我们知道 InnoDB 存储引擎是用 B+ 树来组织数据的。
B+ 树中每一层都是通过双向链表连接起来的,如果是以页为单位来分配存储空间,那么链表中相邻的两个页之间的物理位置并不是连续的,可能离得非常远,那么磁盘查询时就会有大量的随机I/O,随机 I/O 是非常慢的。
解决这个问题也很简单,就是让链表中相邻的页的物理位置也相邻,这样就可以使用顺序 I/O 了,那么在范围查询(扫描叶子节点)的时候性能就会很高。
那具体怎么解决呢?
在表中数据量大的时候,为某个索引分配空间的时候就不再按照页为单位分配了,而是按照区(extent)为单位分配。每个区的大小为 1MB,对于 16KB 的页来说,连续的 64 个页会被划为一个区,这样就使得链表中相邻的页的物理位置也相邻,就能使用顺序 I/O 了。
段(segment)
表空间是由各个段(segment)组成的,段是由多个区(extent)组成的。段一般分为数据段、索引段和回滚段等。
索引段:存放 B + 树的非叶子节点的区的集合;
数据段:存放 B + 树的叶子节点的区的集合;
回滚段:存放的是回滚数据的区的集合
总结:
每一行数据都是放在数据页,按数据页为单位把磁盘上的数据加载到内存的缓存页。真正处理数据的过程是发生在内存中的,所以需要把磁盘中的数据加载到内存中,如果是处理写入或修改请求的话,还需要把内存中的内容刷新到磁盘上,也是以页为单位,将缓存页的数据刷入磁盘上的数据页。
为何不直接更新磁盘数据?
来个请求就直接对磁盘文件进行随机读写,然后更新磁盘文件里的数据,执行请求性能必然极差。因为磁盘随机读写性能极差,所以MySQL才设计了这套机制,通过在内存里更新数据,然后写redo log及事务提交,后台线程不定时地刷新内存数据到磁盘文件。这样每个更新请求,基本都是更新内存,然后顺序写日志文件,这两种操作性能都是很高的。
3、行记录格式-Compact格式
MySQL 数据存储格式主要有两种,一种是行格式,另一种是列格式。其中,行格式存储方式是 MySQL 中默认的存储方式,也是最常用的存储方式。列格式存储方式主要用于存储大数据类型的字段,例如 BLOB 和 TEXT 类型的字段。
InnoDB 提供了 4 种行格式,分别是 Redundant、Compact、Dynamic和 Compressed 行格式。从 MySQL 5.1 版本之后,行格式默认设置成 Compact。
1:变长字段长度列表
mysql中支持一些变长数据类型(比如VARCHAR(M)、TEXT等),它们存储数据占用的存储空间不是固定的,而是会随着存储内容的变化而变化。在Compact行格式中,把所有变长字段的真实数据占用的字节长度都存放在记录的开头部位,从而形成一个变长字段长度列表,各变长字段数据占用的字节数按照列的顺序逆序存放
- 变长字段长度列表中只存储值为 非NULL 的列内容占用的长度,值为 NULL 的列的长度是不储存的 。
- 并不是所有记录都有这个 变长字段长度列表 部分,比方说表中所有的列都不是变长的数据类型的话,这一部分就不需要有
2:NULL值列表
NULL值列表:Compact格式会把所有可以为NULL的列统一管理起来,存在一个NULL值列表,如果表中没有允许为NULL的列,则NULL值列表也不复存在了。
为什么要有NULL值列表?
表中的某些列可能存储NULL值,如果把这些NULL值都放到记录的真实数据中存储会很浪费空间,所以Compact行格式把这些值为NULL的列统一管理起来,存储到NULL值列表中,它的处理过程是这样的:
- 首先统计表中允许存储NULL的列有哪些。
- 根据列的实际值,用0或者1填充NULL值列表,1代表该列的值为空,0代表该列的值不为空。
- 如果表中没有允许存储 NULL 的列,则 NULL值列表 也不存在了。
3:记录头信息
名称 | 大小(单位: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 | 下一条记录的相对位置 |
为什么「变长字段长度列表」的信息要按照逆序存放?
这个设计是有想法的,主要是因为「记录头信息」中指向下一个记录的指针,指向的是下一条记录的「记录头信息」和「真实数据」之间的位置,这样的好处是向左读就是记录头信息,向右读就是真实数据,比较方便。
「变长字段长度列表」中的信息之所以要逆序存放,是因为这样可以使得位置靠前的记录的真实数据和数据对应的字段长度信息可以同时在一个 CPU Cache Line 中,这样就可以提高 CPU Cache 的命中率。
同样的道理, NULL 值列表的信息也需要逆序存放。
varchar(n) 中 n 最大取值为多少?
一行记录最大能存储 65535 字节的数据,但是这个是包含「变长字段字节数列表所占用的字节数」和「NULL值列表所占用的字节数」。所以, 我们在算 varchar(n) 中 n 最大值时,需要减去这两个列表所占用的字节数。
如果一张表只有一个 varchar(n) 字段,且允许为 NULL,字符集为 ascii。varchar(n) 中 n 最大取值为 65532。
计算公式:65535 - 变长字段字节数列表所占用的字节数 - NULL值列表所占用的字节数 = 65535 - 2 - 1 = 65532。
如果有多个字段的话,要保证所有字段的长度 + 变长字段字节数列表所占用的字节数 + NULL值列表所占用的字节数 <= 65535。
行溢出后,MySQL 是怎么处理的?
如果一个数据页存不了一条记录,InnoDB 存储引擎会自动将溢出的数据存放到「溢出页」中。
Compact 行格式针对行溢出的处理是这样的:当发生行溢出时,在记录的真实数据处只会保存该列的一部分数据,而把剩余的数据放在「溢出页」中,然后真实数据处用 20 字节存储指向溢出页的地址,从而可以找到剩余数据所在的页。