Redis在缓存中的应用

1,831 阅读8分钟

Redis 作为一个高性能的内存存储系统,拥有远高于数据库的读写效率,因此它的一个典型的应用场景就是作为缓存,降低数据库压力。

一、缓存的优缺点

优点

  1. 读取速度: 缓存最直接的作用就是可以加快读取速度,存在于内存中的缓存读取速度要远高于磁盘

  2. 降低数据库压力: 数据库能承受的并发量往往比较有限,使用缓存命中后直接返回有助于降低SQL语句执行次数

缺点

  1. 数据不一致: 由于缓存更新策略的原因,无法保证数据库和缓存中的数据一致性

  2. 代码中需要对缓存进行额外处理

二、Redis 作为缓存的使用方式

Redis作为缓存的使用很简单,只有读取和更新两步

读取

  1. 读取 Redis 中对应的key
  2. 有缓存直接返回,否则读取数据库
  3. 数据库读出后写入 Redis ,并返回数据

更新

  1. 更新数据库
  2. 删除 Redis 对应key

上述两个步骤的流程图如下:

从图中来看,读取流程 很好理解:命中了就直接返回,未命中则从数据库读出来再写入缓存,以便下次请求命中。没啥问题。

下面将详细说说 更新流程 为什么是先改数据,再删除缓存。

除使用Redis外,散列表之类的数据结构也可以作为缓存使用,但数据会局限于单个进程内,具体的比较见另外一篇 进程内缓存与Redis的比较


三、数据更新时,删除缓存的原因

先上结论,这么做是为了缓存和数据库之间的数据能有更高的 一致性

单看 更新流程 ,要达到缓存和数据库都更新为最新数据的目的。 缓存可以删除也可以和数据库同步修改,还有二者顺序交换过来也一样能够实现。

这么一排列组合,其实有 删除缓存,更新数据库更新数据库,删除缓存更新缓存,更新数据库更新数据库,更新缓存 这四种方式可选,下面分析这几种方式各自的问题在哪:

\先数据库先缓存
更新缓存① set DB
set cache
②set cache
set DB
删除缓存③set DB
del cache
④del cache
set DB

① 和 ② 更新缓存

不论缓存和数据库的操作顺序,更新缓存都会面临相同的问题,这里就把 ① 和 ② 放一起写了。

这里以先更新数据库,再更新缓存为例,考虑以下场景:

有两个并发的写请求,一个将键a的值设置为 1 ,另一个设置为 2 时。由于两个写请求的处理顺序不确定,可能出现下图中的情况,导致最终缓存和数据库不一致。

同理,将缓存和数据库的操作顺序换过来也会面临一样的情况。

④ 先删除缓存,再更新数据库

与更新缓存不同,对于先删除缓存的情况,考虑存在并发的读和写请求的场景。

写请求先将键a对应的缓存删除,这时发生读取会导致无法命中缓存,将会从数据库中读出旧的数据写回缓存中,最后写请求将新的值写入数据库,导致不一致。如图:

③ 先更新数据库,再删除缓存

最后也就是实际推荐的方式,先更新数据库,再删除缓存 。

使用该方式并不意味着万无一失,与上一种方法类似,在一读一写的情形下,也有可能发生数据的不一致。如图:

但是这种方式相对于其他三种的优点在于,一般来说写数据库的执行速度远慢于写入缓存,如果要发生这种情况,需要数据库的get a发生在set a之前(因为修改有写锁),意味着写入缓存的时间(下面的最后一步 set a =1)比更新数据和删除缓存(上面后两步)这两步加起来还要慢,出现概率较低。

总结一下,这四种数据更新方式都存在缓存与数据库 不一致 的风险,但是相比较而言,最后这种方式出现不一致的条件最为苛刻,数据不一致的概率最低。

数据一致性有办法保证吗?

上面说的四种方式都没办法完全避免数据不一致的问题,难道没有别的方式吗?

想想在数据库内部是如何保证一致性的,把这里的数据库和缓存看做是两张表,在同一个事务中更新两张表时,会对被修改部分加写锁,阻塞其他事务对被修改数据的读写。

类似的,数据库和缓存之间的一致性也可以通过加锁来保证。但是这一方面提高了编码难度,另一方面由于加锁阻塞读写,与使用缓存提高读取效率的初衷背道而驰,并不适用。


四、使用缓存可能导致的问题及处理方法

问题1:数据不一致

通过上面介绍的缓存数据更新的几种方式,我们了解到无论采用哪一种方法,都无法完全避免数据不一致,只能尽量选择出问题概率最小的方式。但是一旦出现了不一致,在不进行重新更新的情况下,缓存中的脏数据将会一直保留下来,被反复读到。

为此,需要给缓存设置一个 过期时间 来避免脏数据一直保留,以在缓存过期后重新获取最新的正确数据。实现 最终一致

问题2: 缓存穿透

当出现代码异常、爬虫遍历或者拒绝服务攻击时,可能出现大量访问缓存中不存在数据的请求。这时,缓存总是无法命中,所有请求都发到数据库上,引起缓存穿透的问题。

有以下的解决方法:

1. 缓存空值

不存在 作为一个特殊的值(比如字符串“null”)缓存下来,命中后也作为一个正常的值返回,可以避免数据库查询。

但是这种方法缺点在于,即使缓存空值也会占用内存,所以空值应设置一个较短的过期时间,避免占用过多内存。

2. 使用布隆过滤器

使用布隆过滤器可以让所有存在的数据以及少量的不存在数据通过,可以有效的避免缓存穿透问题。

缺点在于:布隆过滤器只有两个功能:添加和查询是否存在。不能进行删除,所以添加过的数据被删除后仍然会被放行,只能通过定期重建过滤器来避免。

问题3:缓存击穿

为了解决数据不一致的问题,上面的 问题1 中给出了设置过期时间这个办法来实现最终一致性。但也因为这个过期时间,又引入了新的问题。

对于需要被高频访问的热点数据(比如明星的微博),会有源源不断的大量读请求。正常情况下,这些请求命中缓存直接返回,不会产生什么影响。

但是在缓存过期的时候,需要重新从数据库中读取数据写回缓存。由于这个操作并不能在一瞬间完成,所以在数据写回缓存之前的一段时间里,所有请求都会发到数据库上,引起一瞬间的负载过高。

解决方法:

1. 排他锁

使用排他锁,同一时间只有一个线程去读取数据库,其他未获取锁的进行重试,直到数据写回缓存,将锁释放。

2. 不设置过期时间

对热点数据不设置过期时间,而是采用定时更新缓存等其他方式保证一致性。


五、缓存适用场景

综上所述,使用缓存可以有效降低数据库压力,提高系统的吞吐量,但也会带来一段时间内的数据不一致等问题。总体来说适用于读多写少、高吞吐量、能够转化为键值对格式、一定程度上容忍数据不一致的的场景。

以下场景需要慎用:

  1. 访问量低,数据库完全可以承受,不需要引入缓存带来额外的复杂性
  2. 数据一致性要求严格,比如涉及到资金的场景
  3. 读得少写得多,体现不出来缓存的优势,反而需要很多额外的删除缓存操作

参考