MySQL为什么默认使用InnoDB存储引擎
InnoDB在设计时考虑到了处理巨⼤数据量时的性能,InnoDB⽀持事务(transaction)、回滚(rollback)并且具有崩溃修复的能⼒(crash recovery capabilities),通过多版本并发控制(multi-versioned concurrency control)减少锁定,同时还⽀持外键约束(FOREIGN KEY constraints),通
过缓冲池在主内存中缓存数据从⽽提⾼查询性能,也可以每个表使⽤各⾃的独⽴表空间存储数据并 且⽂件⼤⼩只受限于操作系统,由于InnoDB存储引擎存储数据量⼤,性能⾼,可以有效的保证数据安全等优点,在MySQL5.5版本之后成为默认的存储引擎。
InnoDB存储引擎的架构
下图是官⽅给出的架构图,可以看出其中InnoDB主要包括内存结构和磁盘结构。
内存结构中包括:
- 缓冲池(Buffer Pool)
- 变更缓冲区(Change Buffer)
- ⽇志缓冲区(Log Buffer)
- ⾃适应哈希(Adaptive Hash Index)
磁盘结构中包括:
- 系统表空间(System Tablespace)
- 独⽴表空间(File-Per-Table Tablespaces)
- 通⽤表空间(General Tablespaces)
- 临时表空间(Temporary Tablespaces)
- 撤销表空间(Undo Tablespaces)
- 重做⽇志(Undo Log)
- 双写缓冲区 (Doublewrite Buffer)
其中,表空间⽂件是⽤来存储表中数据的⽂件,表空间⽂件的⼤⼩由存储的数据多少决定,不同的表空间⽂件存储数据的种类也有所不同,在MySQL中表空间分为五类,包括:系统表空间、独⽴表空间、通⽤表空间、临时表空间和撤销表空间,这些在上⾯的InnoDB架构图中都有体现。
表空间
表空间与表空间⽂件的关系是什么?
表空间可以理解为MYSQL为了管理数据⽽设计的⼀种数据结构,主要描述的对结构的定义,表空间⽂件是对定义的具体实现,以⽂件的形式存在于磁盘上。
为什么要设计成内存结构和磁盘结构两个部分?
我们从MySQL实现的⻆度来思考这个问题,数据库的作⽤就是保存数据,⽤⼾的真实数据最终都会保存在磁盘上,在查询数据的过程中,如果每次都从磁盘上读取会严重影响效率,为了提⾼数据的访问效率,InnoDB会把查询到的数据缓存到内存中,当再次查询时,如果⽬标数据已经存在于内存中,就可以从内存中直接读取,从⽽⼤幅提升效率。也就是说磁盘结构中的⽂件是⽤来保存数据实现数据持久化的,内存结构是⽤来缓存数据提升效率的。
使⽤InnoDB存储引擎创建的表对应的数据⽂件在哪里?
当创建⼀个库时,默认会在数据⽬录 /var/lib/mysql ⽣成一个与数据库同名的⼦⽬录中,再使⽤InnoDB存储引擎创建⼀个表时,默认会在数据⽬录 /var/lib/mysql/数据库名 的⽬录中⽣成相应的表空间⽂件,以 .ibd 为⽂件的后缀,⽤来存储数据和索引,如果每个表都对应⼀个表空间⽂件,称为独⽴表空间,在MySQL5.7及以后的版本中默认为每个表⽣成独⽴表空间,可以通过系统变量 innodb_file_per_table[=ON|OFF] 进⾏控制,如果关闭这个选项,则所有表的数据都在系统表空间中存储,独⽴表空间⽂件如下所⽰:
用户数据在表空间中是怎么存储的?
⾸先明确⼀点,⽤⼾的数据以数据⾏的⽅式存储在对应的表空间⽂件中,那么表空间中很多个数据
⾏就需要进⾏管理,以便后续进⾏⾼效的查询,为了⽅便管理,表空间由段 (segment)、区组(group)、区 (extent)、⻚ (page) 、数据⾏组成,其中⻚是 InnoDB 磁盘管理的最⼩单位
可以这么理解,若⼲数据⾏组成了⻚,多个⻚组成了区,多区组成了区组,多个区组组成了段,多
个段组成了表空间。
数据行
数据⾏主要存储真实数据,为了⽅便数据的管理与描述,InnoDB在每个数据⾏中还添加了⼀些额外(管理)信息,于是每⼀个 DYNAMIC 数据⾏都可以划分为两部分,⼀部分存储额外信息,⼀部分
存储真实数据,额外信息部分包含变⻓字段⻓度列表和NULL值列表两个⼤⼩不确定的区域,以及 固定占5字节及40BIT的头信息区域,头信息中存储了⾏的基本信息,包括⾏在⻚内的位置heap_no 、⾏类型 record_type 、下⼀⾏的地址偏移量 next_record 等6项信息,如下图所⽰:

