MySQL - InnoDB存储引擎
# MySQL - InnoDB存储引擎
# InnoDB 存储引擎
InnoDB 存储引擎是MySQL的默认存储引擎,是事务安全的MySQL存储引擎。该存储引擎是第一个完整ACID事务的MySQL存储引擎,其特点是行锁设计、支持MVCC、支持外键、提供一致性非锁定读,同时被设计用来最有效地利用以及使用内存和 CPU。因此很有必要学习下InnoDB存储引擎,它的很多架构设计思路都可以应用到我们的应用系统设计中。
# InnoDB体系架构
我们通过下面这张图先对 InnoDB 存储引擎的体系有一个整体的认识,里面有很多细节后面会分几篇文章来学习。
比如要更新 user 表中 id=1 的这条数据,它的大致流程如下:
- 1、客户端连接到MySQL服务器,将SQL更新语句发送到服务器;MySQL服务器连接池中会有一个连接和客户端建立连接,然后后台线程会从连接中获取到要执行的SQL语句,并发送给SQL接口去调度执行。
- 2、增、删、改 时,会将查询缓存中 user 表相关的缓存都清空。
- 3、SQL语句经过SQL解析器解析、优化器优化,得到一个执行路径,前面这些和执行查询其实都是类似的。
- 4、接着由执行引擎去调用底层的存储引擎接口,根据执行计划完成SQL语句的执行。
- ① 首先查询出要更新的数据,这一步会先判断缓冲池(Buffer Pool)中是否已经存在这条数据,如果已经存在了,则直接从缓存池获取数据返回。否则从磁盘数据文件中加载这条数据到缓冲池中,再返回数据。
- ② 获取到数据后,执行引擎会根据SQL更新数据,然后调用存储引擎更新数据。这一步会对数据加排它锁,避免并发更新问题。之后先写 undolog 到缓冲池,undolog 主要用于事务回滚、MVCC等;同时,undolog 也会产生 redolog 日志。
- ③ 之后更新缓冲池中的数据,同时记录 redolog 到 RedoLog缓冲池,redolog 主要用于保证数据的持久性,宕机恢复数据等。
- ④ 最后提交事务,虽然没有手动 commit 提交事务,update 语句执行完成后也会有隐式的事务提交的。事务提交时,会先在MySQL服务器层面会写入 binlog,binlog是数据持久性的保证。最后将redolog刷入磁盘,完成事务提交。
- 5、最底层的一部分就是磁盘上的数据文件、日志文件等,可以看到,InnoDB 设计了缓冲池来缓冲数据、undolog、redolog 等,这些内存中的数据最终都是要刷新到磁盘中才能保证数据不丢失的。至于为什么要这么设计,我们后面再分析。
# 核心内容
从上面的图中,我们至少可以精炼出如下的核心内容:
- 1、磁盘中数据文件的存储结构
- 2、数据与索引
- 3、Buffer Pool 的设计
- 4、撤销日志 undolog 与事务
- 5、重做日志 redolog 及 RedoLog Buffer 的设计
- 6、并发锁
- 7、binlog 与主从同步
MySQL相关的知识太多了,这个MySQL系列要学习的内容主要就包含上面的一些核心知识点,我们主要就从这些点去研究下MySQL的一些优秀设计思想,然后能够对MySQL进行一些性能优化,就差不多了。
这篇文章我们先看下MySQL和InnoDB的数据文件结构,为后面的研究打下基础。
# MySQL数据目录
# 数据目录
我们可以通过 datadir
这个系统变量查看MySQL的数据目录位置,默认是在 /var/lib/mysql
下。
mysql> show variables like 'datadir';
+---------------+-----------------+
| Variable_name | Value |
+---------------+-----------------+
| datadir | /var/lib/mysql/ |
+---------------+-----------------+
2
3
4
5
6
7
在一个全新安装的数据库的数据目录下,可以看到如下的一些初始化的文件和目录。
我们可以重点关注 ibdata1、ib_logfile0、ib_logfile1
这几个文件,以后会讲到。ibdata1 是共享表空间,ib_logfile0、ib_logfile1 是 redo 日志文件。
还有一个 f4e2d8fde38c.pid
的文件,当MySQL实例启动时,会将自己的进程ID写入一个pid文件。该文件可由参数pid_file
控制,默认位于数据库目录下,文件名为主机名.pid
。
mysql> show variables like 'pid_file';
+---------------+---------------------------------+
| Variable_name | Value |
+---------------+---------------------------------+
| pid_file | /var/lib/mysql/f4e2d8fde38c.pid |
+---------------+---------------------------------+
2
3
4
5
6
7
# 数据库目录
MySQL默认创建了四个系统数据库,除了 information_schema
,另外三个都会有一个目录与之对应。
mysql> show databases;
+--------------------+
| Database |
+--------------------+
| information_schema |
| mysql |
| performance_schema |
| sys |
+--------------------+
2
3
4
5
6
7
8
9
10
我们通过 create database xx;
创建一个测试数据库,并指定了字符集为 utf8mb4
:
mysql> create database test default character set utf8mb4;
Query OK, 1 row affected (0.00 sec)
mysql> show databases;
+--------------------+
| Database |
+--------------------+
| information_schema |
| mysql |
| performance_schema |
| sys |
| test |
+--------------------+
2
3
4
5
6
7
8
9
10
11
12
13
14
创建数据库后就会看到多了一个同名的目录,也就是说MySQL中的数据库在文件系统中其实就是数据目录下的一个子目录。
进入数据库目录下可以看到,创建数据库时会同步创建一个名为 db.opt
的文件,这个文件中包含了该数据库的各种属性,比如说该数据库默认的字符集和比较规则等。
# 系统数据库
前边提到了MySQL的几个系统数据库,下面简单看下每个数据库都是干什么的。
mysql
这个数据库的核心,它存储了MySQL的用户账户和权限信息,一些存储过程、事件的定义信息,一些运行过程中产生的日志信息,一些帮助信息以及时区信息等。
information_schema
这个数据库保存着MySQL服务器所有其他数据库的信息,比如表、视图、触发器、列、索引、锁、事务等等。这些信息并不是真实的用户数据,而是一些描述性信息,也称之为元数据。
performance_schema
这个数据库主要保存MySQL服务器运行过程中的一些状态信息,包括统计最近执行了哪些语句,在执行过程的每个阶段都花费了多长时间,内存的使用情况等等信息。
sys
这个数据库主要是通过视图的形式把 information_schema 和 performance_schema 结合起来,让程序员可以更方便的了解MySQL服务器的一些性能信息。
# 表结构定义文件
在 test 数据库下,先用下面的SQL创建一张 user
表,指定的存储引擎为 InnoDB:
CREATE TABLE `user` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`username` varchar(60) NOT NULL COMMENT '用户名',
`nickname` varchar(240) DEFAULT NULL COMMENT '昵称',
`age` int(11) DEFAULT NULL COMMENT '年龄',
PRIMARY KEY (`id`),
UNIQUE KEY `user_uk_username` (`username`) USING BTREE
) ENGINE=InnoDB;
2
3
4
5
6
7
8
9
创建完成之后就可以看到这张表了:
mysql> show tables;
+----------------+
| Tables_in_test |
+----------------+
| user |
+----------------+
2
3
4
5
6
7
这个时候再看 test 目录,会发现多了两个文件:
user.frm
:表结构定义文件,格式为表名.frm
user.ibd
:表空间文件,格式为表名.ibd
不论表采用哪种存储引擎,每张表都会有一个以.frm
为后缀名的文件,这个文件记录了该表的表结构定义。这个.frm
文件是以二进制格式存储的,直接打开会乱码。
# 表数据文件
InnoDB将数据按表空间(tablespace)进行存储,MySQL数据目录下名为ibdata1
的文件就是默认的表空间文件,也称为共享表空间。可以通过参数innodb_data_file_path
对其进行设置,格式如下:
innodb_data_file_path=datafile1[; datafile2]...
2
可以通过多个文件组成一个表空间,同时制定文件的属性,如:
innodb_data_file_path=/db/ibdata1:2000M;/dr2/db/ibdata2:2000M:autoextend
2
这里将 /db/ibdata1 和 /dr2/db/ibdata2 两个文件用来组成表空间,同时,两个文件的文件名后都跟了属性,表示文件 idbdata1 的大小为2000MB,文件ibdata2的大小为2000MB,如果用完了这2000MB,该文件可以自动增长(autoextend
)。
可以看到默认的 ibdata1 的大小为12M,且支持自动扩展。
mysql> show variables like 'innodb_data_file_path';
+-----------------------+------------------------+
| Variable_name | Value |
+-----------------------+------------------------+
| innodb_data_file_path | ibdata1:12M:autoextend |
+-----------------------+------------------------+
2
3
4
5
6
7
若设置了参数innodb_file_per_table
,InnoDB每个表将产生一个独立表空间。独立表空间的命名规则为 表名.ibd
,例如前面的 user.ibd
。这个配置默认是开启的,就是每张表都有一个独立的表空间文件来存储数据。
mysql> show variables like 'innodb_file_per_table';
+-----------------------+-------+
| Variable_name | Value |
+-----------------------+-------+
| innodb_file_per_table | ON |
+-----------------------+-------+
2
3
4
5
6
7
# InnoDB逻辑存储结构
InnoDB将所有数据都存放在表空间中,表空间又由段(segment)、区(extent)、页(page)组成。InnoDB存储引擎的逻辑存储结构大致如下图。下面我们就一个个来看看。
# 表空间
表空间可以看做是InnoDB存储引擎逻辑结构的最高层,所有的数据都存放在表空间中。在默认情况下InnoDB存储引擎有一个共享表空间ibdata1
,所有数据都存放在这个表空间内。
如果启用了参数innodb_file_per_table
,则每张表内的数据可以单独放到一个表空间内。需要注意的是,这些单独的表空间文件仅存储该表的数据、索引和插入缓冲Bitmap等信息,其余信息还是存放在共享表空间中,例如 undo日志、插入缓冲索引页、系统事务信息、二次写缓冲等。
因此即使在启用了参数innodb_file_per_table
之后,共享表空间的大小还是会不断地增加,例如事务中写入了undo日志,就算回滚了,共享表空间的大小也不会缩小。但是会判断这些undo信息是否还需要,不需要的话,就会将这些空间标记为可用空间,供下次重复使用。
# 行
InnoDB的数据是按行进行存放的,每个页存放的行记录最多允许存放16KB / 2 -200
行的记录,即7992
行记录。
每行记录根据不同的行格式、不同的数据类型,会有不同的存储方式。每行除了记录我们保存的数据之外,还可能会记录事务ID(DB_TRX_ID),回滚指针(DB_ROLL_PTR)等。
# 页
页(Page)
是 InnoDB 磁盘管理的最小单位,默认每个页的大小为16KB
,也就是最多能保证16KB的连续存储空间。
InnoDB 将数据划分为若干个页,以页作为磁盘和内存之间交互的基本单位,也就是一次最少从磁盘中读取一页16KB的内容到内存中,一次最少把内存中的16KB内容刷新到磁盘中。
InnoDB 为了不同的目的设计了若干种不同类型的页面,常用的页面类型有:
# 索引组织表
在介绍后面的内容前,这一小节先简单介绍下表中的数据是如何组织的,后面会单独一篇文章来讲索引。
在InnoDB中,表都是根据主键顺序存放数据的,这种存储方式的表称为索引组织表
。在InnoDB表中,每张表都有个主键,如果在创建表时没有显式地定义主键,则InnoDB会按如下方式选择或创建主键:
- 首先判断表中是否有非空的唯一索引,如果有,则该列即为主键。当表中有多个非空唯一索引时,将选择建表时第一个定义的非空唯一索引为主键。
- 如果不符合上述条件,InnoDB会自动创建一个名为
row_id
的6字节的隐藏列作为主键。
为了能快速的从磁盘中检索出数据,InnoDB采用 B+树
结构来组织数据,通过 B+树
组织起来的结构大概就像下图这个样子。B+树是多层的,B+树每一层中的页都会形成一个双向链表。在这棵树中,只有最底层的叶节点才存储数据,这些数据是按主键顺序存储的。上层的非叶子节点存储的则是索引目录,索引目录则根据主键区间划分了多个页和层级,这样就可以通过类似二分法的方式快速找到某条数据所在的页,然后通过主键定位到具体的某条数据。
可以看到,InnoDB存储引擎表是索引组织的,数据即索引,索引即数据。
# 区
在默认情况下,InnoDB存储引擎页的大小为16KB
,表空间中的页就太多了。为了更好的管理这些页,InnoDB 将物理位置上连续的64个页划为一个区
,任何情况下,每个区的大小都为1MB
。
B+树中每一层都是通过双向链表连接起来的,如果是以页为单位来分配存储空间,本来链表中相邻的两个页之间的物理位置就可能离得非常远,那么磁盘查询时就会有大量的随机I/O,随机I/O是非常慢的。所以应该尽量让链表中相邻的页的物理位置也相邻,这样可以消除很多的随机I/O,使用顺序I/O,尤其是在进行范围查询的时候。
所以在表中数据量大的时候,为某个索引分配空间的时候就不再按照页为单位分配了,而是按照区为单位分配,甚至在表中的数据非常多的时候,可以一次性分配多个连续的区。
不论是系统表空间还是独立表空间,都可以看成是由若干个区组成的,每个区64个页,然后每256个区又被划分成一组。
第一个组最开始的3个页面的类型是固定的,也就是第一个区(extent0)最开始的三个页。分别是:
FSP_HDR
:用来登记整个表空间的一些整体属性以及本组所有区的属性,整个表空间只有一个 FSP_HDR 类型的页面。IBUF_BITMAP
:存储本组所有区的所有页面关于 INSERT BUFFER 的信息。INODE
:索引节点信息。
其余各组则是最开始的2个页面的类型是固定的,分别是:
XDES
:用来登记本组256个区的属性。IBUF_BITMAP
:存储本组所有的区的所有页面关于 INSERT BUFFER 的信息。
从这里也可以看出,索引数据并不时连续存储在区中,因为其中有些页面被用来存储额外的一些管理信息了。
# 段
从前面B+树的结构知道,B+树分为叶子节点和非叶子节点,最底层的叶子节点才存储了数据,非叶子节点是索引目录。如果将叶子节点页和非叶子节点页混合在一起存储,那在检索数据的时候同样也会有大量的随机I/O。
所以 InnoDB 又提出了段的概念,常见的段有数据段、索引段、回滚段等。段是一个逻辑上的概念,并不对应表空间中某一个连续的物理区域,它由若干个完整的区组成(还会包含一些碎片页),不同的段不能使用同一个区。
存放叶子节点的区的集合就是数据段
,存放非叶子节点的区的集合就是索引段
。也就是说一个索引会生成2个段,一个叶子节点段(数据段),一个非叶子节点段(索引段)。
# 行记录格式
目前,InnoDB支持4种行记录格式,分别是 Compact、Redundant、Dynamic和Compressed
行格式。
四种行格式的特性对比如下:
InnoDB 表的默认行格式由参数 innodb_default_row_format
定义,默认值为 DYNAMIC
。
mysql> show variables like 'innodb_default_row_format';
+---------------------------+---------+
| Variable_name | Value |
+---------------------------+---------+
| innodb_default_row_format | dynamic |
+---------------------------+---------+
2
3
4
5
6
7
我们可以通过如下语法来指定表的行格式:
CREATE TABLE <table_name(column_name)> ROW_FORMAT=行格式名称
ALTER TABLE <table_name> ROW_FORMAT=行格式名称
2
3
4
# COMPACT 行记录格式
Compact 设计目标是高效地存储数据,一个页中存放的行数据越多,其性能就越高。
下图显示了 Compact 行记录格式的存储方式:
# 变长字段长度列表
MySQL中有一些变长字段类型,如 VARCHAR(M)、TEXT、BLOB 等,变长字段的长度是不固定的,所以在存储数据的时候要把这些数据占用的字节数也存起来,读取数据的时候才能根据这个长度列表去读取对应长度的数据。
变长字段长度列表 就是用来记录一行中所有变长字段的真实数据所占用的字节长度,并且各变长字段数据占用的字节数是按照列的顺序逆序存放
。
变长字段长度列表中只存储值为非NULL
的列内容占用的长度,值为 NULL 的列的长度是不储存的。如果表中所有的列都不是变长的数据类型的话,就不需要变长字段长度列表了。
若变长字段的长度小于 255字节,就用1字节
表示;若大于 255字节,用2字节
表示,最大不会不超过2字节
,因为MySQL中VARCHAR类型的最大字节长度限制为65535
。
对于一些占用字节数非常多的字段,比方说某个字段长度大于了16KB,那么如果该记录在单个页面中无法存储时,InnoDB会把一部分数据存放到所谓的溢出页
中,在变长字段长度列表处只存储留在本页面中的长度,所以使用两个字节也可以存放下来。
# NULL值列表
表中的某些列可能会存储NULL值,如果把这些NULL值都放到记录的真实数据中会比较浪费空间,所以Compact行格式把这些值为NULL的列存储到NULL值列表中。
如果表中所有列都不允许为 NULL,就不存在NULL值列表了。如果存在允许NULL值的列,则每个列对应一个二进制位,二进制位按照列的顺序逆序排列。
- 二进制位的值为
1
时,代表该列的值为NULL。 - 二进制位的值为
0
时,代表该列的值不为NULL。
另外,NULL值列表必须用整数个字节的位表示(1字节8位),如果使用的二进制位个数不足整数个字节,则在字节的高位补0
。
# 记录头信息
记录头信息是由固定的5个字节组成,5个字节也就是40个二进制位,不同的位代表不同的意思,这些头信息会在后面的一些功能中看到。
每个位的含义如下表:
# 记录真实数据
最后的部分就是实际存储每个列的数据。注意 NULL 不占该部分任何空间,即 NULL 除了占有NULL值列表的标志位,实际存储不占有任何空间。
每行数据除了用户定义的列外,在开头还有两个隐藏列,事务ID列(DB_TRX_ID)
和回滚指针列(DB_ROLL_PTR)
,分别为6字节
和7字节
的大小。若InnoDB表没有定义主键,每行还会增加一个6字节
的行ID列(DB_ROW_ID)
。
# 隐藏主键列
如果我们没有为某个表显式的定义主键,并且表中也没有定义唯一索引,那么InnoDB会自动为表添加一个row_id
的隐藏列作为主键。
为这个row_id隐藏列赋值的方式如下:
- 服务器会在内存中维护一个全局变量,每当向某个包含隐藏的
row_id
列的表中插入一条记录时,就会把该变量的值当作新记录的row_id
列的值,并且把该变量自增1
。 - 每当这个变量的值为
256
的倍数时,就会将该变量的值刷新到系统表空间
的页号为7
的页面中一个Max Row ID
的属性处。 - 当系统启动时,会将页中的
Max Row ID
属性加载到内存中,并将该值加上256
之后赋值给全局变量,因为在上次关机时该全局变量的值可能大于页中Max Row ID
属性值。
# 数据存储演示
我们创建下面一张表:其中 username 非空,nickname、address、email 都可为空。
CREATE TABLE `user` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`username` varchar(60) NOT NULL COMMENT '用户名',
`nickname` varchar(240) DEFAULT NULL COMMENT '昵称',
`address` varchar(240) DEFAULT NULL COMMENT '地址',
`email` varchar(60) DEFAULT NULL COMMENT '邮箱',
PRIMARY KEY (`id`),
UNIQUE KEY `user_uk_username` (`username`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=COMPACT;
2
3
4
5
6
7
8
9
10
再插入两条数据,查询结果如下:
第一条数据的行存储格式:
- 变成字段列表中,按列逆序存储变长字段的长度,address 列的值为 NULL,不存储长度。
- NULL值列表中,按列逆序存储可为空的字段,address 列的值为NULL,二进制标志位为1。username 列非NULL就没有标志位。一个字节没用完,高位补0。
- 数据段中先记录了事务ID和回滚指针两个隐藏列,然后存储了值不为NULL的列,NULL值不占该部分任何空间。
接着看第二条数据的行存储格式:
- 变成字段列表中,按列逆序存储变长字段的长度,email 列的值为 NULL,不存储长度。
- NULL值列表中,按列逆序存储可为空的字段,email 列的值为NULL,二进制标志位为1。
- 数据段中先记录了事务ID和回滚指针两个隐藏列,然后存储了值不为NULL的列,NULL值不占该部分任何空间。
# Redundant行记录格式
Redundant 是 MySQL5.0 版本之前 InnoDB 的行记录存储方式,已经比较老了,现在基本也不再使用这种格式,下面简单了解下就行了。
Redundant 的记录格式大致如下图所示:
# 字段长度偏移列表
Redundant 行记录格式的首部是一个字段长度偏移列表,同样是按照列的顺序逆序放置的。该条记录中所有列(包括隐藏列、NULL值列)的长度信息都按照逆序存储到字段长度偏移列表。
多了个偏移两个字,也就是列表存储的是每个字段的偏移量,那他就是采用两个相邻数值的差值来计算各个列值的长度。
Redundant 并没有NULL值列表,它是将字段长度偏移列表中的各个列对应的偏移量的第一个比特位作为是否为NULL的依据,该比特位也可以被称之为NULL比特位。也就是说在解析一条记录的某个列时,首先看一下该列对应的偏移量的NULL比特位是不是为1,如果为1,那么该列的值就是NULL,否则不是NULL。
# 记录头信息
Redundant 行格式的记录头信息占用6字节,48个二进制位。
每个位的含义如下表所示:
与 Compact 格式相比,多了 n_fields、1byte_offs_flag
两个属性,少了 record_type
属性。n_fields
值代表一行中列的数量,占用10位,这也说明了 Redundant 行格式一行最多支持1023列。1byte_offs_flags
值表示偏移列表占用1字节还是2字节。
# VARCHAR 数据类型
# 字符集
在介绍后面的内容前,先了解下字符集,我们在建表时往往都会设置表的字符集。计算机中只能存储二进制数据,字符集就是字符与二进制数据的映射关系。
可以通过 SHOW CHARSET;
命令查看 MySQL 支持的字符集。可以看到这个MySQL版本一共支持41种字符集,其中的Default collation
列表示这种字符集默认的比较规则。最后一列 Maxlen
代表该种字符集表示一个字符最多需要几个字节。
mysql> SHOW CHARSET;
+----------+---------------------------------+---------------------+--------+
| Charset | Description | Default collation | Maxlen |
+----------+---------------------------------+---------------------+--------+
| big5 | Big5 Traditional Chinese | big5_chinese_ci | 2 |
| dec8 | DEC West European | dec8_swedish_ci | 1 |
| cp850 | DOS West European | cp850_general_ci | 1 |
| hp8 | HP West European | hp8_english_ci | 1 |
| koi8r | KOI8-R Relcom Russian | koi8r_general_ci | 1 |
| latin1 | cp1252 West European | latin1_swedish_ci | 1 |
| latin2 | ISO 8859-2 Central European | latin2_general_ci | 1 |
| swe7 | 7bit Swedish | swe7_swedish_ci | 1 |
| ascii | US ASCII | ascii_general_ci | 1 |
| ujis | EUC-JP Japanese | ujis_japanese_ci | 3 |
| sjis | Shift-JIS Japanese | sjis_japanese_ci | 2 |
| hebrew | ISO 8859-8 Hebrew | hebrew_general_ci | 1 |
| tis620 | TIS620 Thai | tis620_thai_ci | 1 |
| euckr | EUC-KR Korean | euckr_korean_ci | 2 |
| koi8u | KOI8-U Ukrainian | koi8u_general_ci | 1 |
| gb2312 | GB2312 Simplified Chinese | gb2312_chinese_ci | 2 |
| greek | ISO 8859-7 Greek | greek_general_ci | 1 |
| cp1250 | Windows Central European | cp1250_general_ci | 1 |
| gbk | GBK Simplified Chinese | gbk_chinese_ci | 2 |
| latin5 | ISO 8859-9 Turkish | latin5_turkish_ci | 1 |
| armscii8 | ARMSCII-8 Armenian | armscii8_general_ci | 1 |
| utf8 | UTF-8 Unicode | utf8_general_ci | 3 |
| ucs2 | UCS-2 Unicode | ucs2_general_ci | 2 |
| cp866 | DOS Russian | cp866_general_ci | 1 |
| keybcs2 | DOS Kamenicky Czech-Slovak | keybcs2_general_ci | 1 |
| macce | Mac Central European | macce_general_ci | 1 |
| macroman | Mac West European | macroman_general_ci | 1 |
| cp852 | DOS Central European | cp852_general_ci | 1 |
| latin7 | ISO 8859-13 Baltic | latin7_general_ci | 1 |
| utf8mb4 | UTF-8 Unicode | utf8mb4_general_ci | 4 |
| cp1251 | Windows Cyrillic | cp1251_general_ci | 1 |
| utf16 | UTF-16 Unicode | utf16_general_ci | 4 |
| utf16le | UTF-16LE Unicode | utf16le_general_ci | 4 |
| cp1256 | Windows Arabic | cp1256_general_ci | 1 |
| cp1257 | Windows Baltic | cp1257_general_ci | 1 |
| utf32 | UTF-32 Unicode | utf32_general_ci | 4 |
| binary | Binary pseudo charset | binary | 1 |
| geostd8 | GEOSTD8 Georgian | geostd8_general_ci | 1 |
| cp932 | SJIS for Windows Japanese | cp932_japanese_ci | 2 |
| eucjpms | UJIS for Windows Japanese | eucjpms_japanese_ci | 3 |
| gb18030 | China National Standard GB18030 | gb18030_chinese_ci | 4 |
+----------+---------------------------------+---------------------+--------+
41 rows in set (0.14 sec)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
几个常用的字符集如下:例如 latin1
一个字符最大占用 1字节,utf8mb4
一个字符最大占用 4字节。
+----------+---------------------------------+---------------------+--------+
| Charset | Description | Default collation | Maxlen |
+----------+---------------------------------+---------------------+--------+
| latin1 | cp1252 West European | latin1_swedish_ci | 1 |
| ascii | US ASCII | ascii_general_ci | 1 |
| gb2312 | GB2312 Simplified Chinese | gb2312_chinese_ci | 2 |
| gbk | GBK Simplified Chinese | gbk_chinese_ci | 2 |
| utf8 | UTF-8 Unicode | utf8_general_ci | 3 |
| utf8mb4 | UTF-8 Unicode | utf8mb4_general_ci | 4 |
+----------+---------------------------------+---------------------+--------+
2
3
4
5
6
7
8
9
10
11
# 单字节 VARCHAR 长度限制
我们经常会用到变长类型 VARCHAR(M)
,其中的 M 代表该类型最多存储的字符数量,我们可能还知道 VARCHAR 最大可存放 65535 字节的长度,那实际上是这样吗,下面我们来验证下。
我们创建下面的一张表,指定 C1 列为 VARCHAR(65535),注意字符集是 latin1
,也就是1个字符占用1字节
。
mysql> CREATE TABLE `test` (
`ID` BIGINT(20) NOT NULL AUTO_INCREMENT,
`C1` VARCHAR(65535) DEFAULT NULL,
PRIMARY KEY (`ID`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1 ROW_FORMAT=COMPACT;
1118 - Row size too large. The maximum row size for the used table type, not counting BLOBs, is 65535.
This includes storage overhead, check the manual. You have to change some columns to TEXT or BLOBs
2
3
4
5
6
7
8
从创建报错的信息可以了解到,一行数据除了 TEXT、BLOBs 这种大对象类型之外,其他所有的列(不包括隐藏列和记录头信息)占用的字节长度加起来不能超过65535个字节,否则需要将一些过长的列转为 TEXT 或 BLOBs 类型。
也就是说一行数据除了 TEXT、BLOBs 类型的列,限制最大为 65535字节
,注意是一行的总长度,不是一列。
我们预测一下,C1 这个 VARCHAR 最大能设置多大?从 Compact 行格式可以知道,主要有如下几部分的数据:
- 变长字段长度列表:C1 列超过 255字节,需要
2字节
表示长度 - NULL值列表:C1 列可为空,所以需要
1字节
标识 C1 列的值是否为空 - ID列:ID列为 BIGINT 类型,占
8字节
所以 C1 列最多还剩:65535 - 1 - 2 - 8 = 65524。
先设置为 65525 长度试试:可以看到还是报同样的错误。
mysql> CREATE TABLE `test` (
`ID` BIGINT(20) NOT NULL AUTO_INCREMENT,
`C1` VARCHAR(65525) DEFAULT NULL,
PRIMARY KEY (`ID`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1 ROW_FORMAT=COMPACT;
1118 - Row size too large. The maximum row size for the used table type, not counting BLOBs, is 65535.
This includes storage overhead, check the manual. You have to change some columns to TEXT or BLOBs
2
3
4
5
6
7
8
再设置为 65524:创建成功,验证了上面的预测。
mysql> CREATE TABLE `test` (
`ID` BIGINT(20) NOT NULL AUTO_INCREMENT,
`C1` VARCHAR(65524) DEFAULT NULL,
PRIMARY KEY (`ID`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1 ROW_FORMAT=COMPACT;
Query OK, 0 rows affected (0.01 sec)
2
3
4
5
6
7
如果将 C1 列设置为非空,那 NULL 值列表应该就不存在了,所以 VARCHAR 又可以增加 1字节 就是 65525。
下面将 C1 设置为非NULL,长度为 65525:创建成功。
mysql> DROP TABLE test;
Query OK, 0 rows affected (0.01 sec)
mysql> CREATE TABLE `test` (
`ID` BIGINT(20) NOT NULL AUTO_INCREMENT,
`C1` VARCHAR(65525) NOT NULL,
PRIMARY KEY (`ID`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1 ROW_FORMAT=COMPACT;
Query OK, 0 rows affected (0.01 sec)
2
3
4
5
6
7
8
9
10
# 多字节 VARCHAR 长度限制
接着将字符集换成 utf8mb4
,一个字符最多占用 4字节
。这个时候 C1 列 VARCHAR(M) 这个 M 设置多大呢?
要知道 M 指的是字符长度,而不是字节长度,而前面在 latin1
字符集且C1可为空的情况下算出的 65524 表示的既是字符长度又是字节长度。所以这时 C1 的长度实际应该是 M = 65524 / 4 = 16381。
下面验证下,先将长度设置为 16382,注意设置的字符集为 utf8mb4,可以看到创建报了同样的行太大的错误。
mysql> CREATE TABLE `test` (
`ID` BIGINT(20) NOT NULL AUTO_INCREMENT,
`C1` VARCHAR(16382) DEFAULT NULL,
PRIMARY KEY (`ID`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=COMPACT;
1118 - Row size too large. The maximum row size for the used table type, not counting BLOBs, is 65535.
This includes storage overhead, check the manual. You have to change some columns to TEXT or BLOBs
2
3
4
5
6
7
8
接着设置为 16381,创建成功,验证了我们的计算结果。
mysql> CREATE TABLE `test` (
`ID` BIGINT(20) NOT NULL AUTO_INCREMENT,
`C1` VARCHAR(16381) DEFAULT NULL,
PRIMARY KEY (`ID`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=COMPACT;
Query OK, 0 rows affected (0.01 sec)
2
3
4
5
6
7
最后这里总结一下VARCHAR类型:
- 一行数据,除了 TEXT、BLOB 等大对象类型,总长度最大
65535字节
。而且这个 65535 最大长度是包含变长字段长度列表
、NULL值列表
的。 VARCHAR(M)
中的 M 指的是字符长度,而不是字节长度,计算时,要用总长度除以字符集最大长度,例如 utf8mb4 字符集每个字符的最大长度为 4字节。- VARCHAR 类型如果小于255字节,要在变长字段长度列表占
1字节
,否则占2字节
;如果可为NULL,还要在NULL值列表占1字节
,不过这一个字节可以存8个可为NULL的列的状态。所以一个 VARCHAR(M) 的字节长度最大为65532字节
。
# CHAR 数据类型
我们一般会认为 CHAR(M)
是定长类型,M 与 VARCHAR(M) 中的 M 是一样的,指的是字符的长度。类型为CHAR(M)时,对于长度不足的值会用空格来补足,就算存的是空值,也会用空格补足,查询的时候会去除首尾的空格,而VARCHAR就不会。
从下面的列表可以看出,存储 CHAR(4) 只需要4字节,VARCHAR(4)则至少需要1字节用于存储长度。而且 CHAR(4) 会用空格补足长度,这样应该就不需要记录这个字段的长度了。
那 CHAR(M) 的长度会存到变长字段长度列表吗?
在我参考的书籍中,有这样的结论:
- 如果是定长字符类型,例如 latin1,一个字符就是1字节,CHAR(M) 会用空格补足,不需要在变长字段长度列表记录长度。
- 如果是变长字符类型,例如 utf8mb4,一个字符占用
1~4 字节
,CHAR(M) 就会占用M~4M 字节
,会被当成变长字符类型,会将实际长度存储到变长字段长列表中。
但我对这个结论有点迷,我们看下面的测试。
下面新增了一个 C2 列,类型为 VARCHAR(1),原本的 C1 长度减1。可以看到会创建失败,这个可以明确知道原因,因为C2列是变长类型,要在变长字段长度列表占用1字节,所以总长度就就超过了 65535字节。
8(ID字节) + 16380 * 4(C1字节) + 1 * 4(C2字节) + 1(NULL列表) + 2(C1长度) + 1(C2长度) = 65536
CREATE TABLE `test` (
`ID` BIGINT(20) NOT NULL AUTO_INCREMENT,
`C1` VARCHAR(16380) DEFAULT NULL,
`C2` VARCHAR(1) DEFAULT NULL,
PRIMARY KEY (`ID`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=COMPACT;
1118 - Row size too large. The maximum row size for the used table type, not counting BLOBs, is 65535.
This includes storage overhead, check the manual. You have to change some columns to TEXT or BLOBs
2
3
4
5
6
7
8
9
如果将C2改为 CHAR(1),这时就创建成功了。从这里可以看出,是不是可以说明 CHAR(M) 是定长类型,不会在变长字段长度列表占用空间,或者就算占用了也不会计算到总长度列表中?这里先留个疑问。
mysql> CREATE TABLE `test` (
`ID` BIGINT(20) NOT NULL AUTO_INCREMENT,
`C1` VARCHAR(16380) DEFAULT NULL,
`C2` CHAR(1) DEFAULT NULL,
PRIMARY KEY (`ID`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=COMPACT;
Query OK, 0 rows affected (0.01 sec)
2
3
4
5
6
7
8
# 行溢出数据
MySQL中磁盘和内存交互的基本单位是页
,一个页的大小一般是16KB
,也就是16384字节
,而一个VARCHAR(M)类型的列最多可以存储65532字节
,一些大对象如 TEXT、BLOB 可能存储更多的数据,这时一个页可能就存不了一条记录。这个时候就会发生行溢出
,多的数据就会存到另外的溢出页
中。
InnoDB 规定一页至少存储两条记录,如果页中只能存放下一条记录,InnoDB存储引擎会自动将行数据存放到溢出页中。在一般情况下,InnoDB 的数据都是存放在 FIL_PAGE_INDEX
类型的数据页中的。但是当发生行溢出时,溢出的数据会存放到 FIL_PAGE_TYPE_BLOB
类型的溢出页中。
当发生行溢出时,数据页只保存了前768字节的前缀数据,接着是20个字节的偏移量,指向行溢出页,大致如下图所示。
# COMPRESSED 和 DYNAMIC 行记录格式
Compressed 和 Dynamic 行记录格式与 Compact 行记录格式是类似的,只不过在处理行溢出数据时有些区别。
这两种格式采用完全的行溢出方式,数据页不会存储真实数据的前768字节,只存储20个字节的指针来指向溢出页。而实际的数据都存储在溢出页中,看起来就像下面这样:
Compressed 与 Dynamic 相比,Compressed 存储的行数据会以zlib的算法进行压缩以节省空间,因此对于 BLOB、TEXT、VARCHAR 这类大长度类型的数据能够进行非常有效的存储。
MySQL5.7 默认的行记录格式是 Dynamic
。
# 数据页结构
我们前面简单提到了页
的概念,页
是InnoDB存储引擎管理数据库的最小磁盘单位,一个页的大小一般是16KB
。一次至少读取一页的数据到内存,或者刷新一页的数据到磁盘。
我们这节主要来看存放数据记录的页,也就是 INDEX 类型的数据页。
数据页由 7 个部分组成,大致如下图所示:
其中 File Header、Page Header、File Trailer
的大小是固定的,分别为 38、56、8字节
。User Records、Free Space、Page Directory
这些部分为实际的行记录存储空间,因此大小是动态的。
# 记录头信息
前面的文章中简单提到过记录头信息,在介绍后面的内容前,再详细看下记录头信息中存储了哪些内容。
行记录看下来就像下面这样:
delete_mask
这个属性标记当前记录是否被删除,值为1的时候表示记录被删除掉了,值为0的时候表示记录没有被删除。
可以看出,当删除一条记录时,只是标记删除,实际在页中还没有被移除。这样做的主要目的是,以后如果有新纪录插入表中,可以复用这些已删除记录的存储空间。
min_rec_mask
B+树的每层非叶子节点
中的最小记录
都会添加该标记,并设置为1,否则为0。
看下图的索引结构,最底层的叶子节点是存放真实数据的,所以每条记录的 min_rec_mask 都为 0。上面两层是非叶子节点,那么每个页中最左边的最小记录的 min_rec_mask 就会设置为 1。
n_owned
表示当前记录拥有的记录数,页中的数据其实还会分为多个组,每个组会有一个最大的记录,最大记录的 n_owned 就记录了这个组中的记录数。在后面介绍 Page Directory 时会看到这个属性的用途。
heap_no
这个属性表示当前记录在本页中的位置。
record_type
记录类型:0 表示普通记录,1 表示B+树非叶子节点记录,2 表示最小记录,3 表示最大记录,1xx 表示保留
还是以前面索引结构图来看,上面两层的非叶子节点中的记录 record_type 都应该为 1。最底层的叶子节点应该就是普通记录,record_type 为 0。其实每个页还会有一个最小记录和最大记录,record_type 分别为 2 和 3,这个最小记录和最大记录其实就是后面要说的 Infimum 和 Supremum。
next_record
表示从当前记录的真实数据到下一条记录的真实数据的地址偏移量,如果没有下一条记录就是 0。
数据页中的记录看起来就像下图这样,按主键顺序排列后,heap_no
记录了当前记录在本页的位置,然后通过 next_record
连接起来。
注意 next_record
指向的是记录头与数据之间的位置偏移量。这个位置向左读取就是记录头信息,向右读取就是真实数据,而且之前说过变长字段长度列表
和NULL值列表
中都是按列逆序存放的,这时往左读取的标识和往右读取的列就对应上了,提高了读取的效率。
如果删除了其中一条记录,delete_mask
就设置为 1,标记为已删除,next_record
就会设置为 0。其实页中被删除的记录会通过 next_record 形成一个垃圾链表,供以后插入记录时重用空间。
# File Header
File Header 用来记录页的一些头信息,由8个部分组成,固定占用38字节
。
主要先看下如下的一些信息:
FIL_PAGE_SPACE_OR_CHKSUM
这个代表当前页面的校验和(checksum),每当一个页面在内存中修改了,在同步之前就要把它的校验和算出来。在一个页面被刷到磁盘的时候,首先被写入磁盘的就是这个 checksum。
FIL_PAGE_OFFSET
每一个页都有一个单独的页号,InnoDB 通过页号来唯一定位一个页。
如某独立表空间 a.ibd 的大小为1GB,页的大小默认为16KB,那么总共有65536个页。FIL_PAGE_OFFSET 表示该页在所有页中的位置。若此表空间的ID为10,那么搜索页(10,1)就表示查找表a中的第二个页。
FIL_PAGE_PREV
和FIL_PAGE_NEXT
InnoDB 是以页为单位存放数据的,InnoDB 表是索引组织的表,数据是按主键顺序存放的。数据可能会分散到多个不连续的页中存储,这时就会通过 FIL_PAGE_PREV 和 FIL_PAGE_NEXT 将上一页和下一页连起来,就形成了一个双向链表。这样就通过一个双向链表把许许多多的页就都串联起来了,而无需这些页在物理上真正连着。
FIL_PAGE_TYPE
这个代表当前页的类型,InnoDB 为了不同的目的而设计了许多种不同类型的页。
InnoDB 有如下的一些页类型:
# Page Header
Page Header 用来记录数据页的状态信息,由14个部分组成,共占用56字节
。
PAGE_N_DIR_SLOTS
页中的记录会按主键顺序分为多个组,每个组会对应到一个槽(Slot),PAGE_N_DIR_SLOTS
就记录了 Page Directory 中槽的数量。
PAGE_HEAP_TOP
PAGE_HEAP_TOP 记录了 Free Space
的地址,这样就可以快速从 Free Space 分配空间到 User Records 了。
PAGE_N_HEAP
本页中的记录的数量,包括最小记录(Infimum)和最大记录(Supremum)以及标记为删除(delete_mask=1)的记录。
PAGE_FREE
已删除的记录会通过 next_record
连成一个单链表,这个单链表中的记录空间可以被重新利用,PAGE_FREE 指向第一个标记为删除的记录地址,就是单链表的头节点。
PAGE_GARBAGE
标记为已删除的记录占用的总字节数。
PAGE_N_RECS
本页中记录的数量,不包括最小记录和最大记录以及被标记为删除的记录,注意和 PAGE_N_HEAP 的区别。
# Infimum 和 Supremum
InnoDB 每个数据页中有两个虚拟的行记录,用来限定记录的边界。Infimum记录
是比该页中任何主键值都要小的记录,Supremum记录
是比改页中何主键值都要大的记录。这两个记录在页创建时被建立,并且在任何情况下不会被删除。
并且由于这两条记录不是我们自己定义的记录,所以它们并不存放在页的User Records
部分,他们被单独放在一个称为Infimum + Supremum
的部分。
Infimum 和 Supremum 都是由5字节
的记录头和8字节
的一个固定的部分组成,最小记录的固定部分就是单词 infimum
,最大记录的固定部分就是单词 supremum
。由于不存在可变长字段或可为空的字段,自然就不存在可变长度字段列表和NULL值列表了。
Infimum和Supremum记录的结构如下图所示。需要注意,Infimum 记录头的 record_type=2
,表示最小记录;Supremum 记录头的 record_type=3
,表示最大记录。
加上 Infimum 和 Supremum 记录后,页中的记录看起来就像下图的样子。Infimum 记录头的 next_record 指向该页主键最小的记录,该页主键最大的记录的 next_record 则指向 Supremum,Infimum 和 Supremum就构成了记录的边界。同时注意,记录头中 heap_no
的顺序, Infimum 和 Supremum 是排在最前面的。
# User Records 和 Free Space
User Records
就是实际存储行记录的部分,Free Space
明显就是空闲空间。
在一开始生成页的时候,并没有User Records
这个部分,每当插入一条记录,就会从Free Space
部分中申请一个记录大小的空间到User Records
部分,当 Free Space 部分的空间用完之后,这个页也就使用完了。
# Page Directory
首先我们要知道,InnoDB 的数据是索引组织的,B+树
索引本身并不能找到具体的一条记录,只能找到该记录所在的页,页是存储数据的最小基本单位。
如下图,如果我们要查找 ID=32 的这行数据,通过索引只能定位到第 17 页。
定位到页之后我们可以通过最小记录Infimum
的记录头的next_record
沿着链表一直往后找,就可以找到 ID=32 这条记录了。
但是可以想象,沿着链表顺序查找的性能是很低的。所以,页中的数据其实是分为多个组的,这看起来就形成了一个子目录,通过子目录就能缩小查询的范围,提高查询性能了。
Page Directory
翻译过来就是页目录,这部分存放的就是一个个的槽(Slot),页中的记录分为了多个组,槽就存放了每个组中最大的那条记录的相对位置(记录在页中的相对位置,不是偏移量)。这个组有多少条记录,就通过最大记录的记录头中的 n_owned
来表示。
对于分组中的记录数是有规定的:Infimum记录
所在的分组只能有 1 条记录,Supremum记录
所在的分组中的记录条数只能在 1~8
条之间,中间的其它分组中记录数只能在是 4~8
条之间。
Page Directory
的生成过程如下:
- 初始情况下一个数据页里只有
Infimum
和Supremum
两条记录,它们分属于两个组。Page Directory 中就有两个槽,分别指向这两条记录,且这两条记录的n_owned
都等于1
。 - 之后每插入一条记录,都会从页目录中找到
主键值比本记录的主键值大并且差值最小的槽
,然后把该槽对应的记录的n_owned
值加1
,表示本组内又添加了一条记录,直到该组中的记录数等于8条
。 - 在一个组中的记录数等于
8条
后再插入一条记录时,会将组中的记录拆分成两个组,一个组中4条
记录,另一个5条
记录。这个过程会在页目录中新增一个槽来记录这个新增分组中最大的那条记录的相对位置。 - 当记录被删除时,对应槽的最大记录的 n_owned 会减 1,当 n_owned 小于 4 时,各分组就会平衡一下,总之要满足上面的规定。
其实正常情况下,按照主键自增长新增记录,可能每次都是添加到 Supremum
所在的组,直到它的 n_owned
等于8
时,再新增记录时就会分成两个组,一个组4条
记录,一个组5条
记录。还会新增一个槽,指向4条
记录分组中的最大记录,并且这个最大记录的n_owned
会改为4
,Supremum
的n_owned
就会改为5
。
Page Directory 中槽(Slot)的数量就会记录到 Page Header
中的 PAGE_N_DIR_SLOTS
。
我们可以通过下图来理解下 Page Directory 中槽(Slot)和分组中最大记录的关系。
- 首先,Slot0 指向 Infimum 记录,因为最小记录所在的分组只能有一条记录,它的
n_owned=1
. - 接着 Slot1、Slot2、Slot3 分别指向各自分组中的最大记录,且
n_owned=4
,可以想象其实就是Supremum
组分组而来的。 - 最后,Slot4 指向
Supremum
,这是最大记录的组,经过分组后,它的n_owned=5
。
可以看到,页中的数据经过分组后在 Page Directory 中就形成了一个目录槽,每个槽就指向了分组中的最大记录,最大记录的记录头中的 n_owned
就记录了这个组中的记录数。
有了目录槽之后,InnoDB就会利用二叉查找迅速确定记录所在的槽,并找到该槽所在分组中主键值最小的那条记录,再通过最小记录的 next_record 遍历记录,就能快速定位到匹配的那条记录了。
二叉查找的时间复杂度很低,同时在内存中的查找很快,因此通常会忽略这部分查找所用的时间。
# File Trailer
前面介绍 File Header
时说过,在将页写入磁盘时,最先写入的便是 File Header 中的 FIL_PAGE_SPACE_OR_CHKSUM
值,就是页面的校验和。在写入的过程中,数据库可能发生宕机,导致页没有完整的写入磁盘。
为了校验页是否完整写入磁盘,InnoDB 就设置了 File Trailer
部分。File Trailer 中只有一个FIL_PAGE_END_LSN
,占用8字节
。FIL_PAGE_END_LSN 又分为两个部分,前4字节
代表页的校验和;后4字节
代表页面被最后修改时对应的日志序列位置(LSN),与File Header中的FIL_PAGE_LSN
相同。
默认情况下,InnoDB存储引擎每次从磁盘读取一个页就会检测该页的完整性,这时就会将 File Trailer
中的校验和、LSN 与 File Header
中的 FIL_PAGE_SPACE_OR_CHKSUM
、FIL_PAGE_LSN
进行比较,以此来保证页的完整性。
# 文章来源
作者:bojiangzhou 链接:https://juejin.cn/post/6968264298208428046 来源:稀土掘金 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。