谈谈缓存

本文谈谈缓存相关的问题,主要从下面几个点来出发,但是主要谈分布式缓存。

  • 为什么需要用缓存?
  • 缓存的原理是什么?
  • 有哪几种类的缓存?
  • 分布式缓存一般存在哪些问题?
    • 缓存一致性问题
    • 先操作数据库,还是先操作缓存
    • 缓存应该淘汰还是应该修改
    • 缓存不一致应该怎么解?
    • 缓存并发更新问题
    • 缓存穿透问题
    • 缓存雪崩
    • 缓存的「无底洞」现象

为什么需要用缓存

一般我们使用缓存,其实都是为了加速数据访问。

缓存的原理是什么

因为缓存是为了加速数据访问。而为了达到这个目的,往往是采用了下面的思路:

  • 将数据缓存在更高速的读取设备上。比如cpu的寄存器
  • 将数据提前运算并加载,可以减少同样数据的获取步骤,进而提升效率,大多数应用级的cache都是基于这种思路。
  • 将数据放置在距离用户更近的地点,可以加快数据获取速度。比如CDN就是这种思路。

有哪几类缓存

常见的缓存有下面几类:

  • CDN(Content Delivery Network 内容分发网络)。

    • CND的基本原理是广泛采用各种缓存服务器,将这些缓存服务器分布更接近用户的网络中,在用户访问网站时,利用全局负载技术将用户的访问指向距离最近的工作正常的缓存服务器上,由缓存服务器直接响应用户请求。一般会缓存图片,css js甚至视频。
  • 反向代理缓存

    • 反向代理位于应用服务器机房,处理所有对WEB服务器的请求。 如果用户请求的页面在代理服务器上有缓冲的话,代理服务器直接将缓冲内容发送给用户。如果没有缓冲则先向WEB服务器发出请求,取回数据,本地缓存后再发送给用户。通过降低向WEB服务器的请求数,从而降低了WEB服务器的负载。这种缓存一般都是缓存一些静态资源,比如css js和图片。比如常见的nginx就可以用来干这个事情。
  • 应用本地缓存

    • 指的是在应用中的缓存组件,其最大的优点是应用和cache是在同一个进程内部,请求缓存非常快速,没有过多的网络开销。比如常见的guava cache。
  • 分布式缓存

    • 指的是与应用分离的缓存组件或服务,其最大的优点是自身就是一个独立的应用,与本地应用隔离,多个应用可直接的共享缓存。

分布式缓存一般存在哪些问题?

缓存一致性问题

一个最简单的场景,应用架构为Spring MVC + redis缓存 + mysql存储。如果保证redis缓存和mysql数据库的数据一致性呢?

假设先不考虑缓存并发的问题,假设在单个请求下,会有下面4种情况

  • 先更新数据库成功,再更新缓存失败,产生数据不一致
  • 先更新缓存成功,在更新数据库失败, 产生数据不一致
  • 先更新数据库成功,在删除缓存失败,产生数据不一致
  • 先删除缓存成功,在更新数据库失败,产生数据不一致

现在我们考虑一下真实的场景,很可能同时会有多个请求读写相同的key,这样就可能出现「写后立即读」这个场景,如果还有主从同步的从场景,会更加复杂。

先操作数据库,还是先操作缓存?

关于这个问题,行业有两种不同的实践:

Cache Aside Pattern

什么是Cache Aside Pattern呢?简单的说Cache Aside Pattern指的是:

  • 对于读请求

    • 先读缓存
    • 如果缓存miss,则读db,并把结果放置在缓存中
    • 如果缓存hit,那么直接返回缓存中的结果
  • 对于写请求

    • 先写数据库,然后在淘汰缓存(Cache Aside Pattern建议是淘汰缓存,这个在下节有讨论)

Cache Aside Pattern中提出的观点是应该先操作数据库,再淘汰缓存 。但是Cache Aside Pattern也是存在问题的:

如果先操作数据库,再淘汰缓存,在原子性被破坏时:

  • 修改数据库成功了
  • 淘汰缓存失败了

