vlambda博客
学习文章列表

Mysql系列(二)Innodb缓冲池 buffer pool 和 改良版LRU算法

InnoDB存储结构


下图是官方提供的InnoDB总体结构:分为内存结构(下图左侧)和磁盘结构(右侧)两部分。

内存部分由多个缓冲区构成,分为 缓冲池(Buffer Pool,检测BP)日志缓冲(Log Buffer),缓冲区的最小逻辑单位是页(page)。

页是数据库的最小操作单位,无论是在磁盘还是内存中数据库的操作单位都是页,一页等于文件系统的一个或者多个块,mysql默认页大小为16K。



磁盘部分包括各种表空间,主要有以下5种:系统表空间(System Tablespace,又称共享表空间)、独立表空间(File-Per-Table Tablespaces)、undo表空间(undo Tablespaces)、通用表空间(General Tablespaces)、临时表空间。

innodb可以选择使用系统表空间还是独立表空间存储表,如果选择前者,则所有innodb表都保存在 ibdata1 这个表文件中,选择后者则一个innodb表占据一个表文件,拥有自己独立的表文件。





缓冲池 Buffer Pool


缓冲池是mysql向操作系统申请的一片连续内存空间(实际上缓存池实例中的块(chunk)内部是连续的,但chunk之间是离散的),存储单位是页,称为缓冲页或缓存页,缓存页的大小和磁盘页一样为16K。

Mysql系列(二)Innodb缓冲池 buffer pool 和 改良版LRU算法

缓冲池包括:数据页(data Page)、索引页(index Page)、undo页、写缓冲区(change page,简称 CB)、自适应哈希索引(adaptive hash index)和其他信息(如锁信息,数据字典信息)等。



- Buffer Pool 的预读特性

磁盘IO按页读取,查询某条记录不是只读取这条记录,而是读取这条记录所在的整个页并缓存。

根据局部性原理,短期内数据读取是集中在某个小的范围之内的,所以本次读取的数据大概率和上次读取的数据在一个页内。

假设把这个页放到内存缓存,那么这个页下次被命中的可能性比较高,从而避免重复的磁盘IO。所以缓存整个页具有预读的作用,预读也是缓冲池一大作用。


例如 本次查询 id = 5 的行,系统缓存了页号为1001的页,下次查询id = 6的行(假设这两行放在同一页中),就会命中1001号页的缓存,无需进行磁盘IO。

InnoDB怎么在不查询磁盘的情况下知道 id = 6的记录也位于 1001号页呢?很简单,因为该表的索引页缓存了起来,系统查询缓存中的索引页就能得知id=6的数据页页号。


Innodb的预读不仅只读取本次所需的一个页面,还可能读取和该页面相邻近的其他页面。


预读分为两种:线性预读 和 随机预读。

线性预读是指当某个区(extend,1 extend 包含 64 page)内被顺序访问的页面(即离散分布)超过56个时,innodb会把下一个区的全部页面异步载入buffer pool。

随机预读是指当某个区的13个连续页面在buffer pool中,而且这13个页面都在lru热区域的前 1/4 位置内时,innodb都会把本区内的所有页异步载入buffer pool。




- Buffer Pool 的页分类

free page :空闲page,未被使用过的页;

clean page:正在被使用的干净页,即没有被修改过的页;

dirty page:脏页,用户做出DML操作修改数据且这些数据刚好在缓冲池的页就是脏页,这样的页与磁盘中对应的页数据不一致。


针对这3种页,InnoDB使用3种链表维护:

free list:空闲页链表,管理 free page;

flush list:脏页链表,管理 dirty page 并在某个时刻对该链表的脏页进行刷盘,按脏页的修改时间排序,更新操作早的脏页先被刷盘;

lru list:正在使用的内存页链表,里面包含 clean page 和 dirty page,也就是说 lru list 中的页包含 flush list 中的所有脏页。


实际上链表中的节点不是缓存页本身,而是页对应的控制块

Mysql系列(二)Innodb缓冲池 buffer pool 和 改良版LRU算法


除了这3个列表之外,innodb的buffer pool还管理了很多其他链表如管理压缩页的链表等。




- 改良版lru算法


Innodb的buffer pool中,lru链表遵循LRU算法管理缓存页。


刚开始lru列表是空的,所有的内存页都放在 free 列表;当数据从磁盘读到内存,系统先从free列表查找是否有可用的空闲页,有则从free 列表移除放到lru列表,没有则按照lru算法释放旧的缓存页。


注意:free list + lru list 不一定等于 Buffer Pool 的大小,因为 Buffer Pool 还存放 写缓冲区、自适应哈希索引和其他信息。


整个mysql使用的内存区可以划分为多个Buffer Pool ,一个Buffer Pool 可以分为多个块(chunk),每个chunk包含有多个page。


实际上,innodb使用的是一种改良版的LRU算法来管理缓存页,它相比于正常的LRU算法有以下优化。



优化点1:midpoint

