总结自:小林coding,bojiangzhou
虽然说 MySQL 的数据是存储在磁盘里的,但是也不能每次都从磁盘里面读取数据,这样性能是极差的。
要想提升查询性能,加个缓存就行了嘛。所以,当数据从磁盘中取出后,缓存内存中,下次查询同样的数据的时候,直接从内存中读取。为此,Innodb 存储引擎设计了一个缓冲池(Buffer Pool),来提高数据库的读写性能。
Buffer Pool 是在 MySQL 启动的时候,向操作系统申请的一片连续的内存空间,默认配置下 Buffer Pool 只有 128MB
。然后按照默认的16KB
的大小划分出一个个的页, Buffer Pool 中的页就叫做缓存页。
可以通过调整 innodb_buffer_pool_size
参数来设置 Buffer Pool 的大小,一般建议设置成可用物理内存的 60%~80%。
为了更好的管理这些在 Buffer Pool 中的缓存页,InnoDB 为每一个缓存页都创建了一个控制块,控制块信息包括「缓存页的表空间、页号、缓存页地址、链表节点」等等。控制块也是占有内存空间的,它是放在 Buffer Pool 的最前面,接着才是缓存页。
管理Buffer Pool
缓存页哈希表
有些数据页被加载到 Buffer Pool 的缓存页中了,那怎么知道一个数据页有没有被缓存呢?
所以InnoDB还会有一个哈希表数据结构,它用 表空间号+数据页号
作key,value 就是缓存页的地址。
当使用一个数据页的时候,会先通过表空间号+数据页号
作为key去这个哈希表里查一下,如果没有就从磁盘读取数据页,如果已经有了,就直接使用该缓存页。
管理空闲页(Free List)
那当我们从磁盘读取数据的时候,总不能通过遍历这一片连续的内存空间来找到空闲的缓存页吧,这样效率太低了。所以,为了能够快速找到空闲的缓存页,可以使用链表结构,将空闲缓存页的「控制块」作为链表的节点,这个链表称为 Free 链表(空闲链表)。
有了 Free 链表后,每当需要从磁盘中加载一个页到 Buffer Pool 中时,就从 Free链表中取一个空闲的缓存页,并且把该缓存页对应的控制块的信息填上,然后把该缓存页对应的控制块从 Free 链表中移除。
管理脏页(Flush List)
-
Free Page(空闲页),表示此页未被使用,位于 Free 链表;
-
Clean Page(干净页),表示此页已被使用,但是页面未发生修改,位于LRU 链表。
-
Dirty Page(脏页),表示此页「已被使用」且「已经被修改」,其数据和磁盘上的数据已经不一致。当脏页上的数据写入磁盘后,内存数据和磁盘数据一致,那么该页就变成了干净页。脏页同时存在于 LRU 链表和 Flush 链表。
设计 Buffer Pool 除了能提高读性能,还能提高写性能,也就是更新数据的时候,不需要每次都要写入磁盘,而是将 Buffer Pool 对应的缓存页标记为脏页,然后再由后台线程将脏页写入到磁盘。
那为了能快速知道哪些缓存页是脏的,于是就设计出 Flush 链表,它跟 Free 链表类似的,链表的节点也是控制块,区别在于 Flush 链表的元素都是脏页。有了 Flush 链表后,后台线程就可以遍历 Flush 链表,将脏页写入到磁盘。
提升缓存命中率(冷热分离的LRU List)
简单的 LRU 算法的实现思路是这样的:
-
当访问的页在 Buffer Pool 里,就直接把该页对应的 LRU 链表节点移动到链表的头部。
-
当访问的页不在 Buffer Pool 里,除了要把页放入到 LRU 链表的头部,还要淘汰 LRU 链表末尾的节点。
但是简单的 LRU 算法并没有被 MySQL 使用,因为简单的 LRU 算法无法避免下面这两个问题:
-
预读失效;
-
Buffer Pool 污染;
预读失效
根据程序的空间局部性原理,靠近当前被访问数据的数据,在未来很大概率会被访问到。所以,MySQL 在加载数据页时,会提前把它相邻的数据页一并加载进来,目的是为了减少磁盘 IO。
但是可能这些被提前加载进来的数据页,并没有被访问,相当于这个预读是白做了,这个就是预读失效。如果使用简单的 LRU 算法,就会把预读页放到 LRU 链表头部,而当 Buffer Pool空间不够的时候,还需要把末尾的页淘汰掉。
如果这些预读页如果一直不会被访问到,就会出现一个很奇怪的问题,不会被访问的预读页却占用了 LRU 链表前排的位置,而末尾淘汰的页,可能是频繁访问的页,这样就大大降低了缓存命中率。
Buffer Pool 污染
当某一个 SQL 语句扫描了大量的数据时,在 Buffer Pool 空间比较有限的情况下,可能会将 Buffer Pool 里的所有页都替换出去,导致大量热数据被淘汰了,等这些热数据又被再次访问的时候,由于缓存未命中,就会产生大量的磁盘 IO,MySQL 性能就会急剧下降,这个过程被称为 Buffer Pool 污染。
如果我们写了一个全表扫描的查询语句(页中每条数据会被访问最多一次),一下就将整个表的页加载到了 LRU 的头部。
冷热数据分离的LRU List
为了解决简单 LRU 链表的问题,InnoDB在设计 LRU 链表的时候,实际上是采取冷热数据分离
的思想,LRU链表会被拆成两部分,一部分是热数据
(又称new/young列表
),一部分是冷数据
(又称old列表
)。如下图所示。冷数据默认占37%
,通过innodb_old_blocks_pct
参数来设置。
基于冷热分离的LRU链表,这时新加载一个缓存页时,就不是直接放到LRU的头部了,而是放到冷数据区域
的头部。那什么时候将冷数据区域的页移到热数据区域呢?
-
如果是预读机制加载了一些不会被访问的页,慢慢的被淘汰掉就行了。如果预读的页被访问了,就将其放入热数据头部?这样是不行的全表扫描加载进来的页,必然是会被读取至少一次的,而且一页包含很多条记录,可能会被访问多次。
-
所以 InnoDB 设置了一个规则,在第一次访问冷数据区域的缓存页的时候,就在它对应的描述信息块中记录第一次访问的时间,默认要间隔
1秒
后再访问这个页,才会被移到热数据区域的头部。也就是从第一次加载到冷数据区域后,1秒内多次访问都不会移动到热数据区域,基本上全表扫描查询某缓存页的操作1秒内就结束了。(间隔时间是由参数innodb_old_blocks_time
控制的,默认是1000毫秒
)
热数据区域中的页是每访问一次就移到头部吗?也不是的,热数据区域是最频繁访问的数据,如果频繁的对LRU链表进行节点移动操作也是不合理的。所以 InnoDB 就规定只有在访问了热数据区域的 后3/4
的缓存页才会被移动到链表头部,访问 前1/4
中的缓存页是不会移动的。
总结
-
LRU链表分为冷、热数据区域,前
63%
为热数据区域,后37%
为冷数据区域,加载缓存页先放到冷数据区域头部。 -
冷数据区域的缓存页第一次访问超过1秒后,再次访问时才会被移动到热数据区域头部。
-
热数据区域中,只有后
3/4
的缓存页被访问才会移到头部,前1/4
被访问到不会移动。 -
淘汰数据优先淘汰冷数据区域尾部的缓存页。
LRU List 和 Flush List
Flush链表中的缓存页一定是在 LRU 链表中的,而 LRU 链表中不在 Flush链表 中的缓存页就是未修改过的页。可以通过下图来理解 LRU 链表和 Flsuh链表。
可以看到,脏页既存在于 LRU链表 中,也存在于 Flush链表 中。LRU链表 用来管理 Buffer Pool 中页的可用性,Flush链表 用来管理将页刷新回磁盘,二者互不影响。
即 Free + LRU(包含Flush)约等于 所有页数量,因为缓冲池中的页还可能分配给自适应哈希索引、Lock信息、Insert Buffer等
脏页刷脏
引入了 Buffer Pool 后,当修改数据时,首先是修改 Buffer Pool 中数据所在的页,然后将其页设置为脏页,但是磁盘中还是原数据。因此脏页需要被刷入磁盘,保证缓存和磁盘数据一致,但是若每次修改数据都刷入磁盘,则性能会很差,因此一般都会在一定时机进行批量刷盘。
-
当 redo log 日志满了的情况下,会主动触发脏页刷新到磁盘;
-
后台有专门的线程会定时从
LRU链表
尾部扫描一些缓存页,扫描的数量可以通过参数innodb_lru_scan_depth
来设置。如果有脏页,就会把它们刷回磁盘,然后释放掉,不是脏页就直接释放掉,再把它们加回Free链表
中。这种刷新页面的方式被称之为BUF_FLUSH_LRU
。 -
Buffer Pool 没有空闲页时,需要将
LRU List
的尾部淘汰一个数据页淘,如果淘汰的是脏页,需要先将脏页同步到磁盘;这种刷新单个页面到磁盘中的刷新方式被称之为BUF_FLUSH_SINGLE_PAGE
。 -
MySQL 认为空闲时,后台线程会定期将 Flush 链表适量的脏页刷入到磁盘;这种刷新页面的方式被称之为
BUF_FLUSH_LIST
。 -
MySQL 正常关闭之前,会把所有的脏页刷入到磁盘;
查看Buffer Pool状态
我们可以通过 SHOW ENGINE INNODB STATUS;
来查看 InnoDB 的状态信息。但是要注意,状态并不是当前的状态,而是过去某个时间范围内 InnoDB 存储引擎的状态。
缓冲池和内存信息
从输出的内容中,可以找到 BUFFER POOL AND MEMORY
这段关于缓冲池和内存的状态信息。
bash
----------------------
BUFFER POOL AND MEMORY
----------------------
Total large memory allocated 1099431936
Dictionary memory allocated 8281957
Buffer pool size 65535
Free buffers 1029
Database pages 63508
Old database pages 23423
Modified db pages 80
Pending reads 0
Pending writes: LRU 0, flush list 0, single page 0
Pages made young 15278983, not young 2027514654
0.00 youngs/s, 0.00 non-youngs/s
Pages read 83326150, created 1809368, written 21840503
0.00 reads/s, 0.00 creates/s, 0.00 writes/s
Buffer pool hit rate 1000 / 1000, young-making rate 1 / 1000 not 0 / 1000
Pages read ahead 0.00/s, evicted without access 0.00/s, Random read ahead 0.00/s
LRU len: 63508, unzip_LRU len: 0
I/O sum[833]:cur[0], unzip sum[0]:cur[0]
-
Total large memory allocated
:Buffer Pool向操作系统申请的内存空间大小,包括全部控制块、缓存页、以及碎片的大小。 -
Dictionary memory allocated
:为数据字典信息分配的内存空间大小,这个内存空间和Buffer Pool没啥关系,不包括在Total large memory allocated
中。 -
Buffer pool size
:缓存池页的数量,所以缓冲池大小为65535 * 16KB = 1G
。 -
Free buffers
:Free链表中的空闲缓存页数量。 -
Database pages
:LRU链表中的缓存页数量。需要注意的是,Database pages
+Free buffers
可能不等于Buffer pool size
,因为缓冲池中的页还可能分配给自适应哈希索引、Lock信息、Insert Buffer等,而这部分不需要LRU来管理。 -
Old database pages
:LRU冷数据区域(old列表)的缓存页数量,23423/63508=36.88%
,约等于37%
。 -
Modified db pages
:修改过的页,这就是 Flush链表中的脏页数量。 -
Pending reads
:正在等待从磁盘上加载到Buffer Pool中的页面数量。当准备从磁盘中加载某个页面时,会先为这个页面在Buffer Pool中分配一个缓存页以及它对应的控制块,然后把这个控制块添加到LRU的冷数据区域的头部,但是这个时候真正的磁盘页并没有被加载进来,所以Pending reads
的值会加1。 -
Pending writes
:从LRU链表中刷新到磁盘中的页面数量,其实就对应着前面说的三种刷盘的时机:BUF_FLUSH_LRU、BUF_FLUSH_LIST、BUF_FLUSH_SINGLE_PAGE
。 -
Pages made young
:显示了页从LRU的冷数据区域移到热数据区域头部的次数。注意如果是热数据区域后3/4被访问移动到头部是不会增加这个值的。 -
Pages made not young
:这个是由于innodb_old_blocks_time
的设置导致页没有从冷数据区域移到热数据区域的页数,可以看到这个值减少了很多不常用的页被移到热数据区域。 -
xx youngs/s, xx non-youngs/s
:表示 made young 和 not young 这两类每秒的操作次数。 -
xx reads/s, xx creates/s, xx writes/s
:代表读取,创建,写入的速率。 -
Buffer pool hit rate xx/1000
:表示在过去某段时间,平均访问1000次页面,有多少次该页面已经被缓存到Buffer Pool了,表示缓存命中率。这里显示的就是 100%,说明缓冲池运行良好。这是一个重要的观察变量,通常该值不应该小于95%
,否则我们应该看下是否有全表扫描引起LRU链表被污染的问题。 -
young-making rate xx/1000 not xx/1000
:表示在过去某段时间,平均访问1000次页面,有多少次访问使页面移动到热数据区域的头部了,以及没移动的缓存页数量。 -
LRU len
:LRU 链表中节点的数量。 -
I/O sum[xx]:cur[xx]
:最近50s读取磁盘页的总数,现在正在读取的磁盘页数量。
LRU List 信息
我们还可以查询 information_schema
下的 INNODB_BUFFER_PAGE_LRU
来观察LRU链表中每个页的具体信息。
sql
SELECT * FROM information_schema.INNODB_BUFFER_PAGE_LRU WHERE TABLE_NAME = '`hzero_platform`.`iam_role`';
其中的一些信息如下:
-
POOL_ID
:缓冲池ID,我们是可以设置多个缓冲池的。 -
SPACE
:页所属表空间ID,表空间ID也可以从information_schema.INNODB_SYS_TABLES
去查看。 -
PAGE_NUMBER
:页号。 -
PAGE_TYPE
:页类型,INDEX就是数据页。 -
NEWEST_MODIFICATION、OLDEST_MODIFICATION
:LRU热数据区域和冷数据区域被修改的记录,如果想查询脏页的数量,可以加上条件(NEWEST_MODIFICATION > 0 or OLDEST_MODIFICATION > 0)
。 -
NUMBER_RECORDS
:这一页中的记录数。 -
COMPRESSED
:是否压缩了
设置Buffer Pool大小
多线程访问 Buffer Pool 的时候,会涉及到对同一个 Free、LRU、Flush 等链表的操作,例如节点的移动、缓存页的刷新等,那必然是会涉及到加锁的。就算只有一个 Buffer Pool,多线程访问要加锁、释放锁,由于基本都是内存操作,所以性能也是很高的。但在一些高并发的生产环境中,配置多个 Buffer Pool,还是能极大地提高数据库并发性能的。
可以通过参数 innodb_buffer_pool_instances
来配置 Buffer Pool 实例数,通过参数 innodb_buffer_pool_size
设置所有 Buffer Pool 的总大小(单位字节
)。每个 Buffer Pool 的大小就是 innodb_buffer_pool_size / innodb_buffer_pool_instances
。InnoDB 规定,当 innodb_buffer_pool_size
小于1GB
的时候,设置多个实例是无效的,会默认把innodb_buffer_pool_instances
的值修改为1
。
动态调整Buffer Pool大小
可以在运行时动态调整 innodb_buffer_pool_size
这个参数,但 InnoDB 并不是一次性申请 pool_size 大小的内存空间,而是以 chunk
为单位申请。一个 chunk 默认就是 128M
,代表一片连续的空间,申请到这片内存空间后,就会被分为若干缓存页与其对应的描述信息块。
也就是说一个Buffer Pool实例其实是由若干个chunk
组成的,每个chunk里划分了描述信息块和缓存页,然后共用一套 Free链表、LRU链表、Flush链表。每个chunk
的大小由参数 innodb_buffer_pool_chunk_size
控制,这个参数只能在服务器启动时指定,不能在运行时动态修改。
合理设置
在生产环境中安装MySQL数据库,首先我们一般要选择大内存的机器,那我们如何合理的设置 Buffer Pool 的大小呢?
比如有一台 32GB 的机器,不可能说直接给个30G,要考虑几个方面。首先前面说过,innodb_buffer_pool_size
并不包含描述块的大小,实际 Buffer Pool 的大小会超出 innodb_buffer_pool_size
5%
左右。另外机器本身运行、MySQL运行也会占用一定的内存,所以一般 Buffer Pool 可以设置为机器的 50%~60%
左右就可以了,比如32GB的机器,就设置 innodb_buffer_pool_size
为 20GB。
另外,innodb_buffer_pool_size
必须是 innodb_buffer_pool_chunk_size × innodb_buffer_pool_instances
的倍数,主要是保证每一个Buffer Pool实例中包含的chunk数量相同。
比如默认 chunk_size=128MB,pool_size 设置 20GB,pool_instances 设置 16 个,那么 20GB / (128MB * 16) = 10 倍,这样每个 Buffer Pool 的大小就是 128MB * 10 = 1280MB。如果将 pool_instances 设置为 32 个,那么 20GB / (128MB * 32) = 5 倍,这样每个 Buffer Pool 的代销就是 128MB * 5 = 640MB