导致,数据库与缓存的数据不一致。

如果先淘汰缓存,在操作数据库呢?
  • 对于读请求,和Cache Aside Pattern中的读请求是一样的

    • 先读缓存
    • 如果缓存miss,则读db,并把结果放置在缓存中
    • 如果缓存hit,那么直接返回缓存中的结果
  • 对于写请求

    • 先操作缓存,然后在写数据库。

但是对于写请求当原子性被坏以后呢,会发生什么呢?

这里又分了两种情况:

  • 操作缓存使用set
    • 第一步成功,第二步失败,会导致,缓存里是set后的数据,数据库里是之前的数据,数据不一致,业务无法接受。并且,一般来说,数据最终以数据库为准,写缓存成功,其实并不算成功。
  • 操作缓存使用delete
    • 第一步成功,第二步失败,会导致,缓存里没有数据,数据库里是之前的数据,数据没有不一致,对业务无影响。只是下一次读取,会多一次cache miss。但是在高并发情况下,也可能出现缓存和db数据不一致的问题,比如2个请求,一个更新,一个读。可能出现:
      • 更新请求删除缓存
      • 读请求cache miss
      • 读请求查询db并回写cache
      • 更新请求更新db成功

从上面的两种分析,我们可以看到,无论是先操作缓存,还是先操作数据库,都会存在数据一致性问题。

因此我们在做任何技术方案的选型时,一定要具体到特定业务。只有适合的方案,未必有最优的方案。技术人,不是被动接受,而要主动思考。

缓存应该淘汰还是应该修改

我们看看修改缓存和淘汰缓存的区别:

  • 淘汰某个key,操作简单,直接将key置为无效,但下一次该key的访问会cache miss
  • 修改某个key的内容,逻辑相对复杂,但下一次该key的访问仍会cache hit

我们可以看到,两者的区别仅仅在于一次cache miss。

然后考虑一下我们平时缓存在缓存中的对象,基本类型的话,直接set,但是如果是序列化以后的呢,我们会先读出来,反序列化,修改,在序列化,在set….
我们可以看到对于对象类型,或者文本类型,修改缓存value的成本较高。

同时考虑下面的场景,在1和2两个并发写发生时,由于无法保证时序,此时不管先操作缓存还是先操作数据库,都可能出现:

  • 请求1先操作数据库,请求2后操作数据库
  • 请求2先set了缓存,请求1后set了缓存

因此就会导致数据库与缓存之间的数据不一致。因此我们可以得到结论:

  • 「修改缓存」比「淘汰缓存」少因此cache miss
  • 对于复杂对象,「修改缓存」成本较高,而「淘汰缓存」成本很低,而且大部分场景下,「修改缓存」成本会高于“增加一次cache miss”

所以 我们应该使用「淘汰缓存」,而不是「修改缓存」。而Cache Aside Pattern也是建议delete缓存,而不是set缓存。

数据不一致应该怎么解?

此处讨论两种数据不一致的情况,下面就这两种数据不一致的情况分别说一下

  • 缓存和数据库数据不一致
  • 主从同步数据不一致

缓存和数据库数据不一致

通过上面的说明,我们可以看到,读操作一般都没什么问题,但是一涉及到写请求,在高并发的情况下,无论是先操作数据库,还是先操作缓存,其实都会出现缓存和数据库数据不一致的问题。一般针对缓存和数据不一致的问题,也有下面几种常见的解决办法:

  • 采用延时双删策略
  • 异步更新缓存(基于订阅binlog的同步机制)
延时双删策略 + 缓存过期

延时双删策略简单的说就是,在写操作数据库前后,都删除缓存中的数据。伪代码如下:

1
2
3
4
5
6
public void writeRequest(object key, Object value){
redis.delKey(key);
db.writeValue(value);
Thread.sleep(someTime);
redis.delKey(key);
}

如伪代码所示,在写操作数据库的前后,都删除缓存中的数据。但是需要注意的是,在写操作数据成功以后,等待一段时间(这个时间需要自己评估项目的读数据业务逻辑的耗时)在删除一次缓存。这么做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。

当然这种策略还要考虑redis和数据库主从同步的耗时。所以一般休眠时间的考量为:

休眠时间 = 读数据业务逻辑的耗时 + 主从数据同步时间 + 一定的随机时间

同时依赖缓存过期策略,就可以保证缓存和数据库的最终一致性。但是这种方法会增加写请求的耗时,在一些场景下其实并不合适。

基于订阅binlog的同步机制

这种机制类似于mysql的master slave同步。在初始情况下,数据库和缓存数据是一致的,此时,通过订阅数据库的binlog(比如基于canal(阿里的一款开源框架)),然后基于binlog来在缓存上面replay,那么就可以保证最终数据一致性

主从数据同步不一致

常见的数据库集群架构一般为一主多从,主从同步,读写分离

  • 一个主库提供写服务
  • 多个从库提供读服务,可以增加从库提升读性能
  • 主从之间同步数据

但是因为主从同步有延迟,在这个延迟期间,读请求打到从库的时候,可能相关数据还没有同步完成,此时就会读不到数据,或者读取到一个不一致的数据。

一般如何应对主从数据同步不一致的问题呢?一般有下面几种方法:

  • 如果业务可以容忍,那么就可以忽略主从同步不一致
  • 强制读主库
  • 选择性读主库

下面分别说说这几种情况(下面的解决方法的前提是主从同步延迟没那么巨大)

忽略主从同步不一致

一些业务场景比如搜索,比如帖子等的,是可以忍受主从同步延迟的。此时就可以忽略主从同步数据不一致的问题。

强制读主库

这种方案的思路就是:

  • 使用一个高可用主库提供数据库服务
  • 读和写都落到主库上
  • 采用缓存来提升系统读性能

这种方案中,从库仅仅相当于主库的backup,当主库出现问题时,在使得某个从库成为新的主库。但是如果实现不好的话,在切换过程中可能会丢失数据。

选择性读主

选择性读主其实是强制读主库的优化版。这种方案增加一层cache,用来记录刚刚修改过的数据:

  • 当写请求发生时,先写主库
  • 然后将记录的唯一key写入一个cache中,同时为这个key设置一个过期时间,一般这个过期时间比主从同步的延迟时间大一点点就行。

然后当读请求过来的时候:

  • 根据记录的唯一key,先在cache中查询
    • 如果在cache中查询到,说明这个记录刚刚在主库修改了不久,此时就去主库读
    • 如果在cache中没有查询到,说明这个记录要么很久没有修改了,要么主库刚刚写完还没来得及写入cache,此时就去读从库
      • 注意:这种方案读从库依旧可能读取到不一致的数据

根据上面的分析,我们可以看到「选择性读主」,比起写请求无脑写操作主库,读请求无脑读从库的方案,在一定的程度上减少了读取到不一致数据的概率,并没有根本解决问题

缓存并发问题

缓存过期后将尝试从后端数据库获取数据,这是一个看似合理的流程。但是,在高并发场景下,有可能多个请求并发的去从数据库获取数据,对后端数据库造成极大的冲击,甚至导致 “雪崩”现象。此外,当某个缓存key在被更新时,同时也可能被大量请求在获取,这也会导致一致性的问题。那如何避免类似问题呢?

一般会采用锁机制,在缓存更新或者过期的情况下,先尝试获取到锁,当更新或者从数据库获取完成后再释放锁,其他的请求只需要牺牲一定的等待时间,即可直接从缓存中继续获取数据。

一个经典的缓存并发导致的问题

考虑如下业务业务场景:

  • 调用第三方服务,例如微信,一般会分配一个token,每次访问接口需要带上这个token;
  • 这个token是有有效期的,当token过期时,需要去重新认证申请;
  • 可以在token过期前重新申请,但此时旧token会失效。

假如实现方式为:

  • 把token放在缓存中,每次带上token去调用接口;
  • 如果token过期,需要去申请新token;
  • 申请完新token,需要把新token更新到缓存里。