普通LRU遵循新数据从链头加入,链表满了需要释放时从末尾弹出。改良LRU设置了一个 midpoint 点,新页(刚从磁盘读到的页或者刚进入lru list没多久的页)不放在LRU首部,而是放在 midpoint 后的第一个位置,链表满了则从末尾弹出节点。


midpoint 前的页是 热数据 列表区(new list),midpoint 后的页是 冷数据 列表区(old list)。midpoint 默认位于距离 lru 的链头的 5/8 的位置。


使用改良LRU是为了防止某些不常用的数据占用buffer pool空间,比如预读了不常用的页,或者 扫描操作(如全表扫描、索引扫描、大范围查询)查到大量数据,导致缓冲池的热数据被(部分或全部)刷走,这种情况称为缓冲池污染。使用了midpoint之后,被刷走的也只是midpoint后的cold数据。



优化点2:old_blocks_time

InnoDB规定 页读到cold区域之后 需要隔一段时间T才有资格进入到LRU列表的热端(在这段时间T内该cold页再次被访问也不会进入热端列表),这是为了防止某些不常用的页(如全表扫描的页)在短时间(如1秒内)内被多次访问,让系统误以为它是热数据从而将其放入了热端区域。



优化点3:减少热页在链表移动

我们知道热数据页会被频繁的访问,如果一个热数据页每被访问一次就被移动到 lru链表 首部,那么操作内存的开销也不小。Innodb规定热区域的前 1/4 的页被访问后不会移动位置,后 3/4 的页被访问就需要移动到头部,这样可以减少链表的指针操作。


LRU链表还有很多的优化点,这里不一一介绍。




- 缓冲页的哈希处理

我们知道InnoDb访问某页时,不是直接从磁盘读取,而是先从缓冲池(的lru链表)读取页;如果没命中缓存,就从磁盘读取页到缓冲池缓存,下次读到相同的页则直接从缓冲池读取,从而减少磁盘IO。


问题是怎么知道我要查询的页是否在buffer pool呢,难道要对lru链表一个个页遍历?遍历是不可能遍历的,这辈子都不可能遍历的。


其实学过lru算法的小伙伴们都知道,lru算法的实现需要 链表 和 哈希表两个结构。

所以当要访问某个页时,根据该 表空间号+页号 即可得知页在不在buffer pool,在buffer pool的哪个地方。



脏页刷盘

后台有专门的线程负责每隔一段时间就把脏页刷新到磁盘,这样可以不影响用户线程正常处理用户请求。


刷新方式主要有下面几种:


1、从LRU 链表的冷数据区刷新部分页面到磁盘

后台线程会定时从 LRU 链表尾部开始扫描指定数量的页面(比如每次只扫描最靠近末尾的1000个页),发现脏页(控制块的某个属性记录了一个页是不是脏页)则刷新到磁盘,这种刷 新页面的方式称为BUF_FLUSH_LRU。


热数据的脏页会随着它不被访问而进入到冷数据区,从而被检测到并刷盘。如果热数据的脏页一直都在使用,不会进入到冷数据区,也可以通过第二种方式保证热数据的脏页在一定时间内刷盘。


优先刷盘冷数据而不优先刷盘热数据是因为,热数据在短时间可能被多次修改,如果优先刷盘热数据页,这个页很快又会被修改,又需要再刷盘,不如等它变成冷数据再刷盘。


2、从 flush 链表中刷新一部分页面到磁盘

这种刷新页面的方式称为 BUF_FLUSH_LIST。flush链表包括 lru链表热数据页 和 冷数据页的脏页。


3、主动刷盘内存池中被淘汰的脏页

如果在buffer pool已满的情况下,用户线程从磁盘读取某个页要链入lru链表,lru链表会释放尾部的一个页。

假设这个释放的页是一个脏页,那么用户线程就不得不亲自把这个脏页刷盘,因而降低用户请求的速度。这种方式称为 BUF_FLUSH_SINGLE_PAGE。


之所以需要后台线程定时刷盘脏页就是为了尽可能避免发生 BUF_FLUSH_SINGLE_PAGE。




- 多个Buffer Pool

一个mysql实例中,缓冲池不只一个,而是有多个,所有缓存页根据哈希值平均分配到不同缓冲池实例。将内存空间分为多个缓冲池是为了增加临界资源,减少多个线程对buffer pool的竞争(毕竟访问buffer pool的各种链表都需要加锁处理),提高并发。


每个 buffer pool有自己独立的内存空间,独立的lru、free、flush链表。buffer pool的个数不是越多越好,因为管理每一个buffer pool也需要开销。





- Buffer Pool分块

Buffer Pool 分块(chunk)是mysql 5.7.5之后的特性,该特性是指一个buffer pool实例是由多个块组成,每个块的块内空间是连续的,块与块之间是离散的。

在 mysql 5.7.5之前,为buffer pool申请内存空间是整个buffer pool 实例都是连续的。


buffer pool分块是为了方便用户可以在mysql运行期间能够调整buffer pool的大小(innodb_buffer_pool_size)。

