缓存常见的问题及其解决思路

缓存是现代web应用中最常用的技术之一,它有很多使用场景,我们主要讨论缓存作为应用与数据库之间的中间件用以保护后端数据库的场景。
我们简单描述这一场景:
客户端发起数据查询请求,应用先从缓存中查找是否存在已缓存数据,如果找到,则直接从缓存返回数据,如果未找到,则从数据库中查找数据,如果找到,则返回数据,并将数据存入缓存中。在下一次请求中,应用可以直接从缓存中拿到数据,而不用连接数据库,请求速度明显加快,而后端数据库也减少了负载。
以上场景存在几个待考虑的问题:

一、缓存更新

为了确保请求始终能够拿到正确的数据,我们需要在数据发生修改时,保持缓存与数据库的数据一致性。

1. 缓存更新策略

S1. 内存淘汰

Redis 提供 6 种数据淘汰策略:
volatile-lru(least recently used):从已设置过期时间的数据集(server.db[i].expires)中挑选最近最少使用的数据淘汰。
volatile-ttl:从已设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数据淘汰。
volatile-random:从已设置过期时间的数据集(server.db[i].expires)中任意选择数据淘汰。
allkeys-lru(least recently used):当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的 key(这个是最常用的)。
allkeys-random:从数据集(server.db[i].dict)中任意选择数据淘汰。
no-eviction:禁止驱逐数据,也就是说当内存不足以容纳新写入数据时,新写入操作会报错。
4.0 版本后增加以下两种:
volatile-lfu(least frequently used):从已设置过期时间的数据集(server.db[i].expires)中挑选最不经常使用的数据淘汰。
allkeys-lfu(least frequently used):当内存不足以容纳新写入数据时,在键空间中,移除最不经常使用的 key。

S2. 过期淘汰

redis缓存基于内存,而内存是有限的,因而redis支持对缓存设置TTL,即缓存过期时间,缓存过期后redis将自动删除过期数据。

S3. 主动淘汰

内存淘汰策略和过期淘汰策略依赖于Redis完成缓存的淘汰,相对地,也可以自己对缓存进行淘汰。
主动淘汰主要有三种方案:

  • Cache Aside:缓存调用者在更新数据库的同时完成对缓存的更新。
    一致性良好,实现难度一般。
  • Read/Write Through:缓存与数据库集成为一个服务,服务保证两者的一致性,对外显露API接口。调用者调用API,无需知道自己操作的是数据库还是缓存,不关心一致性。
    一致性优秀,实现复杂,性能一般。
  • Write Back:缓存调用者的CRUD都针对缓存完成。由独立线程异步的将缓存数据写到数据库,实现最终一致。
    一致性差,性能高,实现复杂。
    综合对比下,一般我们采用Cache Aside方案,即由缓存调用者处理缓存的主动淘汰。

    策略选择
  • 对于低一致性的业务场景,采用内存淘汰或者过期淘汰即可。
  • 对于高一致性的业务场景,主动更新为主,过期淘汰为辅。

    2. Cache Aside实现中的几个重要问题

  • 删除缓存还是修改缓存?
    采用修改缓存的方式,每次更新数据库都需要更新缓存,而这些缓存未来并不一定会被用到,可能造成内存浪费,此外,采用修改方式还需要处理线程安全问题,而删除缓存的方式,则在每次数据修改时删除掉缓存,并在下一次查询到该数据时,从数据库返回的值中重建该缓存。因而常采用的方式是删除缓存。
  • 如何确保缓存和数据库数据均得到修改?
    在一次数据修改时,为了确保缓存与数据库数据修改的原子性,我们可以采用分布式事务,例如TCC等方式,而在数据修改的顺序上则存在两种选择,我们在并发修改数据条件下考虑这两种选择的优劣。
  • 先写缓存,后写数据库
    假设初始状态下,缓存和数据库的值均为20,线程1发起数据修改请求,将count改为10,先删除缓存,并修改数据库中的值,若线程1还未来得及将数据库中的数据发生修改,线程2发起查询请求,此时缓存不存在,则查询数据库,此时数据库返回的是未被修改的值即20,并且把这个值写入了缓存,线程1执行完数据修改后,数据库值为10,而缓存数据值为10,在缓存数据失效之前这个数据一直处于不一致的状态。
  • 先写数据库,后写缓存
    假设初始状态下,缓存中无数据,数据库中值为20,线程1发起数据修改请求,将count改为10,先修改数据库中的值,并删除缓存数据,若此时线程2发起查询请求,则此时缓存中无数据,查询数据库得到值为20,并将数据缓存,若在线程1删除缓存的操作发生在线程2添加缓存之前,那么缓存中就会存在一个无效的值20,也是产生了不一致的状态。

