缓存系统设计精要

10,216 阅读1小时+

在计算机领域,缓存在程序设计过程中扮演着重要角色。浏览器的资源缓存策略是影响web网站性能的一个关键因素;mysql的Buffer Pool极大的提高了数据库的查询效率;redis作为被广泛应用的缓存数据库,提供了丰富的数据结构和缓存策略来满足开发者的需求。缓存在现代计算机系统中无处不在,是一个非常深入、广泛的话题,本文的目的是从众多已有的系统设计中,提取出有关系统缓存设计的精要,主要从三个方面展开:

  • 多级缓存
  • 缓存淘汰策略
  • 缓存安全

在讨论这些话题的同时,笔者会结合很多实际的例子阐述,其中会涉及到PHP源码、redis源码、浏览器的缓存策略、mysql数据存储设计、缓存淘汰算法等偏底层的内容,如果你是一个比较资深的开发工程师,对这些内容都比较了解,本文将非常适合您。为了照顾初学者,涉及到难懂重要的知识点笔者也将会尽可能的描述清楚。本文篇幅有点长,请读者做好心理预期!

1.多级缓存

1.1 存储器层次结构

计算机使用不同的存储技术对数据进行访问,时间差异很大。速度较快的技术每个字节的成本要比速度较慢的技术高,而且容量较小。CPU和主存(内存)之间的速度差距在增大。基于这种特性,所有的现代计算机系统都使用了金字塔式的存储器层次结构。如下图所示:

存储器层次结构

从高层往低层走,存储设备变得更慢、更便宜、容量更大。最高层(L0)是少量快速的CPU寄存器,CPU可以在一个时钟周期内访问它们 (一个时钟周期通常小于1ns);接下来是一个或多个中小型SRAM高速缓存存储器,可以在几个CPU时钟周期内访问它们;然后是一个大的基于DRAM的主存,可以在几十到几百个CPU时钟周期内访问它们;接下来是慢速但是容量很大的本地磁盘(通常是SSD);最后有些系统甚至包括了一层附加的网络文件系统(Network File System,NFS),比如说腾讯云提供的CFS服务就可以通过挂载到多个服务器实现分布式的数据共享。

下面我们从 CPU 的角度以具体的数据来量化对比CPU、磁盘、网络的速度,对计算机各个组件不同的速度差异有个更直观的认识。

类型 缓存内容 缓存位置 读取数据需要时长 对比基本单位时长
CPU寄存器 4字节 & 8字节 芯片上的CPU寄存器 0.38ns 1s
L1高速缓存 64字节块 芯片上的L1高速缓存 0.5ns 1.3s
L2高速缓存 64字节块 芯片上的L2高速缓存 7ns 18.2s
L3高速缓存 64字节块 芯片上的L3高速缓存 35ns 91s
内存 4KB页 主存 100ns 4分钟
固态硬盘SSD 磁盘扇区 磁盘控制器 150us 4.5天
网络磁盘(NFS本地挂载) 部分文件 本地磁盘 1.5ms 一个半月

CPU 和内存之间的瓶颈被称为冯诺依曼瓶颈, 它们之间至少有着几百倍的速差,可以想象成天上人间,天上一天,地下一年。普通的磁盘读取数据就更慢了,寻址时间10ms,对比CPU的基本单位时长是10个月,可以生一次孩子了!所以说IO 设备是计算机系统的瓶颈。以此想到我们网络中的API请求,动不动就是一百多毫秒,对比CPU的基本单位就是十几年!

相信通过以上描述,读者对计算机组件之间数据处理速度有了深入的印象。

1.2 局部性原理

一个编写良好的计算机程序通常具有良好的局部性(locality)。也就是,它们倾向于引用邻近于其他最近引用过的数据项的数据项,或者最近引用过的数据项本身。这种倾向性,被称为局部性原理(principle of locality),是一个持久的概念,对硬件和软件系统的设计和性能都有着极大的影响。

局部性通常有两种不同的形式: 时间局部性(temporal locality)空间局部性(spatial locality)

  • 时间局部性:被引用过一次的内存位置很可能在不远的将来再被多次引用。
  • 空间局部性:如果一个内存位置被引用了一次,那么程序很可能在不远的将来引用附近的一个内存位置。

Mysql数据库Innodb引擎的设计就很好的考虑了局部性原理。

Innodb引擎以页的形式组织数据,页的默认大小是16KB,存放用户数据记录的页叫“数据页”,为了实现数据更快的查找,InnoDB使用B+树的结构组织存放数据,最下层的叶子节点存放“数据页”,在“数据页”的上层抽出相应的“目录页”,最终形成的基本结构如下图所示:

InnoDB数据存储

数据页中记录是连续存放的,当需要访问某个页的数据时,就会把完整的页的数据全部加载到内存中,也就是说即使我们只需要访问一个页的一条记录,那也需要先把整个页的数据加载到内存中。这就利用了局部性原理中的“空间局部性”。将整个页加载到内存中后就可以进行读写访问了,在进行完读写访问之后并不着急把该页对应的内存空间释放掉,而是将其缓存到Buffer Pool中,这样将来有请求再次访问该页面时,就可以省去磁盘IO的开销了,这又利用了局部性原理中的“时间局部性”。

局部性原理是系统缓存设计中非常重要的理论基石,下文还会多次提到。

1.3 Cpu 高速缓存

本节我们来聊一聊Cpu 高速缓存,Cpu 高速缓存是影响程序性能的一个关键因素。

1.3.1 什么是Cpu 高速缓存?

笔者直接引用维基百科中对Cpu 高速缓存的描述:

在计算机系统中,CPU高速缓存(英语:CPU Cache,在本文中简称缓存)是用于减少处理器访问内存所需平均时间的部件。在金字塔式存储体系中它位于自顶向下的第二层,仅次于CPU寄存器。其容量远小于内存,但速度却可以接近处理器的频率。 当处理器发出内存访问请求时,会先查看缓存内是否有请求数据。如果存在(命中),则不经访问内存直接返回该数据;如果不存在(失效),则要先把内存中的相应数据载入缓存,再将其返回处理器。 缓存之所以有效,主要是因为程序运行时对内存的访问呈现局部性(Locality)特征。这种局部性既包括空间局部性(Spatial Locality),也包括时间局部性(Temporal Locality)。有效利用这种局部性,缓存可以达到极高的命中率。 在处理器看来,缓存是一个透明部件。因此,程序员通常无法直接干预对缓存的操作。但是,确实可以根据缓存的特点对程序代码实施特定优化,从而更好地利用缓存。

1.3.2 为什么需要有Cpu 高速缓存?

上文中我们提到冯诺依曼瓶颈。随着工艺的提升,最近几十年CPU的频率不断提升,而受制于制造工艺和成本限制,目前计算机的内存在访问速度上没有质的突破。因此,CPU的处理速度和内存的访问速度差距越来越大,这种情况下传统的 CPU 直连内存的方式显然就会因为内存访问的等待,导致计算资源大量闲置,降低 CPU 整体吞吐量。同时又由于内存数据访问的热点集中性,在 CPU 和内存之间用较为快速而成本较高(相对于内存)的介质做一层缓存,就显得性价比极高了。

1.3.3 Cpu 高速缓存架构

早期计算机系统的存储器层次结构只有三层:CPU寄存器、DRAM主存储器和磁盘存储。由于CPU和主存之间逐渐增大的差距,系统设计者被迫在CPU寄存器文件和主存之间插入了一个小的SRAM高速缓存存储器,称为L1高速缓存(一级缓存),如下图所示。L1高速缓存的访问速度几乎和寄存器相差无几。

CPU芯片示意图

随着CPU和主存之间的性能差距不断增大,系统设计者在L1高速缓存和主存之间又插入了一个更大的高速缓存,称为L2高速缓存,可以在大约10个时钟周期内访问到它。现代的操作系统还包括一个更大的高速缓存,称为L3高速缓存,在存储器的层次结构中,它位于L2高速缓存和主存之间,可以在大约50个周期内访问到它。下图是简单的高速缓存架构:

高速缓存架构

数据的读取和存储都经过高速缓存,CPU 核心与高速缓存有一条特殊的快速通道;主存与高速缓存都连在系统总线上(BUS),这条总线还用于其他组件的通信。

1.3.4 PHP7数组的设计

关于Cpu 高速缓存的应用,PHP7数组的设计是一个非常经典的案例。在PHP中数组是最常用的一种数据类型,提升数组的性能会使程序整体性能得到提升。我们通过对比PHP7和PHP5 数组的设计,来学习一下PHP 设计者为了提升PHP数组的性能,都进行了哪些思考。