假设,整个buffer pool都是连续的,如果用户增大buffer pool的大小,系统必须分配一个比原来 buffer pool 更大的连续空间,再将原来buffer pool的数据拷贝到新空间,这个CPU时间开销无疑是巨大的。

但是使用了 分块 存储的方式,当想要增大 buffer pool 的大小时,系统只需多申请一个块或者多个块的空间,并将这些块链入这个buffer pool实例中即可。

一个块的大小由参数innodb_buffer_pool_chunk_size控制,默认一个chunk为128M。




- Buffer Pool 预热

Mysql重启时,BP中的热数据会清空,为此mysql提供了缓冲池预热功能,当关机时会把内存中的热数据写入到 ib_buffer_pool 文件中,保存的数据占 lru 的比例可由参数控制,mysql启动时会自动加载热数据到缓冲池。预热功能默认开启。



- Buffer Pool配置参数

innodb_page_size

BP缓冲区大小(单位是页),建议将其设为总内存的 60% ~ 80%。


innodb_old_blocks_pct

midpoint离链尾的百分比,默认37.5%。


innodb_old_blocks_time 

新页需要隔多长时间才能进入lru链表的热端。


innodb_buffer_pool_instances  

Buffer Pool的个数,建议设为多个。


innodb_buffer_pool_dump_at_shutdown 

关闭服务时保存热数据.


innodb_buffer_pool_dump_pct

保存热数据的比例。


innodb_buffer_pool_load_at_startup

开机时载入热数据


可以通过 show variables like '%...%' 查看以上配置项;


注意Innodb的缓冲池 和 查询缓存 是不同的两个东西,前者属于存储引擎层,后者属于服务层,前者是缓存已经读取过的页,后者是缓存查询语句和查询结果的映射关系,后者想要命中缓存必须要做到下一次用相同的sql语句查询。



- 写缓冲 Change Buffer

在进行DML操作时,系统不会直接将变更刷新到磁盘中,而是会先将变更的页写入到缓冲区,经过一系列策略同步到磁盘。


此时分为两种情况:

1、当更改的页存在于 Buffer Pool 的 lru 链表,则直接在缓冲池中修改这个页,这个页会变成脏页,链入到 flush list中,但并不马上刷盘;此时不涉及 change buffer 操作。


2、当更改的页不存在于 Buffer Pool 的 lru 链表,就要先从磁盘读取要修改的数据页到Buffer Pool后再修改(数据不可能在磁盘中直接更改,肯定要读到内存,在内存中修改)。

但为了避免修改操作引发的磁盘读IO,系统会将DML操作记录到 change buffer中,并不马上刷盘。

等下次对这些修改的页进行查询时,由于lru链表不存在该页,会从磁盘读取(磁盘页是更改前的数据),为了避免读到脏数据,该磁盘页会和 change buffer中的更改合并后才链入到 lru链表。

如果未来一段时间都不会查询到这个修改了的页,也会有 insert buffer thread 定时将change buffer 的数据合并到磁盘页中。


使用change buffer可以避免数据更改时因为隐式查询数据带来的磁盘IO,这是change buffer提升性能的地方。

如果做出的更改是对唯一键索引的值的修改,innodb要做唯一性校验,必须查询磁盘,再在lru链表上的页修改,不会在change buffer 中操作。

change buffer 默认占 Buffer Pool 的 25%,最大允许占50%。可以根据写业务的量调整,写操作越频繁,change buffer 带来的性能提升越明显。




日志缓冲区 Log Buffer

log buffer 用来缓存要写入log文件的数据(redo和undo)。

这里的log文件是指Innodb引擎的日志,所以不包括什么binlog日志、慢查询日志之类的其他日志,日志缓冲区会定期刷新到磁盘的log文件中。


从日志缓冲区 log buffer 刷盘到 log文件需要经过 操作系统内核的缓冲区 os cahce(见本文第一张图中的 Operation System Cache),因为IO操作需要委托操作系统来完成。

innodb_flush_log_at_trx_commit参数控制日志刷新的行为和周期,默认为1。log日志刷盘有3种策略:


1、每隔1秒从 log buffer 写入OS cache,并马上刷盘,mysql服务故障或者主机宕机则丢失1秒数据。


2、事务提交时,立刻从 log buffer 写入 os cache, 并马上刷盘,mysql服务故障或者主机宕机不会丢失数据,但会频繁发生磁盘IO。


3、事务提交时,立刻从 log buffer 写入 os cache,每隔1秒刷盘,mysql服务故障不会丢失数据,因为数据已经进入操作系统缓存,与mysql进程无关了,主机宕机则丢失1秒数据。


除此之外,当redo/undo日志缓冲区满了之后,也会触发刷盘。

上面所说的刷盘是指日志数据刷盘到log文件,而不是表数据刷盘到表文件,数据刷盘到表文件是发生在redo日志刷盘到redo log文件之后才发生的,而且是从buffer pool刷盘到表文件的。

刷盘操作是异步IO,由专门的线程完成这件事,不会阻塞用户请求的处理。