MySQL持久化不为人知的一面⭐️卡顿现象的根源与对策
2024新年新气象,小菜同学又踏上了求职之路,但求职路艰辛,新年第一次面试又被面试官给问住了
面试官:你有没有遇到过因为持久化,把线程的查询、修改请求卡住的情况?
小菜(得意的笑,还想给我挖坑):持久化时写redo log的,利用写redo log的顺序性来提升性能,避免随机IO,因此不会卡住其他线程的请求的
...
面试官:好,那我们今天的面试就到这里吧
经历本次面试,小菜同学又重新整理缓冲池、持久化相关的知识点终于搞懂卡顿的根源和对策
文章导图如下:
缓冲池
缓冲池的组成
缓冲池是一块内存区域,用于将磁盘中的页加载到内存,加快访问速度
当访问数据页时需要先判断页是否在缓冲池中,如果不在则需要从磁盘加载到缓冲池(内存)中
那如何判断某个页是否存在于缓冲池中呢?难道去遍历吗?(遍历是不可能遍历的,时间复杂度太高)
实际是通过Key:表空间 + 页号
Value:页 的方式建立散列表,达到O(1)的查找速度
数据页被加载到缓冲池后称为缓存页,每个缓存页对应一个控制块,控制块上记录数据页的相关信息
缓存页和对应的控制块组成chunk,chunk是申请连续空间的基本单位(当这片空间被缓存页、控制块没占满时还会剩下碎片)
为了避免扩容时重新分配内存,还要将数据从旧的空间迁移到新的空间,使用chunk进行扩容
访问缓冲池的线程会加锁,如果并发量大且只有一个缓冲池,开销会很大
使用分段锁的思想:将一个缓冲池分为多个实例,每个实例相当于有一把锁(页hash到实例),每个实例存在数个chunk
调整缓冲池参数如下:
- 使用
innodb_buffer_pool_instances
调整缓冲池实例的数量 - 使用
innodb_buffer_pool_chunk_size
设置每个实例中的chunk数量 - 使用
innodb_buffer_pool_size
规定缓冲池大小,并且其值必须是innodb_buffer_pool_instanes
和innodb_buffer_pool_chunk_size
的倍数
链表管理
缓存页有三种状态:
- 空闲:当还未从磁盘加载数据页时,缓存页是空闲的
- 已使用页干净:当从磁盘加载数据页到缓冲池时,对应缓存页被占用,但未在页上进行写操作(页不脏)
- 已使用脏页:当有写操作对页中某些记录进行修改时,页并不会立马写回磁盘(这样开销太大),而是通过写redo log的形式保证持久化(后文再说),这种被修改但未写回磁盘的页称为脏页
使用不同的链表管理控制块(对应缓存页):
- 空闲链表:管理空闲缓存页的控制块
- 脏页链表:管理脏页缓存页的控制块
注意:链表管理控制块相当于管理对应的缓存页
缓冲池的容量始终是有限的,当缓冲池满时需要将命中不高的页换出,将需要的页换进缓冲池
因此为了提升缓存命中率,使用LRU链表(LRU算法)管理缓存页
当一个页被查询时,LRU算法无论页是否存在都会放到链表头,如果链表已满,则将最后一个节点移除
这种场景下,如果进行范围扫描(页数量多),将会把大量页移除链表,全表扫描场景下情况会更糟糕,这会大大降低缓存命中率
为了避免以上场景发生,MySQL对LRU算法进行优化:
- 将链表分为冷(old)热(young)数据区,初次访问的页只放到old区的头部
使用innodb_old_blocks_pct
规定old区占比 (默认37%) - 全表扫描可能多次访问同一页,所以在规定时间内多次访问某页,不会把它对应控制块放到young区头部
使用innodb_old_blocks_time
规定该时间ms (默认1000ms) - 如果页对应控制块就在young头部附近就不移动(规定在1/4)
注意:LRU链表中也可能存储脏页
持久化
redo log
在聊脏页刷新前需要先搞懂innodb如何持久化
redo log是Innodb存储引擎用于持久化、奔溃恢复的重要日志
前文说过,当数据页遇到写操作变成脏页时需要写入磁盘进行持久化
如果对每一条记录都这么做,遇到一个写操作就写入磁盘,而且写回磁盘时,由于页的无序此时会是随机IO,开销非常大
如果想要存一段时间,等该页的脏记录多了再同时刷盘性价比会高一些,但是如果该期间宕机了,那岂不是会发生数据丢失?(因为此时还没刷入磁盘)
为了防止数据丢失,在宕机时能够进行数据恢复,使用redo log记录页中修改的数据并以顺序写入的方式进行IO(顺序IO)
当脏页被真正刷入磁盘后,对应的redo log就没有用了,因此redo log被设计成环形文件,以覆盖的方式进行追加日志
redo log通常以ib_logfile 0...x命名(末尾为0-x)
使用 innodb_log_group_home_dir
查看redo log文件所在位置
使用 innodb_log_files_in_group
设置redo log文件数,多个文件数串联形成环形文件
使用 innodb_log_file_size
规定每个redo log文件大小
通过这两个参数可以设置redo log文件的大小
当数据页变成脏页时,会往redo log buffer(缓冲区)上写redo log
由于每个事务中存在SQL,每个SQL都可能对应多个redo log,在往redo log buffer写redo log时,可能涉及到多个事务的redo log交替进行写
在进行redo log的刷盘时,会先将数据写入OS的page cache(write),然后根据参数配置 innodb_flush_log_at_trx_commit
不同时机刷入磁盘(fsync)
默认下参数为1,事务提交时会进行fsync将redo log刷入磁盘
当参数为0时,由后台线程进行write再fsync,吞吐量最高,宕机时会丢数据
当参数为2时,事务提交时只进行write写到OS的page cache,吞吐量也不错,但OS宕机时也会丢数据
bin log
redo log是Innodb(物理)奔溃恢复的日志,MySQL还存在逻辑恢复的日志binlog,binlog还用于主从复制
bin log的刷盘与redo log也是类似的,先进行write写到OS的page cache(过程快),再进行fsync刷入磁盘(慢)
可以使用 sync_binlog
控制binlog刷盘时机,类似redo log的 innodb_flush_log_at_trx_commit
默认下参数为1,事务每次提交后进行fsync刷入磁盘
当参数为0时,只write不fsync,由OS接管刷盘时间,吞吐量大,可能丢失数据
当参数为X时,经历X事务提交后进行fsync刷盘
在刷盘的过程中为了保证数据的一致性,在redo log刷盘的同时会对bin log一起刷盘
使用XA事务的两阶段提交:
- redo log prepare (write):redo log从缓冲区写入OS page cache
- bin log write:bin log从缓冲区写入OS page cache
- redo log prepare (fsync):redo log从page cache刷入磁盘
- bin log fsync:bin log从page cache刷入磁盘
- redo log commit:刷盘完成,持久化完成
注意:每个事务的redo log是交替写入buffer的,每次提交事务时可以把其他事务的redo log刷入磁盘(组提交)
崩溃回复时的判断:
- 如果redo log 是commit(已完成第五步)那么直接恢复数据
- 如果redo log 是prepare(未完成第五步),查看binlog是否完整;如果binlog完整(已完成第四步:bin log fsync)说明redo log、bin log都完成刷盘可以恢复数据,否则不恢复
为啥要设计成这样呢?
如果先写完redo log宕机没写bin log,那么主机会通过redo log恢复数据,而从机需要通过binlog恢复数据,此时binlog不存在就会导致数据不一致
如果先写完bin log宕机没写redo log,那么主机就无法通过redo log恢复数据,从而导致数据不一致
double write
在持久化的过程中还存在double write两次写
如果你理解redo log持久化的过程,是不是想说:两次写就是先写redo log再写数据页,分两次刷入磁盘
其实不是的,这里的两次写代表着数据页会分为两次写入磁盘,使用redo log恢复数据需要基于页的完整性,那在页还未刷入磁盘时如何保证页的完整性呢?
思想与redo log类似,通过先顺序写数据页的方式保证~(顺序IO代替随机IO)
checkpoint
将redo log刷入磁盘后,等待后续线程将对应的脏页刷入磁盘后,该redo log就可以被覆盖了
但是如何判断环形redo log可被覆盖呢?
在redo log上记录一些lsn(Log Sequeue Number),lsn是自增的(文件环形达到最大后又从起点开始)
lsn:标识写redo log序列号位置
flushed_to_disk_lsn:标识redo log刷入磁盘序列号位置
checkpoint_lsn:标识checkpoint推动到序列号的位置(可覆盖的位置)
后台线程会定期checkpoint推动可覆盖redo log的标记,每次进行checkpoint更新checkpoint_lsn的位置(更新可覆盖的redo log)
lsn与flushed_to_disk_lsn 之间的redo log是没有刷入磁盘的
flushed_to_disk_lsn与checkpoint_lsn之间的redo log是刷入磁盘的(但是它们对应的数据页可能有的被刷盘,有的没刷盘)
checkpoint_lsn前的redo log表示可覆盖的(对应数据页已经刷盘)
脏页刷新
知道MySQL的持久化机制后,再来看持久化时为啥会卡顿?
写操作太多,很多页没有刷盘,导致redo log占满,此时触发checkpoint将脏页刷入磁盘,空出可覆盖的redo log
又或者是缓冲池已满,要换进新的页时,会将old区末尾的页换出,如果该页是脏页,则又要进行刷盘
除了这种场景外还会有线程定时刷新、关闭前把脏页刷入磁盘等
当发生这种场景时,会暂停用户线程去进行刷盘操作从而造成阻塞(类似于JVM中的GC)
因此我们应该减低这种场景的发生,可以通过调整参数或升级磁盘等多方面实现
当前参数最好经过测试让DBA去调整,总结一下对应的参数
- 缓冲池
- 使用
innodb_buffer_pool_instances
调整缓冲池实例的数量 - 使用
innodb_buffer_pool_chunk_size
设置每个实例中的chunk数量 - 使用
innodb_buffer_pool_size
规定缓冲池大小,并且其值必须是innodb_buffer_pool_instanes
和innodb_buffer_pool_chunk_size
的倍数
- LRU算法
- 使用
innodb_old_blocks_pct
规定old区占比 (默认37%) - 使用
innodb_old_blocks_time
规定该时间ms (默认1000ms)
- redo log
- 使用
innodb_log_group_home_dir
查看redo log文件所在位置 - 使用
innodb_log_files_in_group
设置redo log文件数,多个文件数串联形成环形文件 - 使用
innodb_log_file_size
规定每个redo log文件大小
- bin log、redo log刷盘策略
sync_binlog
innodb_flush_log_at_trx_commit
- 调整io
- 使用
innodb_io_capacity
调整IO能力(使用磁盘IOPS) - 使用
innodb_flush_neighbore
(是否刷脏页的邻居页到磁盘,默认是,使用SSD可以关闭)
总结
本篇文章从MySQL的缓冲池开始,总结Innodb中进行持久化的实现原理
缓冲池由数个实例组成,实例由数个chunk组成,chunk由控制块、缓存页组成,每一个缓存页都有一个对应的控制块(缓冲池 -> 实例 -> chunk -> 控制块、缓存页)
缓存页分为空闲、已使用页干净、已使用脏页三种状态,使用空闲链表、脏页链表、LRU链表对缓存页的控制块进行管理
将LRU链表分为冷热数据区,从磁盘加载的页先放到冷数据区,经过一段时间多次读取后再放入热数据区头部,如果在短时间内多次访问一页则不会放入热数据区(防止范围、全表扫描导致缓存命中率降低),如果页就在热数据区头部附近则不会移动到头部(1/4)
使用先写redo log再将脏页刷盘的方式,用顺序IO替代随机IO
redo log 记录数据页修改的数据,用于实现物理上的数据恢复,由于redo log对应的页刷盘后,该redo log相当于无效,因此被设计成环形文件(可覆盖)
在生成redo log时,会将redo log写在redo log buffer缓冲池,由于每个事务可能对应多条redo log,redo log在缓冲池中是被交替写入的
redo log在进行刷盘时,会先从缓冲池写入操作系统的文件缓存page cache(write 快),再刷入磁盘(fsync 慢)
binlog 是MySQL逻辑上的数据恢复日志,在redo log进行刷盘时,为了保证数据一致性,bin log与redo log 基于XA协议使用两阶段提交
redo log 恢复数据基于页的完整性,double write 先让页顺序写到磁盘(保证页的可用),后续脏页再刷入磁盘
checkpoint 将脏页刷入磁盘,更新redo log上的checkpoint lsn(更新redo log 可覆盖范围)
当redo log被写满或缓冲池已满冷数据区末尾是脏页的场景,都会去让脏页刷新,导致用户线程阻塞,对于这种场景应该让DBA调整参数,升级IO能力解决
最后(不要白嫖,一键三连求求拉~)
本篇文章被收入专栏 MySQL进阶之路,感兴趣的同学可以持续关注喔
本篇文章笔记以及案例被收入 gitee-StudyJava、 github-StudyJava 感兴趣的同学可以stat下持续关注喔~
有什么问题可以在评论区交流,如果觉得菜菜写的不错,可以点赞、关注、收藏支持一下~
关注菜菜,分享更多干货,公众号:菜菜的后端私房菜