我们先来总结一下PHP数组的特性:

  • php中的数组是一个字典dict,存储着key-value对,通过key可以快速的找到value,其中key可以是整形,也可以是字符串(hash array 和 packed array)。
  • 数组是有序的:插入有序、遍历有序。

PHP中数组使用hashTable实现,我们先来了解一下什么是hashTable。

hashTable是一种通过某种hash函数将特定的key映射到特定value的一种数据结构,它维护着键和值的一一对应关系,并且可以快速的根据键找到值(o(1)). 一般HashTable的示意图如下:

hashTable示意图

1) key: 通过key可以快速找到value。一般可以为数字或者字符串。
2) value: 值可以为复杂结构
3) bucket: 桶,HashTable存储数据的单元,用来存储key/value以及其他辅助信息的容器
4) slot:槽,HashTable有多少个槽,一个bucket必须属于具体的某一个slot,一个slot可以有多个
   bucket
5) 哈希函数:一般都是自己实现(time33),在存储的时候,会将key通过hash函数确定所在的slot
6) 哈希冲突: 当多个key经过哈希计算后,得到的slot位置是同一个,那么就会冲突,一般这个时候会有
   2种解决办法:链地址法和开放地址法。其中php采用的是链地址法,即将同一个slot中的bucket通过
   链表连接起来

为了实现数组的插入有序和遍历有序,PHP5使用hashTable + 双链表实现数组,如下图是将key分别为:“a”,"b","c","d",值为“1”,"2","3","4" 插入到数组中后的效果:

PHP5数组实现

上图中b,c,d所在的bucket被存分配到了同一个slot,为了解决hash冲突,slot中多个bucket通过双向链表关联,称为局部双向链表;为了实现有序,slot之间也通过双向链表关联,称为全局双向链表

了解php5数组的实现原理之后,我们发现其中影响性能的两个关键点:

  1. 频繁的内存分配!每向数组中添加一个元素,都需要申请分配一个bucket大小的内存,然后维护多个指针。虽然php是基于内存池的管理方式(预先申请大块内存进行按需分配),但是内存分配带来的性能损耗是不可忽略的。
  2. Cpu 高速缓存命中率低!因为bucket与bucket之间是通过链表指针连接的,bucket随机分配,内存基本不连续,导致Cpu cache降低,不能给遍历数组带来性能提升。

针对以上两点,php7对hashTable的设计进行了改造。既然是字典,还是要使用hashTable,但php7数组的实现去掉了slot,bucket的分配使用了连续内存;bucket间不再使用真实存在的指针进行维护,bucket只维护一个在数组中的索引,bucket与bucket之间通过内存地址进行关联,如下图所示:

PHP7中hashTable实现

PHP7中数组是如何解决hash冲突的

PHP7对zval进行了改造,zval中有一个u2的union联合体,占用4字节大小,存放一些辅助信息。PHP7数组中的value也是一个个的zval指针,当发生hash冲突时,zval中u2部分存放的next指针存放指向下一个bucket数组中的索引,通过这样一种逻辑上的链表就巧妙解决hash冲突。关于PHP7中zval的设计,推荐大家阅读鸟哥的文章:深入理解PHP7内核之zval

可以看到,php7中hashTable对性能提升最重要的改动是bucket的分配使用了连续内存。这样不仅省去了数组元素插入时频繁的内存分配开销;遍历数组也只需要一个bucket一个bucket的访问就好了,Cpu 高速缓存命中率非常高,极大的提升了数组性能;符合局部性原理。

更多关于PHP5和PHP7数组的设计细节,推荐大家研究这篇文章:【PHP7源码学习】系列之数组实现

1.4 浏览器的多级缓存机制

举一个多级缓存的应用案例:浏览器对我们生活的影响是不言而喻的,现代的浏览器已经不同往昔了,快速的信息加载,顺畅的用户交互,这一切都得益于网络技术的普及、H5标准特性的推广应用,当然还有一个重要因素,那就是浏览器的缓存策略。本节我们就来简单介绍一下浏览器的多级缓存机制。

对于一个数据请求来说,可以分为发起网络请求、后端处理、浏览器响应三个步骤。浏览器缓存可以帮助我们在第一和第三步骤中优化性能。比如说直接使用缓存而不发起请求,或者发起了请求但后端存储的数据和前端一致,那么就没有必要再将数据回传回来,这样就减少了响应数据。

浏览器有四种缓存级别,它们各自都有优先级。

  • Service Worker
  • Memory Cache
  • Disk Cache
  • Push Cache

当依次查找缓存且都没有命中的时候,才会去请求网络。

1.4.1 Service Worker

Service Worker 是运行在浏览器背后的独立线程,一般可以用来实现缓存功能。使用 Service Worker的话,传输协议必须为 HTTPS。因为 Service Worker 中涉及到请求拦截,所以必须使用 HTTPS 协议来保障安全。Service Worker 的缓存与浏览器其他内建的缓存机制不同,它可以让我们自由控制缓存哪些文件、如何匹配缓存、如何读取缓存,并且缓存是持续性的

Service Worker 实现缓存功能一般分为三个步骤:首先需要先注册 Service Worker,然后监听到 install 事件以后就可以缓存需要的文件,那么在下次用户访问的时候就可以通过拦截请求的方式查询是否存在缓存,存在缓存的话就可以直接读取缓存文件,否则就去请求数据。

当 Service Worker 没有命中缓存的时候,我们需要去调用 fetch 函数获取数据。也就是说,如果我们没有在 Service Worker 命中缓存的话,会根据缓存查找优先级去查找数据。但是不管我们是从 Memory Cache 中还是从网络请求中获取的数据,浏览器都会显示我们是从 Service Worker 中获取的内容。

1.4.2 Memory Cache

Memory Cache 也就是内存中的缓存,主要包含的是当前页面中已经抓取到的资源,例如页面上已经下载的样式、脚本、图片等。读取内存中的数据肯定比磁盘快,内存缓存虽然读取高效,可是缓存持续性很短,会随着进程的释放而释放。 一旦我们关闭 Tab 页面,内存中的缓存也就被释放了。

当我们访问过页面以后,再次刷新页面,可以发现很多数据都来自于内存缓存

浏览器内存缓存

内存缓存中有一块重要的缓存资源是preloader相关指令(例如<link rel="prefetch">)下载的资源。众所周知preloader的相关指令已经是页面优化的常见手段之一,它可以一边解析js/css文件,一边网络请求下一个资源。

需要注意的事情是,内存缓存在缓存资源时并不关心返回资源的HTTP缓存头Cache-Control是什么值,同时资源的匹配也并非仅仅是对URL做匹配,还可能会对Content-Type,CORS等其他特征做校验。

为什么浏览器数据不都存放在内存中

这个问题就算我不回答,读者肯定也都想明白了,计算机中的内存一定比硬盘容量小得多,操作系统需要精打细算内存的使用,所以能让我们使用的内存必然不多。谷歌浏览器是公认的性能出众的,但是它占用的内存也是很大的。

1.4.3 Disk Cache

Disk Cache 也就是存储在硬盘中的缓存,读取速度慢点,但是什么都能存储到磁盘中,比之 Memory Cache 胜在容量和存储时效性上。

在所有浏览器缓存中,Disk Cache 覆盖面基本是最大的。它会根据 HTTP Herder 中的字段判断哪些资源需要缓存,哪些资源可以不请求直接使用,哪些资源已经过期需要重新请求。并且即使在跨站点的情况下,相同地址的资源一旦被硬盘缓存下来,就不会再次去请求数据。绝大部分的缓存都来自 Disk Cache。

浏览器会把哪些文件丢进内存中?哪些丢进硬盘中

关于这点,网上说法不一,不过有些观点比较靠得住:对于大文件来说,大概率是不存储在内存中的,另外如果当前系统内存使用率高的话,文件优先存储进硬盘。

1.4.4 Push Cache

Push Cache(推送缓存)是 HTTP/2 中的内容,当以上三种缓存都没有命中时,它才会被使用。它只在会话(Session)中存在,一旦会话结束就被释放,并且缓存时间也很短暂,在Chrome浏览器中只有5分钟左右,同时它也并非严格执行HTTP头中的缓存指令。

Push Cache 在国内能够查到的资料很少,也是因为 HTTP/2 在国内不够普及。这里推荐阅读Jake Archibald的 HTTP/2 push is tougher than I thought 这篇文章,文章中的几个结论是:

  • 所有的资源都能被推送,并且能够被缓存,但是 Edge 和 Safari 浏览器支持相对比较差
  • 可以推送 no-cache 和 no-store 的资源
  • 一旦连接被关闭,Push Cache 就被释放
  • 多个页面可以使用同一个HTTP/2的连接,也就可以使用同一个Push Cache。这主要还是依赖浏览器的实现而定,出于对性能的考虑,有的浏览器会对相同域名但不同的tab标签使用同一个HTTP连接。
  • Push Cache 中的缓存只能被使用一次
  • 浏览器可以拒绝接受已经存在的资源推送
  • 你可以给其他域名推送资源

