高并发系统设计-缓存篇

编程/开发
255
0
0
2023-12-15
标签   高并发

常见硬件组件的延时情况如下图:

从这些数据中,你可以看到,做一次内存寻址大概需要 100ns,而做一次磁盘的查找则需要 10ms。可见,我们使用内存作为缓存的存储介质相比于以磁盘作为主要存储介质的数据库来说,性能上会提高多个数量级。所以,内存是最常见的一种缓存数据的介质。

一、缓存案例

1、TLB

Linux 内存管理是通过一个叫做 MMU(Memory Management Unit)的硬件,来实现从虚拟地址到物理地址的转换的,但是如果每次转换都要做这么复杂计算的话,无疑会造成性能的损耗,所以我们会借助一个叫做 TLB(Translation Lookaside Buffer)的组件来缓存最近转换过的虚拟地址,和物理地址的映射。TLB 就是一种缓存组件。

2、抖音

平台上的短视频实际上是使用内置的网络播放器来完成的。网络播放器接收的是数据流,将数据下载下来之后经过分离音视频流,解码等流程后输出到外设设备上播放。播放器中通常会设计一些缓存的组件,在未打开视频时缓存一部分视频数据,比如我们打开抖音,服务端可能一次会返回三个视频信息,我们在播放第一个视频的时候,播放器已经帮我们缓存了第二、三个视频的部分数据,这样在看第二个视频的时候就可以给用户“秒开”的感觉。

3、HTTP协议缓存

当我们第一次请求静态的资源时,比如一张图片,服务端除了返回图片信息,在响应头里面还有一个“Etag”的字段。浏览器会缓存图片信息以及这个字段的值。当下一次再请求这个图片的时候,浏览器发起的请求头里面会有一个“If-None-Match”的字段,并且把缓存的“Etag”的值写进去发给服务端。服务端比对图片信息是否有变化,如果没有,则返回浏览器一个 304 的状态码,浏览器会继续使用缓存的图片信息。通过这种缓存协商的方式,可以减少网络传输的数据大小,从而提升页面展示性能。

二、缓存分类

1、静态缓存

静态缓存在 Web 1.0 时期是非常著名的,它一般通过生成 Velocity 模板或者静态 HTML 文件来实现静态缓存,在 Nginx 上部署静态缓存可以减少对于后台应用服务器的压力

2、分布式缓存

分布式缓存的大名可谓是如雷贯耳了,我们平时耳熟能详的 Memcached、Redis 就是分布式缓存的典型例子。它们性能强劲,通过一些分布式的方案组成集群可以突破单机的限制。所以在整体架构中,分布式缓存承担着非常重要的角色

3、本地缓存

Guava Cache 或者是 Ehcache 等,它们和应用程序部署在同一个进程中,优势是不需要跨网络调度,速度极快,所以可以用来阻挡短时间内的热点查询。

三、缓存的读写策略

1、Cache Aside策略

在更新数据时不更新缓存,而是删除缓存中的数据,在读取数据时,发现缓存中没了数据之后,再从数据库中读取数据,更新到缓存中。

这个策略就是我们使用缓存最常见的策略,Cache Aside 策略(也叫旁路缓存策略),这个策略数据以数据库中的数据为准,缓存中的数据是按需加载的。

Cache Aside 策略是我们日常开发中最经常使用的缓存策略,不过我们在使用时也要学会依情况而变,并不是一成不变的。Cache Aside 存在的最大的问题是当写入比较频繁时,缓存中的数据会被频繁地清理,这样会对缓存的命中率有一些影响。如果你的业务对缓存命中率有严格的要求,那么可以考虑两种解决方案:

一种做法是在更新数据时也更新缓存,只是在更新缓存前先加一个分布式锁,因为这样在同一时间只允许一个线程更新缓存,就不会产生并发问题了。当然这么做对于写入的性能会有一些影响(推荐);

另一种做法同样也是在更新数据时更新缓存,只是给缓存加一个较短的过期时间,这样即使出现缓存不一致的情况,缓存的数据也会很快过期,对业务的影响也是可以接受。

2、Read/Write Through

这个策略的核心原则是用户只与缓存打交道,由缓存和数据库通信,写入或者读取数据。

Write Through

的策略是这样的:先查询要写入的数据在缓存中是否已经存在,如果已经存在,则更新缓存中的数据,并且由缓存组件同步更新到数据库中,如果缓存中数据不存在,我们把这种情况叫做“Write Miss(写失效)”。一般来说,我们可以选择两种“Write Miss”方式:一个是“Write Allocate(按写分配)”,做法是写入缓存相应位置,再由缓存组件同步更新到数据库中;另一个是“No-write allocate(不按写分配)”,做法是不写入缓存中,而是直接更新到数据库中。 我们看到 Write Through 策略中写数据库是同步的,这对于性能来说会有比较大的影响,因为相比于写缓存,同步写数据库的延迟就要高很多了。通过Write Back策略异步的更新数据库。

Read Through

策略就简单一些,它的步骤是这样的:先查询缓存中数据是否存在,如果存在则直接返回,如果不存在,则由缓存组件负责从数据库中同步加载数据。

3、Write Back

这个策略的核心思想是在写入数据时只写入缓存,并且把缓存块儿标记为“脏”的。而脏块儿只有被再次使用时才会将其中的数据写入到后端存储中。 在“Write Miss”的情况下,我们采用的是“Write Allocate”的方式,也就是在写入后端存储的同时要写入缓存,这样我们在之后的写请求中都只需要更新缓存即可,而无需更新后端存储了。注意与上面的write through策略作区分。

我们在读取缓存时如果发现缓存命中则直接返回缓存数据。如果缓存不命中则寻找一个可用的缓存块儿,如果这个缓存块儿是“脏”的,就把缓存块儿中之前的数据写入到后端存储中,并且从后端存储加载数据到缓存块儿,如果不是脏的,则由缓存组件将后端存储中的数据加载到缓存中,最后我们将缓存设置为不是脏的,返回数据就好了。

write back策略多用于向磁盘中写数据。例如:操作系统层面的 Page Cache、日志的异步刷盘、消息队列中消息的异步写入磁盘等。因为这个策略在性能上的优势毋庸置疑,它避免了直接写磁盘造成的随机写问题,毕竟写内存和写磁盘的随机 I/O 的延迟相差了几个数量级呢。

四、缓存高可用

缓存的命中率是缓存需要监控的数据指标,缓存的高可用可以一定程度上减少缓存穿透的概率,提升系统的稳定性。缓存的高可用方案主要包括客户端方案、中间代理层方案和服务端方案三大类:

1、客户端方案

在客户端方案中,你需要关注缓存的写和读两个方面: 写入数据时,需要把被写入缓存的数据分散到多个节点中,即进行数据分片; 读数据时,可以利用多组的缓存来做容错,提升缓存系统的可用性。关于读数据,这里可以使用主从和多副本两种策略,两种策略是为了解决不同的问题而提出的。 具体的实现细节包括:数据分片、主从、多副本

数据分片

一致性Hash算法。在这个算法中,我们将整个 Hash 值空间组织成一个虚拟的圆环,然后将缓存节点的 IP 地址或者主机名做 Hash 取值后,放置在这个圆环上。当我们需要确定某一个 Key 需要存取到哪个节点上的时候,先对这个 Key 做同样的 Hash 取值,确定在环上的位置,然后按照顺时针方向在环上“行走”,遇到的第一个缓存节点就是要访问的节点。

这时如果在 Node 1 和 Node 2 之间增加一个 Node 5,你可以看到原本命中 Node 2 的 Key 3 现在命中到 Node 5,而其它的 Key 都没有变化;同样的道理,如果我们把 Node 3 从集群中移除,那么只会影响到 Key 5 。所以你看,在增加和删除节点时,只有少量的 Key 会“漂移”到其它节点上,而大部分的 Key 命中的节点还是会保持不变,从而可以保证命中率不会大幅下降。 【提示】一致性hash出现的缓存雪崩现象使用虚拟节点解决。一致性hash分片与hash分片的区别在于,缓存命中率的问题,hash分片在存在机器加入或是减少的情况时候,会导致缓存失效,缓存命中率下降。

主从

Redis 本身支持主从的部署方式,但是 Memcached 并不支持,Memcached 的主从机制是如何在客户端实现的。为每一组 Master 配置一组 Slave,更新数据时主从同步更新。读取时,优先从 Slave 中读数据,如果读取不到数据就穿透到 Master 读取,并且将数据回种到 Slave 中以保持 Slave 数据的热度。主从机制最大的优点就是当某一个 Slave 宕机时,还会有 Master 作为兜底,不会有大量请求穿透到数据库的情况发生,提升了缓存系统的高可用性。

多副本