数据库修改是一个相对耗时的操作,因而第一种情况发生的概率将大于第二种情况发生的概率,因而先写数据库,后写缓存是我们较常采用的方式

Q1. 缓存穿透

在查询过程中,若该数据在缓存和数据库中均不存在,则每次查询都会查询数据库,如果客户端无意间或者有意(网络攻击)发出大量这样的查询过程,就会造成后端数据库性能问题甚至导致故障。
解决思路:

  • 若查询发现缓存数据不存在,则在缓存中存入null即空值,并设置一个较短的TTL时间,那么在下一次查询过程中就可以从缓存中直接返回空数据,这样做十分简单,但缺点有二:一是,造成内存空间浪费,redis中可能存储大量空值的缓存数据。二是可能造成一致性问题,在某一个key的空值缓存失效之前,如果数据库插入了这条数据,此时数据是存在的,但是客户端却错误地返回了空值。
  • 另一种常用的做法是采用布隆过滤器,即在应用与缓存之间在加一个中间层,查询请求先经过布隆过滤器,如果布隆过滤器判断数据不存在,则直接响应客户端数据不存在,如果判断数据存在,再通过缓存、数据库查询。这样做的缺点在于布隆过滤器本身的实现难度以及布隆过滤器能够准确判断数据不存在的情况,但对于数据存在的判断则可能产生概率上的误判。
  • 其他:做好数据的基础格式设计和校验、加强用户权限校验、做好热点数据限流等。

Q2. 缓存雪崩

雪崩,顾名思义,在我们的应用场景中缓存作为保护后端数据库的“护城河”存在,缓存雪崩意味着缓存失去保护的作用,有几种场景会导致缓存雪崩。

  • 缓存数据短时间内大量失效
    在缓存的实际应用中我们通常会在应用面临高并发流量之前,例如秒杀,批量将数据提前导入redis,这些数据通常是设置了有效期的,如果存在大量缓存数据在同一时间失效,此时就会出现大量请求直接落到后端数据库,从而造成后端数据库性能问题甚至导致故障。
    解决思路:问题的产生主要是因为大量缓存数据在同一时间失效,我们可以在设置缓存TTL时加一个随机时间长度,从而分散失效时间,避免雪崩。
  • redis宕机
    任何服务都可能发生宕机,redis也不例外,当redis宕机后,同样地会有大量请求直接打到数据库。
    解决思路:采用集群部署方式提高redis的可用性。

Q3. 缓存击穿

雪崩是大量缓存的同时失效,而击穿则是热点key的失效,一个被高并发访问且缓存重建较为复杂的key突然失效,无数的请求给数据库造成巨大冲击。
解决思路:

  • 互斥锁
    第一个执行查询的线程发现key不存在缓存,拿到互斥锁,执行查询,从数据库查询数据,并重建key,重建完key之后释放锁,在线程1释放锁之前,其他进程发现key不存在缓存,尝试拿锁失败,则睡眠等待,并在线程1重建key释放锁之后,重新尝试从缓存中获取值。这种方式简单,可以直接借助setnx实现分布式锁,没有额外内存消耗,保证一致性,但存在阻塞,性能上影响较大。
  • 逻辑过期
    在建立缓存时,应用程序除了写入缓存数据,额外写入一个field(例如expire_time),代表该key的失效时间,此时失效的判断不依赖于redis提供的TTL(不设置),而通过应用程序判断expire_time是否过期。具体的过程如下:首先,第一个执行查询的线程执行查询得到缓存,判断expire_time已过期,拿到互斥锁,直接返回缓存值,同时另开一个线程,执行数据库查询和缓存重建操作,其他进程同样查询得到缓存,判断expire_time已过期,尝试拿锁失败,则直接返回缓存值,并不再尝试缓存重建。这种方式性能较好,但实现复杂,需要额外内存消耗,且不保证一致性。

newcyw
1 声望0 粉丝

« 上一篇
zookeeper简介