互联网架构之缓存架构

miloyang
0 评论
/ /
617 阅读
/
10671 字
08 2023-12

技术简单,但是对系统的性能提升有着显著的效果,小工具大收益,了解一下?

缓存无处不在,我们熟知的redis只是其中一种,比如操作系统中的文件缓存,对磁盘进行IO操作的时候,也会对数据进行缓存,加快操作系统访问磁盘文件数据的速度。数据库的查询缓存,比如索引结构的B+树进行缓存,对热点数据记录也进行缓存。或者是DNS客户端缓存、HTTP代理缓存、CDN缓存,还有一些对象缓存,比如Redis、Memcache等等。

我们先来个缓存的架构图,然后针对于架构图来进行分步的拆分和讲解。

huancunjiagoutu

缓存特点

缓存的特点:

  • 技术简单,可以很方便的添加都应用架构中,不需要复杂的架构技术,对现有的系统架构影响是比较小的。
  • 性能提升显著,使用之后,明显感觉性能提升不少。
  • 应用场景多,在整个计算机体系里面,缓存无处不在,比如cpu中的缓存,cpu也有cache,计算的时候并非每次都从内存中读取,而是预加载一部分的指令和数据到CPU cache里面。

缓存为什么能显著提升性能

主要是缓存有三个方面的特点: huancunxingnengqY

  • 内存存储

    缓存的数据都是来自于内存,这样访问速度更快,内存读取速度远非磁盘读取速度可比的,所以使用缓存会使得系统获得更快的响应性能,系统的访问速度会更快,处理速度也会更快。

  • 最终形态

    缓存中存储的数据,一般都是返回的最终数据的结果形态,比如某个用户的信息可能存在于多个表,通过计算得出一个整体对象,缓存就是缓存这个整体的对象数据,直接从缓存中返回,这就减少了数据从磁盘或者数据库中读取不同数据,进行加工。也减少了CPU的资源消耗,进一步提高响应。

  • 降低负载压力

    因为不需要从外部的IO设备中去读取数据,直接从本地缓存或者内存中读取,减少IO设备的访问压力,因为我们知道,IO操作时最容易出现瓶颈的地方,减少这些设备的访问压力、负载压力,就可以更好的提升整个系统的处理能力。

内存中如何存储?

缓存是存储在内存中的,那么如何快速的从内存中获取一个数据呢?

缓存使用的数据结构主要是hash表,hash表最终存储形式是一个顺序表,也就是一个数组结构,它的特点就是内存中连续存储分配。
所以当我们要在哈希表存储一个数据的时候,通常是以key、value的数据结构进行存储,当我们把kv数据存储在hash表中,主要存储过程如下:

cuncguocheng

  • 我们拿到k、v数据结构,比如key为abc,value为hello,我们先计算abc的hashcode为101,这样的一个整型值,再计算hashcode对应的hash表索引就是对8进行取模,为什么是8?因为hash表真正的物理存储是一个数组,我们建立hash表的长度就是8,你也可以是其他的,根据长度来定
  • 101%8=5,说明这个5就是数组下标的索引值,我们就把acb hello这个k、v值存储在下标为5的数组记录中,这一步是关键的,我们所谓的hash算法也就是指的这一步,就是如何把hash值转成数组中对应的位置进行存储,我们例子中这个就是余数hash,也是常用的一个算法。
  • 我们获取数据的时候,只需要针对于key进行同样的算法,获取到对应的下标,然后找到下标对应的value值,所以我们的hash中的key是必须要唯一的。

通过hash表可以使得整个数据存储或者检索效率时间复杂度都是O(1),所以即使存储非常大的数据量,通过hash表也可以非常快速的进行数据的查找和读取,这种手段缓存可以获得较快的读写访问特性,比数据库中的读写速度要快很多。

缓存关键指标

我们先来说说命中率:
因为缓存是一次写入,多次读取,通过这种手段减少对数据库的频繁访问,我们需要从缓存中读取数据、提高性能,所以判断缓存是否有效,就是看它一次写进去的缓存能不能多次去读出来响应业务的请求,这个判断指标就是命中率。
命中率=正确缓存结构/总查询次数,比如诗词查询九次都可以得到缓存的正确结果,命中率就是90%。

很明显,影响命中率的主要因素就是:

  • 缓存键集合大小
  • 内存空间大小
  • 缓存的寿命

缓存键集合大小

如刚才例子,缓存中的每个对象都是通过缓存键来进行识别,键是缓存中的唯一识别码,定位一个对象的唯一方式就是缓存键进行精确的匹配。

比如我们想缓存每个商品的在线商品信息,就需要使用商品ID作为缓存键,缓存键空间就是你的应用能够生成的所有键的数量,从统计数字上面看,应用生成唯一键越多,重用的机会越小,比如如果是需要根据IP地址缓存天气数据,可能需要40多亿个键,但是如果需要基于国家缓存天气数据,然后在结果中再根据国家的省份、时区、县、乡、村等等格式依次缓存,可能需要的缓存键会大大降低,因为我们之前在redis中也提到过,尽可能的通过hash的结构去存储而非string,因为底层都是RedisObject,大量的string会占用大量的内存空间。 感兴趣的可以移步 redis底层数据结构介绍

所以要尽可能的减少缓存键的数量,键的数量越少,缓存的效率越高,设计缓存的时候要关注缓存键是如何进行设定的,整个集合范围限定在一个即能够高效使用,又可以减少它的数量,这个时候缓存的性能是最好的。

缓存内存空间大小

我们知道缓存都是在内存里面的,一个搭载redis的服务器,绝大部分的内存空间都是被redis占用了,但内存又是一个比较昂贵的资源,所以内存需要受到严格的限制。

如果想缓存更多的对象,势必要先删除老的对象,再添加新的对象,而这些老的对象的删除,直接影响到了缓存的命中率,所以物理上缓存的空间越大,内存越大,缓存的对象也就越多,缓存的命中率也就越高。

缓存对象生存时间

也就是我们常说的TTL,对象缓存的时间越长,被重用的可能性就越高,一般来说我们使缓存失效的方法有两种,一个是超时失效,一个是清除失效。
CgotOV13Eh6AeZaQAABY4_ZmeQk831

  • 超时失效

    就是在写入缓存的时候,设置一个缓存的超时时间,在超时之前访问缓存就会返回缓存的数据,一旦超时,缓存就失效了,这个时候再访问缓存就会返回空,比如redis其实有两个dict,一个正常的一个设置缓存。

  • 清除失效

    就是当有缓存对象更新的时候,通知缓存将已经被更新的数据进行清除,这就下一次访问这个缓存对象键的时候,不得不到数据库中去查找读取,这个时候就会得到最新的数据,因为数据库中的都是最新的,这种其实就是如何保证db和缓存中的数据一致性所做的一个机制。

当然,还有一种失效,就是被动失效,一般是表现在内存空间不够的情况下,就需要将一些老的、僵尸缓存数据进行清除,从而腾出空间。内存空间清除主要是使用LRU算法,就是最久未使用算法,这个算法使用链表结构实现的,所有的缓存对象都放在一个链表上面,当一个对象被访问的时候,就把这个对象移到整个链表的头部,当需要通过LRU算法清除哪些久未使用的对象的时候,就只需要从队尾进行清除即可。

缓存主要类型

代理缓存

代理缓存是在应用程序一端的缓存,代理客户端访问互联网,它的主要作用就是代理互联网访问代理,也同时代理了所有客户端http请求,然后进行页面缓存。比如在一个公司里面,一般都会有自己公司的网络代理服务器,我们把在公司的网络代理服务器称为代理缓存,这个是在客户端的缓存,我们无法进行管理,所以代理缓存虽然存在,但是通常不作为我们系统柜架构中的一部分,我们能够管理的是反向代理缓存。

CgotOV13VcWAYl8OAAC1Z4DMJ1E846

反向代理缓存

反向代理则是代理数据中心输出的,是反向代理的,存在于系统数据中心里面,它是数据中心的统一入口,代理整个数据中心其他服务器的应用处理。

CgoB5l13VcuAQoUeAACKaiIOlJc476

用户通过互联网连接到数据中心的时候,最开始连接的通常是一个反向代理服务器,反向代理服务器根据用户的请求,在本地的反向代理缓存中查找是否有用户请求的数据,如果有则直接返回这个数据,如果没有再把这个请求向下继续转发到web服务器。

那我们有很多服务器,比如前端有前端web服务器,后端有后端web服务器,那如何进行存储和代理呢?

其实我们反向代理服务器是分层结构的,请求来了之后,先到前端服务器,再由前端服务器请求后面的后端服务器以及其他的各类服务器,我们在这个分层服务器结构中,可以对每一层的服务器进行反向代理缓存。

CgotOV13VnaAfPm9AAC46bZcN0w341

如,前段web服务器和后端web服务器,用户(外部流量)接入的时候,先到前端服务器,我们在这里就可以加一层反向代理服务器来代理前端web服务器的http请求,后续到后端后,也是可以加一层后端的反向代理服务器缓存和负载均衡。

通过这样的方式,极大地减少了前端 Web 服务器或者是 Web 服务器的访问压力,同时提高了系统的响应性能。

CDN存储

所谓的 CDN 是指在用户请求的前端(尽量前的前端)为用户提供数据服务。CDN 并不存在于我们的数据中心,也不存在于用户的访问系统一端,它介于两者之间,作为网络服务商的缓存服务。用户进行互联网访问的时候,需要通过互联网网络服务商提供的网络链接才能够连接到数据中心,那么网络服务商就可以在自己提供的网络服务的机房里进行一次缓存操作,提供一次缓存服务。如下图所示。

CgoB5l13Eh6AXvePAAGOZ4Q8scY773

  • 客户端第一次访问example.com的时候,首先访问数据中心,数据中心返回html的静态页面,当然html中包含了html、js、css、图片路径等等,这些静态资源如果全部访问我们的web服务器,那压力太大了,因为这些资源一般都不会改变的,所以这些资源访问的就是CDN服务器。
  • CDN服务器检查自己是否有对应的静态资源,有的话立马返回给客户端,如果没有就自己访问数据中心,获得数据后,缓存在CDN服务器上,供下次访问。

CDN缓存就是网络访问的第一跳,用户请求先到互联网网络服务商的机房,在机房里面部署CDN服务器提供缓存服务,如果CDN中存在用户请求的web响应内容,就可以直接通过CDN返回,如果不存在,CDN会先请求到后面的网络连接,打到数据中心区,数据中心返回的结果也是先通过CDN服务器,CDN服务器把数据缓存在自己的本地,方便后面的用户请求操作响应。

对象缓存

对象缓存,就是有着一套key-value键值对的存储机制。

以上我们讲解的代理缓存、CDN缓存、反向代理缓存,都是通读缓存,它代理了用户的请求,也就是说用户在访问数据的时候,总是要经过通读缓存的

和通读缓存相对应的叫做旁路缓存,是一个有独立的键值对,key-value对象存储。
CgotOV13ExOAP6m3AAB1BADrv1M771

先插入一下各种介质数据访问的延迟,便于对数据的存储、缓存的特性有一个感性的认识。

操作类型 粗略时间 纳秒 粗略时间毫秒
访问本地内存 100ns -
SSD磁盘搜索 100,000ns 0.1ms
网络数据包在同数据中心来回时间 500,000ns 0.5ms
磁盘搜索(非SSD) 10,000,000ns 10ms
按顺序从网络读取1MB数据 10,000,000ns 10ms
按顺序从磁盘(非SSD)读取1MB数据 30,000,000ns 30ms
跨大西洋网络数据包一次来回延迟 150,000,000ns 150ms

1s=1000ms=1,000,000,000ns

缓存注意事项

