页在MySQL运行的过程中起到了非常重要的作用,为了能发挥更好的性能,可以结合自己系统的业务场景和数据大小,对页相关的系统变量进行调整,页的大小就是一个非常重要的调整项。同时关于页的结构也要有所了解,索引原理也是基于页实现的。
1. 页的大小可以设置吗?
MySQL提供了一个专门的系统变量来控制页的大小,可以通过系统变量innodb_page_size 进行调整与查看,在调整页大小的时候需要保证设置的值是操作系统"数据块"4KB的整数倍,从而保证通过操作系统和磁盘交互时"数据块"的完整性,不被分割或浪费,所以规定了innodb_page_size可以设置的值,分别是4096、8192、16384、3276865536,对应4KB、8KB、16KB、32KB、64KB。
2. 页结构
InnoDB在不同的使用场景定义多种不同类型的页,常用的有数据页、Undo Log页、Change Buffer页、Extent Descriptor(XDES)页、InnoDB段信息页 等,每种页的数据结构都不相同,其中最需要我们关注的就是数据页,由于InnoDB中有个概念叫"索引即数据”,所以也叫做索引页。
不论哪种类型的页都具有页头(File Header)和页尾(File Trailer)两个信息。
2.1 页头 File Header
- 页号: FIL_PAGE_OFFSET 占用 4Byte,相当于页的身份证号,通过这个长度可以计算出每个InnoDB表中最多可以拥有 2^(4*8)-1约42亿 个页,表空间第一个页编号从 0 开始,之后的页号分别是1,2,3…依此类推,具体页的偏移量计算公式为:页号*每页大小;那么按照每个页默认 16KB 大小计算,一个表空间最大容量为 2^(4*8)*16KB=64 TB,这也是 InnoDB 表空间最大容量是 64T 的原因;
- 上一页页号:FIL_PAGE_PREV
- 下一页页号: FIL_PAGE_NEXT 多个页通过这两个信息组成双向链表,即使不同的页地址不连续,也可以通过链表连接
- 表空间ID:FIL_PAGE_ARCH_LOG_NO_OR_SPACE_ID ,当前页属于哪个表空间
- 页类型: FIL_PAGE_TYPE,数据页对应的页类型是FIL_PAGE_INDEX=0x45BF
- 最近一次修改的LSN:FIL_PAGE_LSN,占用8Byte
- 已被刷到磁盘的LSN:FIL PAGE FILE_FLUSHLSN,占用8Byte
- 校验和:FIL_PAGE_SPACE_OR_CHKSUM,用于页的完整性校验
2.2 页尾
- 最近一次修改的LSN
- 校验和:对应页头中的校验和
LSN:是"Log Sequence Number"的缩写,表示日志序号。用一个任意的、不断增加的值表示日志中记录的操作对应的时间点,用8字节的无符号长整形表示
如果在数据传输的过程中数据丢失或异常中断,导致一个数据页不完整就可以通过页头和页尾的校验和进行验证,验证算法默认使用 CRC32。
2.3 页主体
页头和页尾中的各个字段描述了当前页的类型以及在文件系统中的位置,也就是说通过页头可以找到对应的页。数据页的主要功能是保存数据,在一个数据页中,除了页头与页尾占用的46个字节之外的空间都用来存储真正的数据,也就是数据行,数据行会与表里的数据行一一对应,基于这一特性MySOL也被称为“行式数据库",也可以把除了页头页尾的区域称为页主体。
页主体中的信息都是和数据相关的,其中包括刚才提到了数据行,还有为了提高查询效率的页目录 Page Directory 和为了方便操作和管理数据页的数据页头 Page Header,这又是三个非常重要的概念,接下来我们逐个讨论。
2.3.1 数据行
数据行主要存储真实数据,为了方便数据的管理与描述,InnoDB在每个数据行中还添加了一些额外(管理)信息,于是每一个 DYNAMIC 数据行都可以划分为两部分,一部分存储额外信息,一部分存储真实数据,额外信息部分包含 变长字段长度列表 和 NULL值列表 两个大小不确定的区域,以及固定占5字节及40BIT的 头信息区域 ,头信息中存储了行的基本信息,包括行在页内的位置heap_no、行类型 record_type 、下一行的地址偏移量 next_record 等6项信息,如下图所示:
总的来说,数据行可以划分为两部分,一部分存储额外信息,一部分存储真实数据;额外信息部分包含变长字段长度列表和NULL值列表两个大小不确定的区域,以及固定占5字节的头信息区域
2.3.2 数据行是如何组织在一起的
数据行通过下一行的地址偏移量,即 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(最大行) ,这样数据行就构建成了一个单向链表,更多的行数据插入后,会按照主键从小到大的顺序进行链接;为了使页的结构更加清晰,通常将页中有数据行的区域称为用户数据区UserRecords,把未被数据行占用的区域称为空闲区Free Space,如下图所示
2.3.3 页目录
如果要查询的数据在某一个页中,如何定位它在页中的位置,一条条遍历吗?
从头开始遍历是一个最简单的方法,也可以实现数据的查找,当按主键或索引查找某条数据时,从头行 infimun 开始,沿着链表顺序逐个比对查找,但一个页有16KB,通常会存在数百行数据每次都要遍历数百行,无法满足高效查询。
引入页目录,提升查询效率
为了提高查询效率,InnoDB采用二分查找来解决查询效率问题。
具体实现方式是,在每一个页中加入一个叫做页目录 Page Directory 的结构,将页内包括头行、尾行在内的所有行进行分组,约定头行单独为一组,其他每个组最多8条数据,同时把每个组最后一行在页中的地址,按主键从小到大的顺序记录在页目录中在,页目录中的每一个位置称为一个槽,每个槽都对应了一个分组,这样在插入数据行完成链接后,一旦最后一个分组中的数据行超过分组的上限8个时,就会分裂出一个新的分组,为了快速判断每个分组是否达到了8个的上限,在每个分组最后一行中用 n_owned 记录了这个分组内的行数,与此同时在页目录中创建一个新的槽,后续插入的行都遵守这个规则;
后续在查询某行时,就可以通过二分查找,先找到对应的槽,然后在槽内最多8个数据行中进行遍历即可,从而大幅提高了查询效率;
例如要查找主键为6的行,先比对槽中记录的主键值,定位到最后一个槽2,再从最后一个槽中的第一条记录遍历,第二条记录就是我们要查询的目标行。
数据也页的完整结构: