目录
InnoDB架构
下图展示了InnoDB引擎架构的内存结构和磁盘结构:
从上图中可以看出,InnoDB分为了内存结构和磁盘结构两大部分,其中,内存结构包含:Buffer Pool、Adaptive Hash Index、Log Buffer、Change Buffer等组件,这里,我们主要介绍缓冲池(Buffer Pool)。
缓冲池(Buffer Pool)
数据预读
程序的局部性原理是指程序在执行时呈现出局部性规律,即在一段时间内,整个程序的执行仅限于程序中的某一部分。相应地,执行所访问的存储空间也局限于某个内存区域。
-- 摘自 百度百科 《程序的局部性原理》
操作系统的磁盘读写,并不是按需读取,而是按页读取,一次至少读一页数据(32位Linux系统默认的 Page Cache 一般是4K,64位系统为8K),如果未来要读取的数据就在页中,就能够省去后续的磁盘IO,提高效率。
程序是有空间局部性的,靠近当前被访问数据的数据,在未来很大概率会被访问到。
数据访问,通常都遵循“集中读写”的原则,使用一些数据,大概率会使用附近的数据,这就是所谓的“局部性原理”,它表明提前加载是有效的,确实能够减少磁盘IO。
数据预读的优点:
- 磁盘访问按页读取能够提高性能,所以缓冲池一般也是按页缓存数据;
- 预读机制启示了我们,能把一些“可能要访问”的页提前加入缓冲池,避免未来的磁盘 IO 操作。
缓冲池(Buffer Pool)简介
Innodb 存储引擎设计了一个缓冲池(Buffer Pool),来提高数据库的读写性能。InnoDB 会把存储的数据划分为若干个页,以页作为磁盘和内存交互的基本单位,一个页的默认大小为 16KB,因此,Buffer Pool 同样需要按页来划分。
在 MySQL 启动的时候,InnoDB 会为 Buffer Pool 申请一片连续的内存空间,然后按照默认的 16KB 的大小划分出一个个的页, Buffer Pool 中的页就叫做 缓存页。此时这些缓存页都是空闲的,之后随着程序的运行,才会有磁盘上的页被缓存到 Buffer Pool 中。
所以,在 MySQL 刚启动的时候,可以观察到使用的虚拟内存空间很大,而使用到的物理内存空间却很小,这是因为只有这些虚拟内存被访问后,操作系统才会触发缺页中断,接着将虚拟地址和物理地址建立映射关系。
缓冲池的作用:
- 当读取数据时,如果数据存在于 Buffer Pool 中,客户端就会直接读取 Buffer Pool 中的数据,否则再去磁盘中读取。
- 当修改数据时,首先是修改 Buffer Pool 中数据所在的页,然后将其页设置为脏页,最后由后台线程将脏页写入到磁盘。
Buffer Pool 除了缓存 索引页 和 数据页,还包含:undo 页、插入缓存页、自适应哈希索引、锁信息等,如下图所示:
为了更好的管理这些在 Buffer Pool 中的缓存页,InnoDB 为每一个缓存页都创建了一个控制块,控制块信息包括:缓存页的表空间、页号、缓存页地址、链表节点等,每一个控制块都唯一地对应一个缓存页。
控制块也会占用内存空间,它是放在 Buffer Pool 的最前面,接着才是缓存页,如下图:
注意,如果缓存页中的剩余内存,不足以分配一对控制块和缓存页,就会产生碎片空间,如上图中,控制块和缓存页之间灰色部分所示。
可以通过如下参数调整缓冲池的大小:
参数 | 默认值 | 作用 |
---|---|---|
innodb_buffer_pool_size | 134217728 | 单位字节,默认值128MB,InnoDB缓存表和索引数据的内存区域,越大性能越好,一般为物理内存的50%~80%。 |
Buffer Pool 的管理
空闲页(Free Page)
Buffer Pool 是一片连续的内存空间,当 MySQL 运行一段时间后,这片连续的内存空间中的缓存页既有空闲的,也有被使用的。为了能够快速高效地找到空闲的缓存页,可以使用链表结构,将空闲缓存页的控制块作为链表的节点,这个链表称为 Free 链表(空闲链表)。
Free 链表上除了有控制块,还有一个头节点,该头节点包含链表的头节点地址、尾节点地址,以及当前链表中节点的数量等信息。
Free 链表节点是一个一个的控制块,而每个控制块包含着对应缓存页的地址,所以相当于 Free 链表节点都对应一个空闲的缓存页。
有了 Free 链表后,每当需要从磁盘中加载一个页到 Buffer Pool 中时,就从 Free 链表中取一个空闲的缓存页,并且把该缓存页对应的控制块的信息填上,然后把该缓存页对应的控制块从 Free 链表中移除。
脏页(Dirty Page)
Buffer Pool 除了能提高读性能,还能提高写性能,即数据更新的时候,不需要每次都要写入磁盘,而是将 Buffer Pool 对应的缓存页标记为脏页,然后再由后台线程将脏页写入到磁盘。
那为了能快速知道哪些缓存页是脏页,于是就设计了 Flush 链表,它跟 Free 链表类似的,链表的节点也是控制块,区别在于 Flush 链表的元素都是脏页。
有了 Flush 链表后,后台线程就可以遍历 Flush 链表,将脏页写入到磁盘。
小结
从前面的介绍,可以看出 Buffer Pool 里有三种页和链表来管理数据:
页 | 作用 | 位置 |
---|---|---|
空闲页(Free Page) | 表示此页未被使用。 | Free 链表 |
干净页(Clean Page) | 表示此页已被使用,但是页面未发生修改。 | LRU 链表 |
脏页(Dirty Page) | 表示此页已被使用且已经被修改,其数据和磁盘上的数据已经不一致。当脏页上的数据写入磁盘后,内存数据和磁盘数据一致,那么该页就变成了干净页。 | 同时存在于 LRU 链表和 Flush 链表 |
InnoDB 的缓存淘汰策略
传统 LRU 算法的缺点
如果 InnoDB 直接使用传统 LRU 算法会带来的问题:
-
预读失效
由于预读(Read-Ahead),提前把页放入了缓冲池,但最终 MySQL 并没有从页中读取数据,称为预读失效。
-
缓冲池污染
当某一个 SQL 语句扫描了大量的数据时,在 Buffer Pool 空间比较有限的情况下,可能会将 Buffer Pool 里的所有页都替换出去,导致大量热数据被淘汰了,等这些热数据又被再次访问的时候,由于缓存未命中,就会产生大量的磁盘 IO,导致 MySQL 性能急剧下降,这种情况叫缓冲池污染。
注意, Buffer Pool 污染并不只是查询语句查询出了大量的数据才出现的问题,即使查询出来的结果集很小,也会造成 Buffer Pool 污染。比如,在一个数据量非常大的表,执行了这条语句:
select * from t_user where name like "%john%";
可能这个查询出来的结果就几条记录,但是由于这条语句会发生索引失效,所以这个查询过程是全表扫描的,然后,就会发生如下的过程:
-
从磁盘读到的页,加入到 LRU 链表的 old 区域头部;
-
从页里读取行记录时,即页被访问时,将该页放到 young 区域头部;
-
接下来拿行记录的 name 字段和目标字符串
john
进行模糊匹配,如果符合条件,就加入到结果集中; -
如此往复,直到扫描完表中的所有记录。
全表扫描结束后,原本 young 区域的热点数据都会被替换掉。
-
InnoDB如何解决预读失效的问题
InnoDB 没有使用严格的 LRU 算法,而是使用一种来最大限度地减少进入缓冲池且从未再次访问的数据量的技术。以确保经常访问的页(热点数据)保留在缓冲池中,即使预读和全表扫描带来了可能访问或可能不访问的新块。
为了解决预读失效的问题,InnoDB 引擎改进了 LRU 算法,将 LRU 链表 划分了 2 个区域:old 区域 和 young 区域。如下图所示:
young 区域在 LRU 链表的前半部分,old 区域则是在后半部分,如下图所示:
划分为两个区域后,预读的页就只需要加入到 old 区域的头部,当页被真正访问的时候,才将页插入 young 区域的头部。如果预读的页一直没有被访问,就会从 old 区域移除,这样就不会影响 young 区域中的热点数据。
这里,以下面的例子,来说明数据的淘汰过程:
假设有一个长度为 10 的 LRU 链表,其中 young 区域占比 70 %,old 区域占比 30 %:
如果,此时有个编号为 20 的页被预读了,这个页只会被插入到 old 区域头部,而 old 区域末尾的页(10号)会被淘汰掉:
那么,这时会出现两种情况:
-
如果 20 号页一直不会被访问;
它也没有占用到 young 区域的位置,而且还会比 young 区域的数据更早被淘汰出去。
-
如果 20 号页被预读后,立刻被访问了。
那么它将会被插入到 young 区域的头部,young 区域末尾的页(7号),会被挤到 old 区域,作为 old 区域的头部,这个过程并不会有页被淘汰:
可以通过如下参数调整两个区域的比例:
参数 | 默认值 | 作用 |
---|---|---|
innodb_old_blocks_pct | 37 | 整个 LRU 链表中 young 区域与 old 区域比例是 63:37 |
InnoDB如何解决 Buffer Pool 污染问题
Buffer Pool 污染问题简介
虽然,通过将LRU链表划分 old 区域 和 young 区域避免了预读失效带来的影响,但是还有个问题无法解决,那就是 Buffer Pool 污染的问题。
举个例子,假设需要批量扫描:21,22,23,24,25 这五个页,这些页都会被逐一访问(读取页里的记录)。
在批量访问这些数据的时候,会被逐一插入到 young 区域头部。
可以看到,原本在 young 区域的热点数据 6 和 7 号页都被淘汰了,这就是 Buffer Pool 污染的问题。
Buffer Pool 污染问题优化
像前面这种全表扫描的查询,很多缓冲页其实只会被访问一次,但是它却只因为被访问了一次而进入到 young 区域,从而导致热点数据被替换了。
LRU 链表中 young 区域就是热点数据,只要我们提高进入到 young 区域的门槛,就能有效地保证 young 区域里的热点数据不会被替换掉。
InnoDB 缓冲池加入了一个“老生代停留时间窗口”的机制:
-
假设 \(T =\) old 区域停留时间窗口;
-
插入 old 区域头部的页,即使立刻被访问,并不会立刻放入 young 区域头部;
-
只有满足“被访问”并且“在老生代停留时间”大于T,才会被放入 young 区域头部;
也就说,只有同时满足 被访问 与 在 old 区域停留时间超过 1 秒 两个条件,才会被插入到 young 区域头部,这样就解决了 Buffer Pool 污染的问题 。
另外,为了防止 young 区域节点频繁移动到头部,InnoDB 针对 young 区域其实做了一个优化:young 区域前面 1/4 被访问不会移动到链表头部,只有 young 区域后面的 3/4 被访问了才会被移动到链表头部。
例如,在大批量的页扫描时,有编号为\(21\),\(22\),\(23\),\(24\),\(25\) 的五个页面将要依次被访问:
如果没有“老生代停留时间窗口”的策略,这些批量被访问的页面,会换出大量热数据:
加入“老生代停留时间窗口”策略后,短时间内被大量加载的页,并不会立刻插入新生代头部,而是优先淘汰那些短时间内仅仅访问了一次的页。
只有在老生代呆的时间足够久,停留时间大于 \(T\),才会被插入新生代头部:
可以通过如下参数调整 old 区域停留时间窗口的大小:
参数 | 默认值 | 作用 |
---|---|---|
innodb_old_blocks_time | 1000 | 单位 \(ms\),指定第一次访问页后的时间窗口,在此期间访问该页不会使其移动到 LRU 列表的前面(最近使用的末尾) |
脏页刷盘时机
InnoDB 的更新操作采用的是 Write Ahead Log 策略,即先写日志,再写入磁盘,通过 redo log 日志让 MySQL 拥有了崩溃恢复能力。
下面几种情况会触发脏页的刷新:
-
当 redo log 日志满了的情况下,会主动触发脏页刷新到磁盘;
-
Buffer Pool 空间不足时,需要将一部分数据页淘汰掉,如果淘汰的是脏页,会先将脏页同步刷新到磁盘;
-
InnoDB 认为空闲时,后台线程会定期将适量的脏页刷入到磁盘;
-
InnoDB 正常关闭之前,会把所有的脏页刷入到磁盘;
如果开启了慢查询监控后:
-
如果出现 偶尔会出现一些用时稍长的SQL ,这可能是因为脏页在刷新到磁盘时可能会给数据库带来性能开销,导致数据库操作抖动;
slow_query_log:是否开启慢查询日志,1表示开启,0表示关闭
-
如果间断出现这种现象,就需要调大 Buffer Pool 空间或 redo log 日志的大小。
参考:
- 15.4 InnoDB Architecture
- 15.5.1 Buffer Pool
- 14.8.3.1 Configuring InnoDB Buffer Pool Size
- 揭开 Buffer Pool 的面纱
- MySQL各种“Buffer”之InnoDB Buffer Pool