框架相关(4)-- 分布式缓存

缓存技术是一个老生常谈的问题,但是它也是解决性能问题的利器,一把瑞士军刀;而且在各种面试过程中或多或少会被问及一些缓存相关的问题,如缓存算法、热点数据与更新缓存、更新缓存与原子性、缓存崩溃与快速恢复等各种与缓存相关的问题。而这些问题中有些问题又是与场景相关,因此如何合理应用缓存来解决问题也是一个选择题。

1、多级缓存介绍

所谓多级缓存,即在整个系统架构的不同系统层级进行数据缓存,以提升访问效率,这也是应用最广的方案之一。我们应用的整体架构如下图所示:

框架相关(4)-- 分布式缓存_第1张图片

整体流程如上图所示:

(1)首先接入Nginx将请求负载均衡到应用Nginx,此处常用的负载均衡算法是轮询或者一致性哈希,轮询可以使服务器的请求更加均衡,而一致性哈希可以提升应用Nginx的缓存命中率;后续负载均衡和缓存算法部分我们再细聊;

(2)接着应用Nginx读取本地缓存(本地缓存可以使用Lua Shared Dict、Nginx Proxy Cache(磁盘/内存)、Local Redis实现),如果本地缓存命中则直接返回,使用应用Nginx本地缓存可以提升整体的吞吐量,降低后端的压力,尤其应对热点问题非常有效;为什么要使用应用Nginx本地缓存我们将在热点数据与缓存失效部分细聊;

(3)如果Nginx本地缓存没命中,则会读取相应的分布式缓存(如Redis缓存,另外可以考虑使用主从架构来提升性能和吞吐量),如果分布式缓存命中则直接返回相应数据(并回写到Nginx本地缓存);

(4)如果分布式缓存也没有命中,则会回源到Tomcat集群,在回源到Tomcat集群时也可以使用轮询和一致性哈希作为负载均衡算法;

(5)在Tomcat应用中,首先读取本地堆缓存,如果有则直接返回(并会写到主Redis集群),为什么要加一层本地堆缓存将在缓存崩溃与快速修复部分细聊;

(6)作为可选部分,如果步骤4没有命中可以再尝试一次读主Redis集群操作,目的是防止当从有问题时的流量冲击;

(7)如果所有缓存都没有命中只能查询DB或相关服务获取相关数据并返回;

(8)步骤7返回的数据异步写到主Redis集群,此处可能多个Tomcat实例同时写主Redis集群,可能造成数据错乱,如何解决该问题将在更新缓存与原子性部分细聊。

整体分了三部分缓存:应用Nginx本地缓存、分布式缓存、Tomcat堆缓存,每一层缓存都用来解决相关的问题,如应用Nginx本地缓存用来解决热点缓存问题,分布式缓存用来减少访问回源率、Tomcat堆缓存用于防止相关缓存失效/崩溃之后的冲击。

虽然就是加缓存,但是怎么加,怎么用细想下来还是有很多问题需要权衡和考量的,接下来部分我们就详细来讨论一些缓存相关的问题。


2、如何缓存数据

2.1、过期与不过期

对于缓存的数据我们可以考虑不过期缓存和带过期时间缓存;什么场景应该选择哪种模式需要根据业务和数据量等因素来决定。

不过期缓存场景一般思路如下图所示:

框架相关(4)-- 分布式缓存_第2张图片

如上图所示,首先写数据库,如果成功则写缓存。这种机制存在一些问题:

(1)事务在提交时失败则写缓存是不会回滚的造成DB和缓存数据不一致;

(2)假设多个人并发写缓存可能出现脏数据的;

(3)同步写对性能有一定的影响,异步写存在丢数据的风险。

如果对缓存数据一致性要求不是那么高,数据量也不是很大,可以考虑定期全量同步缓存。

为解决以上问题可以考虑使用消息机制,如下图所示:

框架相关(4)-- 分布式缓存_第3张图片

(1)把写缓存改成写消息,通过消息通知数据变更;

(2)同步缓存系统会订阅消息,并根据消息进行更新缓存;

(3)数据一致性可以采用:消息体只包括ID、然后查库获取最新版本数据;通过时间戳和内容摘要机制(MD5)进行缓存更新;

(4)如上方法也不能保证消息不丢失,可以采用:应用在本地记录更新日志,当消息丢失了回放更新日志;或者采用数据库binlog,采用如canal订阅binlog进行缓存更新。

对于长尾访问的数据、大多数数据访问频率都很高的场景、缓存空间足够都可以考虑不过期缓存,比如用户、分类、商品、价格、订单等,当缓存满了可以考虑LRU机制驱逐老的缓存数据。

