缓存提升性能的关键性手段


  • 提高「性能」的主要方式是优化,而优化的其中一个主要手段就是添加缓存!

    在软件工程里有这么一句话:「没有银弹」!就是说由于软件工程的复杂性,没有任何一种技术或方法能解决所有问题!软件工程是复杂的,没有银弹!但是,软件工程中的某一个问题,是有银弹的!

    「 计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决」!而「缓存层」可以说是添加得最多的层!主要目的就是为了提高性能!所以,缓存可以说是「性能银弹」!

    本文将探讨如下内容:

    • 缓存的作用
    • 缓存的种类
    • 缓存算法
    • 分布式缓存
    • 缓存的使用
    • 网络中的缓存
    • 应用缓存
    • 数据库缓存
    • 计算机中的缓存

    从代码说起

    fn longRunningOperations(){ ... // 很耗时}let result = longRunningOperations();// do other thing
    

      我们来看上面这段伪代码,longRunningOperations是个很耗时的方法(调用一次要几十秒甚至几分钟),比如:

    • 复杂的业务逻辑计算
    • 复杂的数据查询
    • 耗时的网络操作等

    对于这个方法,如果每次都去调用一次的话,会非常的影响性能,用户体验也非常的不好。

    那我们该如何处理呢?

    一般有几种优化方案:

    • 优化业务代码,比如:更快的数据结构和算法,更快的IO模型,建立数据库索引等
    • 简化业务逻辑,导致耗时的原因可能是业务过于复杂,可以通过简化业务逻辑的方式来减少耗时
    • 将操作的结果存储起来。例如:对于某些统计类的结果,可以先用日终定时的去执行,将结果存储到统计结果表中,查找时,直接从结果中查询即可;对于某些临时操作,可以将结果存储在内存中,再次调用时,直接从内存中获取即可。

    本文主要聊聊第三种方案:使用「缓存」!

    主动缓存与被动缓存

    一般我们使用缓存来存储一些内容,这些内容有如下一些特点(符合一条或多条):

    • 使用较频繁
    • 变更不频繁
    • 获取较耗时
    • 多系统访问

    比如,

    • 字典数据:系统很多地方都会使用字典数据,而字典数据配置完成后一般不会修改,虽然从数据库中直接获取字典数据不是很耗时,但是多了查询和网络传输,性能上还是不如直接从缓存里面取快速
    • 秒杀商品信息:在秒杀时访问量很大,从缓存(静态文件、CDN等)获取要比从数据库查询要快得多
    • 其它访问频次较多的信息:此处的其它信息是因为其缓存的处理方式与上面的字典处理有差异,下面详细说明。

    对于字典数据来说,一般我们的做法是在系统启动时,将字典数据直接加载到缓存中,此类缓存数据一般没有过期时间;当修改字典时,会同时更新缓存中的内容。此类缓存称为「主动缓存」,因为其缓存数据是由用户的主动修改来触发更新的。

    而对于某些信息来说,因为信息量太大,不能一次性全部加载到缓存中,且也不是太清楚哪些数据访问频次高、哪些数据访问频次低。对于这样的数据,一般的做法是:

    • 先到缓存中查找是否有访问的数据,如果有则直接返回给用户
    • 如果没有,则去溯源查找
    • 找到后将其添加到缓存中
    • 最后返回给用户

    此类缓存称为「被动缓存」!其缓存的数据的过期由系统来控制。那系统如何控制呢?这就涉及到缓存置换算法!

    缓存置换算法

    上面说了,对于被动缓存来说,由于信息量太大,数据不能一次全部加载到缓存中,当缓存满了以后,需要新增数据时,就需要确定哪些数据要从缓存里清除,给新数据腾出空间。

    用于判断哪些数据优先从缓存中剔除的算法称为「缓存(页面)置换算法」!

    Wiki中列出了如下置换算法:

    • RR(Random replacement)
    • FIFO(First in first out)
    • LIFO(Last in first out)
    • MRU(Most recently used)
    • LFU(Least-frequently used)
    • LRU(Least recently used)
    • TLRU(Time aware least recently used)
    • PLRU(Pseudo-LRU)
    • LRU-K
    • SLRU(Segmented LRU)
    • MQ(Multi queue)
    • LFRU(Least frequent recently used)
    • LFUDA(LFU with dynamic aging)
    • LIRS(Low inter-reference recency set)
    • ARC(Adaptive replacement cache)
    • CAR(Clock with adaptive replacement)
    • Pannier(Container-based caching algorithm for compound objects)

    一般情况下我们不会自己去实现个缓存,市面上有不少开源的缓存中间件,比如:redis,memcached。这里只简单的梳理几个常用的置换算法。

    FIFO

    FIFO应该算是最简单的置换算法了:

    • 它使用一个队列来维护数据
    • 数据按照加载到缓存的顺序进行排列,先加载的数据在队列头部,后加载的数据在队列尾部
    • 当缓存满了以后,从队列头部清除数据,给需要加载的数据腾出空间
    • 新数据加到队列的尾部

    FIFO的实现很简单,但是其性能并不总是很好。举个简单的例子,假设一个系统需要10个缓存数据,恰巧此时5个数据在队列头部,另外5个数据不在缓存中,又恰巧此时队列又满了。按照FIFO算法,5条不在内存中的数据被加载到了缓存中,而之前的5条数据被清除了。这就需要再次将被清除的5条数据加载到缓存中。这就影响了性能。

    这个问题可能会随着所分配的缓存大小的增加而增加,原本我们使用缓存是为了提高性能的,现在可能会影响性能,这种现象称为「Belady现象」!

    LIFO和FIFO很类似,这里就不赘述了。

    LRU

    目前比较常用的置换算法称为LRU置换算法:优先替换掉「最近最少使用」的数据

    • 每个数据都被关联了该数据上次使用的时间
    • 当需要置换数据的时候,LRU选择最长时间没有使用的数据

    LRU的变体有很多,例如:

    • TLRU(Time aware least recently used):大部分缓存数据是有过期时间的。PLRU从最少使用和过期时间两个维度来置换数据
    • LRU-K:多维护一个队列,用于记录所有缓存数据被访问的次数。当数据的访问次数达到K次的时候,才将数据放入缓存。当需要淘汰数据时,LRU-K会淘汰第K次访问时间距当前时间最大的数据。
    • SLRU(Segmented LRU)2Queue?:一个FIFO队列,一个LRU队列。当数据第一次访问时,将数据缓存在FIFO队列里面,当数据第二次被访问时,则将数据从FIFO队列移到LRU队列里面,两个队列各自按照自己的方法淘汰数据。
    • PLRU(Pseudo-LRU):LRU需要维护数据访问时间,占用了额外的空间,对于空间很小的设备来说,此算法太过浪费空间了。PLRU每个缓存数据只需要1bit来存储数据信息,可以达到LRU的效果。具体流程见下图:

    还有和LRU类似的MRU,LFU这里不在赘述!

    缓存集群

    为了提高缓存的可用性,一般我们至少会对缓存做个主备,即一个主缓存,一个从缓存。

    • 缓存的写入只可以写到主缓存
    • 主缓存同步数据到从缓存中
    • 可以从主缓存读取数据。也可以从从缓存读取数据(不必须)
    • 当主缓存挂掉了,从缓存升级为主缓存

    再安全一点的做法就是做缓存集群:

    • 多台机器缓存了相同的数据,其中一台为主缓存
    • 缓存的写入只可以写到主缓存
    • 主缓存同步数据到其它缓存
    • 可以从主缓存读取数据。也可以从其它缓存中读取数据(不必须)
    • 当主缓存挂掉了,会从其它缓存服务中选择一个作为新的主缓存

    分布式缓存

    无论是单机缓存,主从备份还是缓存集群,都没法解决缓存大小限制的问题。因为一般缓存会使用内存,而一台机器的内存大小是有限的。当需要缓存的数据远远超过一台机器的内存大小的时候,就需要将缓存的数据分布到多台机器上。每台机器只缓存一部分数据,这就是分布式缓存。

    分布式缓存可以解决一台机器缓存数据有限的问题,但是也引入了新的问题:

    • 哪些数据该缓存在哪台服务器上
    • 如何保证每台服务器缓存的数据量基本相同

    一般做法是对key进行hash,然后对服务器数量进行取余,来确定数据在哪台服务器上。这解决了「哪些数据该缓存在哪台服务器上」的问题,但是却无法保证「每台服务器缓存的数据量基本相同」,因为可能多个key的hash取余后都落到了同一个服务器上,这就可能导致其中一台服务器缓存的数量很多,其它服务器缓存的数据量很少。缓存数据量多的服务器可能会内存不够用,触发数据置换,进而导致性能下降。

    可以使用一致性hash环来保证服务器缓存的数据量基本相同,大致逻辑如下:

    • 将0~2^32个点均匀分配到一个圆上
    • 每个点对应一台缓存服务器
    • 缓存服务器数量是远小于2^32个的,所以多个节点对应一台缓存服务器,多出来的节点称为虚拟节点
    • 确保缓存服务器的分布均匀
    • 同样是对key进行hash
    • 对2^32进行求余
    • 结果对应到hash环上
    • 如果正好落到节点上,则数据就缓存到对应的缓存服务器上
    • 否则就存到落点前面的那个节点所对应的缓存服务器上

    无处不在的缓存

    上面聊的主要是应用缓存,实际上,缓存无处不在。

    下面通过我们访问网站的流程,来简单梳理一下,整个过程中,哪些地方可能会用到缓存。

    网络缓存

    当我们在浏览器中输入URL,按下回车后。

    首先,需要查找域名所对应的IP!这里就有各种缓存!

    • 浏览器缓存:浏览器会缓存DNS记录一段时间,首先会从浏览器缓存里去找对应的IP。
    • 系统缓存:如果在浏览器缓存里没有找到需要的记录,就会到系统缓存中查找记录
    • 路由器缓存:如果系统缓存中也没找到,就会到路由器缓存中查找记录
    • ISP DNS 缓存:如果还是找不到,就到ISP缓存DNS的服务器里查找。在这一般都能找到相应的缓存记录。
    • 递归搜索:如果上面的缓存都找不到,就需要从根域名服务器开始递归查找了

    找到IP后,还不一定要发请求,因为你访问的资源可能之前已经访问过,已经被缓存到了浏览器缓存中。此时,浏览器直接返回缓存,而不会发送请求。

    如果没有缓存,则发送请求获取资源。

    后面可能会达到CDN。CDN是一种边缘缓存。在用户访问网站时,利用GSLB(Global Server Load Balance,全局负载均衡)技术将用户的访问指向距离最近的工作正常的缓存服务器上,由缓存服务器直接响应用户请求。如果CDN中找不到需要的资源,则请求可能就到了反向代理。

    某些反向代理能够做到和用户来自同一个网络,那么用户访问反向代理服务器的时候,就会得到很高质量的响应速度,这样的反向代理缓存一般称为边缘缓存,而CDN在边缘缓存的基础上,使用了GSLB

    一般反向代理有两个功能:

    • 隐藏源服务器,防止服务器恶意攻击。客户端感知不到代理服务器和源服务器的区别
    • 缓存,将原始服务器数据进行缓存,减少源服务器的访问压力

    如果反向代理中也找不到需要的资源,请求才到达源服务器来获取资源。

    服务端与数据库缓存

    一般情况下,Server接收到请求后,会根据请求,组装出响应,进行返回。这个过程可能需要查询数据库、进行业务逻辑计算、页面渲染等操作。这里的每一步都可以引入缓存。

    对于数据库查询来说,目前一般的持久化框架都会提供查询缓存。即对于相同的sql,第二次查询开始,可以不用再查询数据库,直接从缓存中获取第一次查询所返回的数据。节省了调用数据库查询的时间消耗。对于某些访问量很大的数据,也可以将其缓存到缓存中间件中。后续直接从缓存中间件中获取。

    而数据库本身也有缓存!

    • 客户端发送一条查询给服务端
    • 服务端检查查询缓存,如果命中缓存,则立刻返回缓存中的结果。如果没找到,则
    • 进行sql解析、预处理、再由优化器生成对应的执行计划
    • 根据执行计划,调用存储引擎的API执行查询
    • 将结果返回给客户端

    mysql的查询缓存可能会降低效率。首先,写缓存是独占模式写入。其次,假设一个查询结果被缓存了,当涉及到的其中一张表数据更新,该缓存都会被置为无效。对于频繁修改的数据,使用缓存就会降低效率。

    对于业务逻辑计算来说,如果某些业务逻辑很复杂,那么可以针对结果进行缓存。可以将结果缓存到数据库或缓存中间件中。对于相同的参数的请求,第二次请求时,就不必进行计算,直接从缓存中返回结果即可。

    对于页面渲染来说,某些访问量很大的页面,且数据基本不变的情况下,可以对页面进行静态化。即生成静态的页面,不必每次访问的时候都动态生成页面进行返回,而是预先生成好页面,将其存到磁盘上,当访问该页面的时候,直接从磁盘获取页面进行返回即可。或者直接将页面内容缓存到缓存中间件中,进一步提高性能。

    另外,对于需要登录的Server来说,用户信息其实也是缓存下来的。不论是存到服务器Session中,还是存到了缓存中间件中。否则,每次用户访问Server都需要到数据库获取用户信息,会影响Server端性能!

    计算机缓存

    最后,运行系统的计算机本身也有很多的缓存!

    我们都知道,一般计算机由CPU、内存、主板、硬盘、显卡、显示器、鼠标、键盘、网卡等组成!其中存储类设备包括了:云存储(例如:百度云盘,NAS等)、本地硬盘、内存、CPU中的高速缓存(我们常说的一级缓存、二级缓存和三级缓存)以及CPU寄存器。它们的速度各异,差异达数个量级。下图显示了各个设备的访问速率。

    ![非功能性约束之性能(1)-性能银弹:缓存](https://p1-tt.byteimg.com/origin/pgc-image/d6c8ccf82f6f49fd9fb04ae7750c181c?from=pc)
    • CPU寄存器最快,达到1ns,但只能存储几百个字节,造价也最贵
    • 高速缓存次之,也达到了10ns,可存储几十兆,造价次之。其中L1,L2,L3速度越来越慢。
    • 然后是内存,为100ns,可达GB级别,造价比缓存便宜(不过这两年的内存价格贵得离谱)
    • 硬盘访问速率为10ms级别,可达TB级别,造价可以说是白菜价了
    • 而云存储则达到了秒级,基本可以无限扩展,只要钱够

    我们都知道CPU的高速缓存是「缓存」,实际上上面的设备,上层设备都可以说是下层设备的「缓存」!

    在《深入理解计算机》一书中,简单的介绍了计算机执行C语言的hello world程序时的计算机流程。

    • 通过鼠标、键盘输入执行命令'./hello'
    • 输入的内容从键盘通过总线,进入寄存器,在进入内存
    • 当按下回车后
    • 通过DMA技术,将目标文件,从硬盘中直接读取到内存中
    • 最后执行程序
    • 将hello world拷贝到寄存器
    • 再从寄存器拷贝到显示器显示

    可以看到,绝大部分的操作,都是数据的拷贝!最终被CPU执行,为了数据能更快的到达CPU,就有了一层一层的「缓存」!

    • CPU寄存器里的数据是直接给CPU使用的,相当于是L1的缓存
    • L1又是L2的缓存,L2又是L3的缓存
    • L3是内存的缓存
    • 内存又是硬盘的缓存。例如:一般硬盘中的数据,都需要先加载到内存中才能被CPU使用。另外硬盘的“HMB内存缓冲技术”,可以借用内存作为硬盘的缓存。
    • 硬盘本身也是有缓存的,这是为了减少IO操作,批量的进行读写。
    • 硬盘也可以是云存储的缓存。例如在网络不太好的情况下,我们可以把电影先下载下来再看,这样就不会有卡顿的情况

    总结

    性能是架构设计时需要着重考虑的一个非功能性约束,而引入缓存是提高系统性能的一个简单且直接的方法。

    本文从一个简单的伪代码开始,简单阐述了,缓存的作用,涉及的技术以及目前缓存的使用场景,以期能对架构设计提供一些参考。

    END
    为了更高的交流,欢迎大家关注我的公众号,扫描下面二维码即可关注,谢谢: