表数据,在磁盘上是怎么组织的?

本文涉及的产品
云数据库 RDS MySQL,集群系列 2核4GB
推荐场景:
搭建个人博客
RDS MySQL Serverless 基础系列,0.5-2RCU 50GB
云数据库 RDS PostgreSQL,集群系列 2核4GB
简介: 表数据,在磁盘上是怎么组织的?


楔子



我们前面介绍了 Buffer Pool,当时在文章里提到了表空间、区、数据页、一个区中的连续数据页,以及数据页号等等,这些概念可能让人一头雾水,本篇文章就来详细探讨一下。

首先上面这些概念当中,有一个我们是熟悉的,那就是数据页。每一行数据都放在数据页里面,一个数据页大小是 16KB,可以包含很多行数据。并且在加载数据的时候是以为单位的,每次会将一个或多个数据页加载到 Buffer Pool 的缓存页当中;而数据刷入磁盘也是以为单位,每次会将一个或多个缓存页刷到磁盘的数据页当中。

而引入数据页的目的前面也说了,MySQL 在执行 CRUD 操作的时候会先将数据从磁盘加载到内存,然后在内存中执行。如果数据每次都一条一条地加载,效率会非常低下,所以 InnoDB 存储引擎引入了数据页的概念。每次加载数据都是加载一页或者多页,这样后续更新数据的时候,发现缓存页当中已经存在,就不用再到磁盘里面读了。


一行数据在磁盘上是如何存储的




那么问题来了,一行数据是怎么存储的呢?其实这和具体的行存储格式有关,比如我们指定 COMPACT 格式。

-- 创建表的时候指定行存储格式
CREATE TABLE table_name
(
    col1 int
) ROW_FORMAT = COMPACT;
-- 也可以修改某张表的行存储格式
ALTER TABLE table_name
    ROW_FORMAT = COMPACT;

对于 COMPACT 行存储格式而言,每一行数据在存储的时候,是以下面这种方式组织的:

任何一行数据在存储的时候都会有一些头字段,负责对该行数据进行一些描述,然后再放上该行数据的每一列的具体的值,这就是所谓的行存储格式。除了 COMPACT 以外,还有其它几种行存储格式,基本都大同小异。

下面来探讨一下变长字段。


变长字段的值是怎么存储的



MySQL 有一些字段的长度是不固定的,比如 VARCHAR(10),那么它可能存储字符串 "satori",也可能存储字符串 "sato"。

现在假设有两行数据,几个字段的类型分别是 VARCHAR(10), CHAR(1), CHAR(1),由于第一个字段是不定长的,那么第一行数据可能是 satori a b,第二行数据可能是 sato a b

然后在写入磁盘的时候,这两行数据是挨在一起的:satori a b sato a b。是的,我们平时在表中看到的多行数据,在磁盘上都是挨着存储的,一大坨数据挤在一块。

那么问题来了,在读取的时候要怎么读呢?比如我们想读取第二行数据。显然目前是很难办到的,因为第一个字段的长度是不固定的,具体多长并不知道,所以无法从混成一坨的数据里区分出,哪些数据是要读取的一行。

为此 MySQL 在存储每一行数据的时候,都会保存变长字段的长度列表,也就是变长字段实际存储的值的长度。所以在存储 satori a b 的时候,要带上一些附加信息,而第一块就是变长字段的长度列表。比如 satori a b 这行数据在存储的时候,其实长下面这样:

0x06 NULL值列表 数据头 satori a b

同理,如果再加上第二行数据 sato a b 的话,那么在磁盘上就长下面这样:

0x06 NULL值列表 数据头 satori a b 0x04 NULL值列表 数据头 sato a b

此时再读取数据的话就没有问题了,比如读取第一行数据。由于 VARCHAR 是变长字段,所以会去变长字段的长度列表中查找实际长度,发现长度为 6,那么就会按照长度为 6 读取第一个字段的值 satoti。后面的两个字段都是 CHAR(1),所以按照长度为 1 读取后面两个字段的值 a b,于是 satori a b 这行数据就被读取出来了,至于读取第二行数据也是相同的方式。

多个变长字段怎么存储

如果一行数据有多个变长字段,那要怎么存储呢?比如 VARCHAR(10)、VARCHAR(10)、VARCHAR(10)、CHAR(1)、CHAR(1),然后一行数据是:satori sato sa a b

方式和上面的说的一样,只不过变长字段的长度列表里面的元素从 1 个变成了 3 个。但需要注意,在实际存放长度的时候,是逆序存放的。所以该行数据在存储的时候长下面这样:

0x02 0x04 0x06 NULL值列表 数据头 satori sato sa a b