如果以上四种缓存都没有命中的话,那么只能发起请求来获取资源了。

浏览器的多级缓存机制到这里就介绍完了,大家可以看到,浏览器多级缓存机制的实现思路,和我们前边说到的计算机系统多级缓存的知识是相呼应的,并且也满足局部性原理。浏览器缓存还有更多值得我们深入学习的内容,感兴趣的读者可以研究一下这篇文章:深入理解浏览器的缓存机制

2. 缓存淘汰策略

根据金字塔存储器层次模型我们知道:CPU访问速度越快的存储组件容量越小。在业务场景中,最常用的存储组件是内存和磁盘,我们往往将常用的数据缓存到内存中加快数据的读取速度,redis作为内存缓存数据库的设计也是基于这点考虑的。但是服务器的内存有限,不可能不断的将数据存入内存中而不淘汰。况且内存占用过大,也会影响服务器其它的任务,所以我们要做到通过淘汰算法让内存中的缓存数据发挥最大的价值。

本节将介绍业务中最常用的三种缓存淘汰算法:

  • Least Recently Used(LRU)淘汰最长时间未被使用的页面,以时间作为参考
  • Least Frequently Used(LFU)淘汰一定时期内被访问次数最少的页面,以次数作为参考
  • 先进先出算法(FIFO)

笔者会结合Redis、Mysql中缓存淘汰的具体实现机制来帮助读者学习到,Mysql和Redis的开发者是怎样结合各自的业务场景,对已有的缓存算法进行改进来满足业务需求的。

2.1 Least Recently Used(LRU)

LRU是Least Recently Used的缩写,这种算法认为最近使用的数据是热门数据,下一次很大概率将会再次被使用。而最近很少被使用的数据,很大概率下一次不再用到。该思想非常契合业务场景 ,并且可以解决很多实际开发中的问题,所以我们经常通过LRU的思想来作缓存,一般也将其称为LRU缓存机制。

2.1.1 LRU缓存淘汰算法实现

本节笔者以leetcode上的一道算法题为例,使用代码(Go语言)实现LRU算法,帮助读者更深入的理解LRU算法的思想。

leetcode 146: LRU缓存机制

运用你所掌握的数据结构,设计和实现一个  LRU (最近最少使用) 缓存机制。它应该支持以下操作: 获取数据 get 和 写入数据 put 。

获取数据 get(key) - 如果密钥 (key) 存在于缓存中,则获取密钥的值(总是正数),否则返回 -1。 写入数据 put(key, value) - 如果密钥已经存在,则变更其数据值;如果密钥不存在,则插入该组「密钥/数据值」。当缓存容量达到上限时,它应该在写入新数据之前删除最久未使用的数据值,从而为新的数据值留出空间。

示例:

LRUCache cache = new LRUCache( 2 /* 缓存容量 */ );
cache.put(1, 1);
cache.put(2, 2);
cache.get(1);       // 返回  1
cache.put(3, 3);    // 该操作会使得密钥 2 作废
cache.get(2);       // 返回 -1 (未找到)
cache.put(4, 4);    // 该操作会使得密钥 1 作废
cache.get(1);       // 返回 -1 (未找到)
cache.get(3);       // 返回  3
cache.get(4);       // 返回  4

我们采用hashmap+双向链表的方式进行实现。可以在 O(1)时间内完成 put 和 get 操作,同时也支持 O(1) 删除第一个添加的节点。

LRU数据结构实现

使用双向链表的一个好处是不需要额外信息删除一个节点,同时可以在常数时间内从头部或尾部插入删除节点。

一个需要注意的是,在双向链表实现中,这里使用一个伪头部和伪尾部标记界限,这样在更新的时候就不需要检查是否是 null 节点。

代码如下:

type LinkNode struct {
    key, val  int
    pre, next *LinkNode
}

type LRUCache struct {
    m          map[int]*LinkNode
    cap        int
    head, tail *LinkNode
}

func Constructor(capacity int) LRUCache {
    head := &LinkNode{0, 0, nil, nil}
    tail := &LinkNode{0, 0, nil, nil}
    head.next = tail
    tail.pre = head
    return LRUCache{make(map[int]*LinkNode), capacity, head, tail}
}

这样就初始化了一个基本的数据结构,大概是这样:

初始化LRU数据结构

接下来我们为Node节点添加一些必要的操作方法,用于完成接下来的Get和Put操作:

func (this *LRUCache) RemoveNode(node *LinkNode) {
    node.pre.next = node.next
    node.next.pre = node.pre
}

func (this *LRUCache) AddNode(node *LinkNode) {
    head := this.head
    node.next = head.next
    head.next.pre = node
    node.pre = head
    head.next = node
}

func (this *LRUCache) MoveToHead(node *LinkNode) {
    this.RemoveNode(node)
    this.AddNode(node)
}

因为Get比较简单,我们先完成Get方法。这里分两种情况考虑:

  • 如果没有找到元素,我们返回-1。
  • 如果元素存在,我们需要把这个元素移动到首位置上去。
func (this *LRUCache) Get(key int) int {
    cache := this.m
    if v, exist := cache[key]; exist {
        this.MoveToHead(v)
        return v.val
    } else {
        return -1
    }
}

现在我们开始完成Put。实现Put时,有两种情况需要考虑。

  • 假若元素存在,其实相当于做一个Get操作,也是移动到最前面(但是需要注意的是,这里多了一个更新值的步骤)。
  • 假若元素不存在,我们将其插入到元素首,并把该元素值放入到map中。如果恰好此时Cache中元素满了,需要删掉最后的元素。

处理完毕,附上Put函数完整代码。

func (this *LRUCache) Put(key int, value int) {
    tail := this.tail
    cache := this.m
    if v, exist := cache[key]; exist {
        v.val = value
        this.MoveToHead(v)
    } else {
        v := &LinkNode{key, value, nil, nil}
        if len(cache) == this.cap {
            delete(cache, tail.pre.key)
            this.RemoveNode(tail.pre)
        }
        this.AddNode(v)
        cache[key] = v
    }
}

至此,我们就完成了一个LRU算法,附上完整的代码:

type LinkNode struct {
    key, val  int
    pre, next *LinkNode
}

type LRUCache struct {
    m          map[int]*LinkNode
    cap        int
    head, tail *LinkNode
}

func Constructor(capacity int) LRUCache {
    head := &LinkNode{0, 0, nil, nil}
    tail := &LinkNode{0, 0, nil, nil}
    head.next = tail
    tail.pre = head
    return LRUCache{make(map[int]*LinkNode), capacity, head, tail}
}

func (this *LRUCache) Get(key int) int {
    cache := this.m
    if v, exist := cache[key]; exist {
        this.MoveToHead(v)
        return v.val
    } else {
        return -1
    }
}

func (this *LRUCache) RemoveNode(node *LinkNode) {
    node.pre.next = node.next
    node.next.pre = node.pre
}

func (this *LRUCache) AddNode(node *LinkNode) {
    head := this.head
    node.next = head.next
    head.next.pre = node
    node.pre = head
    head.next = node
}

func (this *LRUCache) MoveToHead(node *LinkNode) {
    this.RemoveNode(node)
    this.AddNode(node)
}

func (this *LRUCache) Put(key int, value int) {
    tail := this.tail
    cache := this.m
    if v, exist := cache[key]; exist {
        v.val = value
        this.MoveToHead(v)
    } else {
        v := &LinkNode{key, value, nil, nil}
        if len(cache) == this.cap {
            delete(cache, tail.pre.key)
            this.RemoveNode(tail.pre)
        }
        this.AddNode(v)
        cache[key] = v
    }
}

2.1.2 Mysql缓冲池LRU算法

在使用Mysql的业务场景中,如果使用上述我们描述的LRU算法来淘汰策略,会有一个问题。例如执行下面一条查询语句:

select * from table_a;

如果 table_a 中有大量数据并且读取之后不会继续使用,则LRU头部会被大量的 table_a 中的数据占据,这样会造成热点数据被逐出缓存从而导致大量的磁盘IO。所以Mysql缓冲池采用the least recently used(LRU)算法的变体,将缓冲池作为列表进行管理

Mysql的缓冲池

