03高并发下的缓存设计
高并发下的缓存
关于缓存
缓存的基本概念
缓存,是一种存储数据的组件,它的作用是让对数据的请求更快地返回。
缓冲区的基本概念
缓冲区则是一块临时存储数据的区域,这些数据后面会被传输到其他设备上
比如,我们将数据写入磁盘时并不是直接刷盘,而是写到一块缓冲区里面,内核会标识这个缓冲区为脏。当经过一定时间或者脏缓冲区比例到达一定阈值时,由单独的线程把脏块刷新到硬盘上。这样避免了每次写数据都要刷盘带来的性能问题。
缓存分类
缓存主要就是静态缓存、分布式缓存和热点本地缓存这三种。
- 静态缓存:生成Velocity模板或者静态HTML文件来实现静态缓存,在Nginx上部署静态缓存可以减少对于后台应用服务器的压力。
- 分布式缓存:通过一些分布式的方案组成集群可以突破单机的限制。例如Memcached、Redis
- 热点本地缓存:遇到极端的热点数据查询的时候。热点本地缓存主要部署在应用服务器的代码中,用于阻挡热点查询对于分布式缓存节点或者数据库的压力。
缓存的不足
首先,缓存比较适合于读多写少的业务场景,并且数据最好带有一定的热点属性。这是因为缓存毕竟会受限于存储介质不可能缓存所有数据,那么当数据有热点属性的时候才能保证一定的缓存命中率。
其次,缓存会给整体系统带来复杂度,并且会有数据不一致的风险。当更新数据库成功,更新缓存失败的场景下,缓存中就会存在脏数据。对于这种场景,我们可以考虑使用较短的过期时间或者手动清理的方式来解决。
再次,之前提到缓存通常使用内存作为存储介质,但是内存并不是无限的。因此,我们在使用缓存的时候要做数据存储量级的评估,对于可预见的需要消耗极大存储成本的数据,要慎用缓存方案。同时,缓存一定要设置过期时间,这样可以保证缓存中的会是热点数据。
最后,缓存会给运维也带来一定的成本,运维需要对缓存组件有一定的了解,在排查问题的时候也多了一个组件需要考虑在内。
缓存的读写策略
Cache Aside(旁路缓存)策略
简单方案:先更新数据库,再更新缓存。但这个方案会造成缓存和数据库中的数据不一致。
产生这个问题的原因是因为变更数据库和变更缓存是两个独立的操作,而我们并没有对操作做任何的并发控制。那么当两个线程并发更新它们的时候,就会因为写入顺序的不同造成数据的不一致。
解决方案:更新数据时不更新缓存,而是删除缓存中的数据,在读取数据时,发现缓存中没了数据之后,再从数据库中读取数据,更新到缓存中。
以上方案就是Cache Aside策略(也叫旁路缓存策略),这个策略数据以数据库中的数据为准,缓存中的数据是按需加载的。它可以分为读策略和写策略,其中
读策略的步骤是:
- 从缓存中读取数据;
- 如果缓存命中,则直接返回数据;
- 如果缓存不命中,则从数据库中查询数据;
- 查询到数据后,将数据写入到缓存中,并且返回给用户。
写策略的步骤是:
- 更新数据库中的记录;
- 删除缓存记录。
在写策略中,能否先删除缓存,后更新数据库呢?答案是不行的,因为这样也有可能出现缓存数据不一致的问题。以下图为例
那么像Cache Aside策略这样先更新数据库,后删除缓存就没有问题了吗?其实在理论上还是有缺陷的。图示如下:
不过这种问题出现的几率并不高,原因是缓存的写入通常远远快于数据库的写入
Cache Aside策略是我们日常开发中最经常使用的缓存策略,不过我们在使用时也要学会依情况而变。比如说当新注册一个用户,按照这个更新策略,你要写数据库,然后清理缓存(当然缓存中没有数据给你清理)。可当我注册用户后立即读取用户信息,并且数据库主从分离时,会出现因为主从延迟所以读不到用户信息的情况。
而解决这个问题的办法恰恰是在插入新数据到数据库之后写入缓存,这样后续的读请求就会从缓存中读到数据了。并且因为是新注册的用户,所以不会出现并发更新用户信息的情况。
Cache Aside存在的最大的问题是当写入比较频繁时,缓存中的数据会被频繁地清理,这样会对缓存的命中率有一些影响。如果你的业务对缓存命中率有严格的要求,那么可以考虑两种解决方案:
1.一种做法是在更新数据时也更新缓存,只是在更新缓存前先加一个分布式锁,因为这样在同一时间只允许一个线程更新缓存,就不会产生并发问题了。当然这么做对于写入的性能会有一些影响;
2.另一种做法同样也是在更新数据时更新缓存,只是给缓存加一个较短的过期时间,这样即使出现缓存不一致的情况,缓存的数据也会很快过期,对业务的影响也是可以接受。
Read/Write Through(读穿/写穿)策略
Write Through
Write Through的策略是这样的:先查询要写入的数据在缓存中是否已经存在,如果已经存在,则更新缓存中的数据,并且由缓存组件同步更新到数据库中,如果缓存中数据不存在,我们把这种情况叫做“Write Miss(写失效)”。
一般来说,我们可以选择两种“Write Miss”方式:一个是“Write Allocate(按写分配)”,做法是写入缓存相应位置,再由缓存组件同步更新到数据库中;另一个是“No-write allocate(不按写分配)”,做法是不写入缓存中,而是直接更新到数据库中。
在Write Through策略中,我们一般选择“No-write allocate”方式,原因是无论采用哪种“Write Miss”方式,我们都需要同步将数据更新到数据库中,而“No-write allocate”方式相比“Write Allocate”还减少了一次缓存的写入,能够提升写入的性能。
Read Through
Read Through步骤是这样的:先查询缓存中数据是否存在,如果存在则直接返回,如果不存在,则由缓存组件负责从数据库中同步加载数据。
Read Through/Write Through策略的特点是由缓存节点而非用户来和数据库打交道,在我们开发过程中相比Cache Aside策略要少见一些,原因是我们经常使用的分布式缓存组件,无论是Memcached还是Redis都不提供写入数据库,或者自动加载数据库中的数据的功能。而我们在使用本地缓存的时候可以考虑使用这种策略
Write Back(写回)策略
这个策略的核心思想是在写入数据时只写入缓存,并且把缓存块儿标记为“脏”的。而脏块儿只有被再次使用时才会将其中的数据写入到后端存储中。
需要注意的是,在“Write Miss”的情况下,我们采用的是“Write Allocate”的方式,也就是在写入后端存储的同时要写入缓存,这样我们在之后的写请求中都只需要更新缓存即可,而无需更新后端存储了。
如果使用Write Back策略的话,读的策略也有一些变化了。我们在读取缓存时如果发现缓存命中则直接返回缓存数据。如果缓存不命中则寻找一个可用的缓存块儿,如果这个缓存块儿是“脏”的,就把缓存块儿中之前的数据写入到后端存储中,并且从后端存储加载数据到缓存块儿,如果不是脏的,则由缓存组件将后端存储中的数据加载到缓存中,最后我们将缓存设置为不是脏的,返回数据就好了。
缓存如何做到高可用
方案有客户端方案、中间代理层方案和服务端方案三大类
- 客户端方案就是在客户端配置多个缓存的节点,通过缓存写入和读取算法策略来实现分布式,从而提高缓存的可用性。
- 中间代理层方案是在应用代码和缓存节点之间增加代理层,客户端所有的写入和读取的请求都通过代理层,而代理层中会内置高可用策略,帮助提升缓存系统的高可用。
- 服务端方案就是Redis 2.4版本后提出的Redis Sentinel方案。
客户端方案
需要关注缓存的写和读两个方面:
- 写入数据时,需要把被写入缓存的数据分散到多个节点中,即进行数据分片;
- 读数据时,可以利用多组的缓存来做容错,提升缓存系统的可用性。关于读数据,这里可以使用主从和多副本两种策略,两种策略是为了解决不同的问题而提出的。
缓存数据如何分片
分片算法常见的就是Hash分片算法和一致性Hash分片算法两种。
Hash分片的算法就是对缓存的Key做哈希计算,然后对总的缓存节点个数取余。缺点是当增加或者减少缓存节点时,缓存总的节点个数变化造成计算出来的节点发生变化,从而造成缓存失效不可用。
用一致性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中。
这时如果在Node 1和Node 2之间增加一个Node 5,你可以看到原本命中Node 2的Key 3现在命中到Node 5,而其它的Key都没有变化;同样的道理,如果我们把Node 3从集群中移除,那么只会影响到Key 5 。所以你看,在增加和删除节点时,只有少量的Key会“漂移”到其它节点上,而大部分的Key命中的节点还是会保持不变,从而可以保证命中率不会大幅下降。
不过,事物总有两面性。虽然这个算法对命中率的影响比较小,但它还是存在问题:
- 缓存节点在圆环上分布不平均,会造成部分缓存节点的压力较大;当某个节点故障时,这个节点所要承担的所有访问都会被顺移到另一个节点上,会对后面这个节点造成压力。
- 一致性Hash算法的脏数据问题。
解决方案是可以在一致性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算法时一定要设置缓存的过期时间,这样当发生漂移时,之前存储的脏数据可能已经过期,就可以减少存在脏数据的几率。
中间代理层方案
虽然客户端方案已经能解决大部分的问题,但是只能在单一语言系统之间复用。而中间代理层的方案就可以解决这个问题。可以将客户端解决方案的经验移植到代理层中,通过通用的协议(如Redis协议)来实现在其他语言中的复用。
看上图可以发现,所有缓存的读写请求都是经过代理层完成的。代理层是无状态的,主要负责读写请求的路由功能,并且在其中内置了一些高可用的逻辑,不同的开源中间代理层方案中使用的高可用策略各有不同。
服务端方案
Redis在2.4版本中提出了Redis Sentinel模式来解决主从Redis部署时的高可用问题,它可以在主节点挂了以后自动将从节点提升为主节点,保证整体集群的可用性,整体的架构如下图所示:
Redis Sentinel也是集群部署的,这样可以避免Sentinel节点挂掉造成无法自动故障恢复的问题,每一个Sentinel节点都是无状态的。在Sentinel中会配置Master的地址,Sentinel会时刻监控Master的状态,当发现Master在配置的时间间隔内无响应,就认为Master已经挂了,Sentinel会从从节点中选取一个提升为主节点,并且把所有其他的从节点作为新主的从节点。Sentinel集群内部在仲裁的时候,会根据配置的值来决定当有几个Sentinel节点认为主挂掉可以做主从切换的操作,也就是集群内部需要对缓存节点的状态达成一致才行。
Redis Sentinel不属于代理层模式,因为对于缓存的写入和读取请求不会经过Sentinel节点。Sentinel节点在架构上和主从是平级的,是作为管理者存在的,所以可以认为是在服务端提供的一种高可用方案。