在高并发情况下,就可能出现缓存并发问题,而导致token一直失效:

  • 请求1用旧token,访问接口,发现token过期;
  • 请求2用旧token,访问接口,也发现token过期;
  • 请求1去申请新token1;
  • 请求2去申请新token2 (此时token1会过期);
  • 请求1把token1放入缓存,同时使用token1访问接口(此时token1已经过期),发现token1过期,可能会递归申请新token3(此时token2过期);
  • 请求2把token2放入缓存,同时使用token2访问接口(此时token2已经过期),发现token2过期,可能会递归申请新token4(此时token3过期);

这就是非常经典的:高并发请求导致相互失效。

一般这种问题的解决办法其实很简单:

  • 将申请token异步化,也就是有专门的线程定时来申请新token并放置在cache中(这一步要避免并发更新,比如采用MVCC)
  • 同时为cache中的token设置一个更新时间戳,如果这个token刚刚更新过,那么就先不更新,等待一定时间在去申请新token并更新

缓存穿透问题

缓存穿透一般有两种情况:

  • 部分高频率读取的key在DB中没有值
  • 缓存系统刚刚重启,缓存还没有加载

部分高频率读取的key在DB中没有值

在高并发场景下,如果某一个key被高并发访问,没有被命中,出于对容错性考虑,会尝试去从后端数据库中获取,从而导致了大量请求达到数据库,而当该key对应的数据本身就是空的情况下,这就导致数据库中并发的去执行了很多不必要的查询操作,从而导致巨大冲击和压力。

一般都会通过缓存空对象来解决部分高频率读取的key在DB中没有值的问题。对查询结果为空的对象也进行缓存,如果是集合,可以缓存一个空的集合(非null),如果是缓存单个对象,可以通过字段标识来区分(需要区分空值和数据为空值的区别)。这样避免请求穿透到后端数据库。同时,也需要保证缓存数据的时效性。这种方式实现起来成本较低,比较适合命中不高,但可能被频繁更新的数据。

缓存系统刚刚重启,缓存还没有加载

有些情况下,缓存系统因为一些原因刚刚重启,缓存还没来得及加载初始化,而此时如果大量流量进来的话,就会导致大量流量直接打到DB,从而导致缓存雪崩。

一般这种情况的解决办法就是避免短时间大规模重启缓存集群,重启以后增加缓存预热功能

缓存雪崩

缓存雪崩其实发生在缓存穿透场景下,导致大量请求到达后端数据库,从而导致数据库崩溃,整个系统崩溃,发生灾难。

此处特别提一下,平时在设计缓存过期的时候,一般不要产生某一时刻大批量缓存过期的场景,可以加随机时间。将缓存过期的时间分散。避免出现缓存雪崩

缓存的「无底洞」现象

该问题由 facebook 的工作人员提出的, facebook 在 2010 年左右,memcached 节点就已经达3000 个,缓存数千 G 内容。他们发现了一个问题—memcached 连接频率,效率下降了,于是加 memcached 节点,添加了后,发现因为连接频率导致的问题,仍然存在,并没有好转,称之为”无底洞现象”。

目前主流的数据库、缓存、Nosql、搜索中间件等技术栈中,都支持“分片”技术,来满足“高性能、高并发、高可用、可扩展”等要求。有些是在client端通过Hash取模(或一致性Hash)将值映射到不同的实例上,有些是在client端通过范围取值的方式映射的。当然,也有些是在服务端进行的。但是,每一次操作都可能需要和不同节点进行网络通信来完成,实例节点越多,则开销会越大,对性能影响就越大。

一般这种情况,可以从下面几个方面来考虑:

  • 数据分布方式,比如尽可能把相关数据按照业务常见组织结构,放在一个节点,避免访问多个缓存节点后在组装数据
  • IO优化, 可以充分利用连接池,NIO等技术来尽可能降低连接开销,增强并发连接能力。
  • 数据访问方式, 一次性获取大的数据集,会比分多次去获取小数据集的网络IO开销更小
Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×