数据⾏通过下⼀⾏的地址偏移量,即 next_record 将⻚内所有数据⾏组成了⼀个单向链表,这⾥要注意的是,地址偏移量指向的是下⼀⾏中真实数据的起始地址,这样做的好处是,向右是真实数据,向左就是头信息,⽽⽆需额外的⻓度计算,如图所⽰:

怎么标识新页中的第一行和最后一行?
了解了⾏的基本结构和组织⽅式之后,那么当遍历⻚中的⾏时,从哪⾥开始到哪⾥结束呢?为了解
决这个问题,每当创建⼀个新⻚,都会⾃动分配两个⾏,⼀个是⾏类型为2的最⼩⾏ Infimun, heap_no 位置固定为0号,和⼀个是⾏类型为3的最⼤⾏ Supremun , heap_no 位置固定为1号,这两个⾏并不存储任何真实信息,⽽是做为数据⾏链表的头和尾,虽然不存储真实数据,但它们的数据结构和真实数据⾏完全⼀致,只不过数据区域存储的是代表它们⾝份的固定字符串 Infimun 和 Supremun ,新⻚中没有数据时,最⼩⾏ Infimun 的 next_record 直接连接最⼤⾏ Supremun ,最⼤⾏不连接任何⾏,它的 next_record 为0。

当向⼀个新⻚插⼊数据时, heap_no 会从 2 号开始递增,表⽰当前记录在⻚⾯堆中的相对位置;如果是真实数据则 record_type 为0,如果是索引⽬录(B+树⾮叶节点)数据则 record_type 为1;再将 Infimun 连接第⼀个数据⾏,最后⼀⾏真实数据⾏连接 Supremun ,这样数据⾏就构建成了⼀个单向链表,更多的⾏数据插⼊后,会按照主键从⼩到⼤的顺序进⾏链接;为了使⻚的结构更加清晰,通常将⻚中有数据⾏的区域称为⽤⼾数据区 User Records ,把未被数据⾏占⽤的区域称为空闲区 Free Space ,如下图所⽰:

数据区是怎么存储真实数据的?
数据区在数据⾏中的位置如下图所⽰:

从分隔线向右第⼀个字段存储真实数据的主键值,对于主键值有以下⼏种情况:
- 如果表中定义了主键,则直接存储主键的值;
- 如果是复合主键会根据列定义的顺序依次排列在这⾥;
- 如果没有主键,会优先使⽤第⼀个不允许为NULL的 UNIQUE 唯⼀列作为主键;
- 如果既没有主键也没有唯⼀键,那么InnoDB会构建⼀个6字节的字段 DB_ROW_ID 作为⾏的唯⼀标识,存储在真实数据的头部
紧接着是在事务运⾏中两个⾮常重要的固定字段
- 6字节的事务ID字段 DB_TX_ID ,记录创建或最后⼀次修改该记录的事务ID
- 7字节的回滚指针字段 DB_ROLL_PTR ,如果在事务中这条记录被修改,指向这条记录的上⼀个版本
接下来就是除了主键和值为NULL的列之外,其他列的真实数据,按照顺序从左到右依次排列

头信息区域
分隔线向左是额外信息区,第⼀个是固定占5Byte即40个Bit的头信息区域,头信息区由右向左主要
包含以下信息:

- 下⼀⾏地址偏移量: next_record 占16bit,通过这个信息将所有的⾏链接成⼀个单向链表
- ⾏类型: record_type 占3bit,包括四种类型:0:普通数据⾏;1:索引⽬录⾏;2:⻚内最⼩⾏infimun;3:⻚内最⼤⾏supremun;
- ⾏在整个⻚中的位置: heap_no 占13bit
- 分组的⾏数: n_owned 占4bit,只在该⾏是分组最后⼀⾏才有值,这样就可以快速查询⾏数,⽽不⽤⼀条条的累加了
- B+树索引树每层最⼩值标记: min_rec_flag 占1bit,如果当前⾏的类型是⽬录⾏也就是record_type=1 ,同时也是B+索引树某层的最⼩值,则会置为1,会在索引查询时⽤到
- 删除标记: delete_mask 占 1bit ,从⻚中删除数据⾏时,并不会直接移除,⽽是修改这个删除标记为 1
- 预留区:占2bit
删除一行记录时在InnoDB内部执行了哪些操作?
从⻚中删除数据⾏时,并不会直接移除,⽽是修改 delete_mask 这个删除标记为 1 ,并将next_record 改为 0 ,同时将上⼀⾏的 next_record 指向后续的⾏,从⽽把该⾏从链表中断开,如果执⾏事务提交后,则将这⾏的 next_record 指向⼀个被称为垃圾链表的区域,这个链表会被⽤在事务回滚中。