NULL 值是怎么存储的




变长字段的值因为长度不固定,所以需要每一行数据都加入变长字段的长度列表。而磁盘上的一行数据里面,还存在另一种特殊的数据区域,就是 NULL 值列表。

所谓的 NULL 值,就是指没有给该字段赋值,那么它的值就是 NULL。但 NULL 值是怎么存储的呢?难道在存储的时候给一个字符串 "NULL",很明显不是。都为空了,再给个字符串不是浪费空间吗。

所以 NULL 值在存储的时候,是通过二进制的 bit 位来存储的。假设一行字段有多个 NULL,那么这些字段的 NULL,就会以 bit 位的形式存在 NULL 值列表中。

为了更好地说明,我们建一张表:

CREATE TABLE customer
(
    name    VARCHAR(10) NOT NULL,
    address VARCHAR(20),
    gender  CHAR(1),
    job     VARCHAR(30),
    school  VARCHAR(50)
) ROW_FORMAT = COMPACT;

这是一张客户表,有 5 个字段。第一个字段不可为空,其它字段可以为空;并且四个是变长字段,一个定长字段。假设现在这张表里有这样一行数据:

satori NULL m NULL schoolA

那么该数据在磁盘上要如何存储呢?因为它有多个变长字段,还允许多个字段为 NULL。

首先来看变长字段的长度列表,这里有四个变长字段,那么按照之前说的,是不是就存放 school, job, address, name 的值的实际长度呢?理论上是这样,但需要注意一点:如果变长字段的值为  NULL,那么它的长度是不用存储的,因为值为空,压根没有长度。

这里只有 name 和 school 两个字段是有值的,把它们的值的长度逆序存放在变长字段的长度列表中就可以了,如下所示:

0x07 0x06 NULL值列表 数据头 satori NULL m NULL schoolA

然后是 NULL 值列表,所有允许值为 NULL 的字段(注意:是允许),都会对应一个 bit 位。如果 bit 位是 1,说明字段值为 NULL;如果 bit 位是 0,说明字段值不为 NULL。

比如上面有四个字段允许为 NULL,所以对应四个比特位,并且分别为 1010。但是在存储的时候,也是逆序存储的,所以 NULL 值列表里放的实际是 0101。因此该行数据长下面这样:

0x07 0x06 00000101 数据头 satori NULL m NULL schoolA

虽然这里的比特位只有 4 个,但实际存储的时候 NULL 值列表的长度是 8 的倍数,不够就高位补零。

磁盘上的一行数据是怎么读出来的

到此刻为止,我们就知道磁盘的一行数据是怎么读出来的了。首先要读取变长字段的长度列表和 NULL 值列表,然后分析一下,得出有几个变长字段,以及哪几个变长字段的值是 NULL。

此时就可以从变长字段的长度列表中,解析出不为 NULL 的变长字段的值的长度,然后也知道哪几个字段是 NULL。根据这些信息,就可以从实际的列值存储区域里,把每个字段的值依次读取出来了。

如果是变长字段的值,就按照它的长度来读取;如果是 NULL,就知道它是个 NULL,没有值存储;如果是定长字段,就按照定长长度来读取,这样就能完美地把一行数据的值都读取出来了。


数据头是干什么用的




紧跟在变长字段的长度列表和 NULL 值列表之后的就是数据头,那么这个数据头是干啥用的呢?

首先数据头的大小是 40 个 bit 位,其中第 1 个和第 2 个比特位是预留的,没有任何含义。然后第 3 个比特位是 delete_mask,负责标识这行数据是否被删除了,所以 MySQL 在删除一行数据的时候,并没有立刻把它从磁盘中清理掉,而是在数据头中给它的删除标记设置为 1。

然后第 4 个位表示 min_rec_mask,第 5 个位到第 8 个位表示 n_owned,第 9 个位到第 21 个位表示 heap_no,第 22 个位到第 24 位表示 record_type,最后剩余的 16 个位表示 next_record。这些位都代表了什么含义,暂时不需要关心,后面我们会慢慢揭开。


字符编码以及隐藏字段




到此刻相信你一定明白一行数据是怎么存储的了,但是还没完,数据在真正落盘之前还需要两步:

1)根据字符集编码成二进制。

2)在真实的数据部分加入三个隐藏字段,分别是 DB_ROW_ID, DB_TRX_ID, DB_ROLL_PTR。

第一步很好理解,第二步是为什么呢?以及添加的三个字段又是什么含义呢?

  • DB_ROW_ID:行的唯一标识,数据库自动加上去的,如果没有指定主键或 unique key唯一索引,内部会自动使用该字段作为主键;
  • DB_TRX_ID:事务 ID;
  • DB_ROLL_PTR:回滚指针,用来事务回滚的;