缓存虽然高效且简单,但并非无所不能,也并非无所顾忌的一股脑往里面丢,使用缓存,是需要合理使用的。

使用缓存,避免已经问题,或者是根据业务,有以下特点的业务也不太适合使用缓存。

6sfGxqCrdcg

  • 频繁修改的数据

    缓存数据就是为了一次写入多次读取做准备的,但如果写入的数据很快被修改掉了,数据还没来得及更新读取就已经失效或者更新掉,系统的负担就会很重,使用缓存也就没有什么意义,一般来说,数据的读写比例至少要在2:1以上,缓存才会有意义。

  • 没有热点的访问数据

    如果缓存写入的数据并不会多次读取,就是没有热点的数据,此时使用缓存也是没有太大的意义。比如pdd中的热门商品,每s可能被成千上万的客户端进行访问,或者是微博的大V,有这几百、几千万的粉丝,时时刻刻的被访问着,他们的数据也是有热点的,这些热点数据就极大的需要缓存机制。相比较我的微博就几个粉丝,还是僵尸粉。把我的内容缓存起来,没有任何意义,浪费内存资源。

  • 数据不一致和脏读

    业务能够容忍的失效时间之内,保持缓存中的数据和数据库中的数据不一致,如淘宝的商品数据,如果卖家对商品的数据进行编辑,但涉及到落地数据和缓存的时间差,比如1分钟,这1分钟内买家看到的有可能是旧的数据,这些延迟通常可以接收。这种一般就可以通过主动失效时间来解决,比如商品数据的TTL设置1min。
    如果某些业务场景对更新非常敏感,必须要实时看到,这时候就不能通过TTL进行处理了,必须要实时处理。比如订单商品,用户下完单付款完成后,必须要立马看到订单信息。此时应该是落库后,立即清除旧的订单列表缓存,下次访问这个列表的时候从数据库中加载,得到最新的数据。

  • 雪崩、击穿、穿透

    热点数据都是从缓存中读取,而热点数据是数据访问压力最大的一类数据,这些数据从缓存中读取,极大的降低数据库的访问压力。

    但是如果某个热点缓存失效(击穿),或者严重的是所有热点缓存失效(雪崩),或者是数据库中都没有数据(穿透),对于系统的压力立马会提高到一个新的事故,严重的可能导致宕机,系统奔溃。重启缓存也是没用的,因为重启后缓存还是没有数据。这个需要特别注意。

    关于这三个问题,我在另外一个基于redis的文章中有提到过,也给出了一些最佳实践方案,具体可以移步。redis-常见问题解决方案

分布式对象缓存

当今架构中,分布式对象缓存是比较重要的一环。

CgotOV13ExSAW9

所谓分布式对象缓存,就是以一个分布式集群的方式对外提供服务,多个应用系统使用同一个分布式对象缓存的缓存服务,这里的缓存服务由多态服务器组成的,共同构成一个集群对外提供服务,当然,首当其冲要解决的,就是数据进行读写操作的时候,需要正确的找到对应的服务器进行读写操作。也就是写入的数据在A服务器,读取的肯定是在A服务器,如果访问的是B服务器,就没有任何意义。

使用分布式缓存,服务器越多,提供的缓存空间就越大,实现的缓存效果也就越好,通过集群的方式,提供更多的缓存空间。

那么问题来了,如何才能找到正确的缓存服务器呢?
我们以memcached服务器为例,如下图,看看分布式对象的缓存模型:

Cgo9us7nBY044

当需要进行分布式缓存访问的时候,依然是以k-v这样的数据结构进行访问,如key:BEIJING,value:DATE这种键值对。

memcached提供的一个客户端API程序进行访问,客户端API程序会使用自己的路由算法进行路由选择,选择其中一台服务器,找到这台服务器的IP地址和端口之后,通过通讯模块和相对应的服务器进行通信。

那么路由器是如何选择服务器路由选择的呢?主要算法也是上面提到的取模算法,比如上图,有三台服务器,通过key的hashcode再对3取模,就可以获取到其中一台服务器,再通过这个数字查找对应的服务器IP即可,非常简单。 但是,但是,但是:

如果我扩容了呢?三台变四台,那之前对三取模存进去,现在对四取模肯定是取不出来的,这要的后果就是导致雪崩了。

所以为了解决这个问题,业内目前用的较多的是通过一致性哈希算法进行解决。

一致性哈希算法

一致性哈希首先是构建一个一致性哈希环的结构。一致性哈希环的大小是 0~2 的 32 次方减 1,实际上就是我们计算机中无符号整型值的取值范围,这个取值范围的 0 和最后一个值 2 的 32 次方减 1 首尾相连,就构成了一个一致性哈希环,如下图所示。 yizhixinghaxu

也简单,就是对服务器的节点取模,求他的hash值放到环上面,所有的服务器都取hash值放上去。然后key的处理也是,取它的hash值放到环上面,但是放的时候是放在顺时候查找距离它最近的服务器节点的那个服务器上面。通过这种方式也可以实现,key 不变的情况下找到的总是相同的服务器。这种一致性哈希算法除了可以实现像余数哈希一样的路由效果以外,对服务器的集群扩容效果也非常好。

比如key hello计算出来为5,那么最近的就是NODE2的服务器,则直接来NODE2这个服务器来取值。 key为world计算出来为3,也是一样。
当然,比如之前有NODE0、NODE1、NODE2三个节点,之前的key0和key3都是在NODE1节点上,现在新增了NODE3节点,处于NODE2和NODE1节点之间,那岂不是key0和key3找不到准确的数据了? 嗯 是的,是会影响到数据,不能正确的命中,会造成缓存的击穿现象,但是不会影响雪崩,或者影响雪崩的概率小。使用一致性hash算法,可以一定程度的实现缓存服务器的顺利伸缩扩容。

但是,还有一个致命的问题,就是hash算法是一个随机值,如果服务器节点放入一致性hash环上面,可能是不均匀的,也就是两个服务器可能距离很近,而和其他的服务器距离很远,很容易导致数据倾斜,导致有的服务器负载压力特别大,有的压力非常小,同时在进行扩容的时候,新加入的节点只是影响了节点1,并不能分摊到其他所有服务器的访问压力和数据冲突上面,如何解决?

当然,需要有一些改进,改进的方法就是使用虚拟节点,也就是说我们这一个服务器节点放入到一致性hash环上面的时候,并不是把正事的服务器的hash值放到环上,而是将服务器虚拟成若干个虚拟节点,把这些虚拟节点的hash值放到环上面。根据经验,会把一个服务器节点虚拟成200个虚拟节点,然后把这200个虚拟节点放到环上面,key依然是顺时针找到距离最近的虚拟节点,找到虚拟节点后再根据映射关系找到真正的物理节点。

所以:

  • 虚拟节点可以解决我们刚刚提到的数据倾斜问题导致负载不均匀。因为有更多的虚拟节点在环上,所以它们之间的距离总体来说大致是相近的。
  • 在加入一个新节点的时候,是加入多个虚拟节点的,比如 200 个虚拟节点,那么加入进来以后环上的每个节点都可能会受到影响,从而分摊原先每个服务器的一部分负载。

总结

缓存的主要优点是实现简单,使用方便,提升性能显著。
缓存的主要指标就是命中率。
需要合理的运用缓存,使用不恰当可能找出资源的浪费,还有注意到雪崩的问题,要避免。
分布式缓存是用的较多的,要注意算法的实现。
总之,缓存是无处不在的。在整个计算机系统中,在各个地方,只要你能够想得到的,都可以使用缓存来提升性能,甚至应用程序、一段代码中都可以使用缓存。所以我们要多关注缓存的使用,同时也要关注使用缓存的那些注意点。

现在我们再次看看这个图,应该有所感悟了吧。

huancunjiagoutu

人未眠
工作数十年
脚步未曾歇,学习未曾停
乍回首
路程虽丰富,知识未记录
   借此博客,与之共进步