为什么使用缓存
Redis是一个内存型数据库,也就是说,所有的数据都会存在与内存中,基于Redis的高性能特性,我们将Redis用在缓存场景非常广泛。使用起来方便,响应也是远超关系型数据库。
应用场景
Redis的应用场景非常广泛。虽然Redis是一个key-value的内存数据库,但在实际场景中,Redis经常被作为缓存来使用,如面对数据高并发的读写、海量数据的读写等。
举个例子,A网站首页一天有100万人访问,其中有一个“积分商城”的板块,要直接从数据库查询,那么一天就要多消耗100万次数据库请求。如果将这些数据储存到Redis(内存)中,要用的时候,直接从内存调取,不仅可以大大节省系统直接读取磁盘来获得数据的IO开销,提高服务器的资源利用率,还能极大地提升速度。
比如很多大型电商网站、视频网站和游戏应用等,存在大规模数据访问,对数据查询效率要求高。Redis服务可实现页面缓存、应用缓存、状态缓存、事件并行处理,能够有效减少数据库磁盘IO,提高数据查询效率,减轻管理维护工作量,降低数据库存储成本。对传统磁盘数据库是一个重要的补充,成为了互联网应用,尤其是支持高并发访问的互联网应用必不可少的基础服务之一。
具体而言,分布式缓存Redis可用于以下场景:
1、页面缓存
Redis可将Web页面的内容片段,包括HTML,CSS和图片等静态数据,缓存到Redis实例,提高网站的访问性能。
比如在电商类应用中,热销商品展示、秒杀推荐等数据面临高并发读的压力,分布式缓存Redis的高并发及灵活扩展,可轻松支持此类应用。
2、状态缓存
Redis可将Session会话状态及应用横向扩展时的状态数据等缓存到DCS实例,实现状态数据共享。在应对游戏应用中爆发式增长的玩家数据存储和读写请求时,使用分布式缓存Redis可通过将热点数据放入缓存,加快用户端访问速度,提升用户体验。
3、应用对象缓存
Redis可作为服务层的二级缓存对外提供服务,减轻数据库的负载压力,加速应用访问。
4、事件缓存
Redis可提供针对事件流的连续查询(continuous query)处理技术,满足实时性需求。
使用缓存的收益和成本
如图左侧为客户端直接调用存储层的架构,右侧为比较典型的缓存层+存储层架构,下面分析一下缓存加入后带来的收益和成本。
收益:
l 加速读写:因为缓存通常都是全内存的,而存储层通常读写性能不够强悍(例如MySQL),通过缓存的使用可以有效地加速读写,优化用户体验。
l 降低后端负载:帮助后端减少访问量和复杂计算(例如很复杂的SQL语句),在很大程度降低了后端的负载。
成本:
l 数据不一致性:缓存层和存储层的数据存在着一定时间窗口的不一致性,时间窗口跟更新策略有关。
l 代码维护成本:加入缓存后,需要同时处理缓存层和存储层的逻辑,增大了开发者维护代码的成本。
l 运维成本:以Redis Cluster为例,加入后无形中增加了运维成本。
缓存不一致
一致性
1、强一致性
如果你的项目对缓存的要求是强一致性的,那么请不要使用缓存。这种一致性级别是最符合用户直觉的,它要求系统写入什么,读出来的也会是什么,用户体验好,但实现起来往往对系统的性能影响大。
2、弱一致性
这种一致性级别约束了系统在写入成功后,不承诺立即可以读到写入的值,也不承诺多久之后数据能够达到一致,但会尽可能地保证到某个时间级别(比如秒级别)后,数据能够达到一致状态
3**、最终一致性**
最终一致性是弱一致性的一个特例,系统会保证在一定时间内,能够达到一个数据一致的状态。这里之所以将最终一致性单独提出来,是因为它是弱一致性中非常推崇的一种一致性模型,也是业界在大型分布式系统的数据一致性上比较推崇的模型。一般情况下,高可用只确保最终一致性,不确保强一致性。
强一致性,读请求和写请求会串行化,串到一个内存队列里去,这样会大大增加系统的处理效率,吞吐量也会大大降低。
业务场景
在绝大多数的系统中数据库往往是用户并发访问最薄弱的地方,并且在高并发下的读多写少的情况下,我们往往会借助一些中间键,来解决数据访问过大时造成的数据库宕机情况,例如我们可以使用Redis来作为缓存,让请求先访问到Redis,而不是直接访问数据库。而在这种业务场景下,可能会出现缓存和数据库数据不一致性的问题。
问题产生的原因
一般来说读取缓存步骤是不会有什么问题的,但是一旦涉及到数据更新,也就是数据库和缓存都操作,就容易出现缓存(Redis)和数据库(MySQL)间的数据一致性问题。
在数据更新时,我们需要做以下两步:
- 操作MySQL
- 操作缓存
但是无论是先执行步骤1还是先执行步骤2,都有可能出现数据不一致的情况,主要是因为读写是并发的,我们无法保证他们的先后顺序。
相关策略
先做一个说明,从理论上来说,给缓存设置过期时间,是保证最终一致性的解决方案(如果要求强一致性的话,我认为没有必要添加缓存了,直接走数据库)。这种前提下,我们可以对存入缓存的数据设置过期时间,所有的写操作以数据库为准,对缓存操作只是尽最大努力即可。也就是说如果数据库写成功,缓存更新失败,那么只要到达过期时间,则后面的读请求自然会从数据库中读取新值然后回填缓存。因此,接下来讨论的思路不依赖于给缓存设置过期时间这个方案。
给出了三种更新策略:
- 先更新数据库,再更新缓存
- 先删除缓存,再更新数据库
- 先更新数据库,在删除缓存
先更新数据库值,再更新缓存值
最不可能选择的策略,原因是此种策略可能会在线程安全的角度和业务场景角度生成脏数据和性能问题。
原因一:线程安全的角度
同时有请求A和请求B进行更新操作,那么就会出现
- 请求A更新数据库
- 请求B更新数据库
- 请求B更新缓存
- 请求A更新缓存
这就出现请求A更新缓存应该比请求B更新缓存早才对,但是因为网络等原因,B比A更早更新了缓存。这就导致了脏数据,因此不考虑。
业务场景角度
(1)如果是写数据库场景比较多,而读数据场景比较少的业务需求,那么采用这种方案就会导致,数据压根还没读到,缓存就被频繁的更新,浪费性能,缓存此类数据,没有很大的意义。
(2)如果是写入数据库的值,并不是直接写入缓存的,而是要经过一系列复杂的计算再写入缓存。那么,每次写入数据库后,都再次计算写入缓存的值,无疑是浪费性能的。显然,删除缓存更为适合。
后面两种策略都是对缓存进行删除,这里先做一个解释。
例子:数据库在1小时内更新1000次那么缓存也更新1000次,但是这个缓存可能在1小时内只被读了1次,那么就没有必要更新1000次了。反过来,如果是删除的话,那么也只是做了1次删除操作,当缓存真正被读取的时候才去更新。
删除缓存值,再更新数据库值
- 请求A进行更新操作,首先删除缓存
- 请求B查询发现缓存不存在
- 请求B去数据库查询得到旧值
- 请求B将旧值写入缓存
- 请求A将新值写入数据库
上述情况就会导致不一致的情形出现。而且,如果不采用给缓存设置过期时间策略,该数据永远都是脏数据。我们可以采用延迟双删策略,来解决这个问题。
相对应的步骤:
- 先淘汰缓存
- 再写数据库
- 休眠t秒,再次淘汰缓存
这么做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。
// 伪代码
public void updateDb(String key,Object data) {
redis.delKey(key);
db.updateData(data);
Thread.sleep(t);
redis.delKey(key);
}
如果系统中MySQL使用了读写分离模式,那么有可能会出现在主从同步没有完成时,读请求就去读取数据了,这时候就会读取到旧值,这里我们可以延长睡眠时间,让主从同步完成后在进行一次删除(如果不考虑主从的情况下,采用双删不用加延时时间也是可以保证一直性的)。
先更新数据库值,在删除缓存值
假设有两个请求,请求A进行更新操作,请求B进行查询操作。
那么会出现如下情形:
- 请求A进行更新操作,首先更新数据库
- 请求B进行查询操作,击中缓存,得到旧值
- 请求A进行删除缓存操作
在这种情况下如果其他线程并发读缓存的请求不多,那么,就不会有很多请求读取到旧值。而且,请求 A 一般也会很快删除缓存值,这样一来,其他线程再次读取时,就会发生缓存缺失,进而从数据库中读取最新值。所以,这种情况对业务的影响较小。
无论是策略2还是策略3都有可能会出现这种情况:删除缓存失败,这时我们可以采用重试机制来保证数据的一致性。
方案的详细设计
在相关策略的调用中,虽然提出了一些简单解决方案,但是没有考虑到列如 缓存删除失败,数据库更新失败等情况,因此需要增加重试策略,但是还是可能会出现比较不一致的问题,此处详细介绍几种方案。
流程如下:
- 更新数据库数据;
- 缓存因为种种问题删除失败
- 将需要删除的key发送至消息队列
- 自己消费消息,获得需要删除的key
- 继续重试删除操作,直到成功
如果能够成功地删除或更新,我们就要把这些值从消息队列中去除,以免重复操作,此时,我们也可以保证数据库和缓存的数据一致了。否则的话,我们还需要再次进行重试。如果重试超过的一定次数,还是没有成功,我们就需要向业务层发送报错信息了。
// 伪代码
public void updateDb(String key,Object data){
db.updateData(data);
if (!redis.delKey(key)) {
mq.send(key);
new Thread(() -> asyncDel()).start();
}
}
// 异步重试
private void asyncDel() {
int count = 0;
String key = mq.get();
while(!redis.delKey(key)) {
count++;
if (count > 5) {
throw new DelFailException();
}
}
mq.remove(key);
}
这种虽然可以解决,但是会对业务代码造成侵入,而且还需要去维护消息队列,如果可以容忍的话,我觉得是可选的方案之一。
注意 需要使用有序的消息队列,保证消息的有序性。重试删除
订阅binlog
业务代码只会操作数据库,不操作缓存。同时启动一个订阅binlog的程序去监听删除操作,然后投递到消息队列中。再启动一个消费者,根据消息去删除缓存。
canal是用来模拟MySQL slave,来订阅MySQL master 的binlog。
异步重试
总结
对于读多写少的数据,请使用缓存。
为了保持数据库和缓存的一致性,会导致系统吞吐量的下降。
为了保持数据库和缓存的一致性,会导致业务代码逻辑复杂。
缓存做不到绝对一致性,但可以做到最终一致性。
对于需要保证缓存数据库数据一致的情况,请尽量考虑对一致性到底有多高要求,选定合适的方案,避免过度设计。
缓存问题
缓存穿透
问题描述
缓存穿透是指查询一个根本不存在的数据,缓存层和存储层都不会命中,通常出于容错的考虑,如果从存储层查不到数据则不写入缓存层,如下图所示
整个过程分为如下3步:
- 缓存层不命中。
- 存储层不命中,不将空结果写回缓存。
- 返回空结果。
缓存穿透将导致不存在的数据每次请求都要到存储层去查询,失去了缓存保护后端存储的意义。
缓存穿透问题可能会使后端存储负载加大,由于很多后端存储不具备高并发性,甚至可能造成后端存储宕掉。通常可以在程序中分别统计总调用数、缓存层命中数、存储层命中数,如果发现大量存储层空命中,可能就是出现了缓存穿透问题。
解决方案
造成缓存穿透的基本原因有两个。第一,自身业务代码或者数据出现问题,第二,一些恶意攻击、爬虫等造成大量空命中。下面我们来看一下如何解决缓存穿透问题。
缓存空对象
如图所示,当第2步存储层不命中后,仍然将空对象保留到缓存层中,之后再访问这个数据将会从缓存中获取,这样就保护了后端数据源。
缓存空对象会有两个问题:第一,空值做了缓存,意味着缓存层中存了更多的键,需要更多的内存空间,比较有效的方法是针对这类数据设置一个较短的过期时间,让其自动剔除。第二,缓存层和存储层的数据会有一段时间窗口的不一致,可能会对业务有一定影响。例如过期时间设置为5分钟,如果此时存储层添加了这个数据,那此段时间就会出现缓存层和存储层数据的不一致,此时可以利用消息系统或者其他方式清除掉缓存层中的空对象。
布隆过滤器拦截
布隆过滤器:实际上是一个很长的二进制向量和一系列随机映射函数。布隆过滤器可以用于检索一个元素是否在一个集合中。它的优点是空间效率和查询时间都比一般的算法要好的多,缺点是有一定的误识别率和删除困难。可以告诉你某样东西一定不存在或者可能存在。
如图所示,在访问缓存层和存储层之前,将存在的key用布隆过滤器提前保存起来,做第一层拦截。例如:一个推荐系统有4亿个用户id,每个小时算法工程师会根据每个用户之前历史行为计算出推荐数据放到存储层中,但是最新的用户由于没有历史行为,就会发生缓存穿透的行为,为此可以将所有推荐数据的用户做成布隆过滤器。如果布隆过滤器认为该用户id不存在,那么就不会访问存储层,在一定程度保护了存储层。
两种方案比对
缓存雪崩
如图描述了什么是缓存雪崩:由于缓存层承载着大量请求,有效地保护了存储层,但是如果缓存层由于某些原因不能提供服务,于是所有的请求都会达到存储层,存储层的调用量会暴增,造成存储层也会级联宕机的情况。
预防和解决缓存雪崩问题,可以从以下三个方面进行着手。
(1) 保证缓存层服务高可用性。如果缓存层设计成高可用的,即使个别节点、个别机器、甚至是机房宕掉,依然可以提供服务,例如前面介绍过的Redis Sentinel和Redis Cluster都实现了高可用。
(2) 依赖隔离组件为后端限流并降级。无论是缓存层还是存储层都会有出错的概率,可以将它们视同为资源。作为并发量较大的系统,假如有一个资源不可用,可能会造成线程全部阻塞在这个资源上,造成整个系统不可用。降级机制在高并发系统中是非常普遍的。实际项目中,我们需要对重要的资源(例如Redis、MySQL、HBase、外部接口)都进行隔离,让每种资源都单独运行在自己的线程池中,即使个别资源出现了问题,对其他服务没有影响。但是线程池如何管理,比如如何关闭资源池、开启资源池、资源池阀值管理,这些做起来还是相当复杂的。这里推荐使用Java依赖隔离工具Hystrix,他是解决依赖隔离的利器。
(3) 提前演练。在项目上线前,演练缓存层宕掉后,应用以及后端的负载情况以及可能出现的问题,在此基础上做一些预案设定。
缓存击穿(热点数据集中失效)
问题描述
当一个key是热点key,并发量很大,而且重建缓存不能在短时间完成,在缓存失效的一瞬间,就会有大量的线程来重建缓存,造成后端负载加大,甚至让应用崩溃,这就叫缓存击穿。如下图:
解决方案
互斥锁
此方法只允许一个线程重建缓存,其他线程等待重建缓存的线程执行完,重新从缓存获取数据即可,整个过程如图所示。
永远不过期
“永远不过期”包含两层意思:
l 从缓存层面来看,确实没有设置过期时间,所以不会出现热点key过期后产生的问题,也就是“物理”不过期。
l 从功能层面来看,为每个value设置一个逻辑过期时间,当发现超过逻辑过期时间后,会使用单独的线程去构建缓存。
整个过程如图所示:
此方法有效杜绝了热点key产生的问题,但唯一不足的就是重构缓存期间,会出现数据不一致的情况,这取决于应用方是否容忍这种不一致。