缓冲池(Buffer Pool)是主缓存器的一个区域,用于缓存索引、行的数据、自适应哈希索引、插入缓存(Insert Buffer)、锁 还有其他的内部数据结构。Buffer Pool的大小是可以根据我们实际的需求进行更改,那么Buffer Pool的size取多少比较合适?MySQL官网上是这么介绍,在专用服务器上(也就是服务器只承载一个MySQL实例),会将80%的物理内存给到Buffer Pool。Buffer Pool允许直接从内存中读常用数据去处理,会加快很多的速度。为了提高大容量读取操作的效率,Buffer Pool被分成可以容纳多行的页面。为了提高缓存管理的效率,Buffer Pool被实现为页面对应的链接的列表(a linked list of pages)。

Mysql数据库Buffer Pool结构:

Mysql中Buffer Pool结构

当需要空间将新页面缓存到缓冲池的时候,最近最少使用的页面将被释放出去,并将新的页面加入列表的中间。这个中间点插入的策略将列表分为两个子列表:

  • 头部是存放最近访问过的新页面的子列表

  • 尾部是存放那些最近访问较少的旧页面的子列表

该算法将保留 new sublist(也就是结构图中头部)中大量被查询使用的页面。而old sublist 里面较少被使用的页面作为被释放的候选项。

Buffer Pool的工作原理

理解以下几个关键点是比较重要的:

  • 3/8的缓冲池专用于old sublist(也就是3/8来保存旧的页面,其余部分用来存储热数据,存储热数据的部分相对大一些,当然这个值可以自己去调整,通过innodb_old_blocks_pct,其默认值是37,也就是3/8*100,上限100,可调整 5-95,可以改小一些,但是调整后尽量保证这个比例内的数据也就是old sublist中的数据只会被读取一次,若不是了解非常业务数据负载建议不要去修改。)
  • 列表的中点是新子列表的尾部与旧子列表的头部相交的边界。
  • 当InnoDB将页面读入缓冲池时,它最初将其插入中点(旧子列表的头部)。一个页面会被读取是因为它是用户指定的操作(如SQL查询)所需,或者是由InnoDB自动执行的预读操作的一部分 。
  • 访问旧子列表中的页面使其 “ 年轻 ”,将其移动到缓冲池的头部(新子列表的头部)。如果页面是被需要比如(SQL)读取的,它会马上访问旧列表并将页面推入新列表头部。如果预读需要读取的页面,则不会发生对旧列表的first access。
  • 随着数据库的运行,在缓冲池的页面没有被访问则向列表的尾部移动。新旧子列表中的页面随着其他页面的变化而变旧。旧子列表中的页面也会随着页面插入中点而老化。最终,仍未使用的页面到达旧子列表的尾部并被释放。默认情况下,页面被查询读取将被立即移入新列表中,在Buffer Pool中存在更长的时间。

通过对LRU算法的改进,InnoDB引擎解决了查询数据量大时,热点数据被逐出缓存从而导致大量的磁盘IO的问题

2.1.3 Redis近似LRU实现

由于真实LRU需要过多的内存(在数据量比较大时);并且Redis以高性能著称,真实的LRU需要每次访问数据时都做相关的调整,势必会影响Redis的性能发挥;这些都是Redis开发者所不能接受的,所以Redis使用一种随机抽样的方式,来实现一个近似LRU的效果。

在Redis中有一个参数,叫做 “maxmemory-samples”,是干嘛用的呢?

 1 # LRU and minimal TTL algorithms are not precise algorithms but approximated 
 2 # algorithms (in order to save memory), so you can tune it for speed or 
 3 # accuracy. For default Redis will check five keys and pick the one that was 
 4 # used less recently, you can change the sample size using the following 
 5 # configuration directive. 
 6 # 
 7 # The default of 5 produces good enough results. 10 Approximates very closely 
 8 # true LRU but costs a bit more CPU. 3 is very fast but not very accurate. 
 9 # 
10 maxmemory-samples 5

上面我们说过了,近似LRU是用随机抽样的方式来实现一个近似的LRU效果。这个参数其实就是作者提供了一种方式,可以让我们人为干预样本数大小,将其设的越大,就越接近真实LRU的效果,当然也就意味着越耗内存。(初始值为5是作者默认的最佳)

Redis中近LRU的实现

左上图为理想中的LRU算法,新增加的key和最近被访问的key都不应该被逐出。可以看到,Redis2.8当每次随机采样5个key时,新增加的key和最近访问的key都有一定概率被逐出;Redis3.0增加了pool后效果好一些(右下角的图)。当Redis3.0增加了pool并且将采样key增加到10个后,基本等同于理想中的LRU(虽然还是有一点差距)。

Redis中对LRU代码实现也比较简单。Redis维护了一个24位时钟,可以简单理解为当前系统的时间戳,每隔一定时间会更新这个时钟。每个key对象内部同样维护了一个24位的时钟,当新增key对象的时候会把系统的时钟赋值到这个内部对象时钟。比如我现在要进行LRU,那么首先拿到当前的全局时钟,然后再找到内部时钟与全局时钟距离时间最久的(差最大)进行淘汰,这里值得注意的是全局时钟只有24位,按秒为单位来表示才能存储194天,所以可能会出现key的时钟大于全局时钟的情况,如果这种情况出现那么就两个相加而不是相减来求最久的key。

struct redisServer {
       pid_t pid; 
       char *configfile; 
       //全局时钟
       unsigned lruclock:LRU_BITS; 
       ...
};
typedef struct redisObject {
    unsigned type:4;
    unsigned encoding:4;
    /* key对象内部时钟 */
    unsigned lru:LRU_BITS;
    int refcount;
    void *ptr;
} robj;

总结一下:Redis中的LRU与常规的LRU实现并不相同,常规LRU会准确的淘汰掉队头的元素,但是Redis的LRU并不维护队列,只是根据配置的策略要么从所有的key中随机选择N个(N可以配置),要么从所有的设置了过期时间的key中选出N个键,然后再从这N个键中选出最久没有使用的一个key进行淘汰

2.2 Least Frequently Used(LFU)

LFU(Least Frequently Used)算法根据数据的历史访问频率来淘汰数据,其核心思想是“如果数据过去被访问多次,那么将来被访问的频率也更高”。LFU的每个数据块都有一个引用计数,所有数据块按照引用计数排序,具有相同引用计数的数据块则按照时间排序。LFU需要记录所有数据的访问记录,内存消耗较高;需要基于引用计数排序,性能消耗较高。在算法实现复杂度上,LFU要远大于LRU。

2.2.1 LFU缓存淘汰算法实现

本节笔者以leetcode上的一道算法题为例,使用代码实现LFU算法,帮助读者更深入的理解LFU算法的思想。

leetcode 460: LFU缓存

请你为 最不经常使用(LFU)缓存算法设计并实现数据结构。它应该支持以下操作:get 和 put。

  • get(key) - 如果键存在于缓存中,则获取键的值(总是正数),否则返回 -1。
  • put(key, value) - 如果键已存在,则变更其值;如果键不存在,请插入键值对。当缓存达到其容量时,则应该在插入新项之前,使最不经常使用的项无效。在此问题中,当存在平局(即两个或更多个键具有相同使用频率)时,应该去除最久未使用的键。 「项的使用次数」就是自插入该项以来对其调用 get 和 put 函数的次数之和。使用次数会在对应项被移除后置为 0 。 示例:
LFUCache cache = new LFUCache( 2 /* capacity (缓存容量) */ );
cache.put(1, 1);
cache.put(2, 2);
cache.get(1);       // 返回 1
cache.put(3, 3);    // 去除 key 2
cache.get(2);       // 返回 -1 (未找到key 2)
cache.get(3);       // 返回 3
cache.put(4, 4);    // 去除 key 1
cache.get(1);       // 返回 -1 (未找到 key 1)
cache.get(3);       // 返回 3
cache.get(4);       // 返回 4

上一节我们聊到LRU算法,LRU的实现是一个哈希表加上一个双链表,比较简单。而LFU则要复杂多了,需要用两个哈希表再加上N个双链表才能实现 我们先看看LFU的两个哈希表里面都存了什么。

第一个哈希表是key-value的哈希表(以下简称kv哈希表)

LFU表中的kv

这里的key就是输入的key,没什么特别的。关键是value,它的value不是一个简单的value,而是一个节点对象。 节点对象Node包含了key,value,以及频率,这个Node又会出现在第二个哈希表的value中。 至于为什么Node中又重复包含了key,因为某些情况下我们不是通过k-v哈希表拿到Node的,而是通过其他方式获得了Node,之后需要用Node中的key去k-v哈希表中做一些操作,所以Node中包含了一些冗余信息。

