gitbook/高并发系统设计40问/docs/151949.md
2022-09-03 22:05:03 +08:00

147 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 14 | 缓存的使用姿势(二):缓存如何做到高可用?
你好,我是唐扬。
前面几节课,我带你了解了缓存的原理、分类以及常用缓存的使用技巧。我们开始用缓存承担大部分的读压力,从而缓解数据库的查询压力,在提升性能的同时保证系统的稳定性。这时,你的电商系统整体的架构演变成下图的样子:
![](https://static001.geekbang.org/resource/image/6c/05/6c860d61a578cde20591968cc2741a05.jpg)
我们在Web层和数据库层之间增加了缓存层请求会首先查询缓存只有当缓存中没有需要的数据时才会查询数据库。
在这里,你需要关注缓存命中率这个指标(缓存命中率=命中缓存的请求数/总请求数。一般来说在你的电商系统中核心缓存的命中率需要维持在99%甚至是99.9%哪怕下降1%,系统都会遭受毁灭性的打击。
这绝不是危言耸听我们来计算一下。假设系统的QPS是10000/s每次调用会访问10次缓存或者数据库中的数据那么当缓存命中率仅仅减少1%数据库每秒就会增加10000 \* 10 \* 1% = 1000次请求。而一般来说我们单个MySQL节点的读请求量峰值就在1500/s左右增加的这1000次请求很可能会给数据库造成极大的冲击。
命中率仅仅下降1%造成的影响就如此可怕,更不要说缓存节点故障了。而图中单点部署的缓存节点就成了整体系统中最大的隐患,那我们要如何来解决这个问题,提升缓存的可用性呢?
我们可以通过部署多个节点,同时设计一些方案让这些节点互为备份。这样,当某个节点故障时,它的备份节点可以顶替它继续提供服务。**而这些方案就是我们本节课的重点:分布式缓存的高可用方案。**
在我的项目中,我主要选择的方案有**客户端方案、中间代理层方案和服务端方案**三大类:
* **客户端方案**就是在客户端配置多个缓存的节点,通过缓存写入和读取算法策略来实现分布式,从而提高缓存的可用性。
* **中间代理层方案**是在应用代码和缓存节点之间增加代理层,客户端所有的写入和读取的请求都通过代理层,而代理层中会内置高可用策略,帮助提升缓存系统的高可用。
* **服务端方案**就是Redis 2.4版本后提出的Redis Sentinel方案。
掌握这些方案可以帮助你,抵御部分缓存节点故障导致的,缓存命中率下降的影响,增强你的系统的鲁棒性。
## 客户端方案
在客户端方案中,你需要关注缓存的写和读两个方面:
* 写入数据时,需要把被写入缓存的数据分散到多个节点中,即进行数据分片;
* 读数据时,可以利用多组的缓存来做容错,提升缓存系统的可用性。关于读数据,这里可以使用主从和多副本两种策略,两种策略是为了解决不同的问题而提出的。
下面我就带你一起详细地看一下到底要怎么做。
**1.缓存数据如何分片**
单一的缓存节点受到机器内存、网卡带宽和单节点请求量的限制,不能承担比较高的并发,因此我们考虑将数据分片,依照分片算法将数据打散到多个不同的节点上,每个节点上存储部分数据。
这样在某个节点故障的情况下,其他节点也可以提供服务,保证了一定的可用性。这就好比不要把鸡蛋放在同一个篮子里,这样一旦一个篮子掉在地上,摔碎了,别的篮子里还有没摔碎的鸡蛋,不至于一个不剩。
**一般来讲分片算法常见的就是Hash分片算法和一致性Hash分片算法两种。**
Hash分片的算法就是对缓存的Key做哈希计算然后对总的缓存节点个数取余。你可以这么理解
比如说我们部署了三个缓存节点组成一个缓存的集群当有新的数据要写入时我们先对这个缓存的Key做比如crc32等Hash算法生成Hash值然后对Hash值模3得出的结果就是要存入缓存节点的序号。
![](https://static001.geekbang.org/resource/image/72/55/720f7e4543d45fdc71056de280caff55.jpg)
这个算法最大的优点就是简单易理解,缺点是当增加或者减少缓存节点时,缓存总的节点个数变化造成计算出来的节点发生变化,从而造成缓存失效不可用。**所以我建议你,**如果采用这种方法,最好建立在你对于这组缓存命中率下降不敏感,比如下面还有另外一层缓存来兜底的情况下。

**当然了用一致性Hash算法可以很好地解决增加和删减节点时命中率下降的问题。**在这个算法中我们将整个Hash值空间组织成一个虚拟的圆环然后将缓存节点的IP地址或者主机名做Hash取值后放置在这个圆环上。当我们需要确定某一个Key需要存取到哪个节点上的时候先对这个Key做同样的Hash取值确定在环上的位置然后按照顺时针方向在环上“行走”遇到的第一个缓存节点就是要访问的节点。比方说下面这张图里面Key 1和Key 2会落入到Node 1中Key 3、Key 4会落入到Node 2中Key 5落入到Node 3中Key 6落入到Node 4中。
![](https://static001.geekbang.org/resource/image/f9/fe/f9ea0e201aa954cf46c5762835095efe.jpg)
这时如果在Node 1和Node 2之间增加一个Node 5你可以看到原本命中Node 2的Key 3现在命中到Node 5而其它的Key都没有变化同样的道理如果我们把Node 3从集群中移除那么只会影响到Key 5 。所以你看,**在增加和删除节点时只有少量的Key会“漂移”到其它节点上**而大部分的Key命中的节点还是会保持不变从而可以保证命中率不会大幅下降。
![](https://static001.geekbang.org/resource/image/4c/91/4c13c4fd4278dc97d072afe09a1a1b91.jpg)
不过,事物总有两面性。虽然这个算法对命中率的影响比较小,但它还是存在问题:
* 缓存节点在圆环上分布不平均,会造成部分缓存节点的压力较大;当某个节点故障时,这个节点所要承担的所有访问都会被顺移到另一个节点上,会对后面这个节点造成压力。
* 一致性Hash算法的脏数据问题。
极端情况下比如一个有三个节点A、B、C承担整体的访问每个节点的访问量平均A故障后B将承担双倍的压力A和B的全部请求当B承担不了流量Crash后C也将因为要承担原先三倍的流量而Crash这就造成了整体缓存系统的雪崩。
说到这儿,你可能觉得很可怕,但也不要太担心,**我们程序员就是要能够创造性地解决各种问题所以你可以在一致性Hash算法中引入虚拟节点的概念。**
它将一个缓存节点计算多个Hash值分散到圆环的不同位置这样既实现了数据的平均而且当某一个节点故障或者退出的时候它原先承担的Key将以更加平均的方式分配到其他节点上从而避免雪崩的发生。
**其次就是一致性Hash算法的脏数据问题。为什么会产生脏数据呢**比方说在集群中有两个节点A和B客户端初始写入一个Key为k值为3的缓存数据到Cache A中。这时如果要更新k的值为4但是缓存A恰好和客户端连接出现了问题那这次写入请求会写入到Cache B中。接下来缓存A和客户端的连接恢复当客户端要获取k的值时就会获取到存在Cache A中的脏数据3而不是Cache B中的4。
**所以在使用一致性Hash算法时一定要设置缓存的过期时间**这样当发生漂移时,之前存储的脏数据可能已经过期,就可以减少存在脏数据的几率。
![](https://static001.geekbang.org/resource/image/4c/f8/4c10bb2e9b0f6cb9920d4b1c9418b2f8.jpg)
很显然,数据分片最大的优势就是缓解缓存节点的存储和访问压力,但同时它也让缓存的使用更加复杂。在MultiGet批量获取场景下单个节点的访问量并没有减少同时节点数太多会造成缓存访问的SLA即“服务等级协议”SLA代表了网站服务可用性得不到很好的保证因为根据木桶原则SLA取决于最慢、最坏的节点的情况节点数过多也会增加出问题的概率**因此我推荐4到6个节点为佳。**
**2.Memcached的主从机制**
Redis本身支持主从的部署方式但是Memcached并不支持所以我们今天主要来了解一下Memcached的主从机制是如何在客户端实现的。
在之前的项目中我就遇到了单个主节点故障导致数据穿透的问题这时我为每一组Master配置一组Slave更新数据时主从同步更新。读取时优先从Slave中读数据如果读取不到数据就穿透到Master读取并且将数据回种到Slave中以保持Slave数据的热度。
主从机制最大的优点就是当某一个Slave宕机时还会有Master作为兜底不会有大量请求穿透到数据库的情况发生提升了缓存系统的高可用性。
![](https://static001.geekbang.org/resource/image/54/60/5468eb8779396b38c3731839f3d8d960.jpg)
**3.多副本**
其实主从方式已经能够解决大部分场景的问题但是对于极端流量的场景下一组Slave通常来说并不能完全承担所有流量Slave网卡带宽可能成为瓶颈。
为了解决这个问题我们考虑在Master/Slave之前增加一层副本层整体架构是这样的
![](https://static001.geekbang.org/resource/image/67/03/6779f9b6741b7767068df767218bcd03.jpg)
在这个方案中当客户端发起查询请求时请求首先会先从多个副本组中选取一个副本组发起查询如果查询失败就继续查询Master/Slave并且将查询的结果回种到所有副本组中避免副本组中脏数据的存在。
基于成本的考虑每一个副本组容量比Master和Slave要小因此它只存储了更加热的数据。在这套架构中Master和Slave的请求量会大大减少为了保证它们存储数据的热度在实践中我们会把Master和Slave作为一组副本组使用。
## 中间代理层方案
虽然客户端方案已经能解决大部分的问题但是只能在单一语言系统之间复用。例如微博使用Java语言实现了这么一套逻辑我使用PHP就难以复用需要重新写一套很麻烦。**而中间代理层的方案就可以解决这个问题。**你可以将客户端解决方案的经验移植到代理层中通过通用的协议如Redis协议来实现在其他语言中的复用。
如果你来自研缓存代理层,你就可以将客户端方案中的高可用逻辑封装在代理层代码里面,这样用户在使用你的代理层的时候就不需要关心缓存的高可用是如何做的,只需要依赖你的代理层就好了。
除此以外业界也有很多中间代理层方案比如Facebook的[Mcrouter](https://github.com/facebook/mcrouter)Twitter的[Twemproxy](https://github.com/twitter/twemproxy),豌豆荚的[Codis](https://github.com/CodisLabs/codis)。它们的原理基本上可以由一张图来概括:
![](https://static001.geekbang.org/resource/image/c5/43/c517437faf418e7fa085b1850e3f7343.jpg)
看这张图你有什么发现吗? 所有缓存的**读写请求**都是经过代理层完成的。代理层是无状态的主要负责读写请求的路由功能并且在其中内置了一些高可用的逻辑不同的开源中间代理层方案中使用的高可用策略各有不同。比如在Twemproxy中Proxy保证在某一个Redis节点挂掉之后会把它从集群中移除后续的请求将由其他节点来完成而Codis的实现略复杂它提供了一个叫Codis Ha的工具来实现自动从节点提主节点在3.2版本之后换做了Redis Sentinel方式从而实现Redis节点的高可用。
## 服务端方案
Redis在2.4版本中提出了Redis Sentinel模式来解决主从Redis部署时的高可用问题它可以在主节点挂了以后自动将从节点提升为主节点保证整体集群的可用性整体的架构如下图所示
![](https://static001.geekbang.org/resource/image/94/e1/94ae214f840d2844b7b43751aab6d8e1.jpg)
Redis Sentinel也是集群部署的这样可以避免Sentinel节点挂掉造成无法自动故障恢复的问题每一个Sentinel节点都是无状态的。在Sentinel中会配置Master的地址Sentinel会时刻监控Master的状态当发现Master在配置的时间间隔内无响应就认为Master已经挂了Sentinel会从从节点中选取一个提升为主节点并且把所有其他的从节点作为新主的从节点。Sentinel集群内部在仲裁的时候会根据配置的值来决定当有几个Sentinel节点认为主挂掉可以做主从切换的操作也就是集群内部需要对缓存节点的状态达成一致才行。
Redis Sentinel不属于代理层模式因为对于缓存的写入和读取请求不会经过Sentinel节点。Sentinel节点在架构上和主从是平级的是作为管理者存在的**所以可以认为是在服务端提供的一种高可用方案。**
## 课程小结
这就是今天分享的全部内容了,我们一起来回顾一下重点:
分布式缓存的高可用方案主要有三种首先是客户端方案一般也称为Smart Client。我们通过制定一些数据分片和数据读写的策略可以实现缓存高可用。这种方案的好处是性能没有损耗缺点是客户端逻辑复杂且在多语言环境下不能复用。
其次中间代理方案在客户端和缓存节点之间增加了中间层在性能上会有一些损耗在代理层会有一些内置的高可用方案比如Codis会使用Codis Ha或者Sentinel。
最后服务端方案依赖于组件的实现Memcached就只支持单机版没有分布式和HA的方案而Redis在2.4版本提供了Sentinel方案可以自动进行主从切换。服务端方案会在运维上增加一些复杂度。
总体而言分布式缓存的三种方案各有所长有些团队可能在开发过程中已经积累了Smart Client上的一些经验而有些团队在Redis运维上经验丰富就可以推进Sentinel方案有些团队在存储研发方面有些积累就可以推进中间代理层方案甚至可以自研适合自己业务场景的代理层组件具体的选择还是要看团队的实际情况而定。
## 一课一思
结合你们过往的经历,我们来聊一聊缓存高可用的重要性吧,比如当缓存可用性下降会造成什么严重问题呢?你们又是如何来保证缓存的高可用的呢?欢迎在留言区与我一同讨论。
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。