前言
上一篇介绍了InnoDB存储引擎的整体存储结构,这次我们就再来深入分析下InnoDB中的表以及数据到底是怎么存储的。
InnoDB表逻辑存储结构
前面我们介绍了,InnoDB中的数据都被存储在表空间(tabespace)中,而表空间又由段(segment),区(extent),页(page)组成,有时候页也被称为块(block)。大致结构如下图:
(图片来源于《MySQL技术内幕:InnoDB存储引擎》)
表空间
默认情况有有一个共享表空间文件ibdata1,另外提供了一个参数innodb_file_per_table来控制是否开启单独表空间,这个参数默认是开启状态。
另外独占表空间存放表的索引和数据,其他数据如回滚(undo)信息,插入缓冲索引页、系统事务信息,二次写缓冲(Double write buffer)等还是存放在原来的共享表空间内。
段
表空间是由各个segment(段)组成的,常见的有以下段:
- 数据段,即:Leaf node segment。因为InnoDB是索引组织表,数据存在B+树的叶子节点
- 索引段,即:Non-Leaf node segment。B+树中非叶子节点不存储数据,只是索引。
- 回滚段,即:Rollback segment。
段又是由不同的区(extent)组成
区
对于16KB或者小于16KB大小的页(page)来说,区的大小固定为1MB(64个连续的16KB页面,或128个8KB页面,或256个4KB页面)。
对于32KB的页面大小,区段大小为2MB。
对于64KB的页面大小,区段大小为4MB。
页
InnoDB中默认页大小为16KB,可以由变量innodb_page_size修改:
值描述40964KB或者4K81928KB或者8K1638416KB或者16K。默认页的大小3276832KB或者32K(MySQL5.7之后)6553664KB或者64K(MySQL5.7之后)
注意:innodb_page_size是一个global变量,只能在初始化MySQL实例的时候进行设置,中途不能修改,如果没有设置,则会采用默认大小16KB。
初始表空间大小疑问
创建一个表:
CREATE TABLE `my_user` (
`id` varchar(1350) DEFAULT NULL,
`name` varchar(1350) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8
再看一下页的大小:
然后去服务器上查看一下表空间大小:
我们发现,大小只有96KB,但是上面又说页的大小在16KB的时候,一个区就是64个页,那么默认大小应该至少有1MB才对,但是这里为什么只有96KB呢?
这是因为InnoDB为了防止小表过多而占用了太多空间,所以默认情况下会有32个碎片页来分配,初始的时候会分配6个碎片页来存储一些必要信息,然后在需要空间的时候继续按需分配,直到32个碎片页分配完了之后,就会开始整个完整的区开始进行分配。
注意:分配区的时候并不总是单个区来分配,有时候为了保证数据的连续性,InnoDB可能会一次性将4个区加载到段中。
行
InnoDB存储引擎是面向行的(row-oriented),也就是说数据是按行进行存放的,每个页存放的行记录也是有硬性定义的,最多允许存放16KB/2-200,即7992行记录。
每行数据允许的最大长度应略小于半个page大小。例如,对于默认的16KB InnoDB页面大小,最大行长度略小于8KB。对于64KB的页面,最大行长度略小于16KB。
这么设计的原因是为了更好地利用B+树的特性,如果一个页只能存一行数据,那么整个B+树的叶子节点就相当于一个链表,无法很好地利用B+树的特性。想要详细了解B+树特性的,可以点击这里。
表的行格式决定了它的行是如何物理存储的,这反过来又会影响查询和DML操作的性能。当一个磁盘页中可以容纳更多的行时,查询和索引查找可以工作得更快,缓冲池中需要的缓存内存更少,写入更新值所需的I/O也更少。
我们知道,B+树的数据就存储的叶子节点,但是可变长度列(如varchar,text,blob等)是一个例外。如果变长列太长而无法装入B+树页,则存储在单独分配的磁盘页上(溢出页)。这些列称为页外列,页外列的值存储在溢出页的单独链接列表中,每个这样的列都有自己的一个或多个溢出页列表。根据列长度的不同,所有变长列值或前缀都存储在B+树中,以避免浪费存储空间并不得不读取单独的页面。
行格式主要分为四种:REDUNDANT, COMPACT, DYNAMIC, and COMPRESSED。
REDUNDANT格式
REDUNDANT格式提供了与旧版本MySQL的兼容性。
每行存储结构如下:
字段长度偏移列表头信息列1列2列…
特性
REDUNDANT行格式具有如下特性:
- 每个索引记录包含一个6字节的头信息。头信息用于将连续的记录链接在一起,并用于行级锁定。
- 聚集索引中的记录包含所有用户定义列的字段。此外,还有一个6字节的事务ID字段和一个7字节的滚指针字段。
- 如果没有为表定义主键,则每个聚集索引记录还包含一个6字节的行ID字段。
- 每个辅助索引记录包含不在辅助索引中的聚集索引键定义的所有主键列。
- 一个记录包含一个指向记录中每个字段的指针。如记录中字段的总长度小于128字节,则指针为1字节;否则,2个字节。指针数组称为记录目录。指针所指向的区域是记录的数据部分。
- 在内部,像CHAR(10)这样的固定长度字符列以固定长度格式存储。尾随空格不会被截断。
- 将变长列值的前768字节存储在B+tree节点的索引记录中,其余的存储在溢出页中。大于或等于768字节的固定长度列被编码为可变长度列,可以在页外存储。例如,如果字符集的最大字节长度大于3(如:utf8mb4),CHAR(255)列可以超过768字节)。如果列的值为768字节或更少,则不会使用溢出页,因为该值完全存储在B+树节点中。
- 可变长列中null值不占用空间,定长列中null值会占用定义的空间大小。定长列的空值保留固定空间是为了当列从空值更新为非空值时不会导致索引页碎片。
COMPACT格式
与REDUNDANT行格式相比,COMPACT行格式减少了约20%的行存储空间,但会增加某些操作的CPU使用。
对于变长字段的存储方式和REDUNDANT一致。
每行存储结构如下(表头部分没有含义,仅为了便于后文描述):
index1index2index3index4index5index6变长字段长度列表NULL标志位头信息列1列2列…
特性
- 每个索引记录包含一个5字节的头信息(index3),并且前面可能还会有一个可变长度信息(index1和inex2)。头信息用于将连续的记录链接在一起,并用于行级锁定。
- 上图中index1部分最多2个字节。如果列的总长度小于255个字节,则index1部分用1个字节表示;如果列的长度大于255个字节,则inex1部分用2个字节表示。这是因为在InnoDB中varchar类型最大长度为65535,所以用2个字节表示就够了。
- 如果一行中有N个列允许为Null,则null标志位占用N/8个字节,也就是说,每8个Null列,就会占用1个字节.
- 如果一个列中没有Null值也没有可变长度列,上面的index1和index2就会没有(index1和index2同属于一个可变长头信息部分)。
- 将变长列值的前768字节存储在B+tree节点的索引记录中,其余的存储在溢出页中。
- 对于外部存储的列,2字节长度表示内部存储部分的长度加上指向溢出部分的20字节指针。内部部分是768字节,所以长度是768+20。20字节的指针存储列的真实长度。
- 上图中index4开始,存储的是非空列信息。
- 聚集索引中的记录包含所有用户定义列的字段。此外,还有一个6字节的事务ID字段和一个7字节的滚指针字段。
- 如果没有为表定义主键,则每个聚集索引记录还包含一个6字节的行ID字段。
- 每个辅助索引记录包含不在辅助索引中的聚集索引键定义的所有主键列。如果任何一个主键列是可变长度的,那么每个辅助索引的头信息都有一个可变长度的部分来记录它们的长度,即使辅助索引定长列。
- 在内部,对于不可变长字符集,像CHAR(10)这样的固定长度字符列以固定长度格式存储。尾随空格不会被截断。
- 在内部,对于可变长度的字符集,如utf8和utf8mb4, InnoDB尝试通过去除尾部空格将CHAR(N)存储为N个字节。如果CHAR(N)列值的字节长度超过N个字节,则会通过去除尾部空格来达到列值字节长度的最小值。CHAR(N)列的最大长度是最大字符字节长度乘以N(如uft8,CHAR(10)列最大长度为3*10)。
- 为CHAR(N)保留最小的N个字节。在多数情况下,保留最小空间可以在更新的时候不会导致索引页碎片。相比之下,在使用REDUNDANT行格式时,CHAR(N)列占用的最大字符字节长度乘以N(如utf8下,char(10)保留10个字节,而REDUNDANT会保留3*10个字节)。
DYNAMIC格式
DYNAMIC格式提供了和COMPACT相同的存储特征,但为比较长的可变长度的列增加了增强的存储能力,并支持大型索引键前缀。
当用ROW_FORMAT=DYNAMIC创建一个表时,InnoDB可以在完全脱离页面的情况下存储长列的可变长度值(对于VARCHAR、VARBINARY、BLOB和TEXT类型),而聚集索引记录只包含一个指向溢出页面的20字节指针。大于或等于768字节的固定长度字段被编码为可变长度字段。
列是否在页外存储取决于页大小和行的总大小。如果行太长,则选择最长的列进行页外存储,直到聚集索引记录适合B+tree页为止。小于或等于40字节的文本和BLOB列存储在行中。
DYNAMIC行格式最多支持3072字节的索引键前缀。这个特性由innodb_large_prefix变量控制,该变量在默认情况下是启用的。
DYNAMIC行格式保持了在索引节点中存储整个行的效率(REDUNDANT和COMPACT格式也是如此),但DYNAMIC行格式避免了用大量长列的数据字节填充B+树节点的问题。
动态行格式基于这样一种思想:如果长数据值的一部分存储在页外,那么在页外存储整个值通常是最有效的。使用DYNAMIC格式,较短的列可能保留在B+树节点中,从而最小化给定行所需的溢出页数。
其他的特性均与COMPACT行格式一致。
COMPRESSED格式
COMPRESSED行格式提供了与DYNAMIC行格式相同的存储特性和功能,但增加了对表和索引数据压缩的支持。
COMPRESSED行格式使用与DYNAMIC行行格式类似的实现来进行页外存储,同时还考虑了被压缩的表和索引数据的额外存储和性能考虑,并使用更小的页大小。
对于压缩行格式,KEY_BLOCK_SIZE选项控制有多少列数据存储在聚集索引中,以及有多少列数据存储在溢出页面上。
COMPRESSED行格式最多支持3072字节的索引键前缀。这个特性由innodb_large_prefix变量控制,该变量在默认情况下是启用的。
其余特性与COMPACT行格式一样。
指定表的行格式
InnoDB表的默认行格式是由innodb_default_row_format变量定义的,该变量的默认值为DYNAMIC。如果没有显式定义ROW_FORMAT表选项,或者指定了ROW_FORMAT= default,则使用默认的行格式。
SET GLOBAL innodb_default_row_format=DYNAMIC;
注意:有效的innodb_default_row_format选项包括DYNAMIC,COMPACT, 和REDUNDANT。因为system表空间不支持使用COMPACT行格式,因此不能将其定义为缺省值。只能在CREATE TABLE或ALTER TABLE语句中显式地指定它。试图设置innodb_default_row_format变量为COMPACT会报错:
SET GLOBAL innodb_default_row_format=COMPRESSED;
ERROR 1231 (42000): Variable 'innodb_default_row_format'
can't be set to the value of 'COMPRESSED'
另外可以使用CREATE table或ALTER table语句中的ROW_FORMAT表选项显式定义表的行格式。例如:
CREATE TABLE t1 (c1 INT) ROW_FORMAT=DYNAMIC;
修改行格式注意事项
如果一张表的行格式需要从REDUNDANT或COMPACT改成DYNAMIC或COMPACT,需要注意的是:
REDUNDANT和COMPACT行格式支持最大索引键前缀长度为767字节,而DYNAMIC和COMPRESSED行格式支持最大索引键前缀长度为3072字节,尤其是主从复制时尤其要注意,如果主从设置的行格式不一致,可能导致一方的语句执行失败。
InnoDB限制
- 一个表最多可以包含1017列(在MySQL 5.6.9中从之前的1000列限制提高了)。虚拟生成的列包括在这个限制中。
- 一个表最多可以包含64个二级索引。
- 如果innodb_large_prefix是启用的(默认),对于使用DYNAMIC或COMPACT行格式的InnoDB表,索引键前缀限制为3072字节。如果innodb_large_prefix被禁用,对于任何行格式的表,索引键前缀限制为767字节。
innodb_large_prefix已被弃用,将在未来的版本中被删除。innodb_large_prefix是在MySQL 5.5中引入的,用于禁用大索引键前缀,以与不支持大索引键前缀的InnoDB早期版本兼容。
如果在创建MySQL实例时通过指定innodb_page_size选项将InnoDB页面大小减少到8KB或4KB,索引键的最大长度将按比例降低:3072字节限制基于16KB页面大小。也就是说,当页面大小为8KB时,最大索引键长度为1536字节,而当页面大小为4KB时,最大索引键长度为768字节。
- 适用于索引键前缀的限制也适用于全列索引键。
- 多列索引最多允许16列。
- 对于4KB、8KB、16KB和32KB的页,最大行大小(不包括存储在页外的任何可变长度的列)应略小于页大小的一半。然而对于64KB的页来说,最大行大小约为1/4页。
- LONGBLOB和LONGTEXT列必须小于4GB,包括BLOB和TEXT列在内的总行大小也必须小于4GB。
- 如果一行小于半页长,那么它全部存储在本地页中。如果它超过半页,那么将选择可变长度的列进行外部页外存储,直到行能适应半页的大小。
- 虽然InnoDB内部支持大于65,535字节的行大小,但MySQL本身对所有列的合并大小施加了65,535的行大小限制
- InnoDB日志文件的最大大小是512GB。
- 最小表空间大小略大于10MB。最大表空间大小取决于InnoDB的页面大小(表空间的最大值也是表的最大值):
页大小对应的表空间最大值4KB16TB8KB32TB16KB64TB32KB126TB64KB256TB
列的限制
MySQL的硬限制是每个表有4096列,但是对于给定的表,有效的最大值可能更少。确切的列限制取决于几个因素:
- 表的最大行大小限制了列的数量(以及可能的大小),因为所有列的总长度不能超过这个大小
- 单个列的存储需求限制了给定最大行大小内所能容纳的列数。某些数据类型的存储要求取决于存储引擎、存储格式和字符集等因素.
- 存储引擎可能会施加额外的限制,限制表列计数。例如:InnoDB每个表的列数限制为1017。
- 每个表都有一个包含表定义的.frm文件,如果对frm文件做了限制也会影响
行的限制
给定表的最大行大小由下面几个因素决定:
- MySQL表的内部表示的最大行大小限制为65,535字节,即使存储引擎能够支持更大的行。BLOB和TEXT列只对行大小限制贡献9到12个字节,因为它们的内容与行的其余部分分开存储。
- InnoDB中,对于4KB、8KB、16KB和32KB的页,最大行大小(不包括存储在页外的任何可变长度的列)应略小于页大小的一半。然而对于64KB的页来说,最大行大小约为1/4页。
- InnoDB中如果一行小于半页长,那么它全部存储在本地页中。如果它超过半页,那么将选择可变长度的列进行外部页外存储,直到行能适应半页的大小。
举例1
下面的InnoDB和MyISAM示例演示了MySQL最大行大小限制为65,535字节。无论存储引擎是什么,都会强制执行此限制,即使存储引擎可能能够支持更大的行:
InnoDB:
mysql> CREATE TABLE t (a VARCHAR(10000), b VARCHAR(10000),
c VARCHAR(10000), d VARCHAR(10000), e VARCHAR(10000),
f VARCHAR(10000), g VARCHAR(6000)) ENGINE=InnoDB CHARACTER SET latin1;
ERROR 1118 (42000): 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
MyISAM:
mysql> CREATE TABLE t (a VARCHAR(10000), b VARCHAR(10000),
c VARCHAR(10000), d VARCHAR(10000), e VARCHAR(10000),
f VARCHAR(10000), g VARCHAR(6000)) ENGINE=MyISAM CHARACTER SET latin1;
ERROR 1118 (42000): 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
注意:这两个例子中要注意的是编码用的是latin1,如果用的是utf8则大小要除以3,用的是utf8mb4,大小要除以4,后面的例子中也是一样。
上面两个例子中,如果将最后一列g改为text则可以执行成功:
InnoDB:
mysql> CREATE TABLE t (a VARCHAR(10000), b VARCHAR(10000),
c VARCHAR(10000), d VARCHAR(10000), e VARCHAR(10000),
f VARCHAR(10000), g TEXT(6000)) ENGINE=InnoDB CHARACTER SET latin1;
Query OK, 0 rows affected (0.02 sec)
MyISAM:
mysql> CREATE TABLE t (a VARCHAR(10000), b VARCHAR(10000),
c VARCHAR(10000), d VARCHAR(10000), e VARCHAR(10000),
f VARCHAR(10000), g TEXT(6000)) ENGINE=MyISAM CHARACTER SET latin1;
Query OK, 0 rows affected (0.02 sec)
举例2
虽然最大限制为65535,但是实际上每一列还要2个字节来存储大小,所以实际上能存储的是65535-(列数*2),注意这个前提是没有其他列:
mysql> CREATE TABLE t1
(c1 VARCHAR(32765) NOT NULL, c2 VARCHAR(32766) NOT NULL)
ENGINE = InnoDB CHARACTER SET latin1;
Query OK, 0 rows affected (0.02 sec)
上面这个加起来是65535-(2*2)=65531,如果列可以为NULL那么长度每8列还要再减1。
下面这个建立一个65535的列(65533就可以成功)是失败的:
mysql> CREATE TABLE t2
(c1 VARCHAR(65535) NOT NULL)
ENGINE = InnoDB CHARACTER SET latin1;
ERROR 1118 (42000): 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
举例3
上面举的都是变长varchar的例子,这里再来一个定长char的例子:
mysql> CREATE TABLE t4 (
c1 CHAR(255),c2 CHAR(255),c3 CHAR(255),
c4 CHAR(255),c5 CHAR(255),c6 CHAR(255),
c7 CHAR(255),c8 CHAR(255),c9 CHAR(255),
c10 CHAR(255),c11 CHAR(255),c12 CHAR(255),
c13 CHAR(255),c14 CHAR(255),c15 CHAR(255),
c16 CHAR(255),c17 CHAR(255),c18 CHAR(255),
c19 CHAR(255),c20 CHAR(255),c21 CHAR(255),
c22 CHAR(255),c23 CHAR(255),c24 CHAR(255),
c25 CHAR(255),c26 CHAR(255),c27 CHAR(255),
c28 CHAR(255),c29 CHAR(255),c30 CHAR(255),
c31 CHAR(255),c32 CHAR(255),c33 CHAR(255)
) ENGINE=InnoDB ROW_FORMAT=COMPACT DEFAULT CHARSET latin1;
ERROR 1118 (42000): Row size too large (> 8126). Changing some columns to TEXT or BLOB or using
ROW_FORMAT=DYNAMIC or ROW_FORMAT=COMPRESSED may help. In current row format, BLOB prefix of 768
bytes is stored inline.
如果把上面的例子中ENGINE换成MYISAM就会成功,这是为什么呢?
原因上面介绍过了,因为InnoDB引擎限制了一行最大也应略小于页的一半。16KB的一半应该是8192,除去一些其他信息占用的空间和提示中的8126差不多能匹配上,而建表语句中总长度为:25533=8415,已经超过一半了,故而报错。*
总结
本文主要内容来自于官网的翻译,比较偏向于理论性质,这篇文章最主要目的是为了将MySQL及InnoDB的一些特性和限制记录下来,以便后续复习这一块的时候可以更加方便,文章中的部分理论本人并未去实践操作,本文中的内容均基于MySQL5.7版本,如有其他版本,可能会有少许偏差,如上文中默认建表后的表空间大小,用MySQL8.0.21版本建表后,默认大小为112KB而不是96KB。