过期缓存机制,即采用懒加载,一般用于缓存别的系统的数据(无法订阅变更消息、或者成本很高)、缓存空间有限、低频热点缓存等场景;常见步骤是:首先读取缓存如果不命中则查询数据,然后异步写入缓存并设置过期时间,下次读取将命中缓存。热点数据经常使用过期缓存,即在应用系统上缓存比较短的时间。这种缓存可能存在一段时间的数据不一致情况,需要根据场景来决定如何设置过期时间。如库存数据可以在前端应用上缓存几秒钟,短时间的不一致时可以忍受的。

2.2、维度化缓存与增量缓存

对于电商系统,一个商品可能拆成如:基础属性、图片列表、上下架、规格参数、商品介绍等;如果商品变更了要把这些数据都更新一遍那么整个更新成本很高:接口调用量和带宽;因此最好将数据进行维度化并增量更新(只更新变的部分)。尤其如上下架这种只是一个状态变更,但是每天频繁调用的,维度化后能减少服务很大的压力。


3、分布式缓存与应用负载均衡

3.1、缓存分布式

此处说的分布式缓存一般采用分片实现,即将数据分散到多个实例或多台服务器。算法一般采用取模和一致性哈希。如之前说的做不过期缓存机制可以考虑取模机制,扩容时一般是新建一个集群;而对于可以丢失的缓存数据可以考虑一致性哈希,即使其中一个实例出问题只是丢一小部分,对于分片实现可以考虑客户端实现,或者使用如Twemproxy中间件进行代理(分片对客户端是透明的)。如果使用Redis可以考虑使用redis-cluster分布式集群方案。

3.2、应用负载均衡

应用负载均衡一般采用轮询和一致性哈希,一致性哈希可以根据应用请求的URL或者URL参数将相同的请求转发到同一个节点;而轮询即将请求均匀的转发到每个服务器;如下图所示:

框架相关(4)-- 分布式缓存_第4张图片

整体流程:

(1)首先请求进入接入层Nginx;

(2)根据负载均衡算法将请求转发给应用Nginx;

(3)如果应用Nginx本地缓存命中,则直接返回数据,否则读取分布式缓存或者回源到Tomcat。

轮询的优点:到应用Nginx的请求更加均匀,使得每个服务器的负载基本均衡;轮询的缺点:随着应用Nginx服务器的增加,缓存的命中率会下降,比如原来10台服务器命中率为90%,再加10台服务器将可能降低到45%;而这种方式不会因为热点问题导致其中某一台服务器负载过重。

一致性哈希的优点:相同请求都会转发到同一台服务器,命中率不会因为增加服务器而降低;一致性哈希的缺点:因为相同的请求会转发到同一台服务器,因此可能造成某台服务器负载过重,甚至因为请求太多导致服务出现问题。

解决办法是根据实际情况动态选择使用哪种算法:

(1)负载较低时使用一致性哈希;

(2)热点请求降级一致性哈希为轮询;

(3)将热点数据推送到接入层Nginx,直接响应给用户。


4、热点数据与更新缓存

热点数据会造成服务器压力过大,导致服务器性能、吞吐量、带宽达到极限,出现响应慢或者拒绝服务的情况,这肯定是不允许的。可以从如下几个方案去解决。

4.1、单机全量缓存+主从

框架相关(4)-- 分布式缓存_第5张图片

如上图所示,所有缓存都存储在应用本机,回源之后会把数据更新到主Redis集群,然后通过主从复制到其他从Redis集群。缓存的更新可以采用懒加载或者订阅消息进行同步。

4.2、分布式缓存+应用本地热点

框架相关(4)-- 分布式缓存_第6张图片

对于分布式缓存,我们需要在Nginx+Lua应用中进行应用缓存来减少Redis集群的访问冲击;即首先查询应用本地缓存,如果命中则直接缓存,如果没有命中则接着查询Redis集群、回源到Tomcat;然后将数据缓存到应用本地。

此处到应用Nginx的负载机制采用:正常情况采用一致性哈希,如果某个请求类型访问量突破了一定的阀值,则自动降级为轮询机制。另外对于一些秒杀活动之类的热点我们是可以提前知道的,可以把相关数据预先推送到应用Nginx并将负载均衡机制降级为轮询。

因为做了本地缓存,因此对于数据一致性需要我们去考虑,即何时失效或更新缓存:

(1)如果可以订阅数据变更消息,那么可以订阅变更消息进行缓存更新;