主从方式已经能够解决大部分场景的问题,但是对于极端流量的场景下,一组 Slave 通常来说并不能完全承担所有流量,Slave 网卡带宽可能成为瓶颈。为了解决这个问题,我们考虑在 Master/Slave 之前增加一层副本层,整体架构是这样的:

这个方案中,当客户端发起查询请求时,请求首先会先从多个副本组中选取一个副本组发起查询,如果查询失败,就继续查询 Master/Slave,并且将查询的结果回种到所有副本组中,避免副本组中脏数据的存在。基于成本的考虑,每一个副本组容量比 Master 和 Slave 要小,因此它只存储了更加热的数据。在这套架构中,Master 和 Slave 的请求量会大大减少,为了保证它们存储数据的热度,在实践中我们会把 Master 和 Slave 作为一组副本组使用。

2、中间代理层

业界也有很多中间代理层方案,比如 Facebook 的Mcrouter,Twitter 的Twemproxy,豌豆荚的Codis。它们的原理基本上可以由一张图来概括:

3、服务端方案

Redis 在 2.4 版本中提出了 Redis Sentinel 模式来解决主从 Redis 部署时的高可用问题,它可以在主节点挂了以后自动将从节点提升为主节点,保证整体集群的可用性,整体的架构如下图所示:

redis Sentinel 也是集群部署的,这样可以避免 Sentinel 节点挂掉造成无法自动故障恢复的问题,每一个 Sentinel 节点都是无状态的。在 Sentinel 中会配置 Master 的地址,Sentinel 会时刻监控 Master 的状态,当发现 Master 在配置的时间间隔内无响应,就认为 Master 已经挂了,Sentinel 会从从节点中选取一个提升为主节点,并且把所有其他的从节点作为新主的从节点。Sentinel 集群内部在仲裁的时候,会根据配置的值来决定当有几个 Sentinel 节点认为主挂掉可以做主从切换的操作,也就是集群内部需要对缓存节点的状态达成一致才行。

【提示】上述客户端到sentinel集群的连线是虚线,因为对于缓存的写入和读取请求不会经过 Sentinel 节点。

五、缓存穿透

1、帕累托

互联网系统的数据访问模型一般会遵从“80/20 原则”。“80/20 原则”又称为帕累托法则,是意大利经济学家帕累托提出的一个经济学的理论。简单来说,它是指在一组事物中,最重要的部分通常只占 20%,而其他的 80% 并没有那么重要。把它应用到数据访问的领域,就是我们会经常访问 20% 的热点数据,而另外的 80% 的数据则不会被经常访问。既然缓存的容量有限,并且大部分的访问只会请求 20% 的热点数据,那么理论上说,我们只需要在有限的缓存空间里存储 20% 的热点数据就可以有效地保护脆弱的后端系统了,也就可以放弃缓存另外 80% 的非热点数据了。所以这种少量的缓存穿透是不可避免的,但是对系统是没有损害的。

2、回种空值

当我们从数据库中查询到空值或者发生异常时,我们可以向缓存中回种一个空值。但是因为空值并不是准确的业务数据,并且会占用缓存的空间,所以我们会给这个空值加一个比较短的过期时间,让空值在短时间之内能够快速过期淘汰。回种空值虽然能够阻挡大量穿透的请求,但如果有大量的空值缓存,也就会浪费缓存的存储空间,如果缓存空间被占满了,还会剔除掉一些已经被缓存的用户信息反而会造成缓存命中率的下降。所以这个方案,我建议你在使用的时候应该评估一下缓存容量是否能够支撑。如果需要大量的缓存节点来支持,那么就无法通过通过回种空值的方式来解决,这时你可以考虑使用布隆过滤器。

3、布隆过滤器

1970 年布隆提出了一种布隆过滤器的算法,用来判断一个元素是否在一个集合中。这种算法由一个二进制数组和一个 Hash 算法组成。它的基本思路如下:我们把集合中的每一个值按照提供的 Hash 算法算出对应的 Hash 值,然后将 Hash 值对数组长度取模后得到需要计入数组的索引值,并且将数组这个位置的值从 0 改成 1。在判断一个元素是否存在于这个集合中时,你只需要将这个元素按照相同的算法计算出索引值,如果这个位置的值为 1 就认为这个元素在集合中,否则则认为不在集合中。

如何使用布隆过滤器解决缓存穿透呢?

