
这个函数以及其调用的函数可以说是整个LRU模块最重要的函数在整个Buffer Pool模块中也有举足轻重的作用。如果能把这几个函数吃透相信其他函数很容易就能读懂。首先如果是使用ENGINE_NO_CACHE发送过来的SQL需要读取数据则优先从Quick List中获取(buf_quick_lru_get_free)。接着统计Free List和LRU List的长度如果发现他们再Buffer Chunks占用太少的空间则表示太多的空间被行锁自使用哈希等内部结构给占用了一般这些都是大事务导致的。这时候会给出报警。接着查看Free List中是否还有空闲的数据页(buf_LRU_get_free_only)如果有则直接返回否则进入下一步。大多数情况下这一步都能找到空闲的数据页。如果Free List中已经没有空闲的数据页了则会尝试驱逐LRU List末尾的数据页。如果系统有压缩页情况就有点复杂InnoDB会调用buf_LRU_evict_from_unzip_LRU来决定是否驱逐压缩页如果Unzip LRU List大于LRU List的十分之一或者当前InnoDB IO压力比较大则会优先从Unzip LRU List中把解压页给驱逐否则会从LRU List中把解压页和压缩页同时驱逐。不管走哪条路径最后都调用了函数buf_LRU_free_page来执行驱逐操作这个函数由于要处理压缩页解压页各种情况极其复杂。大致的流程首先判断是否是脏页如果是则不驱逐否则从LRU List中把链表删除必要的话还从Unzip LRU List移走这个数据页(buf_LRU_block_remove_hashed)接着如果我们选择保留压缩页则需要重新创建一个压缩页控制体插入LRU List中如果是脏的压缩页还要插入到Flush List中最后才把删除的数据页插入到Free List中(buf_LRU_block_free_hashed_page)。如果在上一步中没有找到空闲的数据页则需要刷脏了(buf_flush_single_page_from_LRU)由于buf_LRU_get_free_block这个函数是在用户线程中调用的所以即使要刷脏这里也是刷一个脏页防止刷过多的脏页阻塞用户线程。如果上一步的刷脏因为数据页被其他线程读取而不能刷脏则重新跳转到上述第二步。进行第二轮迭代与第一轮迭代的区别是第一轮迭代在扫描LRU List时最多只扫描innodb_lru_scan_depth个而在第二轮迭代开始扫描整个LRU List。如果很不幸这一轮还是没有找到空闲的数据页从三轮迭代开始在刷脏前等待10ms。最终找到一个空闲页后page的state为BUF_BLOCK_READY_FOR_USE。控制全表扫描不增加cache数据到Buffer Pool全表扫描对Buffer Pool的影响比较大即使有old list作用但是old list默认也占Buffer Pool的3/8。因此阿里云RDS引入新的语法ENGINE_NO_CACHE(例如SELECT ENGINE_NO_CACHE count(*) FROM t1)。如果一个SQL语句中带了ENGINE_NO_CACHE这个关键字则由它读入内存的数取据页都放入Quick List中当这个语句结束时会删除它独占的数据页。同时引入两个参数。innodb_rds_trx_own_block_max这个参数控制使用Hint的每个事物最多能拥有多少个数据页如果超过这个数据就开始驱逐自己已有的数据页防止大事务占用过多的数据页。innodb_rds_quick_lru_limit_per_instance这个参数控制每个Buffer Pool Instance中Quick List的长度如果超过这个长度后续的请求都从Quick List中驱逐数据页进而获取空闲数据页。删除指定表空间所有的数据页函数(buf_LRU_remove_pages)提供了三种模式第一种(BUF_REMOVE_ALL_NO_WRITE)删除Buffer Pool中所有这个类型的数据页(LRU List和Flush List)同时Flush List中的数据页也不写回数据文件这种适合rename table和5.6表空间传输新特性因为space_id可能会被复用所以需要清除内存中的一切防止后续读取到错误的数据。第二种(BUF_REMOVE_FLUSH_NO_WRITE)仅仅删除Flush List中的数据页同时Flush List中的数据页也不写回数据文件这种适合drop table即使LRU List中还有数据页但由于不会被访问到所以会随着时间的推移而被驱逐出去。第三种(BUF_REMOVE_FLUSH_WRITE)不删除任何链表中的数据仅仅把Flush List中的脏页都刷回磁盘这种适合表空间关闭例如数据库正常关闭的时候调用。这里还有一点值得一提的是由于对逻辑链表的变动需要加锁且删除指定表空间数据页这个操作是一个大操作容易造成其他请求被饿死所以InnoDB做了一个小小的优化每删除BUF_LRU_DROP_SEARCH_SIZE个数据页(默认为1024)就会释放一下Buffer Pool Instance的mutex便于其他线程执行。LRU_Manager_Thread这是一个系统线程随着InnoDB启动而启动作用是定期清理出空闲的数据页(数量为innodb_LRU_scan_depth)并加入到Free List中防止用户线程去做同步刷脏影响效率。线程每隔一定时间去做BUF_FLUSH_LRU即首先尝试从LRU中驱逐部分数据页如果不够则进行刷脏从Flush List中驱逐(buf_flush_LRU_tail)。线程执行的频率通过以下策略计算我们设定max_free_len innodb_LRU_scan_depth * innodb_buf_pool_instances如果Free List中的数量小于max_free_len的1%则sleep time为零表示这个时候空闲页太少了需要一直执行buf_flush_LRU_tail从而腾出空闲的数据页。如果Free List中的数量介于max_free_len的1%-5%则sleep time减少50ms(默认为1000ms)如果Free List中的数量介于max_free_len的5%-20%则sleep time不变如果Free List中的数量大于max_free_len的20%则sleep time增加50ms但是最大值不超过rds_cleaner_max_lru_time。这是一个自适应的算法保证在大压力下有足够用的空闲数据页(lru_manager_adapt_sleep_time)。Hazard Pointer在学术上Hazard Pointer是一个指针如果这个指针被一个线程所占有在它释放之前其他线程不能对他进行修改但是在InnoDB里面概念刚好相反一个线程可以随时访问Hazard Pointer但是在访问后他需要调整指针到一个有效的值便于其他线程使用。我们用Hazard Pointer来加速逆向的逻辑链表遍历。先来说一下这个问题的背景我们知道InnoDB中可能有多个线程同时作用在Flush List上进行刷脏例如LRU_Manager_Thread和Page_Cleaner_Thread。同时为了减少锁占用的时间InnoDB在进行写盘的时候都会把之前占用的锁给释放掉。这两个因素叠加在一起导致同一个刷脏线程刷完一个数据页A就需要回到Flush List末尾(因为A之前的脏页可能被其他线程给刷走了之前的脏页可能已经不在Flush list中了)重新扫描新的可刷盘的脏页。另一方面数据页刷盘是异步操作在刷盘的过程中我们会把对应的数据页IO_FIX住防止其他线程对这个数据页进行操作。我们假设某台机器使用了非常缓慢的机械硬盘当前Flush List中所有页面都可以被刷盘(buf_flush_ready_for_replace返回true)。我们的某一个刷脏线程拿到队尾最后一个数据页IO fixed发送给IO线程最后再从队尾扫描寻找可刷盘的脏页。在这次扫描中它发现最后一个数据页(也就是刚刚发送到IO线程中的数据页)状态为IO fixed(磁盘很慢还没处理完)所以不能刷跳过开始刷倒数第二个数据页同样IO fixed发送给IO线程然后再次重新扫描Flush List。它又发现尾部的两个数据页都不能刷新(因为磁盘很慢可能还没刷完)直到扫描到倒数第三个数据页。所以存在一种极端的情况如果磁盘比较缓慢刷脏算法性能会从O(N)退化成O(N*N)。要解决这个问题最本质的方法就是当刷完一个脏页的时候不要每次都从队尾重新扫描。我们可以使用Hazard Pointer来解决方法如下遍历找到一个可刷盘的数据页在锁释放之前调整Hazard Pointer使之指向Flush List中下一个节点注意一定要在持有锁的情况下修改。然后释放锁进行刷盘刷完盘后重新获取锁读取Hazard Pointer并设置下一个节点然后释放锁进行刷盘如此重复。当这个线程在刷盘的时候另外一个线程需要刷盘也是通过Hazard Pointer来获取可靠的节点并重置下一个有效的节点。通过这种机制保证每次读到的Hazard Pointer是一个有效的Flush List节点即使磁盘再慢刷脏算法效率依然是O(N)。这个解法同样可以用到LRU List驱逐算法上提高驱逐的效率。相应的Patch是在MySQL 5.7上首次提出的阿里云RDS把其Port到了我们5.6的版本上保证在大并发情况下刷脏算法的效率。Page_Cleaner_Thread这也是一个InnoDB的后台线程主要负责Flush List的刷脏避免用户线程同步刷脏页。与LRU_Manager_Thread线程相似其也是每隔一定时间去刷一次脏页。其sleep time也是自适应的(page_cleaner_adapt_sleep_time)主要由三个因素影响当前的lsnFlush list中的oldest_modification以及当前的同步刷脏点(log_sys-max_modified_age_sync有redo log的大小和数量决定)。简单的来说lsn - oldest_modification的差值与同步刷脏点差距越大sleep time就越长反之sleep time越短。此外可以通过rds_page_cleaner_adaptive_sleep变量关闭自适应sleep time这是sleep time固定为1秒。与LRU_Manager_Thread每次固定执行清理innodb_LRU_scan_depth个数据页不同Page_Cleaner_Thread每次执行刷的脏页数量也是自适应的计算过程有点复杂(page_cleaner_flush_pages_if_needed)。其依赖当前系统中脏页的比率日志产生的速度以及几个参数。innodb_io_capacity和innodb_max_io_capacity控制每秒刷脏页的数量前者可以理解为一个soft limit后者则为hard limit。innodb_max_dirty_pages_pct_lwm和innodb_max_dirty_pages_pct_lwm控制脏页比率即InnoDB什么脏页到达多少才算多了需要加快刷脏频率了。innodb_adaptive_flushing_lwm控制需要刷新到哪个lsn。innodb_flushing_avg_loops控制系统的反应效率如果这个变量配置的比较大则系统刷脏速度反应比较迟钝表现为系统中来了很多脏页但是刷脏依然很慢如果这个变量配置很小当系统中来了很多脏页后刷脏速度在很短的时间内就可以提升上去。这个变量是为了让系统运行更加平稳起到削峰填谷的作用。相关函数af_get_pct_for_dirty和af_get_pct_for_lsn。预读和预写如果一个数据页被读入Buffer Pool其周围的数据页也有很大的概率被读入内存与其分开多次读取还不如一次都读入内存从而减少磁盘寻道时间。在官方的InnoDB中预读分两种随机预读和线性预读。***随机预读: *** 这种预读发生在一个数据页成功读入Buffer Pool的时候(buf_read_ahead_random)。在一个Extent范围(1M如果数据页大小为16KB则为连续的64个数据页)内如果热点数据页大于一定数量就把整个Extend的其他所有数据页(依据page_no从低到高遍历读入)读入Buffer Pool。这里有两个问题首先数量是多少默认情况下是13个数据页。接着怎么样的页面算是热点数据页阅读代码发现只有在young list前1/4的数据页才算是热点数据页。读取数据时候使用了异步IO结合使用OS_AIO_SIMULATED_WAKE_LATER和os_aio_simulated_wake_handler_threads便于IO合并。随机预读可以通过参数innodb_random_read_ahead来控制开关。此外buf_page_get_gen函数的mode参数不影响随机预读。***线性预读: *** 这中预读只发生在一个边界的数据页(Extend中第一个数据页或者最后一个数据页)上(buf_read_ahead_linear)。在一个Extend范围内如果大于一定数量(通过参数innodb_read_ahead_threshold控制默认为56)的数据页是被顺序访问(通过判断数据页access time是否为升序或者逆序来确定)的则把下一个Extend的所有数据页都读入Buffer Pool。读取的时候依然采用异步IO和IO合并策略。线性预读触发的条件比较苛刻触发操作的是边界数据页同时要求其他数据页严格按照顺序访问主要是为了解决全表扫描时的性能问题。线性预读可以通过参数innodb_read_ahead_threshold来控制开关。此外当buf_page_get_gen函数的mode为BUF_PEEK_IF_IN_POOL时不触发线性预读。InnoDB中除了有预读功能在刷脏页的时候也能进行预写(buf_flush_try_neighbors)。当一个数据页需要被写入磁盘的时候查找其前面或者后面邻居数据页是否也是脏页且可以被刷盘(没有被IOFix且在old list中)如果可以的话一起刷入磁盘减少磁盘寻道时间。预写功能可以通过innodb_flush_neighbors参数来控制。不过在现在的SSD磁盘下这个功能可以关闭。Double Write Buffer(dblwr)服务器突然断电这个时候如果数据页被写坏了(例如数据页中的目录信息被损坏)由于InnoDB的redolog日志不是完全的物理日志有部分是逻辑日志因此即使奔溃恢复也无法恢复到一致的状态只能依靠Double Write Buffer先恢复完整的数据页。Double Write Buffer主要是解决数据页半写的问题如果文件系统能保证写数据页是一个原子操作那么可以把这个功能关闭这个时候每个写请求直接写到对应的表空间中。Double Write Buffer大小默认为2M即128个数据页。其中分为两部分一部分留给batch write另一部分是single page write。前者主要提供给批量刷脏的操作后者留给用户线程发起的单页刷脏操作。batch write的大小可以由参数innodb_doublewrite_batch_size控制例如假设innodb_doublewrite_batch_size配置为120则剩下8个数据页留给single page write。假设我们要进行批量刷脏操作我们会首先写到内存中的Double Write Buffer(也是2M在系统初始化中分配不使用Buffer Chunks空间)如果dblwr写满了一次将其中的数据刷盘到系统表空间指定位置注意这里是同步IO操作在确保写入成功后然后使用异步IO把各个数据页写回自己的表空间由于是异步操作所有请求下发后函数就返回表示写成功了(buf_dblwr_add_to_batch)。不过这个时候后续的写请求依然会阻塞知道这些异步操作都成功才清空系统表空间上的内容后续请求才能被继续执行。这样做的目的就是如果在异步写回数据页的时候系统断电发生了数据页半写这个时候由于系统表空间中的数据页是完整的只要从中拷贝过来就行(buf_dblwr_init_or_load_pages)。异步IO请求完成后会检查数据页的完整性以及完成change buffer相关操作接着IO helper线程会调用buf_flush_write_complete函数把数据页从Flush List删除如果发现batch write中所有的数据页都写成了则释放dblwr的空间。Buddy伙伴系统与内存分配管理算法类似InnoDB中的伙伴系统也是用来管理不规则大小内存分配的主要用在压缩页的数据上。前文提到过InnoDB中的压缩页可以有16K8K4K2K1K这五种大小压缩页大小的单位是表也就是说系统中可能存在很多压缩页大小不同的表。使用伙伴体统来分配和回收能提高系统的效率。申请空间的函数是buf_buddy_alloc其首先在zip free链表中查看指定大小的块是否还存在如果不存在则从更大的链表中分配这回导致一些列的分裂操作。例如需要一块4K大小的内存则先从4K链表中查找如果有则直接返回没有则从8K链表中查找如果8K中还有空闲的则把8K分成两部分低地址的4K提供给用户高地址的4K插入到4K的链表中便与后续使用。如果8K中也没有空闲的了就从16K中分配16K首先分裂成2个8K高地址的插入到8K链表中低地址的8K继续分裂成2个4K低地址的4K返回给用户高地址的4K插入到4K的链表中。假设16K的链表中也没有空闲的了则调用buf_LRU_get_free_block获取新的数据页然后把这个数据页加入到zip hash中同时设置state状态为BUF_BLOCK_MEMORY表示这个数据页存储了压缩页的数据。释放空间的函数是buf_buddy_free相比于分配空间的函数有点复杂。假设释放一个4K大小的数据块其先把4K放回4K对应的链表接着会查看其伙伴(释放块是低地址则伙伴是高地址释放块是高地址则伙伴是低地址)是否也被释放了如果也被释放了则合并成8K的数据块然后继续寻找这个8K数据块的伙伴试图合并成16K的数据块。如果发现伙伴没有被释放函数并不会直接退出而是把这个伙伴给挪走(buf_buddy_relocate)例如8K数据块的伙伴没有被释放系统会查看8K的链表如果有空闲的8K块则把这个伙伴挪到这个空闲的8K上这样就能合并成16K的数据块了如果没有函数才放弃合并并返回。通过这种relocate操作内存碎片会比较少但是涉及到内存拷贝效率会比较低。Buffer Pool预热