一、Innodb Buffer Pool简介
我们知道Mysql是基于磁盘的永久性存储的一个数据库,但是磁盘的读写速度远远赶不上内存的速度,当数据库访问量级比较大时,频繁的磁盘IO不仅速度慢,还有可能造成数据库的崩溃。为了缓解这一问题,通过使用内存来弥补磁盘缓慢读写的性能,尽量减少磁盘的IO,因而产生了Innodb缓冲池,提高数据库的速度以及稳定性。
缓冲池缓存全部数据?缓冲池只是缓存了部分数据以及索引等信息,遵循着部分原则,使得缓冲池中缓存着相对较“热”的数据,如果缓存了全部数据,不仅非常耗费内存,并且维护缓存以及磁盘数据的一致性也是很艰难的。
缓冲池缓存数据单位?数据在缓冲池中以什么样的形式存储呢?上文(Mysql—Innodb引擎逻辑结构)中提到数据在Innodb中是以页的形式存储的,页默认是16k大小,扇区是512b,每次获取数据都是以页的形式来返回,在数据库中有一个概念叫做“局部性原理”,符合条件的某条记录,其周围的记录被读取的概率更大,因此一般会将目标记录的周围数据一起返回,也就是所谓的预读。缓冲池中也是以页的形式来缓冲数据以及索引,与数据存储最小单位一致,便于管理,效率最也比较高。
Innodb buffer pool中主要缓存了数据页,也就是实体数据,提高查询的效率,而不是每次都去磁盘io,另外缓冲池中还包含了插入缓冲、自适应哈希、锁信息等其它缓冲的数据,包含信息如下图:
在Mysql启动期间,分配一段连续的内存,平均划分为相同的物理块,物理块分为控制体和缓存页,每个缓存页会跟随一个控制体,控制体中包含了表空间编号、页号以及页的地址、锁信息等位置记录信息,如下图所示:
二、缓冲池管理
缓冲池内部是以控制体-缓存页的结构来组织的。管理这些缓存页的关系,则依托于Innodb中的三个链表,FREE链表、LRU链表以及FLU链表。
2.1 FREE链表
当我们进行缓存初始化的时候,磁盘中的信息是没有缓存到缓冲池中的,之后随着程序的运行,会不断有磁盘中的页数据缓存至缓冲池中。
即将要缓存的数据是放在那一个位置上呢?哪些缓存页是空的?哪些缓存页是在用的呢?FREE链表便是要解决这几个问题,FREE链表记录了缓冲池中没有使用到的缓存页,以链表的形式来记录页的使用情况,每个节点有指针指向控制块,控制块指向缓存页,以此来指示哪些缓存页是空闲的,也就是可以被存储使用的。
当innodb buffer pool初始化分配物理块之后,会将所有节点放到FREE链表中,也就是所有缓存页都是空闲可用的。我们可以看到在FREE链表存储控制信息,每个节点通过pre和next指针分别指向前后节点,通过ctl指针指向控制体,在FREE链表内还会有一个控制头信息,里边指针指向了链表的头和尾,且记录了总的节点数量(空闲可用缓存页数量),如下图所示:
2.2 LRU链表
提高缓冲池命中几率?缓冲池的大小是有限的,当我们不断的从磁盘读取数据时,有限的缓冲池终究会耗尽,因此要有一个机制保障缓冲池中缓存的是使用频率比较高的数据,这样使得70%的数据访问量走内存查询,那么不仅速度提升,数据库的压力也会降低,使得有限的缓存发挥了它最大的作用。
当想到如何保障有限的缓存总是缓存最热的数据时候,我们会想到LRU,LRU全称为Latest Recently Used最近最少使用,也是一种算法,当然可以使用这种算法,以此保障使用频率较高的数据存储于缓存中。每当FREE链表中分配一个节点缓存数据页时,还需要在该链表维护一下已经使用的缓存页节点。假设有如下LRU链表,长度为10,首尾节点如图:
当有数据页需要缓存到缓冲池中时,先去FREE链表找到一个空的控制体,然后在LRU链表记录一下使用的控制体节点,假设当前要插入控制体节点13,则头部插入,尾部需要淘汰,则操作过程如下:
预读失效?上文提到了预读功能,那么相反预读也会有失效的情况,也就是预读进来的页后来并没有被实际查询,这样就造成了在缓冲池中的数据非热点数据,就会影响性能,为了避免这种情况,对LRU链表进行了优化,宗旨就是减少预读失效的页在LRU列表停留时间,让真正读取的页加入头部。
对于预读失效解决方式如下,一方面当节点树量大于512时候,将链表分成了新生代(new)和老年代(old),分别占列表的70%、30%(占比可可配置),总是从年老代列表尾部进行淘汰,另一方面链表首尾相连,中间连接的地方叫做Mid point,结构如下:
加入当前要插入节点13,则会把13放到old list的头部,并且把old list尾部节点淘汰,只有当被查询命中时且满足一定规则,才会被放到new list的头结点,操作过程如下:
缓冲池污染?通过分割链表解决了预读失效的问题,但是若此时有全表扫面该如何处理,按照当前的处理方式,假如有如下的sql:select * form table where name like ‘%ldy%’,我们知道这种方式是会扫描全表的,因此对于LRU链表会经历如下的过程:
1.全表记录分批返回,加入到old list头部;
2.遍历查找页中记录匹配条件,此时会把页放于new list头部;
3.如此循环往复,直至扫面完毕;
此时全表扫描已经导致缓存数据不是热点的数据了,因此缓存变成了一个脏缓存,也就是所谓的“缓冲池污染”。为了解决该问题,加了一个间隔时间innodb_old_blocks_time,第一次访问不会把这个页放在new list头部,当第二次访问该缓存页时(该缓存页没有被淘汰掉),如果距离上一次访问的时间小于这个时间,就不会把这个缓存页放到new list区域,这样就可以降低在短时间内有大量全表扫描对的缓存命中率的影响,操作过程如下:
现在由于扫面,要插入51,52,53,54的页到缓存中,
刚刚进入到old list的页第一次访问不会立即放入到new list头部,并且由于间隔时间innodb_old_blocks_time限制,只有大于该时间才会放到new list头部,过程如下:
若没有上述两个限制条件,新插入的条件需要查询遍历记录,则可能导致的列表如下:
为了判断需要查询的数据是否在缓存中,在LRU链表之上建立一个hash的映射,用以判断页是否存在于缓冲池之中。
2.3 FLU链表
Innodb引擎的机制是修改数据的时候会先修改缓存中的数据(若数据存在于缓冲池),当我们修改了某个缓存页的数据,那它就和磁盘上的页不一致了,这样的缓存页也被称为脏页。
解决“脏页”问题,最简单的做法就是每发生一次修改就立即同步到磁盘上对应的页上,但是频繁的往磁盘中写数据会严重的影响程序的性能。所以每次修改缓存页后,我们并不着急立即把修改同步到磁盘上,而是在未来的某个时间点进行同步,但是如果不立即同步到磁盘的话,那之后再同步的时候我们怎么知道中哪些页是脏页,哪些页从来没被修改过呢?总不能把所有的缓存页都同步到磁盘上。因此,我们创建一个存储脏页的链表,凡是修改过的缓存页都会被包装成一个节点加入到这个链表中,因为这个链表中的页都是需要被刷新到磁盘上的,维护该链表可以分辨哪些缓存页是需要回写到磁盘中的。
三、多实例缓冲池
上文可知,Innodb的buffer pool以链表的方式来组织分配管理,每个页是唯一存在的,因此操作缓存页的时候需要锁来保证一致性,也就是说free、lru、flu链表的使用操作是需要锁开销来保证安全的,因此在高并发或者访问的量比较大时,是比较影响性能的,因此设计出了多buffer pool实例的方式,并发情况下多实例减少锁开销,实例和实例之间是隔离的关系,独立的链表,独立的控制体等。
有利必有弊,多实例虽然增加了缓冲池在高并发、高访问量下的性能,也同样增加了引擎管理的开销,需要引擎去监控、操作各个缓冲池的实例,以保证缓冲池每个都是可用且安全的。
在实际经验下,普遍认为,当缓冲内存小于1G的时候,无需使用多实例,当可用缓冲内存大于1G的时候,根据实际并发以及访问量来评估设定该参数,建议每个buffer pool不小于1G。
四、参数设定
name | explain | remark |
innodb_buffer_pool_size | 缓冲池大小(字节单位,默认128M) | 例如:134217728字节 |
innodb_buffer_pool_instances | 缓冲池实例数量(1-64) | 设置缓冲池的数量,建议buffer pool大于1G时,在设置此参数 |
innodb_buffer_pool_chunk_size | buffer pool动态调整大小 | 修改buffer pool时,会以chunk大小来动态申请(参数在服务启动后不可修改) |
innodb_old_blocks_pct | LRU old占比(默认37) | LRU链表old与new长度约为3:7 |
innodb_old_blocks_time | LRU链表old节点访问更新间隔时间(默认1000ms) | 新节点插入old head,第二次和第一次访问时间间隔大于该参数,才将其移动至new head |
innodb_read_ahead_threshold | 线性预读异步请求顺序页访问数(0-64,1extend=64page) | 线性预读:顺序访问页超过该数值,将下一区(extend)加入缓存; 随机预读:顺序访问页超过该数值,将该区剩余的页加入缓存;(默认关闭) |
innodb_random_read_ahead | 随机预读开关(默认OFF) | 控制随机预读功能的开关 |
如何配置缓冲池?缓冲池的大小调整支持脱机和联机两种情况。在不关闭数据库服务的情况下,我们可以通过调整innodb_buffer_pool的大小调节缓冲池大小,调整是有规则的,调整的大小必须是innodb_buffer_pool_chunk_size*innodb_buffer_pool_instances的倍数大小,如果调整的值不满足此规则,则innodb会调整为等于或不小于innodb_buffer_pool_chunk_size*innodb_buffer_pool_instances的倍数。innodb_buffer_pool_chunk_size参数以1M的单位进行设置调整,并且只能在启动之前设置,无法在运行中进行调整。
如何配置多缓冲池?理论上缓冲池越大越好,因为缓存的数据越多,则磁盘读取越少,提高性能和效率,在不影响服务器其他服务的前提下,设置大小为总内存的70%~80%。时下内存资源越来越丰富,当内存足够大时,可以划分多个buffer pool实例,在减少锁开销的前提下提高性能。mysql5.7下若分配的缓冲内存(innodb_buffer_pool_size)大于1G时,会默认分成8个实例。最好的情况是多实例下,每个实例的大小都大于1G。
如何设置预读策略?上文提到过预读的概念,是局部性原理而引出来的一个数据额外读取方式,异步的把目前页的下页或者下区放在在缓冲池中。预读分为线性预读和随机预读。
1.线性预读(liner read ahead):通过顺序访问缓冲池中的页面,来预测很快将会访问到的页,从而提前放入缓存,提高速度。当顺序访问的页的数量超过某一个值时,会将下一个区(extend)中的所有页都加入到缓存中,一个区由64个页组成(见文章:Mysql—Innodb引擎逻辑结构),innodb中的innodb_read_ahead_threshold参数控制了顺序访问的页数,默认是56,也就是说,在一个区中顺序访问56个页时,就会异步的将下一个区(extend)中的所有页放进缓存中。
2.随机预读(random read ahead):当一个extend中的部分页被随机访问后,会把该extend中剩余的页异步加入到缓冲池中。因为这种方式需要innodb维护以及操作方面存在复杂性,是会消耗性能,性价比不高,因此在5.5已经默认是关闭的状态,如果打开,可以通过参数innodb_random_read_ahead设置为ON,打开此功能。
五、实时缓冲池运行状态
若我们调整buffer pool的各种参数时,需要知道当前buffer pool的运行情况,以此来判定是否需要调整部分参数,使得缓冲池更好更高效的运行,避免缓冲池过大导致的内存浪费,或者过小导致频繁更新而增加引擎负担,可以通过下面sql查看当前buffer pool的使用情况:show engine innodb status \G,
部分参数的意义如下表格所示:
name | explain | remark |
Total large memory | 缓冲池总的大小 | |
Dictionary memory | 数据字典的大小 | |
Buffer pool size | 缓冲池页的总数量 | |
Free buffers | 可使用的页数量 | FREE链表节点数量 |
Database pages | 数据页的数量 | LRU链表节点数量 |
Old database pages | 优先淘汰列表的数量 | LRU链表old list的节点数量 |
Modified db pages | 缓冲池中已经修改的页的数量 | 脏页的数量;FLU链表数量 |
六、加入缓存的历程
当我们一次查询,查询到一个页时,该页可能会经历磁盘到缓冲池的过程,过程如下图:
1.查询到一个页时,首先需要判断该页是否在缓存中,依据LRU链表的hash映射判断,该哈希表是通过space id(表空间)和page no(页编号)一起映射的一个page hash,页可以根据其space id和page no定位到对应的buffer pool instance以及控制体、缓存页,若在,则直接返回,若不在,则需要去磁盘查询。
2.磁盘查询到数据之后,需要将其放于缓冲池中,首先需要到free list获取一个空闲的缓存页,如果缓存还有空间的空间页,则移除该空闲页对应的控制体,并且在lru list的old list的head部加入该控制体,然后将缓存页缓存至该空闲缓存页中;
3.若缓存页已经全部用完,此时没有空闲的空闲页,则需要额外的操作进行回收空闲页,以供新的页进行缓存;
4.先查看lru list是否有可以淘汰的缓存页,如果有,则将该缓存页从lru list移除,并加入到free list中,然后在去free list中寻找空闲页;循环lru list第一次时,最多会找到100个页,第二次会遍历整个lru list。
5.如果lru list没有可以淘汰的缓存页,接下来会进行单页刷脏,因为刷脏是磁盘进行IO,比较耗费性能,所以采用单页,当单页刷脏释放一个缓存页时,就会到free list进行查找空闲页,但是我们知道数据库多线程情况下,可能刚刚释放的空闲页可能被其它线程使用,因此如果当前线程依然没有获取到空心页,会重复上面的流程继续查找。
最糟糕的情况就是用户线程进来,发现没有可用的空闲页,则会走上边的遍历列表、刷脏的过程,是不理想的,是需要尽量避免上述这种情况,应该尽量保持free list具有可用性,或者lru list的可替换性。
如何保持缓存页可用量?为了爆保持缓冲池中总是有可用的空间页,避免上述耗性能的情况发生,因此在innodb中会做一些操作来保证空闲页的可用量。刷脏操作便是很重要的一项工作,也可以叫做checkpoint,目前innodb中刷脏机制可以分为两种:
1.Sharp Checkpoint:当数据库服务关闭时,会将所有的脏页刷回磁盘,通过参数innodb_fast_shutdown=1来设置;
2.Fuzzy CheckPoint:在运行期间,经由各个参数以及状态会进行刷脏操作;
其中Fuzzy CheckPoint又分了四种情况:
- Master Thread Checkpoint
- FLUSH_LRU_LIST Checkpoint
- Async/Sync Flush Checkpoint
- Dirty Page too much Checkpoint
Master Thread Checkpoint
Mysql主线程会以十秒的时间间隔从flu list中对一定比例的脏页进行刷脏的操作,不会阻塞用户线程;
FLUSH_LRU_LIST Checkpoint
缓冲池中默认要保持free list有100左右的空闲缓存页可以使用,因此当free list中的空闲页少于100时,会触发这种机制。在innodb1.1.x版本之前,需要去检查空闲页的数量是否能满足用户查询线程的需求,如果不够,则会从lru list进行末尾淘汰释放空闲页,如果淘汰的页中有脏页的话,还需要执行checkpoint刷脏操作,这种操作是会阻塞用户线程的。在innodb1.2.x版本之后,这个一系列的检查操作放在了单独的线程里Page Cleaner Thread,因此不会阻塞用户线程,还添加了控制参数innodb_lru_scan_depth,可以设置free list空闲页的数量。
Async/Sync Flush Checkpoint
当重做日志文件(redo log)不可用时,需要刷新一定的脏页到磁盘,使得redo log可以循环使用。在innodb1.2.x之前,async flush checkpoint会阻塞当前用户线程,sync flush checkpoint会阻塞所有用户线程,在innodb1.2.x版本之后则使用单独的线程Page Cleaner Thread处理。
Dirty Page too much Checkpoint
Innodb1.2.x有专门的线程进行脏页的刷新工作,这样可以减轻对用户线程的影响,innodb_max_dirty_pages_pct_lwm参数表示脏页的占比(flu列表的长度)超过该比例时,会有后台线程进行刷脏的操作,当脏页的占比超过了innodb_max_dirty_pages_pct_lwm,并且还超过了innodb_max_dirty_pages_pct时,会采用勤快刷脏的机制,也就是加快刷脏的速度,
六、资源地址
文档:《Mysql技术内幕-innodb存储引擎》《高性能Mysql》