前言
在应用系统中,为加速数据访问,会把高频的数据放在「缓存」(Redis、MongoDB)里,减轻数据库的压力。在操作系统中,为了减少磁盘IO,同时为了快速响应,引入了「缓冲池」(buffer pool)机制。
MySQL作为一个存储系统,为提高性能,减少磁盘IO,同样具有「缓冲池」(buffer pool)机制。
介绍
Buffer Pool作为InnoDB内存结构的四大组件之一,是InnoDB存储引擎层的缓冲池。Buffer Pool以Page页为单位,缓存最热的数据页(data page)与索引页(index page),Page页默认大小16K,BP的底层采用链表数据结构管理Page。
Buffer Pool默认大小 128M。
Buffer Pool 除了缓存「索引页」和「数据页」,还包括日志缓存 log buffer( undo 页 、redo log),插入缓存、自适应哈希索引、锁信息等。
也就是说,所有数据页的读写操作都需要通过buffer pool进行,innodb 读操作,先从buffer_pool中查看数据的数据页是否存在,如果不存在,则将page从磁盘读取到buffer pool中。innodb 写操作,先把数据和日志写入 buffer pool 和 log buffer,再由后台线程以一定频率将 buffer 中的内容刷到磁盘,「这个刷盘机制叫做Checkpoint」。
写操作的事务持久性由redo log 落盘保证,buffer pool只是为了提高读写效率。
Buffer Pool控制块
为了更好管理的缓存页,Buffer Pool有一个「描述数据的区域」叫控制块。InnoDB 为每一个缓存的数据页都创建了一个单独的区域,记录的数据页的元数据信息,包括数据页所属表空间、数据页编号、缓存页在Buffer Pool中的地址,链表节点信息、一些锁信息以及 LSN 信息等。
「控制块和缓存页是一一对应的,它们都被存放到 Buffer Pool 中,其中控制块被存放到 Buffer Pool 的前边,缓存页被存放到 Buffer Pool 后边」,控制块大概占缓存页大小的5%,16 * 1024 * 0.05 = 819个字节左右。
但是需要注意的是,一般数据页虽然有默认大小,但是有的数据页会超出大小,如果剩余空间不够一堆控制块和缓冲页,那么就部分空间就属于碎片空间。
Buffer Pool的管理
Buffer Pool里有三个链表,LRU链表,free链表,flush链表,InnoDB正是通过这三个链表的使用来控制数据页的更新与淘汰的」。
1、Buffer Pool的初始化
当启动 Mysql 服务器的时候,需要完成对 Buffer Pool 的初始化过程,即分配 Buffer Pool 的内存空间,把它划分为若干对控制块和缓存页。
划分空间后,Buffer Pool的缓存页是都是空的,当要对数据执行增删改查的操作的时候,才会把数据对应的页从磁盘文件里读取出来,放入Buffer Pool中的缓存页中。当BufferPool中间有的页数据持久化到硬盘后,这些数据页又会被空闲出来。
- 「申请空间」
Mysql 服务器启动,就会根据设置的Buffer Pool大小(innodb_buffer_pool_size)超出一些,去操作系统「申请一块连续内存区域」作为Buffer Pool的内存区域。- 「划分空间」
当内存区域申请完毕之后,数据库就会按照默认的缓存页的16KB的大小以及对应的800个字节左右的控制块的大小,在Buffer Pool中划分**「成若干个【控制块&缓冲页】对」**。
2、Free链表
Free链表即空闲链表,是一个双向链表,由一个基础节点和若干个子节点组成,记录空闲的数据页对应的控制块信息。说白了,Free链表的设计是为了找到空闲的缓存页。
1)节点信息
「基节点」
- 基础节点是一块单独申请的内存空间(约占40字节)。并不在Buffer Pool的连续内存空间里。基础节点会引用链表的头节点和尾节点。
「子节点」
- 每个子节点就是个空闲缓存页的控制块的地址信息,也就是说只要一个缓存页空闲,那它的控制块就会被放入free链表。每个控制块块里都有两个指针free_pre(指向上一个节点),free_next(指向下一个节点)。
磁盘页加载到BufferPool的缓冲池流程
- 从free链表中,获取头节点,取出一个空闲的控制块以及对应缓冲页
- 把磁盘上的数据页读取到对应的缓存页,同时把相关的一些描述数据写入缓存页的控制块(例如:页所在的表空间、页号之类的信息)
- 把该控制块对应的free链表节点从链表中移除,表示该缓冲页已经被使用了
2)如何确定数据页是否被缓存
数据库提供了一个数据页缓存哈希表,以表空间号+数据页号作为key,缓存页的控制块地址作为value。
也就是说,如果要使用缓冲页,就会先去数据页缓冲哈希表中查找,表中存在的话,再根据控制块的地址去找缓冲页,如果没找到缓冲页,那么就会先读取数据页数据,从空闲列表中获取空闲的缓冲页就行写入,写入完毕之后,移出空闲链表,并更新控制块的地址信息。
大致过程:
- 通过sql语句中的数据库名和表名可以知道要加载的数据页处于哪个表空间。
- 「根据表空间号、表名称本身通过一致性算法得到索引根节点数据页号」。
- 进而根据根节点的数据页号,通过B+tree一层一层往下找,最后找到想要的数据页,再从数据页缓存哈希表得到对应缓存页地址。
- 通过缓存页地址就可以在Buffer Pool池中定位到缓存页。
3、LRU链表
当free链表的所有子节点都没有,但是Buffer pool的大小是固定的,就会从LRU链表中获取相对非热点数据的节点,来做数据覆盖。
基于此,InnoBD采用了LRU(Least recently used)算法,将频繁访问的数据放在链表头部,而不怎么访问的数据链表末尾,空间不够的时候就从尾部开始淘汰,从而腾出空间。
LRU算法的目的就是为让被访问的缓存页能够尽量排到靠前的位置。LRU链表本质上也是有控制块组成的。
当数据库从磁盘加载一个数据页到Buffer Pool中的时候,会将一些变动信息也写到控制块
中,并且将控制块从Free链表中脱离加入到LRU链表中。
当访问的页在 Buffer Pool 里,就将该页对应的控制块移动到 LRU 链表的头部节点。
当访问的页不在 Buffer Pool 里,除了要把控制块放入到 LRU 链表的头部,还要淘汰 LRU 链表末尾的节点。
LRU链表区域划分
LRU链表是Msyql 基于 LRU 设计了冷热数据分离的处理方案,分为young区域和old区域,young区域位于链表头部(5/8),存放热点数据。old区域,位于链表尾部(3/8),存放非热点数据。
存放规则
**每次有数据被加载到buffer pool后,先插入到old区域的头部,并标记第一次访问的时间,后续如果在访问到当前页,并且访问时间间隔大于设置的间隔时间innodb_old_blocks_time(默认1s),才会把当前页给提高到young区域。**通过这种方式,就避免了全部扫描导致的只访问一次的数据页覆盖掉热点数据的问题。
但是热点区域的数据被频繁访问,如果young区域每次访问某一页,就把当前页移动到young区域的head,会导致LRU链表频繁的变形,因此mysql又做了一个优化:young区域前1/4被访问不会被移动到头部,只有后面的3/4被访问才会移动到头部。
4、Flush链表
对数据的读写都是先对Buffer Pool中的缓存页进行操作,然后在通过后台线程将脏页写入到磁盘,持久化到磁盘中,即刷脏。
脏页:当执行写入操作时,先更新的是缓存页,此时缓存页跟磁盘页的数据就会不一致,这就是常说的脏页
Flush链表与Free链表的结构很类似,也由基节点与子节点组成。Flush链表是一个双向链表,链表节点是被修改过的缓存页对应的控制块。Flush链表作用:帮助定位脏页,需要刷盘的缓存页。
基节点:和free链表一样,连接首尾结点,并记录存储了有多少个描述信息块。
子节点:每个节点是脏页对应的控制块,即只要一个缓存页被修改,那它的控制块就会被放入Flush链表,每个控制块块里都有两个指针pre(指向上一个节点),next(指向下一个节点)
写入过程
- 更新Buffer Pool中的数据页,一次内存操作
- 将更新操作顺序写Redo log,一次磁盘顺序写操作(顺序写Redo log,每秒几万次)
Change Buffer
在MySQL5.5之前,叫插入缓冲(Insert Buffer),只针对INSERT做了优化;现在对DELETE和UPDATE也有效,叫做写缓冲(Change Buffer)。
**它是一种应用在非唯一普通索引页(non-unique secondary index page)不在缓冲池中,对页进行了写操作,并不会立刻将磁盘页加载到缓冲池,而仅仅记录缓冲变更(Buffer Changes),等未来数据被读取时,再将数据合并(Merge)恢复到缓冲池中的技术。**写缓冲的目的是降低写操作的磁盘IO,提升数据库性能。
change buffer 只适用于非唯一普通索引页?
**如果索引设置了唯一属性,在进行修改操作的时候,innodb必须进行唯一性检查,也就是说,即便索引页不在缓冲池中,磁盘的读取也是不可避免的。**此时就应该直接把相应的页放入缓冲池然后进行修改,而没有必要进行"写缓冲"这多余操作。
change buffer中数据刷新的触发时机?
- 相关数据页被访问
- 后台线程,在数据库空闲时
- 数据库缓冲池不够用
- 数据库正常关闭
- redo log写满
Doublewrite Buffer
一般情况下,Linux文件系统的页大小是4KB,而我们知道 mysql的页大小为16KB(oracle为8KB),这就说明,mysql将buffer pool中的一页数据刷到磁盘里面,需要写4个文件系统页。
但是这个操作并不是原子性的,如果写到一半断电,就会发生"页数据损坏"。
Doublewrite Buffer是内存+磁盘结构,内存结构是由128个页组成,大小为16KB × 128 = 2MB,"Double"的由来是因为数据写两次磁盘:一次是写DWB的内存区域(此过程分两次写入,每次写入16KB × 64 = 1MB的数据,会分两次刷入磁盘),一次是直接写入Data File(.ibd)。
MySQL 8.0较MySQL 5.6、5.7版本的Doublewrite Buffer产生了一些新的变化,DWB磁盘结构的数据存放从共享系统表空间中分离出来,存放在单独的.dblwr文件中;
如何修复页数据损坏?
采用double write buffer 来进行修复,Doublewrite Buffer是为了保证因系统页损坏导致的MySQL数据丢失的保证方案。redo log无法修复这类页数据损坏异常。(修复的前提是页数据正确并且redo日志正常。) double write buffer 和传统的buffer不同,它分为内存和磁盘两层架构。
工作流程如下(刷盘):
- 页数据先copy到内存中的DWB中
- DWB的内存中的数据页,会先刷到DBW的磁盘上
- DWB内存中的数据页,在刷到数据磁盘的存储文件上 (.ibd文件)
两次磁盘写会不会影响性能?
- 内存copy到DWB的内存区域,速度很快
- DWB的内存写到DWB的磁盘,属于顺序追加写,速度也快
- 刷磁盘,随机写,但是本来就需要进行。
- 另外,128页工2M的DWB,会分两次刷入磁盘,每次最多64页,数据量小,执行快,因此综合来看,虽然性能有影响,但是影响不大。
疑问
1、MySQL 是怎么判断脏页的?
脏页的控制块同时存在于 LRU 链表和 Flush 链表。
2、触发刷脏页的条件
1)redo log快写满的时候
2)为了保证MySQL中的空闲页面的数量,Page Cleaner线程会从LRU 链表尾部淘汰一部分页面作为空闲页。如果对应的页面是脏页的话,就需要先将页面Flush到磁盘。
3)MySQL中脏页太多的时候。innodb_max_dirty_pages_pct 表示的是Buffer Pool最大的脏页比例,默认值是75%,当脏页比例大于这个值时会强制进行刷脏页,保证系统有足够可用的Free Page。
4)MySQL实例正常关闭的时候,也会触发MySQL把内存里面的脏页全部刷新到磁盘。
总结
Buffer Pool 里有三种数据页页和链表来管理数据,Free Page(空闲页)、Clean Page(干净页)、Dirty Page(脏页)。
Free Page(空闲页)。表示此数据页未被使用,是空的,其控制块位于 Free 链表;
Clean Page(干净页)。表示此数据页已被使用,缓存了数据, 其控制块位于LRU 链表。
Dirty Page(脏页)。表示此数据页【已被使用】且【已经被修改】,数据页中数据和磁盘上的数据已经不一致。脏页的控制块同时存在于 LRU 链表和 Flush 链表。 当脏页上的数据写入磁盘后,内存数据和磁盘数据一致,那么该页就变成了干净页。