(2)如果无法订阅消息或者订阅消息成本比较高,并且对短暂的数据一致性要求不严格(比如在商品详情页看到的库存,可以短暂的不一致,只要保证下单时一致即可),那么可以设置合理的过期时间,过期后再查询新的数据;

(3)如果是秒杀之类的,可以订阅活动开启消息,将相关数据提前推送到前端应用,并将负载均衡机制降级为轮询;

(4)建立实时热点发现系统来对热点进行统一推送和更新。


5、更新缓存与原子性

正如之前说的如果多个应用同时操作一份数据很可能造成缓存数据是脏数据,解决办法:

(1)更新数据时使用更新时间戳或者版本对比,如果使用Redis可以利用其单线程机制进行原子化更新;

(2)使用如canal订阅数据库binlog;

(3)将更新请求按照相应的规则分散到多个队列,然后每个队列的进行单线程更新,更新时拉取最新的数据保存;

(4)分布式锁,更新之前获取相关的锁。


6、缓存崩溃与快速修复

6.1、取模

对于取模机制如果其中一个实例坏了,如果摘除此实例将导致大量缓存不命中,瞬间大流量可能导致后端DB/服务出现问题。对于这种情况可以采用主从机制来避免实例坏了的问题,即其中一个实例坏了可以那从/主顶上来。但是取模机制下如果增加一个节点将导致大量缓存不命中,一般是建立另一个集群,然后把数据迁移到新集群,然后把流量迁移过去。

6.2、一致性哈希

对于一致性哈希机制如果其中一个实例坏了,如果摘除此实例将只影响一致性哈希环上的部分缓存不命中,不会导致瞬间大量回源到后端DB/服务,但是也会产生一些影响。

另外也可能因为一些误操作导致整个缓存集群出现了问题,如何快速恢复呢?

6.3、快速恢复

如果出现之前说到的一些问题,可以考虑如下方案:

(1)主从机制,做好冗余,即其中一部分不可用,将对等的部分补上去;

(2)如果因为缓存导致应用可用性已经下降可以考虑:1、部分用户降级,然后慢慢减少降级量;2、后台通过Worker预热缓存数据。

也就是如果整个缓存集群坏了,而且没有备份,那么只能去慢慢将缓存重建;为了让部分用户还是可用的,可以根据系统承受能力,通过降级方案让一部分用户先用起来,将这些用户相关的缓存重建;另外通过后台Worker进行缓存数据的预热。


一致性Hash算法:

假设我们有一个网站,最近发现随着流量增加,服务器压力越来越大,之前直接读写数据库的方式不太给力了,于是我们想引入Redis作为缓存机制。现在我们一共有三台机器可以作为Redis服务器,如下图所示。     

框架相关(4)-- 分布式缓存_第7张图片

要解决的问题

一般来说我们在大规模访问,大并发流量下都会使用到分布式缓存,即将廉价机器部署在同一个子网内,形成多机器集群,然后通过负载均衡以及一定的路由规则进行读请求的分流,将请求映射到对应的缓存服务器上。如何对请求与缓存服务器之间进行精准映射,以及优雅的扩展,剔除缓存服务器是分布式缓存部署的痛点。

接下来我们会对解决以上问题的一些传统做法进行分析。

1.请求与缓存服务器之间精准映射问题.

最简策略-随机选取:

含义:将每一次Redis请求随机发送到一台Redis服务器.

产生的问题:

(1)同一份数据可能被存在不同的机器上而造成数据冗余。

(2)有可能某数据已经被缓存但是访问却没有命中,因为无法保证对相同key的所有访问都被发送到相同的服务器。

因此,随机策略无论是时间效率还是空间效率都非常不好。


保证相同key每次访问同一台Redis服务器-计算哈希:

含义:保证对相同key的访问会被发送到相同的服务器。

方案描述:

对于每次访问,可以按如下算法计算其哈希值:

h = Hash(key) %3

其中Hash是一个从字符串到正整数的哈希映射函数。这样,如果我们将Redis Server分别编号为0、1、2,那么就可以根据上式和key计算出服务器编号h,然后去访问。

这个方法虽然解决了上面提到的两个问题,但是存在一些其它的问题。如果将上述方法抽象,可以认为通过:

h = Hash(key) %N

这个算式计算每个key的请求应该被发送到哪台服务器,其中N为服务器的台数,并且服务器按照0 – (N-1)编号。


对于根据请求的key进行hash 运算定位Redis缓存服务器产生的问题:容错性和扩展性将会变得极差。

容错性:指当系统中某一个或几个服务器变得不可用时,整个系统是否可以正确高效运行;