Null值列表
头信息区再向左就是NULL值列表的可变区域,⽤来存储数据⾏中所有列允许为Null的值从⽽节省空间,具体的实现⽅式是,⽤1BIT的⼤⼩来表⽰⾏中某⼀列是否为空,这样空列就不需要记录在真
实数据区域中了
为每个没有定义 NOT NULL 约束也就是可以为NULL的列在NULL值列表中都安排了⼀个bit位,按
列序号从⼩到⼤的顺序从右⾄左依序安排,这就是常说的逆序排列,NULL值列表最⼩1字节即8bit,如果没有那么多可以为NULL的列,则会⽤0补满8bit,如果为值为NULL的列超过8个,则新
开辟1字节的空间,依此类推
如果某列为空,则NULL值列表中对应的bit设置为1,这样只⽤了⼀bit就存储了NULL列,⾮常节省
空间

变长字段列表
⾏结构的最左侧是变⻓字段列表,也叫可变字段⻓度列表,在这个列表中记录了数据⾏中所有变⻓
字段的实际⻓度,这样做的⽬的,是为了在真实数据区域,可以根据列的⻓度进⾏列与列之间的分
割
需要记录的变⻓字段类型常⻅的有varchar、varbinary、text、blob,以及当使⽤了例如utf-8、gbk等变⻓字符集的char类型,当char类型的字节数可能超过768个字节时,⽐如使⽤utf8mb4字符集时定义了char(255),这个字段的最⼤字节数是4*255=1020,每个变⻓字段分配1 ~ 2个字节来存放这些字段的真实⼤⼩,放置顺序也是按表中字段的顺序从右⾄左逆序排列。

页
⾸先要明确⼀点,MySQL中的⻚是应⽤层的⼀个概念,是MySQL根据⾃⾝的应⽤场景,定义的⼀
种数据结构。
通常操作系统中的⽂件系统在管理磁盘⽂件时以4KB⼤⼩为⼀个管理单元,称为"数据块",但是在
数据库的应⽤场景⾥,查询时数据量都⽐较⼤,如果也使⽤4KB做数据存储的最⼩的单元,就显的
有点⼩了,同时会造成频繁的磁盘I/O,导致降低效率,所以MySQL根据⾃⾝情况定义了⼤⼩为16KB的⻚,做为磁盘管理的最⼩单位。
⻚的16KB⼤⼩是MySQL的⼀个默认设置,可以适⽤于⼤多数场景,当然也可以根据⾃⼰的实际业务场景进⾏修改⻚的⼤⼩,通过系统变量 innodb_page_size 进⾏调整与查看,在调整⻚⼤⼩的时候需要保证设置的值是操作系统"数据块" 4KB的整数倍,从⽽保证通过操作系统和磁盘交互 时"数据块"的完整性,不被分割或浪费,所以规定了 innodb_page_size 可以设置的值,分别是 4096 、 8192 、 16384 、 32768 、 65536 ,对应 4KB 、 8KB 、 16KB 、 32KB 、64KB

每次内存与磁盘的交互⾄少读取⼀⻚,所以在磁盘中每个⻚内部的地址都是连续的,之所以这样
做,是因为在使⽤数据的过程中,根据局部性原理,将来要使⽤的数据⼤概率与当前访问的数据在
空间上是临近的,所以⼀次从磁盘中读取⼀⻚的数据放⼊内存中,当下次查询的数据还在这个⻚中
时就可以从内存中直接读取,从⽽减少磁盘I/O,提⾼性能。
在不同的使⽤场景中,⻚的结构也有所不同,在MySQL中有多种不同类型的⻚,但不论哪种类型的⻚都会包含⻚头(File Header)和⻚尾(File Trailer),在这⻚头和⻚尾之间的⻚主体信息根据不同的类型有不同的结构,最常⽤的就是⽤来存储数据和索引的"索引⻚",也叫做"数据⻚",⻚的主体信息使⽤数据"⾏"进⾏填充,⻚的基本结构如下图所示:

