传统的LRU算法存在这两个问题:
- 预读失效 导致的缓存命中率下降
- 缓存污染 导致的缓存命中率下降
Redis的缓存淘汰算法是通过实现LFU算法来避免 [缓存污染] 而导致缓存命中率下降的问题(redis 没有预读机制)
Mysql 和 Linux操作系统是通过改进LRU算法来避免 [预读失效和缓存污染] 而导致缓存命中率下降的问题。
Linux和MySQL的缓存
Linux操作系统的缓存
在应用程序读取文件的数据的时候,Linux操作系统是会对读取的文件数据进行缓存的,会缓存在文件系统中的 Page Cache(页缓存):
Page Cache 属于内存空间里的数据,由于内存访问比磁盘访问快很多,在下一次访问相同的数据就不需要通过I/O了,命中缓存就直接返回数据即可。
因此,Page Cache 起到了加速访问数据的作用。
MySQL的缓存
MySQL的数据是存储在磁盘里的,为了提升数据库的读写性能,Innodb存储引擎设计了一个缓冲池( Buffer Pool),Buffer Pool 属于内存空间里的数据。
有了缓冲池后:
- 当读取数据时,如果数据存在于Buffer Pool中,客户端就会直接读取Buffer Pool中的数据,否则再去磁盘中读取。
- 当修改数据时,首先是修改Buffer Pool中数据所在的页,然后将其页设置为脏页,最后由后台线程将脏页写入到磁盘。
传统LRU是如何管理内存数据的?
Linux的Page Cache 和 MySQL的Buffer Pool的大小是有限的,并不能无限地缓存数据,对于一些频繁访问的数据我们希望可以一直留在内存中,而一些很少访问的数据希望可以在某些时机可以淘汰掉,从而保证内存不会因为满了而导致无法再缓存新的数据,同时还能保证常用数据留在内存中。
最容易想到的就是LRU(Least recently used)算法。
LRU算法一般是用 [链表] 作为数据结构来实现的,链表头部的数据是最近使用的,而链表末尾的数据是最久没被使用的。当空间不够了,就淘汰最久没被使用的节点,也就是链表末尾的数据,从而腾出内存空间。
因为 Linux 的 Page Cache 和 MySQL 的 Buffer Pool 缓存的基本数据单位都是页(Page)单位,所以后续以「页」名称代替「数据」。
传统的LRU算法的实现思路:
- 当访问的页在内存中,就直接把该页对应的LRU链表节点移动到链表的头部
- 当访问的页不在内存里,除了要把该页放入到LRU链表的头部,还要淘汰LRU链表末尾的页。
比如下图,假设LRU链表长度为 5 ,LRU链表从左到右有编号为1,2,3,4,5的页。
如果访问了3号页,因为3号页已经在内存了,所以把3号页移动到链表头部即可,表示最近被访问了。
接下来如果访问了 8 号页,因为8号页不在内存中,而且LRU链表的长度为5,所以必须要淘汰数据,以腾出内存空间缓存 8 号页,于是就会淘汰末尾的 5 号页,然后再将 8 号页插入到头部:
传统的LRU算法并没有被Linux和Mysql使用,因为传统的LRU算法无法避免两个问题:
- 预读失效导致缓存命中率下降
- 缓存污染导致缓存命中率下降
预读失效怎么办?
什么是预读机制?
Linux操作系统为基于Page Cache的读缓存机制提供预读机制。
- 应用程序只想读取磁盘文件A的offset 为 0-3KB范围内的数据,由于磁盘的基本读写单位为block(4KB),于是操作系统至少会读 0-4KB内容,这恰好可以在一个page中装下
- 但是操作系统出于空间局部性原理(靠近当前被访问数据的数据,在未来很大概率会被访问到),会选择将磁盘块offset[4KB ,8KB]、[8KB, 12KB]、[12KB, 16KB]都加载到内存中,于是额外在内存中申请了 3 个page;
上图中,应用程序利用read系统调用读取4KB数据,实际上内核使用预读机制完成了16KB数据的读取,也就是通过一次磁盘顺序读将多个Page数据装入Page Cache。
这样下次读取 4KB 数据后面的数据的时候,就不用从磁盘读取了,直接在Page Cache即可命中数据。因此,预读机制的好处是减少了磁盘I/O次数,提高系统磁盘I/O吞吐量。
MYSQL Innodb存储引擎的Buffer Pool也有类似的预读机制,MySQL从磁盘加载页时,会提前把它邻近的页一并加载进来,目的是为了减少磁盘 IO。
预读失效会带来什么问题?
预读失效:提前加载进来的页,并没有被访问,相当于这个预读工作白做了。
如果使用传统的LRU算法,就会把 [预读页] 放到LRU链表头部,而当内存空间不够的时候,还需要把末尾的页淘汰掉。
如果这些 [预读页]一直不被访问到,就会出现一个奇怪的问题:不会被访问的预读页占用了LRU链表前排的位置,而末尾淘汰的页可能是热点数据,这样就大大降低了缓存命中率
如何避免预读失效造成的影响?
要避免预读失效带来的影响,最好就是让预读页停留在内存里的时间尽可能要短,让真正被访问的页才移动到LRU链表的头部,从而保证真正被读取的热数据留在内存里的时间尽可能长。
Linux操作系统和MySQL Innodb通过改进传统的LRU链表来避免预读失效带来的影响,具体的改进分别如下:
- Linux操作系统实现了两个LRU链表:活跃LRU链表 和 非活跃LRU链表
- MySQL的Innodb存储引擎是在一个 LRU 链表上划分 2 个区域:young区域和old区域
这两个改进方式,设计思想类似,都是将数据划分为冷数据和热数据,然后分别进行LRU算法。
不再像传统的LRU算法一样,所有数据都只用一个LRU算法管理。
Linux如何避免预读失效带来的影响?
Linux系统实现两个LRU链表:活跃LRU链表 和 非活跃LRU链表
- active list :存放的是最近被访问过(活跃)的内存页
- inactive list:存放的是很少被访问(非活跃)的内存页
有了这两个LRU链表后,预读页就只需要加入到 inactive list区域的头部,当页被真正访问时,才将页插入到active list 的头部。如果预读的页一直没有被访问,就会从inactive list移除,这样就不会影响active list中的热点数据。
MySQL是如何避免预读失效带来的影响?
MySQL 的 Innodb 存储引擎是在一个 LRU 链表上划分来 2 个区域,young 区域 和 old 区域。
young 区域在 LRU 链表的前半部分,old 区域则是在后半部分,这两个区域都有各自的头和尾节点,如下图:
young区域与old区域在LRU链表中的占比关系并不是一比一的,而是63:37(默认比例)的关系。
划分这两个区域后,预读的页就只需要加入到old区域的头部,当页被真正访问的时候,才将页插入young区域的头部。如果预读的页一直没有被访问,就会从 old 区域移除,这样就不会影响 young 区域中的热点数据。
缓存污染怎么办?
虽然Linux和 MySQL通过改进传统的LRU数据结构,避免了预读失效的影响。
但是如果还是使用 [只要数据被访问一次,就将数据加入到活跃的LRU链表头部(或young区域)] 这种方式的话,那么还存在缓存污染的问题。
当我们在批量读取数据的时候,由于数据被访问了一次,这些大量数据就会被加入到 [活跃LRU链表] 里,然后之前缓存在活跃LRU链表里的热点数据就全部被淘汰了,如果这些大量的数据在很长一段时间内都不会被访问的话,那么整个活跃LRU链表(Young区域)就被污染了。
缓存污染会带来什么问题?
缓存污染带来的影响是很致命的,等这些热数据又被再一次访问的时候,由于缓存没有命中,就会产生大量的磁盘I/O,系统性能会急剧下降。
我们以MySQL为例,Linux发生缓存污染的现象也是类似。
当某个SQL语句扫描了大量的数据时,在Buffer Pool空间比较有限的情况下,可能会将Buffer Pool里的所有页都替换出去,导致大量热数据都被淘汰了,等这些热数据又被再一次访问的时候,由于缓存未命中,就会产生大量的磁盘I/O,MySQL的性能会急剧下降。
注意,缓存污染并不只是查询语句查询出了大量数据才出现的问题,即使查询出的结果很小,也会造成混存污染
比如,在一个数据量非常大的表,执行以下语句:
select * from t_user where name like "%name%";
可能这个查询出来的结果只有几条,但是由于这条语句会发生索引失效,所以这个查询过程是全表扫描,接着会发生如下过程:
- 从磁盘读的页加入到LRU链表的old区域头部
- 当从页里读取行记录时,也就是页被访问的时候,就要将页放到young区域头部
- 接下来拿行记录的name字段与查询条件进行模糊查询,如果符合条件,就加入到结果集里
- 如此往复,直到扫描完表中所有的记录
由于这条 SQL 语句访问的页非常多,每访问一个页,都会将其加入 young 区域头部,那么原本 young 区域的热点数据都会被替换掉,导致缓存命中率下降。那些在批量扫描时,而被加入到 young 区域的页,如果在很长一段时间都不会再被访问的话,那么就污染了 young 区域。
举个例子,假设需要批量扫描:21,22,23,24,25 这五个页,这些页都会被逐一访问(读取页里的记录)
在批量访问这些页的时候,会被逐一插入到 young 区域头部
原本在young区域的 6 和 7 号页都被淘汰了,而批量扫描的页基本占满了young区域,如果这些页在很长一段时间内都不会被访问,那么就对young区域造成了污染。
如果 6和 7 号页是热点数据,那么在淘汰后,后续有SQL再次读取 6 和 7 号页时,由于未被命中。就要从磁盘中重新读取,降低了MySQL的性能,这就是缓存污染带来的影响。
如何避免缓存污染造成的影响?
前面的LRU算法只要数据被访问一次,就将数据加入活跃LRU链表(young 区域),这种LRU算法进入活跃LRU链表的门槛太低了。正是因为门槛太低,才导致在发生缓存污染的时候,很容易就将原本在活跃LRU链表里的热点数据淘汰了。
所以,只要我们提高进入到活跃LRU链表(young 区域)的门槛,就能有效地保证活跃LRU链表(young 区域)里的热点数据不会被轻易替换掉。
Linux操作系统和MySQL Innodb的存储引擎分别是这样提高门槛:
- Linux操作系统:在内存页被访问第二次的时候,才将页从inactive list 升级到active list里
- MySQL Innodb:在内存页被访问第二次的时候,并不会马上将该页从old区域升级到young区域,因为还要进行停留在old区域的时间判断:
- 如果第二次访问时间与第一次访问的时间在1秒内,那么该页就不会被从old区域升级到young区域
- 如果第二次的访问时间与第一次访问时间超过 1 秒,那么该页就会从 old 区域升级到 young 区域。
提高了进入活跃LRU链表(或 young区域)的门槛后,就很好地避免了缓存污染带来的影响。
在批量读取数据的时候,如果这些大量数据只会被访问一次,那么它们就不会进入到活跃LRU链表(或 young 区域),也就不会把热点数据淘汰,只会待在非活跃LRU链表(old 区域中),后续很快也被淘汰。