第二张哈希表,频率哈希表,这个就要复杂多了

LFU中的hash表

这张哈希表中的key是频率,也就是元素被访问的频率(被访问了1次,被访问了两次等等),它的value是一个双向链表 刚才说的Node对象,现在又出现了,这里的Node其实是双向链表中的一个节点。 第一张图中我们介绍了Node中包含了一个冗余的key,其实它还包含了一个冗余的频率值,因为某些情况下,我们需要通过Node中的频率值,去频率哈希表中做查找,所以也需要一个冗余的频率值。

下面我们将两个哈希表整合起来看看完整的结构:

LFU整合数据结构

k-v哈希表中key1指向一个Node,这个Node的频率为1,位于频率哈希表中key=1下面的双链表中(处于第一个节点)。

根据上边的描述就可以定义出我们要使用到的数据结构和双链表的基本操作代码(使用Go语言):

type LFUCache struct {
    cache               map[int]*Node
	freq                map[int]*DoubleList
	ncap, size, minFreq int
}

//节点node
type Node struct {
	key, val, freq int
	prev, next     *Node
}

//双链表
type DoubleList struct {
	tail, head *Node
}

//创建一个双链表
func createDL() *DoubleList {
	head, tail := &Node{}, &Node{}
	head.next, tail.prev = tail, head

	return &DoubleList{
		tail: tail,
		head: head,
	}
}

func (this *DoubleList) IsEmpty() bool {
	return this.head.next == this.tail
}

//将node添加为双链表的第一个元素
func (this *DoubleList) AddFirst(node *Node) {
	node.next = this.head.next
	node.prev = this.head

	this.head.next.prev = node
	this.head.next = node
}

func (this *DoubleList) RemoveNode(node *Node) {
	node.next.prev = node.prev
	node.prev.next = node.next

	node.next = nil
	node.prev = nil
}

func (this *DoubleList) RemoveLast() *Node {
	if this.IsEmpty() {
		return nil
	}

	lastNode := this.tail.prev
	this.RemoveNode(lastNode)

	return lastNode
}

下边我们来看一下LFU算法的具体的实现吧,get操作相对简单一些,我们就先说get操作吧。 get操作的具体逻辑大致是这样:

  • 如果key不存在则返回-1
  • 如果key存在,则返回对应的value,同时:
    • 将元素的访问频率+1
      • 将元素从访问频率i的链表中移除,放到频率i+1的链表中
      • 如果频率i的链表为空,则从频率哈希表中移除这个链表

第一个很简单就不用说了,我们看下第二点的执行过程:

LFU中get的实现

假设某个元素的访问频率是3,现在又被访问了一次,那么就需要将这个元素移动到频率4的链表中。如果这个元素被移除后,频率3的那个链表变成空了(只剩下头结点和尾节点)就需要删除这个链表,同时删除对应的频率(也就是删除key=3),我们看下执行过程:

LFU中get元素删除链表

LFU中Get方法代码实现:

func (this *LFUCache) Get(key int) int {
    if node, ok := this.cache[key]; ok {
		this.IncrFreq(node)
		return node.val
	}

	return -1
}

func(this *LFUCache) IncrFreq(node *Node) {
	_freq := node.freq
	this.freq[_freq].RemoveNode(node)
	if this.minFreq == _freq && this.freq[_freq].IsEmpty() {
		this.minFreq++
		delete(this.freq, _freq)
	}
	node.freq++

	if this.freq[node.freq] == nil {
		this.freq[node.freq] = createDL()
	}
	this.freq[node.freq].AddFirst(node)
}

put操作就要复杂多了,大致包括下面几种情况

  • 如果key已经存在,修改对应的value,并将访问频率+1
    • 将元素从访问频率i的链表中移除,放到频率i+1的链表中
    • 如果频率i的链表为空,则从频率哈希表中移除这个链表
  • 如果key不存在
    • 缓存超过最大容量,则先删除访问频率最低的元素,再插入新元素
      • 新元素的访问频率为1,如果频率哈希表中不存在对应的链表需要创建
    • 缓存没有超过最大容量,则插入新元素
      • 新元素的访问频率为1,如果频率哈希表中不存在对应的链表需要创建

我们在代码实现中还需要维护一个minFreq的变量,用来记录LFU缓存中频率最小的元素,在缓存满的时候,可以快速定位到最小频繁的链表,以达到 O(1) 时间复杂度删除一个元素。 具体做法是:

  • 更新/查找的时候,将元素频率+1,之后如果minFreq不在频率哈希表中了,说明频率哈希表中已经没有元素了,那么minFreq需要+1,否则minFreq不变。
  • 插入的时候,这个简单,因为新元素的频率都是1,所以只需要将minFreq改为1即可。

我们重点看下缓存超过最大容量时是怎么处理的:

LFU中put操作

LFU中Put方法代码实现:

func (this *LFUCache) Put(key int, value int)  {
    if this.ncap == 0 {
		return
	}
	//节点存在
    if node, ok := this.cache[key]; ok {
		node.val = value
		this.IncrFreq(node)
	} else {
		if this.size >= this.ncap {
			node := this.freq[this.minFreq].RemoveLast()
			delete(this.cache, node.key)
			this.size--
		}
		x := &Node{key: key, val: value, freq: 1}
		this.cache[key] = x
		if this.freq[1] == nil {
			this.freq[1] = createDL()
		}
		this.freq[1].AddFirst(x)
		this.minFreq = 1
		this.size++
	}
}

通过对一道LFU基本算法的分析与实现,相信读者已经领悟到了LFU算法的思想及其复杂性。很多算法本身就是复杂的,不但要整合各种数据结构,还要根据应用场景进行分析,并不断改进。但是算法确确实实的解决很多实际的问题,我们已经知道了缓存的重要性,但一个好的缓存策略除了要充分利用各种计算机存储组件,良好的算法设计也是必不可少的。所以我们再来总体回顾一下本节LFU算法的实现吧:

type LFUCache struct {
    cache               map[int]*Node
	freq                map[int]*DoubleList
	ncap, size, minFreq int
}

func(this *LFUCache) IncrFreq(node *Node) {
	_freq := node.freq
	this.freq[_freq].RemoveNode(node)
	if this.minFreq == _freq && this.freq[_freq].IsEmpty() {
		this.minFreq++
		delete(this.freq, _freq)
	}
	node.freq++

	if this.freq[node.freq] == nil {
		this.freq[node.freq] = createDL()
	}
	this.freq[node.freq].AddFirst(node)
}


func Constructor(capacity int) LFUCache {
    return LFUCache{
		cache: make(map[int]*Node),
		freq:  make(map[int]*DoubleList),
		ncap:  capacity,
	}
}


func (this *LFUCache) Get(key int) int {
    if node, ok := this.cache[key]; ok {
		this.IncrFreq(node)
		return node.val
	}

	return -1
}


func (this *LFUCache) Put(key int, value int)  {
    if this.ncap == 0 {
		return
	}
	//节点存在
    if node, ok := this.cache[key]; ok {
		node.val = value
		this.IncrFreq(node)
	} else {
		if this.size >= this.ncap {
			node := this.freq[this.minFreq].RemoveLast()
			delete(this.cache, node.key)
			this.size--
		}
		x := &Node{key: key, val: value, freq: 1}
		this.cache[key] = x
		if this.freq[1] == nil {
			this.freq[1] = createDL()
		}
		this.freq[1].AddFirst(x)
		this.minFreq = 1
		this.size++
	}
}

//节点node
type Node struct {
	key, val, freq int
	prev, next     *Node
}

//双链表
type DoubleList struct {
	tail, head *Node
}

//创建一个双链表
func createDL() *DoubleList {
	head, tail := &Node{}, &Node{}
	head.next, tail.prev = tail, head

	return &DoubleList{
		tail: tail,
		head: head,
	}
}

func (this *DoubleList) IsEmpty() bool {
	return this.head.next == this.tail
}

//将node添加为双链表的第一个元素
func (this *DoubleList) AddFirst(node *Node) {
	node.next = this.head.next
	node.prev = this.head

	this.head.next.prev = node
	this.head.next = node
}

func (this *DoubleList) RemoveNode(node *Node) {
	node.next.prev = node.prev
	node.prev.next = node.next

	node.next = nil
	node.prev = nil
}

func (this *DoubleList) RemoveLast() *Node {
	if this.IsEmpty() {
		return nil
	}

	lastNode := this.tail.prev
	this.RemoveNode(lastNode)

	return lastNode
}

2.2.2 Redis LFU淘汰策略

一般情况下,LFU效率要优于LRU,且能够避免周期性或者偶发性的操作导致缓存命中率下降的问题,在如下情况下:

~~~~~A~~~~~A~~~~~A~~~~A~~~~~A~~~~~A~~|
~~B~~B~~B~~B~~B~~B~~B~~B~~B~~B~~B~~B~|
~~~~~~~~~~C~~~~~~~~~C~~~~~~~~~C~~~~~~|
~~~~~D~~~~~~~~~~D~~~~~~~~~D~~~~~~~~~D|

会将数据D误认为将来最有可能被访问到的数据。

Redis作者曾想改进LRU算法,但发现Redis的LRU算法受制于随机采样数maxmemory_samples,在maxmemory_samples等于10的情况下已经很接近于理想的LRU算法性能,也就是说,LRU算法本身已经很难再进一步了。

于是,将思路回到原点,淘汰算法的本意是保留那些将来最有可能被再次访问的数据,而LRU算法只是预测最近被访问的数据将来最有可能被访问到。我们可以转变思路,采用LFU(Least Frequently Used)算法,也就是最频繁被访问的数据将来最有可能被访问到。在上面的情况中,根据访问频繁情况,可以确定保留优先级:B>A>C=D。

在LFU算法中,可以为每个key维护一个计数器。每次key被访问的时候,计数器增大。计数器越大,可以约等于访问越频繁。

上述简单算法存在两个问题:

  • 在LRU算法中可以维护一个双向链表,然后简单的把被访问的节点移至链表开头,但在LFU中是不可行的,节点要严格按照计数器进行排序,新增节点或者更新节点位置时,时间复杂度可能达到O(N)。
  • 只是简单的增加计数器的方法并不完美。访问模式是会频繁变化的,一段时间内频繁访问的key一段时间之后可能会很少被访问到,只增加计数器并不能体现这种趋势。 第一个问题很好解决,可以借鉴LRU实现的经验,维护一个待淘汰key的pool。第二个问题的解决办法是,记录key最后一个被访问的时间,然后随着时间推移,降低计数器。

我们前边说过Redis对象的结构:

typedef struct redisObject {
    unsigned type:4;
    unsigned encoding:4;
    unsigned lru:LRU_BITS; /* LRU time (relative to global lru_clock) or
                            * LFU data (least significant 8 bits frequency
                            * and most significant 16 bits access time). */
    int refcount;
    void *ptr;
} robj;

在LRU算法中,24 bits的lru是用来记录LRU time的,在LFU中也可以使用这个字段,不过是分成16 bits与8 bits使用:

       16 bits      8 bits
      +----------------+--------+
      + Last decr time | LOG_C  |
      +----------------+--------+

高16 bits用来记录最近一次计数器降低的时间ldt,单位是分钟,低8 bits记录计数器数值counter。

Redis4.0之后为maxmemory_policy淘汰策略添加了两个LFU模式:

  • volatile-lfu:对有过期时间的key采用LFU淘汰算法
  • allkeys-lfu:对全部key采用LFU淘汰算法

还有2个配置可以调整LFU算法:

lfu-log-factor 10
lfu-decay-time 1
  • lfu-log-factor可以调整计数器counter的增长速度,lfu-log-factor越大,counter增长的越慢。

  • lfu-decay-time是一个以分钟为单位的数值,可以调整counter的减少速度

源码实现

在lookupKey中:

robj *lookupKey(redisDb *db, robj *key, int flags) {
    dictEntry *de = dictFind(db->dict,key->ptr);
    if (de) {
        robj *val = dictGetVal(de);

        /* Update the access time for the ageing algorithm.
         * Don't do it if we have a saving child, as this will trigger
         * a copy on write madness. */
        if (server.rdb_child_pid == -1 &&
            server.aof_child_pid == -1 &&
            !(flags & LOOKUP_NOTOUCH))
        {
            if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) {
                updateLFU(val);
            } else {
                val->lru = LRU_CLOCK();
            }
        }
        return val;
    } else {
        return NULL;
    }
}

当采用LFU策略时,updateLFU更新lru:

/* Update LFU when an object is accessed.
 * Firstly, decrement the counter if the decrement time is reached.
 * Then logarithmically increment the counter, and update the access time. */
void updateLFU(robj *val) {
    unsigned long counter = LFUDecrAndReturn(val);
    counter = LFULogIncr(counter);
    val->lru = (LFUGetTimeInMinutes()<<8) | counter;
}

降低LFUDecrAndReturn

首先,LFUDecrAndReturn对counter进行减少操作:

/* If the object decrement time is reached decrement the LFU counter but
 * do not update LFU fields of the object, we update the access time
 * and counter in an explicit way when the object is really accessed.
 * And we will times halve the counter according to the times of
 * elapsed time than server.lfu_decay_time.
 * Return the object frequency counter.
 *
 * This function is used in order to scan the dataset for the best object
 * to fit: as we check for the candidate, we incrementally decrement the
 * counter of the scanned objects if needed. */
unsigned long LFUDecrAndReturn(robj *o) {
    unsigned long ldt = o->lru >> 8;
    unsigned long counter = o->lru & 255;
    unsigned long num_periods = server.lfu_decay_time ? LFUTimeElapsed(ldt) / server.lfu_decay_time : 0;
    if (num_periods)
        counter = (num_periods > counter) ? 0 : counter - num_periods;
    return counter;
}

函数首先取得高16 bits的最近降低时间ldt与低8 bits的计数器counter,然后根据配置的lfu_decay_time计算应该降低多少。

LFUTimeElapsed用来计算当前时间与ldt的差值:

/* Return the current time in minutes, just taking the least significant
 * 16 bits. The returned time is suitable to be stored as LDT (last decrement
 * time) for the LFU implementation. */
unsigned long LFUGetTimeInMinutes(void) {
    return (server.unixtime/60) & 65535;
}

/* Given an object last access time, compute the minimum number of minutes
 * that elapsed since the last access. Handle overflow (ldt greater than
 * the current 16 bits minutes time) considering the time as wrapping
 * exactly once. */
unsigned long LFUTimeElapsed(unsigned long ldt) {
    unsigned long now = LFUGetTimeInMinutes();
    if (now >= ldt) return now-ldt;
    return 65535-ldt+now;
}

具体是当前时间转化成分钟数后取低16 bits,然后计算与ldt的差值now-ldt。当ldt > now时,默认为过了一个周期(16 bits,最大65535),取值65535-ldt+now。

然后用差值与配置lfu_decay_time相除,LFUTimeElapsed(ldt) / server.lfu_decay_time,已过去n个lfu_decay_time,则将counter减少n,counter - num_periods。

增长LFULogIncr

增长函数LFULogIncr如下:

/* Logarithmically increment a counter. The greater is the current counter value
 * the less likely is that it gets really implemented. Saturate it at 255. */
uint8_t LFULogIncr(uint8_t counter) {
    if (counter == 255) return 255;
    double r = (double)rand()/RAND_MAX;
    double baseval = counter - LFU_INIT_VAL;
    if (baseval < 0) baseval = 0;
    double p = 1.0/(baseval*server.lfu_log_factor+1);
    if (r < p) counter++;
    return counter;
}

counter并不是简单的访问一次就+1,而是采用了一个0-1之间的p因子控制增长。counter最大值为255。取一个0-1之间的随机数r与p比较,当r<p时,才增加counter,这和比特币中控制产出的策略类似。p取决于当前counter值与lfu_log_factor因子,counter值与lfu_log_factor因子越大,p越小,r<p的概率也越小,counter增长的概率也就越小。增长情况如下:

# +--------+------------+------------+------------+------------+------------+
# | factor | 100 hits   | 1000 hits  | 100K hits  | 1M hits    | 10M hits   |
# +--------+------------+------------+------------+------------+------------+
# | 0      | 104        | 255        | 255        | 255        | 255        |
# +--------+------------+------------+------------+------------+------------+
# | 1      | 18         | 49         | 255        | 255        | 255        |
# +--------+------------+------------+------------+------------+------------+
# | 10     | 10         | 18         | 142        | 255        | 255        |
# +--------+------------+------------+------------+------------+------------+
# | 100    | 8          | 11         | 49         | 143        | 255        |
# +--------+------------+------------+------------+------------+------------+

可见counter增长与访问次数呈现对数增长的趋势,随着访问次数越来越大,counter增长的越来越慢。

新生key策略

另外一个问题是,当创建新对象的时候,对象的counter如果为0,很容易就会被淘汰掉,还需要为新生key设置一个初始counter,createObject:

robj *createObject(int type, void *ptr) {
    robj *o = zmalloc(sizeof(*o));
    o->type = type;
    o->encoding = OBJ_ENCODING_RAW;
    o->ptr = ptr;
    o->refcount = 1;

    /* Set the LRU to the current lruclock (minutes resolution), or
     * alternatively the LFU counter. */
    if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) {
        o->lru = (LFUGetTimeInMinutes()<<8) | LFU_INIT_VAL;
    } else {
        o->lru = LRU_CLOCK();
    }
    return o;
}

counter会被初始化为LFU_INIT_VAL,默认5。

总结一下:Redis的LFU缓存淘汰策略复用了redis对象中的24 bits lru, 不过分成了分成16 bits与8 bits使用,高16 bits用来记录最近一次计数器降低的时间ldt,单位是分钟,低8 bits记录计数器数值counter。Redis对象的计数器并不是线性增长的,而是提供了lfu-log-factor配置项来控制技术器的增长速度。为了解决历史数据影响将来数据的“缓存污染”问题,Redis对象的计数会根据lfu_decay_time配置项随时间做调整。redis为每一个新增的key都设置了初始counter,目的是防止新增的key很容易就被淘汰掉

2.3 先进先出算法(FIFO)

FIFO(First in First out),先进先出。在FIFO Cache设计中,核心原则就是:如果一个数据最先进入缓存中,则应该最早淘汰掉。实现方法很简单,只要使用队列数据结构即可实现。

FIFO缓存淘汰过程

因为缓存命中率比较低,FIFO缓存策略通常不会在项目中使用。客观唯心主义的理论:存在即合理,下边笔者就描述一下FIFO队列在Redis主从复制过程中的应用。

在Redis主从结构中,主节点要将数据同步给从节点,从一开始主从建立连接到数据同步一共分为三个阶段:

Redis主从同步过程

第一阶段首先建立连接,然后第二阶段主节点发送rdb文件给从节点同步全量数据;我们主要看第三阶段主节点反复同步增量数据给从节点的过程是什么样的。

从节点和主节点建立连接时,主节点服务器会维护一个复制积压缓冲区来暂存增量的同步命令;当从节点向主节点要求同步数据时,主节点根据从节点同步数据的offset将数据增量的同步给从节点,反复进行。

复制积压缓冲区是一个先进先出(FIFO)的环形队列,用于存储服务端执行过的命令,每次传播命令,master节点都会将传播的命令记录下来,保存在这里。

复制积压缓冲区结构

复制积压缓冲区由两部分组成:偏移量和字节值。字节值是redis指令字节的存储(redis指令以一种Redis序列化文本协议的格式存储),偏移量offset就是当前字节值在环形队列中的偏移量。

复制积压缓冲区组成

主节点维护了每个从节点的offset,这样每次同步数据时,主节点就知道该同步哪一部分数据给从节点了。通过这样一个复制积压缓冲区,Redis的主从架构实现了数据的增量同步,想要了解更多主从同步细节的读者可以参考我的另一篇博客:Redis高可用——主从复制

2.4 FIFO、LRU、LFU缓存淘汰策略对比

本节花费了大量篇幅介绍缓存淘汰策略,我们再来从缓存命中率、实现复杂度、存储成本、缺陷这四个方面来对这三种缓存策略做一个对比:

缓存淘汰策略 缓存命中率 实现复杂度 存储成本 缺陷
FIFO 非常简单 很低 速度很快,不过没有什么实用价值
LRU 相对简单 偶发性的、周期性的批量操作会导致LRU命中率急剧下降,缓存污染情况比较严重。
LFU 非常高 相对复杂 很高,需要很大的存储空间 L存在历史数据影响将来数据的“缓存污染”

Redis、Mysql的缓存设计都考虑了本节讲到的缓存淘汰策略的思想,并结合自身的业务场景进行了改进实现。缓存淘汰策略没有十全十美的,根据具体的业务和需求选择合适缓存淘汰算法,提升缓存命中率是我们学习本节的目的。

3. 缓存安全

缓存安全是非常热点的话题,大多数企业在的架构师在设计缓存系统时都会考虑“缓存安全”相关的问题,尤其是当服务到达一定量级之后,“缓存安全”就是一个不得不考虑的问题了。另外在面试过程中,“缓存安全”相关的问题也是热点,因为懂得这些太重要了,每一个问题产生的原因可能是什么?有效的解决方案有哪些?怎样避免这些问题的产生?这些都是一个合格的程序员应该知道的。

本节就结合redis缓存中间件的使用,和大家一块谈谈“缓存安全”中最常见的问题。并介绍相关的解决方案和规避方法。

3.1 缓存预热

俗话说万事开头难,对于应用了redis缓存中间件的系统来说也会存在这样的问题。当系统并发很高,并采用了主从架构的时候,服务启动就是一件很困难的事情!究其原因,系统刚启动时,缓存中没有数据,那么服务启动就需要大量的访问数据库,并瞬间产生大量的缓存数据,主从之间吞吐量增大,数据同步频度增加,可能服务刚启动不久就会宕机。

解决方案也很简单,我们最好先统计出访问频度比较高的热点数据,根据统计结果将数据分类,让redis缓存服务优先加载那些访问频度高的热点数据。当然了,这个过程最好做成“预加载”,在服务启动之前先加载了热点数据,手工执行方案难以实现时,可以借助脚本或者CDN的方式进行。

总结来讲:缓存预热就是系统启动前,提前将相关的缓存数据直接加载到缓存系统。避免用户请求的时候,先查询数据库,然后再将数据缓存的问题,让用户直接查询被预热的缓存数据。

3.2 缓存雪崩

缓存雪崩是缓存服务中经常遇见的问题,下边是一个缓存雪崩事故发生的大致过程:

  • 系统平稳运行中,忽然数据库连接量激增
  • 应用服务器无法及时处理请求,客户端开始大量出现408、500错误页面
  • 客户反复刷新页面尝试获取数据,导致服务端数据库访问激增,数据库崩溃
  • 数据库崩溃以后,应用服务器也就随之崩溃了
  • 尝试重启服务器无效,这时候redis服务器崩溃,雪崩开始出现,redis集群开始崩溃
  • 重启数据库无效,因为流量太高,数据库启动即崩溃

排查上述缓存雪崩问题出现的原因,我们就会得到这样一个结论:

产生“缓存雪崩”的根本原因是:在一个较短的时间内,缓存中较多的key集中过期了

我们可以使用这个结论来解释一下上述现象发生的过程。缓存中大量的key同时过期以后,redis缓存无法命中,请求就会到达数据库;如果并发量很大,数据库根本无法及时处理,Redis的请求就会被积压,并逐渐出现超时现象;数据库随着流量的激增崩溃了,这个时候重启服务器是没有意义的,因为系统的关键问题是缓存中无可用数据,Redis的服务器资源被占用,服务器也随着崩溃,集群崩塌,应用服务器也会随着客户端的请求增加而崩溃。出现了这个局面以后,我们重启服务器、redis和数据库都是没有意义的!下面缓存雪崩的简单示意图:

缓存中大量的key同时过期

如果“缓存雪崩”问题已经发生了,应该怎样解决呢?下边列举一些有效的方案:

  • 页面静态化处理,一旦使用了页面静态化,客户端的数据就不用从缓存中获取了,这样可以减轻缓存的压力,缺点是数据更新不及时。

  • 构建多级缓存,构建多级缓存可以给每一级的缓存提供更多的数据保障,比如在redis和mysql数据库中间再加上一层ehcache缓存,当缓存中大量key过期时,ehcache缓存可以替mysql数据库暂时抵挡一部分流量。构建多级缓存会增加系统的复杂性。

  • 优化mysql。比如给mysql增加buffer pool,这样当大量流量到达mysql时,mysql的吞吐量可以支撑并发,不至于马上崩溃,也能防止雪崩现象发生。

  • 监控redis的性能指标。根据分析我们知道,“雪崩”伴随着的肯定CPU占用率急剧上升,同时客户端请求的平均响应时间也会增大,对这些性能指标进行监控能帮助我们更早的发现问题。

  • 限流、降级。这是从客户端的角度来考虑,牺牲一部分客户体验,限制一些客户端请求,能有效的降低应用服务器的压力。待系统平稳运行后再逐渐恢复。

既然我们知道了“缓存雪崩”产生的原因是一个较短的时间内,大量的热点key集中过期导致的,我们有必要学习一些方法来预防“缓存雪崩”的发生。最有效的方法就是根据业务数据将缓存的有效期错峰,数据的过期时间采用固定时间 + 随机时间戳的方式,稀释集中到期key的数量,甚至说超热的数据,采用永久key也是可以的。还记得我们第二部分提到的缓存淘汰策略吗?将LRU切换为LFU淘汰策略,也是可以有效预防“缓存雪崩”的。如果你认为问题还是不太好解决,想要采用手动维护的方式也是可以的,可以定期对访问数据做统计,给热点数据续租过期时间也是可以的。