以存储用户信息的表为例进行讲解。首先我们初始化一个很大的数组,比方说长度为 20 亿的数组,接下来我们选择一个 Hash 算法,然后我们将目前现有的所有用户的 ID 计算出 Hash 值并且映射到这个大数组中,映射位置的值设置为 1,其它值设置为 0。新注册的用户除了需要写入到数据库中之外,它也需要依照同样的算法更新布隆过滤器的数组中相应位置的值。那么当我们需要查询某一个用户的信息时,先查询这个 ID 在布隆过滤器中是否存在,如果不存在就直接返回空值,而不需要继续查询数据库和缓存,这样就可以极大地减少异常查询带来的缓存穿透。

布隆过滤器优点:

(1)性能高。无论是写入操作还是读取操作,时间复杂度都是 O(1) 是常量值

(2)节省空间。比如,20 亿的数组需要 2000000000/8/1024/1024 = 238M 的空间,而如果使用数组来存储,假设每个用户 ID 占用 4 个字节的空间,那么存储 20 亿用户需要 2000000000 * 4 / 1024 / 1024 = 7600M 的空间,是布隆过滤器的 32 倍。

布隆过滤器缺点:

(1)它在判断元素是否在集合中时是有一定错误几率的,比如它会把不是集合中的元素判断为处在集合中。

原因:Hash算法本身的缺陷。

解决方案:使用多个 Hash 算法为元素计算出多个 Hash 值,只有所有 Hash 值对应的数组中的值都为 1 时,才会认为这个元素在集合中。

(2)不支持删除元素。布隆过滤器不支持删除元素的缺陷也和 Hash 碰撞有关。举一个例子,假如两个元素 A 和 B 都是集合中的元素,它们有相同的 Hash 值,它们就会映射到数组的同一个位置。这时我们删除了 A,数组中对应位置的值也从 1 变成 0,那么在判断 B 的时候发现值是 0,也会判断 B 是不在集合中的元素,就会得到错误的结论。

解决方案:我会让数组中不再只有 0 和 1 两个值,而是存储一个计数。比如如果 A 和 B 同时命中了一个数组的索引,那么这个位置的值就是 2,如果 A 被删除了就把这个值从 2 改为 1。这个方案中的数组不再存储 bit 位,而是存储数值,也就会增加空间的消耗。

4、狗桩效应

比方说当有一个极热点的缓存项,它一旦失效会有大量请求穿透到数据库,这会对数据库造成瞬时极大的压力,我们把这个场景叫做“dog-pile effect”(狗桩效应)。解决狗桩效应的思路是尽量地减少缓存穿透后的并发,方案也比较简单:

(1)在代码中控制在某一个热点缓存项失效之后启动一个后台线程,穿透到数据库,将数据加载到缓存中,在缓存未加载之前,所有访问这个缓存的请求都不再穿透而直接返回。

(2)通过在 Memcached 或者 Redis 中设置分布式锁,只有获取到锁的请求才能够穿透到数据库

六、CDN

1、静态资源加速的原因

在我们的系统中存在着大量的静态资源请求:对于移动 APP 来说,这些静态资源主要是图片、视频和流媒体信息;对于 Web 网站来说,则包括了 JavaScript 文件、CSS 文件、静态 HTML 文件等等。它们的读请求量极大并且对访问速度的要求很高还占据了很高的带宽,这时会出现访问速度慢带宽被占满影响动态请求的问题,那么你就需要考虑如何针对这些静态资源进行读加速了。

2、CDN

静态资源访问的关键点是就近访问,即北京用户访问北京的数据,杭州用户访问杭州的数据,这样才可以达到性能的最优。我们考虑在业务服务器的上层增加一层特殊的缓存,用来承担绝大部分对于静态资源的访问,这一层特殊缓存的节点需要遍布在全国各地,这样可以让用户选择最近的节点访问。缓存的命中率也需要一定的保证,尽量减少访问资源存储源站的请求数量(回源请求)。这一层缓存就是CDN。

CDN(Content Delivery Network/Content Distribution Network,内容分发网络)。简单来说,CDN 就是将静态的资源分发到位于多个地理位置机房中的服务器上,因此它能很好地解决数据就近访问的问题,也就加快了静态资源的访问速度。

3、搭建CDN系统

搭建一个 CDN 系统需要考虑哪两点:

(1)如何将用户的请求映射到 CDN 节点上