现在我们就初步地把磁盘上的数据和内存里的数据关联起来了,这些都是有机一体的。


行溢出是个什么东西?




每一行数据都是放在数据页里的,而一个数据页大小是 16KB,那么问题来了,万一行数据的大小超过了数据页的大小该怎么办?比如有一张表的字段类型是 VARCHAR(65532),最多能容纳 63KB 的数据,已经远超一个数据页的大小了。

如果是这种情况的话,MySQL 会在该字段中仅存储一部分数据,同时包含一个指针,指向其它的数据页,这些数据页使用链表串起来,共同存放这个 VARCHAR(65532) 里的数据。

这个过程就叫做行溢出,就是一行数据存储的内容太多了,一个数据页放不下了。只能将溢出的部分放在其它的数据页当中,而这些数据页就叫做溢出页

不止 VARCHAR,包括其它的一些字段类型也是一样的,比如 TEXT、BLOB,都有可能出现溢出,然后一行数据就会存储在多个数据页里。后续加载到 Buffer Pool 的时候,也会对应多个缓存页。


数据页到底长什么样子?



前面反复提到数据页,一行一行的数据是存在数据页里的,并且每次加载数据的时候,都会以数据页为单位,将一个或多个数据页加载到缓存页。当缓存页发生更新后,会在一个合适的时机,将缓存页刷新到数据页。

那么数据页到底长什么样子呢?它的大小默认是 16KB,这 16KB 存储的都是数据行吗?其实一个数据页拆分成了很多个部分,大体上来说包含了文件头、数据页头、最大记录和最小记录、数据行区域、空闲空间、数据页目录、文件尾部。

哇,怎么冒出这么多概念,不用惊讶,这不过是 MySQL 官方设计出来的一个存储格式罢了。正如每一行数据一样,包含的并非全部都是真实数据,还有一部分特殊数据,比如 NULL 值列表、数据头等等。而数据页也是同理,它包含的也并非全部都是数据行,同样会存在一部分特殊数据。

然后我们看一下图中的数据行区域空闲区域,其实这两个区域可以看成一个整体,都是用来存储数据的。据行区域表示已使用的部分,空闲区域表示未使用的部分,随着写入的数据行越来越多,那么据行区域会越来越大,而空闲区域会越来越小。

而一个数据页如果一行数据都没有,那么它的据行区域就是空的,换句话说,此时就没有据行区域

然后我们要往一张空表中插入一行数据,那么首先要从磁盘上加载一个空的数据页到缓存页当中,因为 CRUD 都是在内存中操作的。注意:数据页和缓存页是一一对应的,结构也相同,只不过在磁盘上叫数据页,在 Buffer Pool 中叫缓存页。

所以往缓存页当中插入一条数据,实际上就是往缓存页的数据行区域当中插入一条数据,然后空闲区域会减少一些。

接着我们不停地向缓存页写入数据,当它的空闲区域全部耗尽时,就是这个页满了。此时数据行区域内可能有很多行数据,空闲区域也会消失。

然后缓存页在更新的时候,一定会在 LRU 链表和 flush 链表中,并且在 LRU 链表中还会不断移动。而在之后的某一时刻,后台 IO 线程会将脏的缓存页刷新到磁盘。


表空间和数据区



对于 MySQL 而言,一个逻辑概念上的数据库,在物理层面上对应一个目录;数据库里的一张表,对应目录里的一个文件。对于 InnoDB 存储引擎来说,文件的后缀名为 .ibd。我们平时创建的表,都有一个表空间的概念,而在物理层面,表空间其实就是磁盘上的一些 .ibd 数据文件。

有的表空间,比如系统表空间可能对应的是多个磁盘文件;而我们自己创建的表,其表空间可能只对应一个 表名.ibd 数据文件。比如有一张表叫 student,那么在磁盘上就会对应一个 student.ibd 文件,该文件就是表 student 的表空间。

然后在表空间的磁盘文件里,存放着 16KB 的数据页,而数据页的个数则与写入的数据量有关。表数据越多,数据页就越多,这是肯定的,但大量的数据页其实是不好管理的。所以 MySQL 又引入了一个数据区的概念,英文叫 extent。

一个数据区对应着连续的 64 个数据页,每个数据页是 16KB,所以一个数据区是 1MB,然后 256 个数据区又被划分为一个。也就是说:

  • 一组数据区里面包含了 256 个数据区;
  • 一个数据区里面包含了 64 个数据页;