总结来讲:缓存雪崩就是瞬间过期数据量太大,给数据库服务器造成压力。如果能够有效避免过期时间集中,就可以有效防止雪崩问题的出现,再配合其它策略的一起使用,并监控服务器的运行数据并做及时的调整,基本是可以防止“缓存雪崩”情况发生的。

3.3 缓存击穿

了解了缓存雪崩之后,我们不妨思考这样一个问题:造成缓存雪崩的原因是大量的热点key在集中过期,假如一个超热的key过期,会不会也造成这种问题呢?

答案是肯定的!我们试想这样的场景,双11秒杀活动一台mac电脑99元,秒杀活动一开始,几百万的QPS上来了,缓存数据过期了,这么大的访问量达到了数据库,数据库肯定挂了!这就是我们本节要讲的“缓存击穿”。

对比“缓存雪崩”我们会发现,“缓存击穿”和“缓存雪崩”在很多现象上来说是比较相似的,而且我们上节说到的解决“缓存雪崩”的方案拿到这里,大都是能够适用的。和“缓存雪崩”不同的是,“缓存击穿”发生后,redis的内存和CPU并不会有异常,也不会造成应用服务器的崩溃,也就是说“缓存击穿”不太容易发生,造成的后果也没有“缓存雪崩”严重。但是在“秒杀”场景下确确实实会存在这样的问题。

我们还是先来说一下如何解决和预防“缓存击穿”的问题吧。一般来讲,“缓存击穿”的问题发生前都是可以预见的,比如“秒杀”活动开始前后,肯定会有大量的客户端请求,那么当系统中高热的缓存key过期后,手动的加载到redis肯定是可以的。再或者活动期间我们就使用永久的key,并配置LFU的缓存淘汰策略,也是可以解决这个问题的。当然还有其它的一些解决方案,就比如作者之前曾分析过12306是怎样承受高并发流量的,其中使用了本地缓存配合redis缓存的多级缓存方式,这种方式对于解决缓存击穿也是有效的,只要本地缓存和redis中的缓存不要同时过期就行,大量的流量也不会压到数据库。

总结一下:缓存过期就是单个高热数据过期的瞬间,数据访问量较大,未命中redis后,流量到达了数据库。应对策略是以预防为主,配合监控及时调整就可以,主要是在设计缓存系统时要考虑到这个问题。

3.4 缓存穿透

本小节我们来讨论一下“缓存穿透”,首先“缓存穿透”和“缓存击穿”是不一样的,“缓存穿透”经常被黑客利用,目的是为了拖垮我们的服务器

我们看一下发生“缓存穿透”是一种什么样的现象:系统平稳运行时,有了突发流量,Redis缓存的命中率开始下降,内存并没有什么异常,但是CPU开始激增,数据库访问也开始激增,直到数据库崩溃

追查问题发生的本质原因竟然是客户端访问的key在Redis缓存中不存在,并且数据库中也没有相关数据,基本上都是这样的key,似乎有人故意而为之。遇到这种情况,开发者一定要提高警惕,很有可能是出现了黑客攻击!

查到问题以后,我们该如何解决呢?大部分人最先想到的就是既然黑客知道我们缓存中没有key,那么就将key缓存到Redis中,value是NULL就可以了。如果你这么想,只能说明你太年轻了,首先黑客不至于傻到使用相同的key做攻击,再者大量的无效key缓存到redis内存中,redis就失去了缓存的意义了。当然,如果你发现这些请求都来自同一个ip或者客户端,可以临时的设置黑名单将攻击流量拒之门外,但是黑客一般都会采用DDos(分布式拒绝服务攻击),这种方法往往是无效的。

面对这样一种困境,业内最常用的方案是使用“布隆过滤器”。虽然有一定的误判概率,但是只要参数设置的合理,确实能够有效的解决问题。

布隆过滤器是什么?

可以把布隆过滤器理解为一个不怎么精确的set结构,当你使用它的contains方法判断某个对象是否存在时,它可能会误判。但是布隆过滤器也不是特别的不精确,只要参数设置的合理,它的精确度是可以得到保障的。当布隆过滤器说某个值存在时,这个值可能不存在;但是它只要说某个值不存在,这个值就肯定不存在。

布隆过滤器的实现原理

每个布隆过滤器对应到Redis的数据结构里边就是一个大型的位数组和几个不一样的无偏hash函数(能够把元素的hash值算的比较均匀,让函数被hash映射到位数组中的位置比较随机)。如果所示,f、g、h就是这样的hash函数。

布隆过滤器数据结构
向布隆过滤器中添加key时,会使用多个hash函数对key进行hash,先算得一个整数索引值,然后对位数组长度进行取模运算得到一个位置,每个hash函数都会算得一个不同的位置,再把位数组的这几个位置都设置为1。 向布隆过滤器中询问key是否存在时,跟添加一样,也会把key通过hash得到的几个位置都算出来,看看数组中这几个位置是否都为1,只要有一个位置为0,那么说明这个值在布隆过滤器中是不存在的。

除了布隆过滤器这种方案,笔者认为,对业务中的key进行加密也是很有必要的,因为只要业务中的key如果是有规律可循的,黑客一般是不会知道的,我们就可以通过key的判断将黑客的攻击流量抵挡到redis缓存服务器前边。此外缓存数据的监控一定要做好,能够及时的发现缓存命中率下降,就能够越早的采取措施。

总结一下:“缓存穿透”是客户端访问了缓存和数据库中都不存在的数据,大量的这种访问会击垮数据库,当运维人员监控到这种情况时,一定要及时给出报警信息,根据上边提到的措施进行有效处理。

3.5 性能指标监控

前边几个小节,我们在讨论各种缓存隐患时,反复的强调做好监控的重要性。这里笔者单独抽出一个小节来总结一下在平时的缓存系统业务场景中,我们应该监控哪些性能指标,有哪些命令能够帮助我们分析问题,这些措施和技能对于保证我们的系统安全是非常有用的。

对于性能指标监控,笔者这里列出五大类,分别以表格的形式呈现。

  • 性能指标:Performance
Name Description
latency Redis响应一个请求的时间
instantaneous_ops_per_sec 平均每秒处理请求总数
hit rate(calculated) 缓存命中率(计算出来的)
  • 内存指标:Memory
Name Description
use_memory 已使用内存
mem_fragmentation_ratio 内存碎片率
evicted_keys 由于最大内存限制移除key的数量
blocked_clients 由于BLPOP、BRPOP or BLPUSH、BRPUSH而被受阻塞的客户端
  • 基本活动指标:Basic activity
Name Description
connected_clients 客户端连接数
connected_slaves Slave数量
master_last_io_seconds_ago 最近一次主从交互之后的秒数
key_space 数据库中key值总数
  • 持久性指标:Persistence
Name Description
rdb_last_save_time 最后一次持久化保存到数据库的时间戳
rdb_change_since_last_save 自最后一次持久化以来数据库的更改次数
  • 错误指标:Error
Name Description
rejected_connections 由于达到maxclient限制而被拒绝的连接数
keyspace_misses key值查找失败(没有命中)次数
master_link_down_since_seconds 主从断开的持续时间(以秒为单位)

以上所列举的都是服务中比较重要的指标,监控redis的这些指标有一些开源的工具,例如:Cloud Insight RedisPrometheusRedis-statRedis-faina。但是这些工具在我们比较定制化的业务中,往往不能起到太大效果,往往企业会针对自己的业务开发自己的监控服务。

Redis提供了一些命令也能帮助我们排查问题。比如showlog、monitor;此外redis还提供了benchmark指令,通过使用它我们可以得到redis的吞吐量、缓存命中率这些性能指标数据。

4.后记

这篇文章断断续续的整理了半个月,笔者参考了大量的资料才提炼出了以上内容。文章中很多地方确实不够深入,也有一些空洞的理论,加上笔者本身工作经验不是很丰富,关于mysql、redis和php,很多底层的知识也没有研究到位,文章中难免有表达不恰当和错误之处,希望读者查找出以后一定在留言区进行指正。

回过头来看这篇文章,笔者在这里大谈“缓存”确实有点大言不惭,但是都是我的心血,也就当是我的一篇学习笔记了。

涉及到“缓存”相关的系统设计和应用绝不仅仅是我提到的这些,比如:缓存数据结构的选择和压缩、多级缓存之间的数据同步等等,这些都没有谈到,如果将来有机会,希望能深入的学习一下这些知识,再对文章做一些补充。