我们知道如果页在磁盘中可以被连续读取,那么查询效率就⾼,否则果询效率就低。
如何提⾼⻚内的查询效率?
为了提⾼查询效率,InnoDB采⽤⼆分查找来解决查询效率问题。
具体实现⽅式是,在每⼀个⻚中加⼊⼀个叫做⻚⽬录 Page Directory 的结构,将⻚内包括头⾏、尾⾏在内的所有⾏进⾏分组,约定头⾏单独为⼀组,其他每个组最多8条数据,同时把每个组最后⼀⾏在⻚中的地址,按主键从⼩到⼤的顺序记录在⻚⽬录中在,⻚⽬录中的每⼀个位置称为⼀个槽,每个槽都对应了⼀个分组,这样在插⼊数据⾏完成链接后,⼀旦最后⼀个分组中的数据⾏超过分组的上限8个时,就会分裂出⼀个新的分组,为了快速判断每个分组是否达到了8个的上限,在每个分组最后⼀⾏中⽤ n_owned 记录了这个分组内的⾏数,与此同时在⻚⽬录中创建⼀个新的槽,后续插⼊的⾏都遵守这个规则。
后续在查询某⾏时,就可以通过⼆分查找,先找到对应的槽,然后在槽内最多8个数据⾏中进⾏遍
历即可,从⽽⼤幅提⾼了查询效率。例如要查找主键为6的⾏,先⽐对槽中记录的主键值,定位到最后⼀个槽2,再从最后⼀个槽中的第⼀条记录遍历,第⼆条记录就是我们要查询的⽬标⾏。

除了可以通过⻚⽬录 Page Directory 来提高查询效率外,还可以通过定义区的数据结构来提高查询效率,如下所述。
区
InnoDB如何保证页在磁盘中的连续性?
由于磁盘的随机访问,需要尽可能在磁道上读取连续的数据,减少磁头的移动,从⽽提升效率,MySQL使⽤ Extent(区) 这个结构来管理⻚,规定每个区固定⼤⼩为 1MB ,可以存放 64 个⻚,这时如果跨页读数据时,⼤概率都在附近的地址,可以⼤幅减少碰头移动
同时,如果频繁的读取某个区中的⻚,可以把整个区都读取出来放⼊内存中,减少后续查询对磁盘
的访问次数,进⼀步提升效率,如图所⽰

当表中的数据很少时如何避免空间浪费?
为了节省空间,最初只创建7个初始⻚,⽽不是⼀个完整的区,可以通过以下SQL查看:
select * FROM information_schema.INNODB_TABLESPACES WHERE name = 'test_db/student'\G
*************************** 1. row ***************************
SPACE: 13
NAME: test_db/student
FLAG: 16417
ROW_FORMAT: Dynamic
PAGE_SIZE: 16384 # ⻚⼤⼩
ZIP_PAGE_SIZE: 0
SPACE_TYPE: Single
FS_BLOCK_SIZE: 65536
FILE_SIZE: 114688 # 数据⽂件初始⼤⼩
ALLOCATED_SIZE: 114688
AUTOEXTEND_SIZE: 0
SERVER_VERSION: 8.0.33
SPACE_VERSION: 1
ENCRYPTION: N
STATE: normal
1 row in set (0.01 sec)
# 根据数据⽂件⼤⼩和每⻚⼤⼩计算出⻚数
# 114688 / 16384 = 7 个数据⻚
这些零散⻚会放在表空间中⼀个叫碎⽚区的区域,随着数据量的增加,会申请新的⻚来存储数据,
当碎⽚区达到32个⻚的时候,后续每次都会申请⼀个完整的区来存储更多的数据
区组
不同的区在磁盘上⼤概率是不连续的,那么这个问题其实是InnoDB如何⾼效的的管理区?
当表中的数据越来越多,为了有效的管理区,定义了区组的结构,每个区组固定管理256个区即 256MB ,通过区组可以在物理结构层⾯⾮常⾼效的管理和定位到每个区。

第⼀个区组中的⾸个区的前四⻚⽐特殊,也就是初始⻚中的前4⻚,分别是:
- File Space Header: 表空间和区组中条⽬信息
- Insert Buffer Bitmap:Change Buffer相关信息
- File Segment inode: 段信息
- B-tree Node:索引根信息
其他为空闲⻚⽤来存储真实的数据。
其他区组中⾸个区的结构都⼀样,前两个⻚分别是:
- Extent Descriptor(XDES):区组条⽬信息
- Insert Buffer Bitmap:Change Buffer相关信息
使⽤区组结构有效的管理区,每个区组固定管理256个区即 256MB ,区组条⽬信息中会记录每个区的偏移并⽤双向链表连接。
段
以上讲到的区、区组还有⻚这种都是物理结构,在物理结构的基础上,定义了⼀个逻辑上的概念,也就是"段","段"并不对应表空间中的连续的物理区域,可以看做是 "区" 和 "⻚" 的⼀个附加标注信息,段的主要作⽤是区分不同功能的区和在碎⽚区中的⻚,主要分为"叶⼦节点段"和"⾮叶⼦节点段"等,这两个段和我们常说的B+树索引中的叶⼦、⾮叶⼦节点对应,可以简单的理解为"⾮叶⼦节点段" 存储和管理索引树,"叶⼦节点段"存储和管理实际数据,从逻辑上讲,最终由 "叶⼦节点段" 和 "⾮叶⼦节点段" 等段构成了表空间 .ibd ⽂件,如下图所示
