缓存作为磁盘以外的一种存储数据的方式,它有着比磁盘更快的存取效率,因此,可以有效提高系统的性能。在单体系统中,一般会用到本地缓存。但在分布式系统中,本地缓存就显得不够用了,这时往往要用到分布式缓存。
分布式缓存特性
本地缓存因为就在应用系统进程的内存里面,不需要网络和对象拷贝的开销所以性能非常高,不过也正因为数据保存在应用系统进程的内存中也有了一些局限。例如,多个独立应用系统之间难于共享缓存、缓存服务不利于扩展等。为了解决这些问题,以Jboss Cache、Memcache、Redis为代表的分布式缓存也开始出现,它们以不同的方式实现了分布式缓存的特性。
1、缓存共享
分布式系统是多个单体系统的集合,分布式缓存可以让这些单体系统共享缓存数据。分布式缓存实现缓存共享一般有两种方式,分别是同步数据和独立部署。
-
同步数据
这种方式的实现原理是,缓存存在于单体系统应用进程的内存中(和本地缓存一样),当一个单体系统的缓存发生变化时,把变化的内容同步到其他单体系统,从而达到缓存共享的效果。这也就是Jboss Cache的思想,这种方式的确解决了共享的问题,但是这种缓存同步的操作如果太多那么本身就会成为一个很大的性能问题。 -
独立部署
这种方式是把缓存服务作为一个组件单独部署,所有的单体系统都可以通过网络请求从缓存服务器存取数据。MemCache和Redis就是这种方式,不过这种方式会有额外的网络开销,相比于直接在本地就能获取的数据,自然会有额外的性能开销。
2、缓存扩展
当缓存数据量规模很大时,单台服务器的内存是无法容纳的,那么此时就需要支持缓存的节点扩展。以独立部署方式实现缓存共享的分布式缓存服务因为是独立部署,因此,更有利于做扩展。一般会按照一定的算法,把缓存的数据分配到多个节点。
3、支持高可用
分布式缓存一般应用于分布式系统,分布式系统之所以要采用分布式架构,往往是因为系统要应对高并发的场景,而高并发场景又容易造成服务器负载过重从而导致崩溃。因此,分布式缓存作为分布式架构的重要组件,必须要支持高可用。当一个缓存服务节点挂掉,可以马上切换到另外的缓存服务节点,以保证系统能正常运行。
分布式缓存要注意的问题
问题一:缓存一致性问题
缓存的数据来源于数据库,可能会出现数据库数据跟缓存数据不一致的情况,这就是缓存一致性的问题。缓存一致性问题主要原因是在于修改数据库的同时还需要把最新的数据更新到缓存中去,但是这个过程出现一些问题从而导致缓存数据与数据库的数据不一致。
譬如A、B两个请求先后修改一个数据a。正常情况下,A修改数据库-->A修改缓存-->B修改数据-->B修改缓存。这时候没有问题,如下图:
但是,在并发的情况下(A、B两个请求同时发起)很有可能产生如下情况,A修改数据库-->B修改数据库-->B修改缓存-->A修改缓存。这时候数据库a=3,而缓存a=2,缓存数据与数据库的数据不一致,如下图:
-
解决方案一:先修改数据库、再删除缓存。
这种策略也称为Cache Aside(旁路缓存),其核心在于:
修改数据时,先修改数据库,然后不对缓存进行修改而是直接删除缓存。
读取数据时,先从缓存读取。如果缓存没有则从数据库读取数据,最后把读取到的数据写入到缓存。
只要保证数据变更时缓存一定会删除,那么查询缓存的时候每次都会从数据库加载数据,所以也就避免了修改缓存数据导致的不一致问题,如下图:
-
解决方案二:数据标识版本号(乐观锁)
方案一其实只是一定程度上解决了一致性问题。为什么这么说呢?如下图:
按照上面的步骤,数据库最终结果是a=3,但缓存最终结果是a=2。因此,旁路缓存这种策略并没有完全解决问题。另一个解决方案,可以给数据标识版本号,也就是乐观锁。在设计数据表的时候,就应该设置一个记录数据版本号的字段,版本号字段类型可以是整型,版本号越大代表数据越新。缓存数据的时候,也把版本号一起缓存。这样,并发修改数据的过程,如下图:
当写入缓存的数据版本比当前缓存的数据版本要低级,就放弃写入。这个方案在缓存过期情况下同样适用,如下图:
如果上图中Step6写入缓存失败,那么Step7就可以把a=2写进缓存,这样也会产生数据不一致。这时候,就要引入消息队列。把写入缓存失败的请求放进消息队列,然后有一个线程在消费消息,完成写入缓存的工作,当然写入缓存时要先校验数据版本。
问题二:缓存击穿问题
缓存击穿就是在某一热点数据缓存失效后,查询数据的请求不得不进入到数据库查询数据,这个过程中如果请求并发量非常大,有可能数据库负载过重而崩溃。
- 解决方案一:加锁
在查询数据请求未命中缓存时,查询数据库操作前进行加锁,加锁后后面的请求就会阻塞,避免了大量的请求同时进入到数据库查询数据。然后,在数据库查询数据后,再把数据写入缓存。
- 解决方案二:设置合理的过期时间
可以把热点数据的缓存过期时间控制在系统低流量的时间段,避过流量高峰期。
- 解决方案三:不设置过期时间
可以不设置过期时间来保证热点数据缓存永远不会失效,然后通过后台的线程来定时把最新的数据同步到缓存里去。
问题三:缓存穿透问题
通常情况,一个请求查询数据,会先从缓存查找,如果在缓存里找不到,就到数据库查找。而缓存穿透就是请求系统里根本不存在的数据,导致请求总是进入到数据库。如果大量的请求同时进入到数据库,就有可能导致数据库崩溃。譬如,我们通过文章的id来查询文章信息,但是请求的id在数据库根本不存在。
- 解决方案一:缓存null值
可以把请求不存在数据缓存一个对应key的null值,譬如,某个id在第一次请求的时候发现是系统不存在的数据,就把这个id作为key,值为null缓存起来。下次再有请求查找这个id,就可以在缓存找到这个id,并且返回null。但是这种做法仍然存在风险,如果攻击者使用大量不同的数据(譬如,各种系统不存在的文章id),这样就会产生大量无意义的null值缓存,使得缓存服务器内存暴增。因此,一方面null值缓存的过期时间要尽可能小,防止无意义内容过多占用内存。另一方面,可以通过业务量估算数据范围,得到合理的id大致的范围,请求的数据id一旦超出范围,就直接返回null。譬如,目前文章的数据量只有10000条数据,按照大概平均每天的业务量,每天最多增长100条数据,一年内也不会超过10万条数据,那么请求的id范围就估计在1到10万之间,如果某个请求超过这范围,就可以直接返回null。
- 解决方案二:布隆过滤器
布隆过滤器,是巴顿.布隆于1970年提出的,其主旨是采用一个很长的二进制数组,通过一系列的Hash函数来确定某个数据是否存在。布隆过滤器本质上是一个N位的二进制数组,每一位的初始值都是0,任何一个数据都可以通过一系列的Hash函数来在这个二进制数组确定几个下标,然后这几个下标的元素的值置为1。相比于方案一有占用内存过多的风险,利用布隆过滤器的优点在于它不需要占用很多内存。
举个例子,现在知道了文章的id范围是1到1000,现在通过布隆过滤器表示这1000个id。一开始需要初始化布隆过滤器,也就是说1到1000都要能在数组中表示出来。譬如,id1经过3次Hash,得出3个数(1、5、9),那么数组下标为1、5、99的元素就置为1。id2经过3次Hash,也得出3个数(1、3、98),那么数组下标为1、3、98的元素就置为1。然后到id3...,一直到id1000。
这样,当一个请求过来,譬如请求id为1,经过3次Hash计算后,得出1、5、99,然后对应数组下标这3个元素值都为1,就判断id1存在于系统。如果有一个请求的id找到的3个数组元素中只要有一个为0,就判断这个id不存在于系统。布隆过滤器的具体应用可以查找相关资料,这里不详细展开。
问题四:缓存雪崩问题
缓存雪崩是指缓存数据某一时刻出现大量失效或者缓存服务不可用的情况,导致大量请求进入到数据库,可能导致数据库崩溃。
- 解决方案一:缓存分片
缓存分片主要目的是分流,把缓存的数据拆分到多个缓存服务节点,每个节点会存储一部分的缓存数据,减轻了单个节点的访问压力达到分流效果,而且就算一个节点出现故障也不会造成整个缓存服务的不可用。
- 解决方案二:缓存热备选主
这种方式是通过冗余多个备用节点,当主节点发生故障时,通过某种算法从备用节点中重新选举出一个节点作为主节点来对外提供服务。
问题五:热key问题
如果某个key被大量访问,就会导致某个缓存服务节点流量暴增,等访问超出单节点负载,就可能会出现单点故障,单点故障后转移该key的数据到其他节点,单点问题依旧存在,则可能继续会让被转移到的节点也出现故障,最终影响整个缓存服务集群。
- 解决方案一:生成多个副本
对于热点key,生成多个副本,分别被多个缓存服务节点持有,然后按照某种算法实现负载均衡。这样本来单台节点的流量就会被分到多个节点上,降低单个节点的压力。
- 解决方案二:本地缓存
针对热点key在本地应用服务器上包一层短存活期的本地缓存,用于缓冲热点服务器的压力。
分布式缓存Redis/Memcache对比
目前比较常用的分布式缓存中间件有Redis、MemCache,因此,在这里对这两者做了一个对比。对于这两者,各有各的特点,因此,不一定要二选一,可以混合使用。
对比项 | Redis | Memcached |
---|---|---|
高可用 | 支持主从节点复制配置,从节点可使用RDB和缓存的AOF命令进行同步和恢复;支持Sentinel和Cluster(从3.0版本开始)等高可用集群方案 | memcached服务器互不通信,分布式部署取决于memcached客户端,需要二次开发 |
队列 | 支持lpush/brpop、publish/subscribe/psubscribe等队列和订阅模式 | 不支持队列,可通过第三方MemcachQ来实现 |
适用场景 | 复杂的数据结构,有持久化、高可用需求 | 只需key-value数据结构,数量量非常大,并发量非常大的业务 |
过期策略 | 主动过期 +惰性过期 | 懒淘汰机制,每次往缓存放入数据的时候,都会存一个时间,在读取的时候要和设置的时间做 TTL 比较来判断是否过期。 |
虚拟内存使用 | 有自己的VM机制,理论上能够存储比物理内存更多的数据,当数据超量时,为引发swap,把冷数据刷到磁盘上 | 所有的数据储存在物理内存里 |
网络模型 | 非阻塞I/O模型 ,提供一些非KV存储之外的排序,集合功能,在执行这些功能时,复杂的CPU计算会阻塞整个I/O调度 | 非阻塞I/O模型 ,但是使用了多线程,不会出现一个逻辑复杂的请求阻塞对其它请求的响应的场景 |
数据结构 | key-value,哈希,列表,集合,有序集合 | 纯key-value |
持久化 | 有,RDB和AOF | 无 |
存储value容量 | 最大512M | 最大1M |
多线程 | 支持单线程 | 支持多线程,CPU利用方面优于Redis |
单机QPS | 约10W | 约60W |