本文是学习掘金小册《MySQL 是怎样运行的:从根儿上理解 MySQL》的一篇学习笔记,内容从本人视角出发理解课程,有删改之处,如有疑惑或者感兴趣,请自行购买本课程,强烈推荐
本文大量引用小册文字及图片
前言
页:innoDB 管理内存的基本单位,默认 16 KB
结构
User Records-记录在页中的位置
每次插入记录时,从 free space 区域划分一部分空间用来存储用户记录
记录头信息
尝试向该页插入数据
- delete_mask
标记该记录是否被删除,标记删除而不是实际从磁盘移除是为了提高性能,因为移除他们并将其它记录重新排列需要性能消耗
这些被标记删除的数据会组成一个垃圾链表,在这个链表中的记录占用的空间被称为可重用空间,之后如果有新记录插入到该表的话,可以把这些可重用空间覆盖掉
- min_rec_mask
标记 B+ 树每层非叶子节点中的最小记录
- n_owned
如果是所属槽的最大记录,则记录该槽位下记录数量
- heap_no
记录在本页中的位置最大最小记录单独存放在 infimum, supremum区,可类比链表的基节点,存储头尾指针,实际上页中记录也是单链表串联的
- record_type
标识当前记录的类型,0表示普通记录,1 表示 B+ 树非叶节点记录,2 表示最小记录,3 表示最大记录
- next_record
保存下一条记录的指针,从 infirmum 开始,到 supremum 结束
如果删除第二条记录:红框标注变动处
为什么next_record 指针指向的是下一条记录的真实数据的开始部分而不是整条记录的开始呢?
因为从这个位置出发,向左读取就是记录头信息,向右就是记录的真实数据,在记录头信息中逆序存放着 null 值列表和变长字段长度列表,这样是的列与这两个信息的距离更近,内存命中率更高,性能更好
那如果我们再把第二条记录 insert 回来呢?
上图中,该记录直接复用记录位置
实际是垃圾链表非空,插入时直接取了这一块
Page Directory
记录按单链表串起来了,单链表访问的时间复杂度为 O(n),每次访问一条记录都遍历那也太 LOW 了。
O(n) 嫌复杂度高是吧?来个 O(logn)
优化无处不在,链表遍历不好用,那就给他加个目录
如示意图所示:
- 页中数据被分为两组,槽(slot)内数值代表指向记录的偏移量(从页面的0字节开始数)
- 偏移值记录的是该组最大记录的位置,也就是每个子链表的尾节点
- 注意最小和最大记录的 n_owned 属性
-
- 最小记录的 n_owned 为 1,这就代表该组记录只有 1 条记录
- 最大记录的 n_owned 为 5, 代表该组记录有 5 条记录
每个分组的条数是怎么规定的呢?对于最小记录所在的分组只能有 1 条记录,最大记录所在分组记录数为 1-8 条,剩下的普通记录所在分组的条数在 4- 8 条之间。故分组的步骤如下
- 初始时两个分组,最大最小记录所在分组
- 插入一条记录,都会从 page dirctory 中找到主键值比本记录主键值大且差值最小的槽,然后把该槽对应记录的 n_owned + 1, 表示槽内增加了一条记录,直至该组中记录数等于8个
- 当到了 8 个再插入时,该槽就一分为二,一个四条,一个五条,并在 page dirctory 中新建一个槽来记录这个新增分组中最大记录的偏移量,并相应修改两个n_owned值
如上图,共十六条记录,假设查找主键值为 6 的记录:初始 low = 0, high = 4
-
- 计算中间槽位置:(0+4)/2=2,槽 2 对应记录主键值为 8,又因为 8 > 6, 所以设置 high = 2, low 不变
- 二分计算mid = (0+2)/2 = 1,槽 1 对应记录主键值为 4,又因为 4 <6,故low = 1, high 不变
- 此时 mid = 0, 所以确定记录在 high 对应的槽内,从 8 对应记录遍历吗? 记录这条链表是个单链表,没有前向指针,所以要从 4 对应的就向后遍历
可以看出整个查询过程就是个二分过程+链表遍历过程
-
- 二分找到对应槽位
- 通过记录的 next_records 属性遍历该槽所在组的哥哥记录
Page Header
设计InnoDB的大叔们为了能得到一个数据页中存储的记录的状态信息,比如本页中已经存储了多少条记录,第一条记录的地址是什么,页目录中存储了多少个槽等等,特意在页中定义了一个叫Page Header的部分,它是页结构的第二部分,这个部分占用固定的56个字节,专门存储各种状态信息,具体各个字节都是干嘛的看下表:
| 名称 | 占用空间大小 | 描述 |
|---|---|---|
| PAGE_N_DIR_SLOTS | 2 | 在页目录中的槽数量 |
| PAGE_HEAP_TOP | 2 | 还未使用的空间最小地址,也就是说从该地址之后就是Free Space |
| PAGE_N_HEAP | 2 | 本页中的记录的数量(包括最小和最大记录以及标记为删除的记录) |
| PAGE_FREE | 2 | 第一个已经标记为删除的记录地址(各个已删除的记录通过next_record也会组成一个单链表,这个单链表中的记录可以被重新利用) |
| PAGE_GARBAGE | 2 | 已删除记录占用的字节数 |
| PAGE_LAST_INSERT | 2 | 最后插入记录的位置 |
| PAGE_DIRECTION | 2 | 记录插入的方向 |
| PAGE_N_DIRECTION | 2 | 一个方向连续插入的记录数量 |
| PAGE_N_RECS | 2 | 该页中记录的数量(不包括最小和最大记录以及被标记为删除的记录) |
| PAGE_MAX_TRX_ID | 8 | 修改当前页的最大事务ID,该值仅在二级索引中定义 |
| PAGE_LEVEL | 2 | 当前页在B+树中所处的层级 |
| PAGE_INDEX_ID | 8 | 索引ID,表示当前页属于哪个索引 |
| PAGE_BTR_SEG_LEAF | 10 | B+树叶子段的头部信息,仅在B+树的Root页定义 |
| PAGE_BTR_SEG_TOP | 10 | B+树非叶子段的头部信息,仅在B+树的Root页定义 |
在这里我们先唠叨一下PAGE_DIRECTION和PAGE_N_DIRECTION的意思:
- PAGE_DIRECTION假如新插入的一条记录的主键值比上一条记录的主键值大,我们说这条记录的插入方向是右边,反之则是左边。用来表示最后一条记录插入方向的状态就是PAGE_DIRECTION。
- PAGE_N_DIRECTION假设连续几次插入新记录的方向都是一致的,InnoDB会把沿着同一个方向插入记录的条数记下来,这个条数就用PAGE_N_DIRECTION这个状态表示。当然,如果最后一条记录的插入方向改变了的话,这个状态的值会被清零重新统计。
File Header
上边唠叨的Page Header是专门针对数据页记录的各种状态信息,比方说页里头有多少个记录了呀,有多少个槽了呀。我们现在描述的File Header针对各种类型的页都通用,也就是说不同类型的页都会以File Header作为第一个组成部分,它描述了一些针对各种页都通用的一些信息,比方说这个页的编号是多少,它的上一个页、下一个页是谁啦吧啦吧啦~ 这个部分占用固定的38个字节,是由下边这些内容组成的:
- FIL_PAGE_SPACE_OR_CHKSUM
代表页面的校验和
- FLI_PAGE_OFFSET
页号,innodb通过页号定位到具体页面
- FIL_PAGE_TYPE
页的类型
最后一行索引页,存放我们的普通用户数据,以及对应的索引(聚簇,二级等索引)
- FIL_PAGE_PREV和FIL_PAGE_NEXT
页的前后指针,也就意味着页是通过双向链表串在一起的,index类型页有,并不是所有类型的页都有
File Trailer
页从磁盘加载到了内存,那么该页要是被修改了需要刷回磁盘,而在刷盘过程中断电了怎么办,于是为了检测页面完整性引入了 尾部。分为两个小部分:
- 前四个字节代表校验和
与 File Header 中的校验和对应,当页面被修改时,在刷盘前就要将其校验和计算出来,由于 File Header 在页面头部,故头部校验和会首先刷到磁盘,当完全写完时才写 Trailer,那么如果刷盘过程中出现问题了,则 header 和 trailer 中的校验和就会不一致,也就意味着这中间出了问题
- 后四个字节代表页面最后被修改时对应的日志序列位置(LSN)
同样是为了校验完整性
File Header 和 File Trailer 是所有类型页面通用的。
小结:
- innodb 为了实现不同目的设计了不同数据页,我们把用于存放记录的页叫做数据页
- 一个数据页可以被大致分为 7 个部分
-
- File Header
- Page Header
- Infimum + Supremum
- User Records
- Free Space
- Page Directory
- File Trailer
- 每个记录头信息有个 next_record 属性,从而使页中所有记录串联成一个单链表
- 为了优化单链表的查询性能, innodb 设计了Page Dirctory,目录中保存了分组最大记录的偏移量,通过二分加组内遍历方式查找
- 每个数据页在 Page Head 中维护了一个前后指针,形成双向链表
- 为了保证数据完整性的快速校验,页面尾部设置了校验和以及 LSN值