-
InnoDB为了减少与磁盘之间的交互次数,采用 Page 的数据结构作为磁盘和内存之间交互的基本单位 -
一般情况下,一个 Page 的大小为 16 KB
-
InnoDB为不同的目的设计了不同的页,比如:存放表空间头部信息的页;存放 undo 日志的页等。 -
页的数据结构如下图所示:
-
记录(Row)头信息
-
一条记录的格式
-
row_id在未指定主键和唯一约束的列的情况下,row_id将作为该记录的主键来保证数据的唯一性(对应主键列 6 字节)。 -
trx_id事务 ID(6字节)、roller_pointer(7字节) -
deleted_flag- 物理删除:delete 语句删除
- 逻辑删除:0 :未删除,1:已删除
- 对于删除操作:
- 将 delete_flag 置为 1
- 把删除掉的记录,组成一个垃圾链表,提高空间的可重用
-
min_rec_flag- B+ 树每层非叶子节点中标识最小的目录项记录
-
n_owner- 把一个页划分为若干个组,组内主键值最大的记录会保存该值,表示改组中存在的记录数
-
heap_no- 堆号:每条插入进来的记录都会分配一个堆号,从 heap_no = 2开始
InnoDB默认Infimum的 heap_no = 0,Supermum的 heap_no = 1
-
record_type
- 记录的类型:
- 0:普通记录
- 1:
B+树非叶子节点目录项记录 - 2:
Infimum记录 - 3:
Supremum记录
- 记录的类型:
-
next_record- 下一条记录
- 从当前记录真实数据到下一条 记录的真实数据 的开始位置地址
-
-
Page Directory-
特点
- 按照主键从小到大依次排序
- 单向链表
-
分组规则
- 对于
Infimum记录,组里只能有一条记录,就是它自己 - 对于
Supremum记录,组里只能有 1~8条记录 - 对于一般的其它记录,组里只能有 4~8 条数据
- 对于
-
分组步骤
- 首先,初始状态下,没有记录,因此一个新的数据页只有两个组,一个是
Infimum组,一条记录;另一个是Supremum,一条记录 - 插入数据时,首先向
Supremum组插入数据,更新n_owner - 当
Supremum组满后,进行拆分,一个组拆分成 (4 + 5) 条数据,申请新的槽位
- 首先,初始状态下,没有记录,因此一个新的数据页只有两个组,一个是
-
-
删除记录的执行流程
- 首先,将
deleted_flag置为 1,表示该记录在逻辑上已经被删除了 - 更新该记录的上一条记录的
next_record指向下一条记录 - 更新该记录所在组内的
n_owner
- 首先,将
-
插入记录的执行流程
- 如果当前组内存在之前逻辑删除的记录,即
deleted_flag置为 1,且此时在垃圾链表中没有被占用。- 那么就会将当前的记录的
deleted_flag置为 0, 同时将数据更新 - 将本身这条记录的
next_record指向上一条记录的next_record,再修改上一条记录的next_record指向当前记录; - 更新当前组内的
n_owner
- 那么就会将当前的记录的
- 页满之后的插入操作
- 申请一个新的空白页
- 为了保证主键有序,
InnoDB会将记录进行排序,将相关的数据进行迁移 - 因此一般建议主键是自增的,可以有效避免数据在页之间的迁移
- 如果当前组内存在之前逻辑删除的记录,即
-
单页数据查询过程
- 通过二分查找到对应的槽位
- 遍历槽位内的单链表,找到相对应的数据
-
B 树与 B+ 树
-
相同点
- 一个叶子节点可以存储多个元素
- 两者的叶子节点都是有序的
- 每个叶子节点到根节点的长度都相同
-
不同点
-
B+ 树的叶子节点带有指针,在 MySQL 的实现中则是双向指针,这样可以有效提高查找速度,比如说,对于
SELECT * FROM student WHERE id BETWEEN 5 AND 10
可以极大地提高查找速度
-
B+ 树的叶子节点带有非叶子节点的冗余节点
-
-
-
聚簇索引(主键)
record_type为 1,表示该记录是一个目录项。记录通过 “主键 + 页号” 的方式存储对应的信息
-
二级索引(非主键)
- 非叶子节点也是通过 “主键 + 页号” 的方式组成的
- 叶子节点由 “索引列 + 主键” 组成
- 查找数据时,通过主键再 “回表” 进行查询
-
联合索引(多列)
- 最左原则,多列同时创建索引时,会优先按照左边的列建立索引
- 因此只有最左边的列在查询时才能享受到索引带来的性能的提升
- 对于非最左列的查询不会建立索引(因为首先按照最左列已经创建了索引),因此使用非最左列进行查询时将会带来巨大的性能损失
-
目录项记录的唯一性
- 每个目录项对于二级索引,按照 “索引列的值 + 主键值 + 页号” 来作为目录项记录的唯一标识
-
概述
- 每个控制块一一对应这缓冲页
- 整个区间的内存是连续的
innodb_buffer_pool_size不包含控制块区域的大小,因此一般情况下申请的空间会大于innodb_buffer_pool_size指定的大小innodb_buffer_poll_size默认大小为 128 MB- 通过 Hash 表来判断数据库中的页是否加载到缓冲区,其中,Hash 表的 key 为 “表空间 + 页号”,value 为缓冲区中的控制块
-
free 链表(空闲链表)
- 遍历 free 链表,填充对应的缓冲页,移除该元素
-
-
修改后的缓冲页(脏页)放入该链表,在某一时刻将数据刷到磁盘上
- 从 flush 链表中刷新一部分页面到磁盘,这是通过后台线程来实现的,
- 根据系统的繁忙程度来确定刷新的速度(BUFFER_FLUSH_LIST)。
- 系统很繁忙的情况下,会使得刷新脏页到磁盘的速度会很慢,可能会导致这么一种情况:当读取不在缓冲区中的数据页时,由于当前缓冲区的所有页都是脏页,将会导致无法将磁盘中的页读到缓冲区中。在这种情况下,将会去查看
LRU链表的尾部,检测是否存在可以直接释放的未修改的缓冲页;如果没有,将不得不将LRU链表尾部的一个脏页同步刷新到磁盘中。(BUFFER_FLUSH_SINGLE_PAGE)
- 从
LRU链表的了冷数据汇总刷新一部分页到磁盘(BUFFER_FLUSH_LRU)
- 从 flush 链表中刷新一部分页面到磁盘,这是通过后台线程来实现的,
-
-
LRU 链表(Last Recently Used)
-
在访问某个在缓冲区的页时,将它作为最近使用的页,放到 LRU 列表的头部,此时这个页是一个热数据。
-
使用热数据可以提高命中率,从而提高数据的访问速度。
-
存在的问题
-
预读问题
InnoDB认为对于当前页的读取,对于当前读取页之后的页,认为在查询该页周围的页,也是你将要读取的页。因此它会把当前读取页周围的一些页一并加载到缓冲池中,导致一部分缓冲池内的页失效。- 预读分为两种:线性预读和随机预读
- 线性预读:表空间—> 区(64个页) —> 页,当线性访问超过一定的阈值时,就会执行线性预读
- 随机预读:默认不开启,只要连续读取超过一定阈值,就会读取整个区的页。
- 解决方案
- 当页从磁盘中加载时,
InnoDB会将加载的页放入冷数据区域。通过适当增大冷数据区的大小可以有效解决该问题。 - 设置冷数据区比例的参数:
innodb_old_blocks_pct(默认 37)
- 当页从磁盘中加载时,
-
全表查询导致缓冲页失效
- 由于未加上查询条件,或者未命中索引,将会导致全表扫描,直接将整个表的页全部读取到缓冲池中,使得整个缓冲池中的页失效
- 解决方案
- 在从磁盘加载页到冷数据区之后,通过设置一个时间阈值,只有在该页上访问超过这个时间阈值时才能进入热数据区。
- 设置时间阈值的参数:
innodb_old_blocks_time(默认为 1000 ms)
-
-
-
Chunk 和 Buffer Pool
- 由于多个线程的访问,可能会由于加锁的原因导致性能的下降,因此可以将 Buffer Pool 设置为多个实例
- Chunk :细化 Buffer Pool,优化 Buffer Pool(因为较小连续的内存空间要比较大的连续内存空间更加容易分配)
-
在 Buffer Pool中修改了页,如果在将 Buffer Pool 中的内容冲洗到磁盘上的这一过程出现了问题,导致内存中的数据失效,那么这个已经提交的事务在数据库中所做的修改就丢失了。这时需要通过 redo 日志文件来恢复
-
redo 简单日志类型
-
redo 复杂日志类型
- 一般 SQL 执行时使用的都是复杂日志类型
- 类型
- MLOG_REC_INSERT(type = 9)(非紧凑型)
- MLOG_COMP_REC_INSERT(type = 38)(紧凑型)
- MLOG_COMP_PAGE_CREATE(type = 58)
- MLOG_COMP_REC_DELETE(type = 42)
- MLOG_COMP_LIST_START_DELETE(type = 44)
- MLOG_COMP_LIST_END_DELETE(type = 43)
-
redo 日志组
-
MTR (Mini Transaction)
- 对底层的页进行一次原子访问的过程被称为一个 MTR,与 redo 日志组类似
- 一个事务可以包含多个 SQL 语句;一条 SQL 语句可以包含多个 MTR;一个 MTR 包含多个 redo 日志。
-
redo log block
-
redo 日志缓冲区(log buffer)
-
内冲中多个连续的 redo log block (连续的内存空间)
-
对于多个事务,由于不同的事务之间有可能是并发执行的,因此多个事务的 MTR 可能是交替执行的
-
对于一般的的事务,它的执行过程一般如下
- 执行相关的 SQL 语句
- 得到相关的 redo 日志
- 根据 redo 日志的原子性,将它们聚集到 MTR 的维度
- 最后将 MTR 写到 redo log block 中(此时在内存)
-
-
redo 日志刷盘和日志文件组
-
redo 日志文件格式
- LSN(log sequence number)
- 用于记录当前 redo 日志文件已经写入的总量
- 初始值为 8704。而
fileOffset为 2048,即 redo 日志开始写入的偏移位置
- LSN(log sequence number)
由于事务需要保证原子性,然而有时由于异常的原因导致事务需要回滚,因此此时需要解决一致性的问题,此时就需要 undo log 来完成。
对于一般的读数据操作,不会记录相应的 undo log
-
事务 id(与 row_id 类似)
-
INSERT 对应的 undo log
-
DELETE 对应的 undo log
-
UPDATE 对应的 undo log
- 日志结构
-
ACID
- 原子性(Atomicity):对于一个事务来讲,要么全部执行,要么全部不执行
- 一致性(Consitency):在事务开始前和开始后,数据库的完整性没有被破坏
- 隔离性(Isolation):多个事务的处理互相独立,互不干扰
- 持久性(Durability):事务结束后,对数据的修改是永久的
-
事务并发执行时可能带来的问题
-
脏读
一个事务读取了另一个事务未提交的数据
-
脏写
一个事务对于数据的修改覆盖了另一个事务对于此数据的写
-
不可重复读
一个事务修改了另一个未提交的事务读取的数据
-
幻读
当一个事务涉及修改表中的全部字段时,另一个事务修改了表内的数据,使得第一个事务发现还存在没有修改的数据行,就好像发生了幻觉一样
-
-
事务隔离级别
隔离级别 脏读 不可重复读 幻影读 未提交读 可能发生 可能发生 可能发生 提交读 - 可能发生 可能发生 可重复读 - - 可能发生 可序列化 - - - -
MVCC(Mut- Version Concurency Controller 多版本并发控制)
利用记录的版本链和 ReadView 来控制并发事务访问相同记录时的行为
-
ReadView
一致性视图,用来判断版本链中哪个版本是当前事务可见的
- m_ids:生成 ReadView 时,当前系统中活跃的读写事务
- min_trx_id:在生成 ReadView 时,当前系统中活跃的事务中最小的事务 id
- max_trx_id:在生成 ReadView 时,系统应当分配给下一事务的 id
- creator_trx_id:生成该 ReadView 的事务的事务 id
-
每次更新该记录后,都会将旧值放到一条 undo 日志中。随着更新次数的增多,所有的版本都会i被
roll_pointer属性链接成一条链表,被称为版本链 -
ReadView 判断版本的可见性
- 如果 trx_id == creator_id, 说明当前事务修改的是自己的修改记录,所以当前版本可以被该事务访问
- 如果 trx_id < min_trx_id,则说明生成该版本的事务在当前事务生成 ReadView 之前就已经被提交了,所以当前版本可以被当前事务访问
- 如果 trx_id >= max_trx_id。则说明生成该版本的事务在当前事务生成 ReadView 之后才开始的,,所以该版本对于当前事务来讲不可以访问
- 如果 trx_id in m_ids,则说明创建 ReadView 时生成的该版本的事务还是活跃的,该版本不可以被当前事务访问
- 如果 trx_id not in m_ids,则说明创建 ReadView 时生成该版本的事务已经被提交,该版本可以被访问
- 如果某个版本的数据对当前事务不可见,那么将会顺着版本链找到下一个版本,并继续执行上面的步骤判断可见性,直到找到版本链中的最后一个版本
-
提交读和可重复读的最大区别在于 ReadView 的生成时机不同
对于提交读来讲,每次读取数据前都会生成一个 ReadView
对于可重复读来讲,在同一个事务中,只有在第一次读取数据时生成一个 ReadView
-
