扩展性:指当加入新的服务器后,整个系统是否可以正确高效运行。

现假设有一台服务器宕机了,那么为了填补空缺,要将宕机的服务器从编号列表中移除,后面的服务器按顺序前移一位并将其编号值减一,此时每个key就要按h = Hash(key) % (N-1)重新计算;同样,如果新增了一台服务器,虽然原有服务器编号不用改变,

但是要按h = Hash(key) % (N+1)重新计算哈希值。因此系统中一旦有服务器变更,大量的key会被重定位到不同的服务器从而造成大量的缓存不命中。而这种情况在分布式系统中是非常糟糕的。

一个设计良好的分布式哈希方案应该具有良好的单调性,即服务节点的增减不会造成大量哈希重定位。一致性hash算法就是这样一种hash方案。

解决方法-一致性hash算法:

算法简述

一致性哈希算法(Consistent Hashing)最早在论文《Consistent Hashing and Random Trees: Distributed Caching Protocols for Relieving Hot Spots on the World Wide Web》中被提出。简单来说,一致性哈希将整个哈希值空间组织成一个虚拟的圆环,如假设某哈希函数H的值空间为0 – 2^32-1(即哈希值是一个32位无符号整形),整个哈希空间环如下:

框架相关(4)-- 分布式缓存_第8张图片

整个空间按顺时针方向组织。0和2^32-1在零点中方向重合。

下一步将各个服务器使用H进行一个哈希,具体可以选择服务器的ip或主机名作为关键字进行哈希,这样每台机器就能确定其在哈希环上的位置,这里假设将上文中三台服务器使用ip地址哈希后在环空间的位置如下:

接下来使用如下算法定位数据访问到相应服务器:将数据key使用相同的函数H计算出哈希值h,通根据h确定此数据在环上的位置,从此位置沿环顺时针“行走”,第一台遇到的服务器就是其应该定位到的服务器。

例如我们缓存服务器中有A、B、C、D四个key对应的数据对象,经过哈希计算后,在环空间上的位置如下:

框架相关(4)-- 分布式缓存_第9张图片

截止到现在似乎还没有什么觉得神奇的地方,请往下看:

容错性与可扩展性分析

下面分析一致性哈希算法的容错性和可扩展性。现假设Redis-2宕机了:

框架相关(4)-- 分布式缓存_第10张图片

我们可以看到ACD节点并不受影响,只有B节点被重定向至Redis-0。

下面考虑另外一种情况,如果我们在系统中增加一台服务器Redis-3 Server:

框架相关(4)-- 分布式缓存_第11张图片

可以发现对于C这个key,重新定位至Redis-3 服务器,其他非C的key均不受影响。

综上所述,一致性哈希算法对于节点的增减都只需重定位环空间中的一小部分数据,具有较好的容错性和可扩展性。


数据倾斜问题:

解决办法-虚拟节点

一致性哈希算法在服务节点太少时,容易因为节点分部不均匀而造成数据倾斜问题。例如我们的系统中有两台服务器,其环分布如下:

框架相关(4)-- 分布式缓存_第12张图片

此时必然造成大量数据集中到Redis-1上,而只有极少量会定位到Redis-0上。为了解决这种数据倾斜问题,一致性哈希算法引入了虚拟节点机制,即对每一个服务节点计算多个哈希,每个计算结果位置都放置一个此服务节点,称为虚拟节点。具体做法可以在服务器ip或主机名的后面增加编号来实现。例如上面的情况,我们决定为每台服务器计算三个虚拟节点,于是可以分别计算“Redis-1 #1”、“Redis-1 #2”、“Redis-1 #3”、“Redis-0 #1”、“Redis-0 #2”、“Redis-0 #3”的哈希值,于是形成六个虚拟节点:

框架相关(4)-- 分布式缓存_第13张图片

同时数据定位算法不变,只是多了一步虚拟节点到实际节点的映射,例如定位到“Redis-1#1”、“Redis-1#2”、“Redis-1#3”三个虚拟节点的数据均定位到Redis-1上。这样就解决了服务节点少时数据倾斜的问题。在实际应用中,通常将虚拟节点数设置为32甚至更大,因此即使很少的服务节点也能做到相对均匀的数据分布。


总结

目前一致性哈希基本成为了分布式系统组件的标准配置,例如Redis的各种客户端都提供内置的一致性哈希支持。本文只是简要介绍了这个算法的思想,以及在分布式应用中的应用场景。

你可能感兴趣的:(框架相关(4)-- 分布式缓存)