22 KiB
表
索引组织表
innodb存储引擎中,表都是根据主键顺序组织存放的,这种存储方式被称为索引组织表(index organized table)。在innodb存储引擎表中,每张表都有主键(primary key),如果在创建表时没有显式指定主键,那么innodb会按照如下方式创建主键:
- 首先判断表中是否存在非空的唯一索引(unique not null)字段,如果有,则其为主键
- 如果不存在非空唯一索引,那么innodb会自动创建一个6字节大小的指针作为主键
如果有多个非空唯一索引,innodb存储引擎将会选择第一个定义的非空唯一索引作为主键。
innodb逻辑存储结构
在innodb的存储逻辑结构中,所有的数据都被逻辑存放在表空间(table space)中。表空间则由段(segement),区(extent),页(page)组成。
组成如图所示:
表空间
表空间为innodb存储引擎逻辑结构的最高层,所有数据都存放于表空间中。innodb存在一个默认的共享表空间ibdata1,在开启innodb_file_per_table参数后,每张表内的数据可以单独存放到一个表空间。
innodb_file_per_table
innodb_file_per_table参数启用会导致每张表的数据、索引、插入缓冲bitmap页存放到单独的文件中;但是其他数据,例如回滚(undo)信息,插入缓冲页,系统事务信息,double write buffer等还是存放在默认的共享表空间中。
段(segment)
如上图所示,表空间是由段(segment)所组成的,常见的段分为数据段,索引段,回滚段等。
在innodb存储引擎中,数据即索引,索引即数据。数据段即为B+树的叶子节点(Leaf node segment),索引段即为B+树的非叶子节点(Non-leaf node segment)。
区(Extent)
区是由连续页组成的空间,在任何情况下每个区的大小都为1MB。为了保证区中页的连续性,innodb存储引擎会一次性从磁盘申请4~5个区。在默认情况下,innodb存储引擎中页的大小为16KB,一个区中包含64个页。
在新建表时,新建表的大小为96KB,小于一个Extent的大小1MB,因为每个段Segmenet开始时,都会有至多32个页大小的碎片页,等使用完这些页后才会申请64个连续页作为Extent。
都与一些小表或是undo这样的段,可以在开始时申请较少的空间,节省磁盘容量开销。
页(Page)
innodb存储引擎中页的大小默认为16KB,默认的页大小可以通过innodb_page_size参数进行修改。通过该参数,可以将innodb的默认页大小设置为4K,8K。
页是innodb磁盘管理的最小单位,在innodb中,常见的页有:
- 数据页(B-tree node)
- undo页(undo log page)
- 系统页(system page)
- 事务数据页(transaction system page)
- 插入缓冲位图页(insert buffer bitmap)
- 插入缓冲空闲列表页(insert buffer free list)
- 未压缩的二进制大对象页(uncompressed blob page)
- 压缩的二进制大对象页(compressed blob page)
行
innodb存储引擎是面向行的,数据按行进行存放。每个页中至多可以存放16KB/2 - 200行的记录,即7992行记录。
innodb行记录格式
innodb存储引擎以行的形式进行存储,可以通过show table status like '{table_name}'的语句来查询表的行格式,示例如下:
show table status like 'demo_t1'
| Name | Engine | Version | Row_format | Rows | Avg_row_length | Data_length | Max_data_length | Index_length | Data_free | Auto_increment | Create_time | Update_time | Check_time | Collation | Checksum | Create_options | Comment |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| demo_t1 | InnoDB | 10 | Dynamic | 0 | 0 | 16384 | 0 | 32768 | 0 | 1 | 2025-01-30 15:18:57 | null | null | utf8mb4_0900_ai_ci | null |
上述示例中表的row_format为dynamic。
Compact
在使用Compact行记录格式时,一个页中存放数据越多,其性能越高。
Compact格式下行记录的存储格式如下:
| 变长字段长度列表 | NULL标志位 | 记录头信息 | 列1数据 | 列2数据 | ...... |
变长字段长度列表
Compact行记录格式其首部是一个非Null变长字段长度列表,其按照列的顺序逆序放置,变长列的长度为:
- 如果列的长度小于255字节,用1字节表示
- 如果列的长度大于255字节,用2个字节表示
变长字段的长度不能小于2字节,因为varchar类型最长长度限制为65535。
NULL标志位
NULL标志位为bitmap,代表每一列是否为空。如果行中存在n个字段可为空,那么NULL标志位部分的长度为ceiling(n/8)。
记录头信息
record header,固定占用5字节,其中,record header的各bit含义如下所示:
| 名称 | 大小(bit) | |
|---|---|---|
| () | 1 | 未知 |
| () | 1 | 未知 |
| deleted_flag | 1 | 该行是否已经被删除 |
| min_rec_flag | 1 | 该行是否为预订被定义的最小记录行 |
| n_owned | 4 | 该记录拥有的记录数 |
| heap_no | 13 | 索引堆中该条记录的排序记录 |
| record_type | 3 | 记录类型,000代表普通,001代表B+树节点指针, 010代表infimum,011代表supremum,1xx保留 |
| next_record | 16 | 页中下一条记录相对位置 |
除了上述3个部分之外,其他部分就是各个列的实际值。
在Compact格式中,NULL除了占有NULL标志位外,不占用任何实际空间。
每行数据中,除了有用户自定义的列外,还存在两个隐藏列,即
事务id列和回滚指针列,长度分别为6字节和7字节。如果innodb表没有自定义主键,每行还会增加一个rowid列。
行溢出数据
innodb存储引擎可能将一条记录中某些数据存储在真正的存储数据页面之外。一般来说,blob、lob这类大对象的存储位于数据页面之外。
当行数据的大小特别大,导致一个页无法存放2条行数据时,innodb会自动将占用空间大的blob字段或varchar字段值放到额外的uncompressed blob page中。
dynamic
dynamic格式几乎和compact格式相同,但是对于每个blob字段,其存储只消耗20字节用于存储指针。
而对于Compact格式,其会在blob格式中存储768字节的前缀字节。
char存储结构
char(n)字段中n代表字符长度而非字节长度,故而在不同字符集下,char类型字段的内部存储可能不是定长的。
例如,在utf8字符集下,ab和我们两个字符串,其字符数都是2个,但是ab其占用2字节,而我们占用4字节,即使同样是char(2)类型的字符串,其占用字节数量仍然有可能不同。
innodb数据页结构
innodb中页是磁盘管理的最小结构,页类型为B-tree Node的页存放的即是表中行的实际数据。
innodb数据页由如下7个部分组成:
- File Header(文件头)
- Page Header(页头)
- Infimum和Supremum Records
- user records(用户记录,即行记录)
- free space(空闲空间)
- page directory(页目录)
- file trailer(文件结尾信息)
file header,page header,file trailer的大小是固定的,分别为38,56,8字节,这些空间用于标记页的一些信息,例如checksum,数据页所在B+树的层数等。
File Header
File Header用于记录页的一些头信息,由8个部分组成,共占38字节:
| 名称 | 大小 | 说明 |
|---|---|---|
| FIL_PAGE_SPACE_OR_CHKSUM | 4 | 代表页的checksum值 |
| FIL_PAGE_OFFSET | 4 | 表空间中页的偏移位置 |
| FIL_PAGE_PREV | 4 | 当前页的上一个页,B+树决定叶子节点为双向列表 |
| FIL_PAGE_NEXT | 4 | 当前页的下一个页 |
| FIL_PAGE_LSN | 8 | 代表该页最后被修改的日志序列位置LSN |
| FIL_PAGE_TYPE | 2 | 存储引擎页类型 |
| FIL_PAGE_FILE_FLUSH_LSN | 8 | 该值仅在系统表空间的一个页中定义,代表文件至少被更新到了该LSN值,对于独立的表空间,该值为0 |
| FIL_PAGE_ARCH_LOG_NO_OR_SPACE_ID | 4 | 代表页属于哪个表空间 |
Infimum和Supremum record
在innodb存储引擎中,每个数据页都有两行虚拟的行记录,用于限定记录边界。Infimum是比该页中所有主键值都要小的值,Supremum是比任何可能值都要大的值。这两个值在页创建时被建立,并且在任何情况下都不会被删除。
user record 和 free space
user reocrd代表实际存储行记录中的内容,free space则是代表空闲空间,同样是链表数据结构,在一条记录被删除后,该空间会被加入到空闲列表中。
page directory
page directory(页目录)中存放了记录的相对位置,这些记录指针被称为目录槽(directory slots)。innodb中槽是一个稀疏目录,一个槽中可能包含多个记录,记录Infimum的n_owned总为1,记录Supremum的n_owned取值范围为[1,8],其他用户记录的n_owned为[4,8]。当记录被插入或删除时,需要对槽进行分裂或平衡的维护操作。
在slots中,记录按照索引键值顺序存放,可以通过二叉查询迅速找到记录的指针。
由于在innodb存储引擎中,page directory是稀疏目录,二叉查找结果只是一个粗略的结果,innodb存储引擎必须通过record header中的next_record来继续查找相关记录。
B+树索引
例如,在上图示例中,其在查询ID=32的时,只能查询到该记录位于page 17中。
在B+树的叶子节点,每个页的File Header中都存有指向前一个页和后一个页的指针,
故而每个页之间是通过双向列表结构来维护的;但是对于页中的记录,记录与记录之间维维护的时单向列表的关系。对于Compact或Dynamic行格式的页,其每条记录的record header中都包含一个next_record字段指向下一条记录。故而,位于同一个页中的记录可以单向访问。
但是,在一个页内查找某条记录时,沿着单向链表进行查找其效率很低。故而,page中的数据时机被分为了多个组,被分为的组构成了一个subdirectory,故而,通过子目录能够缩小查询范围,提高查询性能。
page directory是一个能够存储多个slots的部分,每个slot中存储了group中最后一条记录的相对位置。假设slot中最大一条记录为r,那么group中记录的条数被存储在r记录record header的n_owned字段中。
group中record的数量约束如下:
- infimum group中records数量限制为
[1,8] - supremum group中records数量限制为
[1,8] - 其他group中records限制为
[4,8]
page directory生成过程如下所示:
- 最开始时,页中只有infimum和supremum两条记录,它们分别属于两个group。page directory中有两个slots指向这两条记录,两个slot的n_owned都为1
- 后续,当新的记录被插入页时,系统会查找page directory中主键值大于待插入记录的第一个slot。slot对应最大记录的
n_owned字段会增加1,代表group中新插入了记录,直到group中的记录数量到达8 - 当新纪录被插入到的group中记录数大于8时,group中的记录被分为2个group,一个group包含4条记录,另一个group包含5条记录。该过程将会向page directory中新增一个新的slot
- 当记录被删除时,slot最最大一条记录的
n_owned将会减少1,当n_owned字段小于4时,会触发再平衡操作,平衡后的page directory满足上述要求
page directory中的slots数量如page header中的PAGE_N_DIR_SLOTS所示。
一旦innodb中页包含page directory后,其会通过二分查找快速的定位slot,并且从group中最小记录开始,通过next_record指针来遍历页中的记录列表。这样能够快速的定位记录位置。
File Trailer
完整性校验
在innodb中,页的大小为16KB,可能和磁盘的扇区大小不同。通常磁盘的扇区大小为512字节,故而在写入一个页到磁盘中时,需要分32个扇区进行写入。
在写入一个页时,可能会发生宕机场景,这时,一个页只写入了一部分,可能会发生脏写。此时,可以通过double write buffer机制对脏写的页进行恢复。
在一个页被写入到磁盘中时,首先会被写入的是File Header的FIL_PAGE_SPACE_OR_CHKSUM,该部分是page的checksum。
innodb设置了File Trailer部分来校验page是否被完全写入到磁盘中,File Trailer中只包含一个FIL_PAGE_END_LSN部分,占用8字节,前4字节代表该页的checksum值,后4字节和File Header中的FIL_PAGE_LSN相同,代表最后一次修改该页关联的LSN位置。将上述两个字段和File Header中的FIL_PAGE_SPACE_OR_CHKSUM和FIL_PAGE_LSN值进行比较,查看是否一致,从而保证页的完整性。
默认情况下,在innodb每次从磁盘读取page时,都会检查页面的完整性。
分区表
innodb存储引擎支持分区功能,分区过程将一个表或索引分为多个更小、更可管理的部分。
对于访问数据库的应用而言,逻辑上的一个表或索引,其物理上可能由多个物理分区组成,每个分区都是独立的对象,既可以独自进行处理,又可以作为一个更大对象的一部分进行处理。
mysql仅支持水平分区(将同一张表中的不同行记录分配到不同的物理文件中),并不支持垂直分区(将同一张表中的不同列分配到不同的物理文件中)。
目前mysql支持如下几种类型的分区:
RANGE: 行数据基于一个给定连续区间的列值被放入分区LIST: 和RANGE类似,但LIST分区面对的是更加离散的值HASH: 根据用户自定义的表达式返回值来进行分区,返回值不能为负数KEY:根据mysql提供的哈希函数来进行分区
不论创建何种类型的分区,如果表中存在唯一索引或主键,分区列必须是唯一索引的一个组成部分。
partition keys & primary keys & unique keys
对于分区表而言,所有在partition expression中使用到的col,其必须被包含在分区表所有的唯一索引中。
所有唯一索引中包含主键索引。
正确建立分区表的声明示例如下:
CREATE TABLE t1 (
col1 INT NOT NULL,
col2 DATE NOT NULL,
col3 INT NOT NULL,
col4 INT NOT NULL,
UNIQUE KEY (col1, col2, col3)
)
PARTITION BY HASH(col3)
PARTITIONS 4;
CREATE TABLE t2 (
col1 INT NOT NULL,
col2 DATE NOT NULL,
col3 INT NOT NULL,
col4 INT NOT NULL,
UNIQUE KEY (col1, col3)
)
PARTITION BY HASH(col1 + col3)
PARTITIONS 4;
CREATE TABLE t7 (
col1 INT NOT NULL,
col2 DATE NOT NULL,
col3 INT NOT NULL,
col4 INT NOT NULL,
PRIMARY KEY(col1, col2)
)
PARTITION BY HASH(col1 + YEAR(col2))
PARTITIONS 4;
CREATE TABLE t8 (
col1 INT NOT NULL,
col2 DATE NOT NULL,
col3 INT NOT NULL,
col4 INT NOT NULL,
PRIMARY KEY(col1, col2, col4),
UNIQUE KEY(col2, col1)
)
PARTITION BY HASH(col1 + YEAR(col2))
PARTITIONS 4;
create table t2_partition (
col1 int ,
col2 date,
col3 int null,
col4 int null,
unique index `idx_t2_partition_cols` (col1, col2,col3),
primary key (col2, col1)
)
partition by hash(col1)
partitions 4;
表中不存在唯一索引
如果表中没有唯一索引(也未定义primary key),那么上述要求并不会生效,可以在partition expression中使用任意cols。
后续向分区表添加唯一索引
如果想要向分区表中添加唯一索引,那么新增的唯一索引中必须包含partition expression中所有的列。
对非分区表进行分区
可以参照如下示例对非分区表进行分区:
CREATE TABLE np_pk (
id INT NOT NULL AUTO_INCREMENT,
name VARCHAR(50),
added DATE,
PRIMARY KEY (id)
);
可以按id进行分区
ALTER TABLE np_pk
PARTITION BY HASH(id)
PARTITIONS 4;
分区类型
RANGE
RANGE为较常见的分区类型,如下示例为创建RANGE类型分区表的示例:
create table t1 (
id bigint auto_increment not null,
tran_date datetime(6) not null default now(6) on update now(6),
primary key (id, tran_date)
) engine=innodb
partition by range (year(tran_date)) (
partition part_y_let_2024 values less than (2025),
partition part_y_eq_2025 values less than (2026)
);
上述创建了表t1,t1主键为(id, tran_date),且按照tran_date字段的年部分进行分区,分区类型为RANGE。
表t1的分区为两部分,分区1为tran_date位于2024或之前年份的分区part_y_let_2024,分区2是tran_date位于2025年的分区part_y_eq_2025。
information_schema.partitions
如果要查看表的分区情况,可以查询information_schema.partitions表,示例如下
select * from information_schema.partitions where table_schema = 'learn_innodb' and table_name = 't1';
查询结果如下所示:
| TABLE_CATALOG | TABLE_SCHEMA | TABLE_NAME | PARTITION_NAME | SUBPARTITION_NAME | PARTITION_ORDINAL_POSITION | SUBPARTITION_ORDINAL_POSITION | PARTITION_METHOD | SUBPARTITION_METHOD | PARTITION_EXPRESSION | SUBPARTITION_EXPRESSION | PARTITION_DESCRIPTION | TABLE_ROWS | AVG_ROW_LENGTH | DATA_LENGTH | MAX_DATA_LENGTH | INDEX_LENGTH | DATA_FREE | CREATE_TIME | UPDATE_TIME | CHECK_TIME | CHECKSUM | PARTITION_COMMENT | NODEGROUP | TABLESPACE_NAME |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| def | learn_innodb | t1 | part_y_eq_2025 | null | 2 | null | RANGE | null | year(`tran_date`) | null | 2026 | 0 | 0 | 16384 | 0 | 0 | 0 | 2025-02-08 01:05:53 | null | null | null | default | null | |
| def | learn_innodb | t1 | part_y_let_2024 | null | 1 | null | RANGE | null | year(`tran_date`) | null | 2025 | 0 | 0 | 16384 | 0 | 0 | 0 | 2025-02-08 01:05:53 | null | null | null | default | null |
为分区表预置如下数据
insert into t1(tran_date) values ('2023-01-01'), ('2024-12-31'), ('2025-01-01');
可以看到分区表中数据分布如下:
select partition_name, table_rows from information_schema.partitions where table_schema = 'learn_innodb' and table_name = 't1';
| PARTITION_NAME | TABLE_ROWS |
|---|---|
| part_y_eq_2025 | 1 |
| part_y_let_2024 | 2 |
易知('2023-01-01'), ('2024-12-31')数据位于part_y_let_2024分区,故而该分区存在两条数据;而('2025-01-01')数据位于part_y_let_2024分区,该分区存在一条数据。
看select语句查询了哪些分区
数据分布可以通过如下方式验证:
explain select * from t1 where tran_date >= '2023-01-01' and tran_date <= '2024-12-31';
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | SIMPLE | t1 | part_y_let_2024 | index | PRIMARY | PRIMARY | 16 | null | 2 | 50 | Using where; Using index |
explain select * from t1 where tran_date >= '2025-01-01'
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | SIMPLE | t1 | part_y_eq_2025 | index | PRIMARY | PRIMARY | 16 | null | 1 | 100 | Using where; Using index |
插入超过分区范围的数据
如果尝试向分区表中插入超过所有分区范围的数据,会执行失败,示例如下:
insert into t1(tran_date) values ('2026-01-01');
由于分区表t1中现有分区最大只支持2025年的分区,故而当尝试插入2026年分区时,会抛出如下错误:
[2025-02-08 01:32:51] [HY000][1526] Table has no partition for value 2026
向分区表中添加分区
mysql支持向分区表中添加分区,语法如下
alter table t1 add partition (
partition part_year_egt_2026 values less than maxvalue
);
添加分区后,mysql支持tran_date大于等于2026的分区,执行如下语句
insert into t1(tran_date) values ('2026-01-01'), ('2027-12-31'), ('2028-01-01');
之后,再次分区分区中的数据分区,结果如下
| PARTITION_NAME | TABLE_ROWS |
|---|---|
| part_y_eq_2025 | 1 |
| part_y_let_2024 | 2 |
| part_year_egt_2026 | 3 |
information.partitions表中,table_rows字段可能存在不准确的情况,如果想要获取准确的值,需要执行analyze table {schema}.{table_name}语句
向分区表中删除分区
当想要删除分区表中现存分区时,可以通过执行如下语句
alter table t1 drop partition part_year_egt_2026;
在删除分区后,分区中的数据也会被删除,执行查询语句后
select * from t1;
可知part_year_egt_2026被删除后分区中数据全部消失
| id | tran_date |
|---|---|
| 1 | 2023-01-01 00:00:00.000000 |
| 2 | 2024-12-31 00:00:00.000000 |
| 3 | 2025-01-01 00:00:00.000000 |