对于表空间(.ibd 文件)而言,它的第一组数据区的第一个数据区的前 3 个数据页,都是固定的,里面存放了一些描述性的数据。

  • 第一个数据页 FSP_HDR:存放表空间和该组数据区的一些属性;
  • 第二个数据页 IBUF_BITMAP:存放 insert buffer 的一些属性;
  • 第三个数据页 INODE:存放一些特殊信息;


而其它组数据区的第一个数据区的前 2 个数据页是固定的,用于描述当前的数据区。这些描述信息我们不需要关心到底是什么,只需要知道第一组数据区的第一个数据区的前 3 个数据页,和剩余的每组数据区的第一个数据区的前 2 个数据页,都是用来存放一些描述信息即可。

总结:我们平时创建的表都有对应的表空间,每个表空间对应磁盘上的 .ibd 数据文件。在表空间里有很多组数据区,每组数据区包含了 256 个数据区,每个数据区包含了 64 个数据页。一个数据页是 16KB,一个数据区就是 1MB,一组数据区就是 256 MB。

需要执行 CRUD 操作时,就从对应的 .ibd 文件中加载一些数据页 Buffer Pool 的缓存页里去。


小结



在逻辑层面上,我们看到的是一个个数据库,数据库里面的一张张表。而在物理层面,就是一个个目录,目录里面的一个个 .ibd 文件。每个文件里面包含了多组数据区,一组数据区包含 256 个数据区,每个数据区里面包含 64 个数据页,每个数据页里面包含一些描述信息和存储的行数据。

在使用上,我们只需要关注逻辑层面,但是想要深入 MySQL,就需要关注物理层面了。

相关实践学习
如何在云端创建MySQL数据库
开始实验后,系统会自动创建一台自建MySQL的 源数据库 ECS 实例和一台 目标数据库 RDS。
全面了解阿里云能为你做什么
阿里云在全球各地部署高效节能的绿色数据中心,利用清洁计算为万物互联的新世界提供源源不断的能源动力,目前开服的区域包括中国(华北、华东、华南、香港)、新加坡、美国(美东、美西)、欧洲、中东、澳大利亚、日本。目前阿里云的产品涵盖弹性计算、数据库、存储与CDN、分析与搜索、云通信、网络、管理与监控、应用服务、互联网中间件、移动服务、视频服务等。通过本课程,来了解阿里云能够为你的业务带来哪些帮助     相关的阿里云产品:云服务器ECS 云服务器 ECS(Elastic Compute Service)是一种弹性可伸缩的计算服务,助您降低 IT 成本,提升运维效率,使您更专注于核心业务创新。产品详情: https://www.aliyun.com/product/ecs
相关文章
|
索引
SPSS数据文件的结构重组
SPSS数据文件的结构重组
121 0
|
存储 Oracle 关系型数据库
oracle数据库 修改表空间数据文件大小,优化存储
oracle数据库 修改表空间数据文件大小,优化存储
|
SQL 存储 HIVE
iceberg 分区是如何读写和维护
了解iceberg 分区的信息和数据维护,以及分区变更之后对数据读取到影响
iceberg 分区是如何读写和维护
|
SQL 存储 监控
为什么我建议需要定期重建数据量大但是性能关键的表
为什么我建议需要定期重建数据量大但是性能关键的表
为什么我建议需要定期重建数据量大但是性能关键的表
|
存储 监控 数据库
【DBMS 数据库管理系统】数据仓库 数据组织 ( 数据组织级别 | 元数据 | 粒度 | 分割 | 数据组织形式 )(二)
【DBMS 数据库管理系统】数据仓库 数据组织 ( 数据组织级别 | 元数据 | 粒度 | 分割 | 数据组织形式 )(二)
184 0
|
前端开发 数据库 数据库管理
【DBMS 数据库管理系统】数据仓库 数据组织 ( 数据组织级别 | 元数据 | 粒度 | 分割 | 数据组织形式 )(一)
【DBMS 数据库管理系统】数据仓库 数据组织 ( 数据组织级别 | 元数据 | 粒度 | 分割 | 数据组织形式 )(一)
330 0
|
存储 缓存 分布式计算
指定表和分区来预先缓存,查询分析更高效 | 学习笔记
快速学习指定表和分区来预先缓存,查询分析更高效。
151 0
|
存储 关系型数据库 索引
索引组织表
说明聚集索引及索引组织表的关系及使用场景,优势。
2323 0
|
Go 索引
维护索引——通过重组索引提高性能
原文:维护索引——通过重组索引提高性能 前言: 如果碎片程度小于30%,建议使用重组而不是重建。因为重组不会锁住数据页或者数据表,并且降低CPU的资源。 总得来说,重组会清空当前的B-TREE,特别是索引的叶子节点,重组数据页和消除碎片。
1070 0