你可能会觉得这很简单啊,只需要告诉用户 CDN 节点的 IP 地址,然后请求这个 IP 地址上面部署的 CDN 服务就可以了啊。但是,并不是这样,需要把ip替换为相应的域名。那么如何做到这一点呢?这就需要依靠 DNS 来帮我们解决域名映射的问题了。DNS(Domain Name System,域名系统)实际上就是一个存储域名和 IP 地址对应关系的分布式数据库。而域名解析的结果一般有两种,一种叫做“A 记录”,返回的是域名对应的 IP 地址;另一种是“CNAME 记录”,返回的是另一个域名,也就是说当前域名的解析要跳转到另一个域名的解析上。

举个例子:比如你的公司的一级域名叫做 example.com,那么你可以把你的图片服务的域名定义为“img.example.com”,然后将这个域名的解析结果的 CNAME 配置到 CDN 提供的域名上,比如 uclound 可能会提供一个域名是“80f21f91.cdn.ucloud.com.cn”这个域名。这样你的电商系统使用的图片地址可以是“img.example.com/1.jpg”。

用户在请求这个地址时,DNS 服务器会将域名解析到 80f21f91.cdn.ucloud.com.cn 域名上,然后再将这个域名解析为 CDN 的节点 IP,这样就可以得到 CDN 上面的资源数据了。

域名层级解析优化

因为域名解析过程是分级的,每一级有专门的域名服务器承担解析的职责,所以域名的解析过程有可能需要跨越公网做多次 DNS 查询,在性能上是比较差的。一个解决的思路是:在 APP 启动时对需要解析的域名做预先解析,然后把解析的结果缓存到本地的一个 LRU 缓存里面。这样当我们要使用这个域名的时候,只需要从缓存中直接拿到所需要的 IP 地址就好了,如果缓存中不存在才会走整个 DNS 查询的过程。同时为了避免 DNS 解析结果的变更造成缓存内数据失效,我们可以启动一个定时器定期地更新缓存中的数据。

(2)如何根据用户的地理位置信息选择到比较近的节点。

GSLB(Global Server Load Balance,全局负载均衡)的含义是对于部署在不同地域的服务器之间做负载均衡,下面可能管理了很多的本地负载均衡组件。它有两方面的作用:一方面,它是一种负载均衡服务器,负载均衡,顾名思义嘛,指的是让流量平均分配使得下面管理的服务器的负载更平均;另一方面,它还需要保证流量流经的服务器与流量源头在地缘上是比较接近的。

GSLB 可以通过多种策略来保证返回的 CDN 节点和用户尽量保证在同一地缘区域,比如说可以将用户的 IP 地址按照地理位置划分为若干个区域,然后将 CDN 节点对应到一个区域上,根据用户所在区域来返回合适的节点;也可以通过发送数据包测量 RTT 的方式来决定返回哪一个节点。

总结:DNS 技术是 CDN 实现中使用的核心技术,可以将用户的请求映射到 CDN 节点上;DNS 解析结果需要做本地缓存,降低 DNS 解析过程的响应时间;GSLB 可以给用户返回一个离着他更近的节点,加快静态资源的访问速度。

拓展

(1)百度域名的解析过程

一开始,域名解析请求先会检查本机的 hosts 文件,查看是否有 www.baidu.com 对应的 IP;如果没有的话,就请求 Local DNS 是否有域名解析结果的缓存,如果有就返回标识是从非权威 DNS 返回的结果;如果没有就开始 DNS 的迭代查询。先请求根 DNS,根 DNS 返回顶级 DNS(.com)的地址;再请求.com 顶级 DNS 得到 baidu.com 的域名服务器地址;再从 baidu.com 的域名服务器中查询到 www.baidu.com 对应的 IP 地址,返回这个 IP 地址的同时标记这个结果是来自于权威 DNS 的结果,同时写入 Local DNS 的解析结果缓存,这样下一次的解析同一个域名就不需要做 DNS 的迭代查询了。

(2)CDN延时

一般我们会通过 CDN 厂商的接口将静态的资源写入到某一个 CDN 节点上,再由 CDN 内部的同步机制将资源分散同步到每个 CDN 节点,即使 CDN 内部网络经过了优化,这个同步的过程是有延时的,一旦我们无法从选定的 CDN 节点上获取到数据,我们就不得不从源站获取数据,而用户网络到源站的网络可能会跨越多个主干网,这样不仅性能上有损耗也会消耗源站的带宽,带来更高的研发成本。所以我们在使用 CDN 的时候需要关注 CDN 的命中率和源站的带宽情况。