大家好,今天小编来为大家解答以下的问题,关于天上人间动漫网站源码分享,天上人间下载点这个很多人还不知道,现在让我们一起来看看吧!
在计算机领域,缓存在程序设计过程中扮演着重要角色。浏览器的资源缓存策略是影响web网站性能的一个关键因素;mysql的BufferPool极大的提高了数据库的查询效率;redis作为被广泛应用的缓存数据库,提供了丰富的数据结构和缓存策略来满足开发者的需求。缓存在现代计算机系统中无处不在,是一个非常深入、广泛的话题,本文的目的是从众多已有的系统设计中,提取出有关系统缓存设计的精要,主要从三个方面展开:
多级缓存缓存淘汰策略缓存安全
在讨论这些话题的同时,笔者会结合很多实际的例子阐述,其中会涉及到PHP源码、redis源码、浏览器的缓存策略、mysql数据存储设计、缓存淘汰算法等偏底层的内容,如果你是一个比较资深的开发工程师,对这些内容都比较了解,本文将非常适合您。为了照顾初学者,涉及到难懂重要的知识点笔者也将会尽可能的描述清楚。本文篇幅有点长,请读者做好心理预期!
1.多级缓存
1.1存储器层次结构
计算机使用不同的存储技术对数据进行访问,时间差异很大。速度较快的技术每个字节的成本要比速度较慢的技术高,而且容量较小。CPU和主存(内存)之间的速度差距在增大。基于这种特性,所有的现代计算机系统都使用了金字塔式的存储器层次结构。如下图所示:
从高层往低层走,存储设备变得更慢、更便宜、容量更大。最高层(L0)是少量快速的CPU寄存器,CPU可以在一个时钟周期内访问它们(一个时钟周期通常小于1ns);接下来是一个或多个中小型SRAM高速缓存存储器,可以在几个CPU时钟周期内访问它们;然后是一个大的基于DRAM的主存,可以在几十到几百个CPU时钟周期内访问它们;接下来是慢速但是容量很大的本地磁盘(通常是SSD);最后有些系统甚至包括了一层附加的网络文件系统(NetworkFileSystem,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)。也就是,它们倾向于引用邻近于其他最近引用过的数据项的数据项,或者最近引用过的数据项本身。这种倾向性,被称为局部性原理(principleoflocality),是一个持久的概念,对硬件和软件系统的设计和性能都有着极大的影响。
局部性通常有两种不同的形式:时间局部性(temporallocality)和空间局部性(spatiallocality)。
时间局部性:被引用过一次的内存位置很可能在不远的将来再被多次引用。空间局部性:如果一个内存位置被引用了一次,那么程序很可能在不远的将来引用附近的一个内存位置。
Mysql数据库Innodb引擎的设计就很好的考虑了局部性原理。
Innodb引擎以页的形式组织数据,页的默认大小是16KB,存放用户数据记录的页叫“数据页”,为了实现数据更快的查找,InnoDB使用B+树的结构组织存放数据,最下层的叶子节点存放“数据页”,在“数据页”的上层抽出相应的“目录页”,最终形成的基本结构如下图所示:
数据页中记录是连续存放的,当需要访问某个页的数据时,就会把完整的页的数据全部加载到内存中,也就是说即使我们只需要访问一个页的一条记录,那也需要先把整个页的数据加载到内存中。这就利用了局部性原理中的“空间局部性”。将整个页加载到内存中后就可以进行读写访问了,在进行完读写访问之后并不着急把该页对应的内存空间释放掉,而是将其缓存到BufferPool中,这样将来有请求再次访问该页面时,就可以省去磁盘IO的开销了,这又利用了局部性原理中的“时间局部性”。
局部性原理是系统缓存设计中非常重要的理论基石,下文还会多次提到。
1.3Cpu高速缓存
本节我们来聊一聊Cpu高速缓存,Cpu高速缓存是影响程序性能的一个关键因素。
1.3.1什么是Cpu高速缓存?
笔者直接引用维基百科中对Cpu高速缓存的描述:
在计算机系统中,CPU高速缓存(英语:CPUCache,在本文中简称缓存)是用于减少处理器访问内存所需平均时间的部件。在金字塔式存储体系中它位于自顶向下的第二层,仅次于CPU寄存器。其容量远小于内存,但速度却可以接近处理器的频率。当处理器发出内存访问请求时,会先查看缓存内是否有请求数据。如果存在(命中),则不经访问内存直接返回该数据;如果不存在(失效),则要先把内存中的相应数据载入缓存,再将其返回处理器。缓存之所以有效,主要是因为程序运行时对内存的访问呈现局部性(Locality)特征。这种局部性既包括空间局部性(SpatialLocality),也包括时间局部性(TemporalLocality)。有效利用这种局部性,缓存可以达到极高的命中率。在处理器看来,缓存是一个透明部件。因此,程序员通常无法直接干预对缓存的操作。但是,确实可以根据缓存的特点对程序代码实施特定优化,从而更好地利用缓存。
1.3.2为什么需要有Cpu高速缓存?
上文中我们提到冯诺依曼瓶颈。随着工艺的提升,最近几十年CPU的频率不断提升,而受制于制造工艺和成本限制,目前计算机的内存在访问速度上没有质的突破。因此,CPU的处理速度和内存的访问速度差距越来越大,这种情况下传统的CPU直连内存的方式显然就会因为内存访问的等待,导致计算资源大量闲置,降低CPU整体吞吐量。同时又由于内存数据访问的热点集中性,在CPU和内存之间用较为快速而成本较高(相对于内存)的介质做一层缓存,就显得性价比极高了。
1.3.3Cpu高速缓存架构
早期计算机系统的存储器层次结构只有三层:CPU寄存器、DRAM主存储器和磁盘存储。由于CPU和主存之间逐渐增大的差距,系统设计者被迫在CPU寄存器文件和主存之间插入了一个小的SRAM高速缓存存储器,称为L1高速缓存(一级缓存),如下图所示。L1高速缓存的访问速度几乎和寄存器相差无几。
随着CPU和主存之间的性能差距不断增大,系统设计者在L1高速缓存和主存之间又插入了一个更大的高速缓存,称为L2高速缓存,可以在大约10个时钟周期内访问到它。现代的操作系统还包括一个更大的高速缓存,称为L3高速缓存,在存储器的层次结构中,它位于L2高速缓存和主存之间,可以在大约50个周期内访问到它。下图是简单的高速缓存架构:
数据的读取和存储都经过高速缓存,CPU核心与高速缓存有一条特殊的快速通道;主存与高速缓存都连在系统总线上(BUS),这条总线还用于其他组件的通信。
1.3.4PHP7数组的设计
关于Cpu高速缓存的应用,PHP7数组的设计是一个非常经典的案例。在PHP中数组是最常用的一种数据类型,提升数组的性能会使程序整体性能得到提升。我们通过对比PHP7和PHP5数组的设计,来学习一下PHP设计者为了提升PHP数组的性能,都进行了哪些思考。
我们先来总结一下PHP数组的特性:
php中的数组是一个字典dict,存储着key-value对,通过key可以快速的找到value,其中key可以是整形,也可以是字符串(hasharray和packedarray)。数组是有序的:插入有序、遍历有序。
PHP中数组使用hashTable实现,我们先来了解一下什么是hashTable。
hashTable是一种通过某种hash函数将特定的key映射到特定value的一种数据结构,它维护着键和值的一一对应关系,并且可以快速的根据键找到值(o(1)).一般HashTable的示意图如下:
1)key:通过key可以快速找到value。一般可以为数字或者字符串。\n2)value:值可以为复杂结构\n3)bucket:桶,HashTable存储数据的单元,用来存储key/value以及其他辅助信息的容器\n4)slot:槽,HashTable有多少个槽,一个bucket必须属于具体的某一个slot,一个slot可以有多个\nbucket\n5)哈希函数:一般都是自己实现(time33),在存储的时候,会将key通过hash函数确定所在的slot\n6)哈希冲突:当多个key经过哈希计算后,得到的slot位置是同一个,那么就会冲突,一般这个时候会有\n2种解决办法:链地址法和开放地址法。其中php采用的是链地址法,即将同一个slot中的bucket通过\n链表连接起来\n复制代码
为了实现数组的插入有序和遍历有序,PHP5使用hashTable+双链表实现数组,如下图是将key分别为:“a”,&34;,&34;,&34;,值为“1”,&34;,&34;,&34;插入到数组中后的效果:
上图中b,c,d所在的bucket被存分配到了同一个slot,为了解决hash冲突,slot中多个bucket通过双向链表关联,称为局部双向链表;为了实现有序,slot之间也通过双向链表关联,称为全局双向链表。
了解php5数组的实现原理之后,我们发现其中影响性能的两个关键点:
频繁的内存分配!每向数组中添加一个元素,都需要申请分配一个bucket大小的内存,然后维护多个指针。虽然php是基于内存池的管理方式(预先申请大块内存进行按需分配),但是内存分配带来的性能损耗是不可忽略的。Cpu高速缓存命中率低!因为bucket与bucket之间是通过链表指针连接的,bucket随机分配,内存基本不连续,导致Cpucache降低,不能给遍历数组带来性能提升。
针对以上两点,php7对hashTable的设计进行了改造。既然是字典,还是要使用hashTable,但php7数组的实现去掉了slot,bucket的分配使用了连续内存;bucket间不再使用真实存在的指针进行维护,bucket只维护一个在数组中的索引,bucket与bucket之间通过内存地址进行关联,如下图所示:
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标准特性的推广应用,当然还有一个重要因素,那就是浏览器的缓存策略。本节我们就来简单介绍一下浏览器的多级缓存机制。
对于一个数据请求来说,可以分为发起网络请求、后端处理、浏览器响应三个步骤。浏览器缓存可以帮助我们在第一和第三步骤中优化性能。比如说直接使用缓存而不发起请求,或者发起了请求但后端存储的数据和前端一致,那么就没有必要再将数据回传回来,这样就减少了响应数据。
浏览器有四种缓存级别,它们各自都有优先级。
ServiceWorkerMemoryCacheDiskCachePushCache
当依次查找缓存且都没有命中的时候,才会去请求网络。
1.4.1ServiceWorker
ServiceWorker是运行在浏览器背后的独立线程,一般可以用来实现缓存功能。使用ServiceWorker的话,传输协议必须为HTTPS。因为ServiceWorker中涉及到请求拦截,所以必须使用HTTPS协议来保障安全。ServiceWorker的缓存与浏览器其他内建的缓存机制不同,它可以让我们自由控制缓存哪些文件、如何匹配缓存、如何读取缓存,并且缓存是持续性的。
ServiceWorker实现缓存功能一般分为三个步骤:首先需要先注册ServiceWorker,然后监听到install事件以后就可以缓存需要的文件,那么在下次用户访问的时候就可以通过拦截请求的方式查询是否存在缓存,存在缓存的话就可以直接读取缓存文件,否则就去请求数据。
当ServiceWorker没有命中缓存的时候,我们需要去调用fetch函数获取数据。也就是说,如果我们没有在ServiceWorker命中缓存的话,会根据缓存查找优先级去查找数据。但是不管我们是从MemoryCache中还是从网络请求中获取的数据,浏览器都会显示我们是从ServiceWorker中获取的内容。
1.4.2MemoryCache
MemoryCache也就是内存中的缓存,主要包含的是当前页面中已经抓取到的资源,例如页面上已经下载的样式、脚本、图片等。读取内存中的数据肯定比磁盘快,内存缓存虽然读取高效,可是缓存持续性很短,会随着进程的释放而释放。一旦我们关闭Tab页面,内存中的缓存也就被释放了。
当我们访问过页面以后,再次刷新页面,可以发现很多数据都来自于内存缓存
内存缓存中有一块重要的缓存资源是preloader相关指令(例如<linkrel=&34;>)下载的资源。众所周知preloader的相关指令已经是页面优化的常见手段之一,它可以一边解析js/css文件,一边网络请求下一个资源。
需要注意的事情是,内存缓存在缓存资源时并不关心返回资源的HTTP缓存头Cache-Control是什么值,同时资源的匹配也并非仅仅是对URL做匹配,还可能会对Content-Type,CORS等其他特征做校验。
为什么浏览器数据不都存放在内存中?
这个问题就算我不回答,读者肯定也都想明白了,计算机中的内存一定比硬盘容量小得多,操作系统需要精打细算内存的使用,所以能让我们使用的内存必然不多。谷歌浏览器是公认的性能出众的,但是它占用的内存也是很大的。
1.4.3DiskCache
DiskCache也就是存储在硬盘中的缓存,读取速度慢点,但是什么都能存储到磁盘中,比之MemoryCache胜在容量和存储时效性上。
在所有浏览器缓存中,DiskCache覆盖面基本是最大的。它会根据HTTPHerder中的字段判断哪些资源需要缓存,哪些资源可以不请求直接使用,哪些资源已经过期需要重新请求。并且即使在跨站点的情况下,相同地址的资源一旦被硬盘缓存下来,就不会再次去请求数据。绝大部分的缓存都来自DiskCache。
浏览器会把哪些文件丢进内存中?哪些丢进硬盘中?
关于这点,网上说法不一,不过有些观点比较靠得住:对于大文件来说,大概率是不存储在内存中的,另外如果当前系统内存使用率高的话,文件优先存储进硬盘。
1.4.4PushCache
PushCache(推送缓存)是HTTP/2中的内容,当以上三种缓存都没有命中时,它才会被使用。它只在会话(Session)中存在,一旦会话结束就被释放,并且缓存时间也很短暂,在Chrome浏览器中只有5分钟左右,同时它也并非严格执行HTTP头中的缓存指令。
PushCache在国内能够查到的资料很少,也是因为HTTP/2在国内不够普及。这里推荐阅读JakeArchibald的HTTP/2pushistougherthanIthought这篇文章,文章中的几个结论是:
所有的资源都能被推送,并且能够被缓存,但是Edge和Safari浏览器支持相对比较差可以推送no-cache和no-store的资源一旦连接被关闭,PushCache就被释放多个页面可以使用同一个HTTP/2的连接,也就可以使用同一个PushCache。这主要还是依赖浏览器的实现而定,出于对性能的考虑,有的浏览器会对相同域名但不同的tab标签使用同一个HTTP连接。PushCache中的缓存只能被使用一次浏览器可以拒绝接受已经存在的资源推送你可以给其他域名推送资源
如果以上四种缓存都没有命中的话,那么只能发起请求来获取资源了。
浏览器的多级缓存机制到这里就介绍完了,大家可以看到,浏览器多级缓存机制的实现思路,和我们前边说到的计算机系统多级缓存的知识是相呼应的,并且也满足局部性原理。浏览器缓存还有更多值得我们深入学习的内容,感兴趣的读者可以研究一下这篇文章:深入理解浏览器的缓存机制
2.缓存淘汰策略
根据金字塔存储器层次模型我们知道:CPU访问速度越快的存储组件容量越小。在业务场景中,最常用的存储组件是内存和磁盘,我们往往将常用的数据缓存到内存中加快数据的读取速度,redis作为内存缓存数据库的设计也是基于这点考虑的。但是服务器的内存有限,不可能不断的将数据存入内存中而不淘汰。况且内存占用过大,也会影响服务器其它的任务,所以我们要做到通过淘汰算法让内存中的缓存数据发挥最大的价值。
本节将介绍业务中最常用的三种缓存淘汰算法:
LeastRecentlyUsed(LRU)淘汰最长时间未被使用的页面,以时间作为参考LeastFrequentlyUsed(LFU)淘汰一定时期内被访问次数最少的页面,以次数作为参考先进先出算法(FIFO)
笔者会结合Redis、Mysql中缓存淘汰的具体实现机制来帮助读者学习到,Mysql和Redis的开发者是怎样结合各自的业务场景,对已有的缓存算法进行改进来满足业务需求的。
2.1LeastRecentlyUsed(LRU)
LRU是LeastRecentlyUsed的缩写,这种算法认为最近使用的数据是热门数据,下一次很大概率将会再次被使用。而最近很少被使用的数据,很大概率下一次不再用到。该思想非常契合业务场景,并且可以解决很多实际开发中的问题,所以我们经常通过LRU的思想来作缓存,一般也将其称为LRU缓存机制。
2.1.1LRU缓存淘汰算法实现
本节笔者以leetcode上的一道算法题为例,使用代码(Go语言)实现LRU算法,帮助读者更深入的理解LRU算法的思想。
leetcode146:LRU缓存机制
运用你所掌握的数据结构,设计和实现一个LRU(最近最少使用)缓存机制。它应该支持以下操作:获取数据get和写入数据put。
获取数据get(key)-如果密钥(key)存在于缓存中,则获取密钥的值(总是正数),否则返回-1。写入数据put(key,value)-如果密钥已经存在,则变更其数据值;如果密钥不存在,则插入该组「密钥/数据值」。当缓存容量达到上限时,它应该在写入新数据之前删除最久未使用的数据值,从而为新的数据值留出空间。
示例:
LRUCachecache=newLRUCache(2/*缓存容量*/);cache.put(1,1);cache.put(2,2);cache.get(1);//返回1cache.put(3,3);//该操作会使得密钥2作废cache.get(2);//返回-1(未找到)cache.put(4,4);//该操作会使得密钥1作废cache.get(1);//返回-1(未找到)cache.get(3);//返回3cache.get(4);//返回4复制代码
我们采用hashmap+双向链表的方式进行实现。可以在O(1)时间内完成put和get操作,同时也支持O(1)删除第一个添加的节点。
使用双向链表的一个好处是不需要额外信息删除一个节点,同时可以在常数时间内从头部或尾部插入删除节点。
一个需要注意的是,在双向链表实现中,这里使用一个伪头部和伪尾部标记界限,这样在更新的时候就不需要检查是否是null节点。
代码如下:
typeLinkNodestruct{\nkey,valint\npre,next*LinkNode\n}\n\ntypeLRUCachestruct{\nmmap[int]*LinkNode\ncapint\nhead,tail*LinkNode\n}\n\nfuncConstructor(capacityint)LRUCache{\nhead:=&LinkNode{0,0,nil,nil}\ntail:=&LinkNode{0,0,nil,nil}\nhead.next=tail\ntail.pre=head\nreturnLRUCache{make(map[int]*LinkNode),capacity,head,tail}\n}\n复制代码
这样就初始化了一个基本的数据结构,大概是这样:
接下来我们为Node节点添加一些必要的操作方法,用于完成接下来的Get和Put操作:
func(this*LRUCache)RemoveNode(node*LinkNode){\nnode.pre.next=node.next\nnode.next.pre=node.pre\n}\n\nfunc(this*LRUCache)AddNode(node*LinkNode){\nhead:=this.head\nnode.next=head.next\nhead.next.pre=node\nnode.pre=head\nhead.next=node\n}\n\nfunc(this*LRUCache)MoveToHead(node*LinkNode){\nthis.RemoveNode(node)\nthis.AddNode(node)\n}\n复制代码
因为Get比较简单,我们先完成Get方法。这里分两种情况考虑:
如果没有找到元素,我们返回-1。如果元素存在,我们需要把这个元素移动到首位置上去。
func(this*LRUCache)Get(keyint)int{\ncache:=this.m\nifv,exist:=cache[key];exist{\nthis.MoveToHead(v)\nreturnv.val\n}else{\nreturn-1\n}\n}\n复制代码
现在我们开始完成Put。实现Put时,有两种情况需要考虑。
假若元素存在,其实相当于做一个Get操作,也是移动到最前面(但是需要注意的是,这里多了一个更新值的步骤)。假若元素不存在,我们将其插入到元素首,并把该元素值放入到map中。如果恰好此时Cache中元素满了,需要删掉最后的元素。
处理完毕,附上Put函数完整代码。
func(this*LRUCache)Put(keyint,valueint){\ntail:=this.tail\ncache:=this.m\nifv,exist:=cache[key];exist{\nv.val=value\nthis.MoveToHead(v)\n}else{\nv:=&LinkNode{key,value,nil,nil}\niflen(cache)==this.cap{\ndelete(cache,tail.pre.key)\nthis.RemoveNode(tail.pre)\n}\nthis.AddNode(v)\ncache[key]=v\n}\n}\n复制代码
至此,我们就完成了一个LRU算法,附上完整的代码:
typeLinkNodestruct{\nkey,valint\npre,next*LinkNode\n}\n\ntypeLRUCachestruct{\nmmap[int]*LinkNode\ncapint\nhead,tail*LinkNode\n}\n\nfuncConstructor(capacityint)LRUCache{\nhead:=&LinkNode{0,0,nil,nil}\ntail:=&LinkNode{0,0,nil,nil}\nhead.next=tail\ntail.pre=head\nreturnLRUCache{make(map[int]*LinkNode),capacity,head,tail}\n}\n\nfunc(this*LRUCache)Get(keyint)int{\ncache:=this.m\nifv,exist:=cache[key];exist{\nthis.MoveToHead(v)\nreturnv.val\n}else{\nreturn-1\n}\n}\n\nfunc(this*LRUCache)RemoveNode(node*LinkNode){\nnode.pre.next=node.next\nnode.next.pre=node.pre\n}\n\nfunc(this*LRUCache)AddNode(node*LinkNode){\nhead:=this.head\nnode.next=head.next\nhead.next.pre=node\nnode.pre=head\nhead.next=node\n}\n\nfunc(this*LRUCache)MoveToHead(node*LinkNode){\nthis.RemoveNode(node)\nthis.AddNode(node)\n}\n\nfunc(this*LRUCache)Put(keyint,valueint){\ntail:=this.tail\ncache:=this.m\nifv,exist:=cache[key];exist{\nv.val=value\nthis.MoveToHead(v)\n}else{\nv:=&LinkNode{key,value,nil,nil}\niflen(cache)==this.cap{\ndelete(cache,tail.pre.key)\nthis.RemoveNode(tail.pre)\n}\nthis.AddNode(v)\ncache[key]=v\n}\n}\n复制代码
2.1.2Mysql缓冲池LRU算法
在使用Mysql的业务场景中,如果使用上述我们描述的LRU算法来淘汰策略,会有一个问题。例如执行下面一条查询语句:
select*fromtable_a;\n复制代码
如果table_a中有大量数据并且读取之后不会继续使用,则LRU头部会被大量的table_a中的数据占据,这样会造成热点数据被逐出缓存从而导致大量的磁盘IO。所以Mysql缓冲池采用theleastrecentlyused(LRU)算法的变体,将缓冲池作为列表进行管理。
Mysql的缓冲池
缓冲池(BufferPool)是主缓存器的一个区域,用于缓存索引、行的数据、自适应哈希索引、插入缓存(InsertBuffer)、锁还有其他的内部数据结构。BufferPool的大小是可以根据我们实际的需求进行更改,那么BufferPool的size取多少比较合适?MySQL官网上是这么介绍,在专用服务器上(也就是服务器只承载一个MySQL实例),会将80%的物理内存给到BufferPool。BufferPool允许直接从内存中读常用数据去处理,会加快很多的速度。为了提高大容量读取操作的效率,BufferPool被分成可以容纳多行的页面。为了提高缓存管理的效率,BufferPool被实现为页面对应的链接的列表(alinkedlistofpages)。
Mysql数据库BufferPool结构:
当需要空间将新页面缓存到缓冲池的时候,最近最少使用的页面将被释放出去,并将新的页面加入列表的中间。这个中间点插入的策略将列表分为两个子列表:
头部是存放最近访问过的新页面的子列表尾部是存放那些最近访问较少的旧页面的子列表
该算法将保留newsublist(也就是结构图中头部)中大量被查询使用的页面。而oldsublist里面较少被使用的页面作为被释放的候选项。
理解以下几个关键点是比较重要的:
3/8的缓冲池专用于oldsublist(也就是3/8来保存旧的页面,其余部分用来存储热数据,存储热数据的部分相对大一些,当然这个值可以自己去调整,通过innodb_old_blocks_pct,其默认值是37,也就是3/8*100,上限100,可调整5-95,可以改小一些,但是调整后尽量保证这个比例内的数据也就是oldsublist中的数据只会被读取一次,若不是了解非常业务数据负载建议不要去修改。)列表的中点是新子列表的尾部与旧子列表的头部相交的边界。当InnoDB将页面读入缓冲池时,它最初将其插入中点(旧子列表的头部)。一个页面会被读取是因为它是用户指定的操作(如SQL查询)所需,或者是由InnoDB自动执行的预读操作的一部分。访问旧子列表中的页面使其“年轻”,将其移动到缓冲池的头部(新子列表的头部)。如果页面是被需要比如(SQL)读取的,它会马上访问旧列表并将页面推入新列表头部。如果预读需要读取的页面,则不会发生对旧列表的firstaccess。随着数据库的运行,在缓冲池的页面没有被访问则向列表的尾部移动。新旧子列表中的页面随着其他页面的变化而变旧。旧子列表中的页面也会随着页面插入中点而老化。最终,仍未使用的页面到达旧子列表的尾部并被释放。默认情况下,页面被查询读取将被立即移入新列表中,在BufferPool中存在更长的时间。
通过对LRU算法的改进,InnoDB引擎解决了查询数据量大时,热点数据被逐出缓存从而导致大量的磁盘IO的问题。
2.1.3Redis近似LRU实现
由于真实LRU需要过多的内存(在数据量比较大时);并且Redis以高性能著称,真实的LRU需要每次访问数据时都做相关的调整,势必会影响Redis的性能发挥;这些都是Redis开发者所不能接受的,所以Redis使用一种随机抽样的方式,来实现一个近似LRU的效果。
在Redis中有一个参数,叫做“maxmemory-samples”,是干嘛用的呢?
1algorithms(inordertosavememory),soyoucantuneitforspeedor\n3usedlessrecently,youcanchangethesamplesizeusingthefollowing\n5\n7trueLRUbutcostsabitmoreCPU.3isveryfastbutnotveryaccurate.\n939;tdoitifwehaveasavingchild,asthiswilltrigger\n*acopyonwritemadness.*/\nif(server.rdb_child_pid==-1&&\nserver.aof_child_pid==-1&&\n!(flags&LOOKUP_NOTOUCH))\n{\nif(server.maxmemory_policy&MAXMEMORY_FLAG_LFU){\nupdateLFU(val);\n}else{\nval->lru=LRU_CLOCK();\n}\n}\nreturnval;\n}else{\nreturnNULL;\n}\n}\n复制代码
当采用LFU策略时,updateLFU更新lru:
/*UpdateLFUwhenanobjectisaccessed.\n*Firstly,decrementthecounterifthedecrementtimeisreached.\n*Thenlogarithmicallyincrementthecounter,andupdatetheaccesstime.*/\nvoidupdateLFU(robj*val){\nunsignedlongcounter=LFUDecrAndReturn(val);\ncounter=LFULogIncr(counter);\nval->lru=(LFUGetTimeInMinutes()<<8)|counter;\n}\n复制代码
降低LFUDecrAndReturn
首先,LFUDecrAndReturn对counter进行减少操作:
/*IftheobjectdecrementtimeisreacheddecrementtheLFUcounterbut\n*donotupdateLFUfieldsoftheobject,weupdatetheaccesstime\n*andcounterinanexplicitwaywhentheobjectisreallyaccessed.\n*Andwewilltimeshalvethecounteraccordingtothetimesof\n*elapsedtimethanserver.lfu_decay_time.\n*Returntheobjectfrequencycounter.\n*\n*Thisfunctionisusedinordertoscanthedatasetforthebestobject\n*tofit:aswecheckforthecandidate,weincrementallydecrementthe\n*counterofthescannedobjectsifneeded.*/\nunsignedlongLFUDecrAndReturn(robj*o){\nunsignedlongldt=o->lru>>8;\nunsignedlongcounter=o->lru&255;\nunsignedlongnum_periods=server.lfu_decay_time?LFUTimeElapsed(ldt)/server.lfu_decay_time:0;\nif(num_periods)\ncounter=(num_periods>counter)?0:counter-num_periods;\nreturncounter;\n}\n复制代码
函数首先取得高16bits的最近降低时间ldt与低8bits的计数器counter,然后根据配置的lfu_decay_time计算应该降低多少。
LFUTimeElapsed用来计算当前时间与ldt的差值:
/*Returnthecurrenttimeinminutes,justtakingtheleastsignificant\n*16bits.ThereturnedtimeissuitabletobestoredasLDT(lastdecrement\n*time)fortheLFUimplementation.*/\nunsignedlongLFUGetTimeInMinutes(void){\nreturn(server.unixtime/60)&65535;\n}\n\n/*Givenanobjectlastaccesstime,computetheminimumnumberofminutes\n*thatelapsedsincethelastaccess.Handleoverflow(ldtgreaterthan\n*thecurrent16bitsminutestime)consideringthetimeaswrapping\n*exactlyonce.*/\nunsignedlongLFUTimeElapsed(unsignedlongldt){\nunsignedlongnow=LFUGetTimeInMinutes();\nif(now>=ldt)returnnow-ldt;\nreturn65535-ldt+now;\n}\n复制代码
具体是当前时间转化成分钟数后取低16bits,然后计算与ldt的差值now-ldt。当ldt>now时,默认为过了一个周期(16bits,最大65535),取值65535-ldt+now。
然后用差值与配置lfu_decay_time相除,LFUTimeElapsed(ldt)/server.lfu_decay_time,已过去n个lfu_decay_time,则将counter减少n,counter-num_periods。
增长LFULogIncr
增长函数LFULogIncr如下:
/*Logarithmicallyincrementacounter.Thegreateristhecurrentcountervalue\n*thelesslikelyisthatitgetsreallyimplemented.Saturateitat255.*/\nuint8_tLFULogIncr(uint8_tcounter){\nif(counter==255)return255;\ndoubler=(double)rand()/RAND_MAX;\ndoublebaseval=counter-LFU_INIT_VAL;\nif(baseval<0)baseval=0;\ndoublep=1.0/(baseval*server.lfu_log_factor+1);\nif(r<p)counter++;\nreturncounter;\n}\n复制代码
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|100hits|1000hits|100Khits|1Mhits|10Mhits|\n|0|104|255|255|255|255|\n|1|18|49|255|255|255|\n|10|10|18|142|255|255|\n|100|8|11|49|143|255|\n#+——–+————+————+————+————+————+\n复制代码
可见counter增长与访问次数呈现对数增长的趋势,随着访问次数越来越大,counter增长的越来越慢。
新生key策略
另外一个问题是,当创建新对象的时候,对象的counter如果为0,很容易就会被淘汰掉,还需要为新生key设置一个初始counter,createObject:
robj*createObject(inttype,void*ptr){\nrobj*o=zmalloc(sizeof(*o));\no->type=type;\no->encoding=OBJ_ENCODING_RAW;\no->ptr=ptr;\no->refcount=1;\n\n/*SettheLRUtothecurrentlruclock(minutesresolution),or\n*alternativelytheLFUcounter.*/\nif(server.maxmemory_policy&MAXMEMORY_FLAG_LFU){\no->lru=(LFUGetTimeInMinutes()<<8)|LFU_INIT_VAL;\n}else{\no->lru=LRU_CLOCK();\n}\nreturno;\n}\n复制代码
counter会被初始化为LFU_INIT_VAL,默认5。
总结一下:Redis的LFU缓存淘汰策略复用了redis对象中的24bitslru,不过分成了分成16bits与8bits使用,高16bits用来记录最近一次计数器降低的时间ldt,单位是分钟,低8bits记录计数器数值counter。Redis对象的计数器并不是线性增长的,而是提供了lfu-log-factor配置项来控制技术器的增长速度。为了解决历史数据影响将来数据的“缓存污染”问题,Redis对象的计数会根据lfu_decay_time配置项随时间做调整。redis为每一个新增的key都设置了初始counter,目的是防止新增的key很容易就被淘汰掉。
2.3先进先出算法(FIFO)
FIFO(FirstinFirstout),先进先出。在FIFOCache设计中,核心原则就是:如果一个数据最先进入缓存中,则应该最早淘汰掉。实现方法很简单,只要使用队列数据结构即可实现。
因为缓存命中率比较低,FIFO缓存策略通常不会在项目中使用。客观唯心主义的理论:存在即合理,下边笔者就描述一下FIFO队列在Redis主从复制过程中的应用。
在Redis主从结构中,主节点要将数据同步给从节点,从一开始主从建立连接到数据同步一共分为三个阶段:
第一阶段首先建立连接,然后第二阶段主节点发送rdb文件给从节点同步全量数据;我们主要看第三阶段主节点反复同步增量数据给从节点的过程是什么样的。
从节点和主节点建立连接时,主节点服务器会维护一个复制积压缓冲区来暂存增量的同步命令;当从节点向主节点要求同步数据时,主节点根据从节点同步数据的offset将数据增量的同步给从节点,反复进行。
复制积压缓冲区是一个先进先出(FIFO)的环形队列,用于存储服务端执行过的命令,每次传播命令,master节点都会将传播的命令记录下来,保存在这里。
复制积压缓冲区由两部分组成:偏移量和字节值。字节值是redis指令字节的存储(redis指令以一种Redis序列化文本协议的格式存储),偏移量offset就是当前字节值在环形队列中的偏移量。
主节点维护了每个从节点的offset,这样每次同步数据时,主节点就知道该同步哪一部分数据给从节点了。通过这样一个复制积压缓冲区,Redis的主从架构实现了数据的增量同步,想要了解更多主从同步细节的读者可以参考我的另一篇博客:Redis高可用——主从复制
2.4FIFO、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和数据库都是没有意义的!下面缓存雪崩的简单示意图:
如果“缓存雪崩”问题已经发生了,应该怎样解决呢?下边列举一些有效的方案:
页面静态化处理,一旦使用了页面静态化,客户端的数据就不用从缓存中获取了,这样可以减轻缓存的压力,缺点是数据更新不及时。构建多级缓存,构建多级缓存可以给每一级的缓存提供更多的数据保障,比如在redis和mysql数据库中间再加上一层ehcache缓存,当缓存中大量key过期时,ehcache缓存可以替mysql数据库暂时抵挡一部分流量。构建多级缓存会增加系统的复杂性。优化mysql。比如给mysql增加bufferpool,这样当大量流量到达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
平均每秒处理请求总数
hitrate(calculated)
缓存命中率(计算出来的)
内存指标:Memory
Name
Description
use_memory
已使用内存
mem_fragmentation_ratio
内存碎片率
evicted_keys
由于最大内存限制移除key的数量
blocked_clients
由于BLPOP、BRPOPorBLPUSH、BRPUSH而被受阻塞的客户端
基本活动指标:Basicactivity
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的这些指标有一些开源的工具,例如:CloudInsightRedis、Prometheus、Redis-stat、Redis-faina。但是这些工具在我们比较定制化的业务中,往往不能起到太大效果,往往企业会针对自己的业务开发自己的监控服务。
Redis提供了一些命令也能帮助我们排查问题。比如showlog、monitor;此外redis还提供了benchmark指令,通过使用它我们可以得到redis的吞吐量、缓存命中率这些性能指标数据。
4.后记
这篇文章断断续续的整理了半个月,笔者参考了大量的资料才提炼出了以上内容。文章中很多地方确实不够深入,也有一些空洞的理论,加上笔者本身工作经验不是很丰富,关于mysql、redis和php,很多底层的知识也没有研究到位,文章中难免有表达不恰当和错误之处,希望读者查找出以后一定在留言区进行指正。
回过头来看这篇文章,笔者在这里大谈“缓存”确实有点大言不惭,但是都是我的心血,也就当是我的一篇学习笔记了。
涉及到“缓存”相关的系统设计和应用绝不仅仅是我提到的这些,比如:缓存数据结构的选择和压缩、多级缓存之间的数据同步等等,这些都没有谈到,如果将来有机会,希望能深入的学习一下这些知识,再对文章做一些补充。
原文链接:https://juejin.cn/post/6844904136207499277
如果你还想了解更多这方面的信息,记得收藏关注本站。
