分布式缓存常见的几种问题

分布式缓存常见的几种问题

为什么使用分布式缓存和分布式缓存的应用场景就先不说了。我们着重看看,分布式缓存的若干问题

缓存选型

当前主流的做缓存的开源组件有Memcache和Redis,它们各有千秋,那么到底怎么选择呢?我们一项一项比较看看。

Memcache Redis
数据类型 只支持简单的数据类型 支持K/V之外,还有list、set、zset、hash等数据结构
持久化 不支持持久化 支持AOF和RDB
键大小 键长最大为250个字符 最大支持512M
值大小 值不能超过1MB,但是可以修改配置以支持更大的value 最大支持512M
线程模型 多线程模型 单线程模型
内存管理 使用Slab Allocation。原理相当简单,预先分配一系列大小固定的组,然后根据数据大小选择最合适的块存储。避免了内存碎片。 Redis通过定义一个数组来记录所有的内存分配情况, Redis采用的是包装的malloc/free。由于malloc 首先以链表的方式搜索已管理的内存中可用的空间分配,导致内存碎片比较多。
集群部署 支持 支持
分布式 使用Magent在客户端进行一致性hash做分布式 Redis支持在服务器端做分布式(PS:Twemproxy/Codis/Redis-cluster多种分布式实现方式)

缓存穿透

缓存穿透指的是,每次查询数据,发现查询不到,然后又去数据库查询,数据库也没。第二次同样数据过来查询,还是走缓存,再走数据库,查询两次。

这种情况一般可以用以下方式解决。

布隆过滤器

一个很长的二进制向量和一系列随机映射函数组成,将确定不存在的数据构建到过滤器中,用它来过滤请求。

缓存空对象

就算数据库里查询出来的是个null对象,也把它丢到缓存里,下来再来的时候就不用再去数据库里面查了。

缓存雪崩

缓存雪崩指的是在某个时刻,大批量缓存同时失效,导致所有的查询全部被倒流到数据库,给数据库和内存造成巨大的压力,甚至造成数据库宕机。

它也有几种解决办法

加锁排队

通过加锁或者排队机制来限制读数据库写缓存的线程数量

缓存时间增加随机值

避免多个缓存key在同一时间失效,导致压力更加集中。

缓存预热

缓存预热就是系统上线后,将相关的缓存数据直接加载到缓存系统。

缓存无底洞

键值数据库由于通常采用哈希函数将key映射到各个节点上,造成key的分布与业务无关,但是由于数据量和访问量的持续增长,造成需要添加大量节点做水平扩容,导致键值分布到更多的节点上,所以无论是Memcache还是Redis的分布式,批量操作通常需要从不同节点上获取,相对于单机批量操作只涉及一次网络操作,分布式批量操作会涉及多次网络时间。

  • 客户端一次批量操作会涉及多次网络操作,也就意味着批量操作会随着节点的增多,耗时会不断增大。
  • 网络连接数变多,对节点的性能也有一定影响。

有几种解决方案:

串行命令

逐次执行n个get命令

串行IO

Redis Cluster使用CRC16算法计算出散列值,再取对16383的余数就可以算出slot值,同时Smart客户端会保存slot和节点的对应关系,有了这两个数据就可以将属于同一个节点的key进行归档,得到每个节点的key子列表,之后对每个节点执行mget或者Pipeline操作

并行IO

将串行IO中的串行操作改为并行操作

hash_tag实现

Redis Cluster的hash_tag功能,他可以将多个key强制分配到一个节点上

总结

方案 优点 缺点 网络IO
串行命令 编程简单如果少量keys,性能可以满足要求 大量keys请求延迟严重 O(keys)
串行IO 编程简单少量节点,性能满足要求 大量node延迟严重 O(nodes)
并行IO 利用并行特性,延迟取决于最慢的节点 编程复杂由于多线程,问题定位可能较难 O(max_slow(nodes))
hash_tag 性能最高 业务维护成本较高容易出现数据倾斜 O(1)

分布式缓存的一致性

在读取的情况下,先读取缓存,如果缓存有数据,直接返回,没有的话,从数据库读取数据,然后设置到缓存中,返回数据。这种情况下,数据是可以保持一致性的。但是在更新或者删除的时候呢?怎样确保新数据更新至数据库之后,不会读取到缓存里的旧数据呢?

下面我们看下常见的几种策略。

先更新缓存,再更新数据库

这种场景会有问题,如果更新缓存成功了,更新数据库失败了。并且缓存没有做持久化,在更新完之后就挂了,那么数据就永远地丢失了。

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

这里就是把上面的更新操作换成删除操作。删除操作在并发情况下会有问题。假设两个线程A和B,线程A刚删除完缓存的时候,线程B正巧来查询,这时候B发现缓存里面没有数据,于是去数据库里查询,然后更新到缓存中,之后A又把最新的数据更新到数据库里。这个时候,缓存中的数据时更新前的老数据,数据库里面是最新的数据。数据就脏了,并且会一直保持下去。

先更新数据库,再更新缓存

这个场景下会有ABBA问题,线程A和B先后更新一个值,A改为1,B改为2.系统要求值最后值为2

因为线程调度的不确定性和网络等原因,存在下面这种时序:

  1. A更新了数据库,值设置为1
  2. B更新了数据库,值设置为2
  3. B更新了缓存,值设置为2
  4. A更新了缓存,值设置为1

此时就产生了脏数据

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

这种情况也会存在并发问题。线程A和B,线程A做更新操作,线程B做查询操作。

  1. 缓存刚好失效
  2. 线程B查询数据库,获取一个旧值
  3. 线程A将新值写入数据库
  4. 线程A删除缓存
  5. 线程B将查询的旧值写入缓存

这种情况下就会产生脏数据。但是概率极低。因为,要在步骤3耗费的时间小于步骤2的情况下,步骤4才有可能比步骤5先发生。但数据库肯定是读操作远远快于写操作的,所以这一情形很难出现。

如果非要解决这个问题的话,采用下文说的延时双删策略。

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

还是线程A和B,线程A负责写入新值,线程B负责查询。这两个线程同时执行的话,存在下面这种场景:

  1. 线程A先删除缓存
  2. 线程B查询缓存,发现缓存不存在
  3. 线程B查询数据库得到旧值
  4. 线程B将旧值写入缓存
  5. 线程A将新值写入缓存

这个时候又出现了脏数据的问题,并且脏数据也有可能长时间存在。

延时双删

这个时候可以采用延时双删策略,以达到最终一致性的目的。

意思就是,先删除缓存,在更新数据库,隔特定时间,再删除缓存一次。就是在上文的场景中把步骤5改为,等待1S(举例,实际时间需更具业务确定,但不能太短,防止线程B还没写入就已经开始删数据),再次删除缓存。

这种策略下,如果数据库做了读写分离,还会存在问题。睡眠时间就得加上主从同步的延时时间。

等待的这个时间也可以改为异步线程执行,以免减低系统的吞吐量。

第二次删除也存在删除缓存失败的可能,这个时候可以借助消息队列做重试,直到成功为止。

其实延时双删也只是个最终一致性策略,更新完数据库第二次删除的那段时间,数据还是不一致的。

延时双删的补偿策略

本章节所讲的策略,可以说是延时双删的补偿策略,确保数据最后一致行的机制。

策略1:

采用消息队列

采用消息队列补偿

  1. 更新数据库数据;
  2. 缓存因为种种问题删除失败
  3. 将需要删除的key发送至消息队列
  4. 自己消费消息,获得需要删除的key
  5. 继续重试删除操作,直到成功
策略2:

策略1的缺陷在于对业务代码的大量侵入,为此,有人提出了订阅数据库的binlog的方案

启动一个订阅程序去订阅数据库的binlog,获得需要操作的数据。在应用程序中,另起一段程序,获得这个订阅程序传来的信息,进行删除缓存操作。

订阅数据库binlog补偿

  1. 更新数据库数据
  2. 数据库会将操作信息写入binlog日志当中
  3. 订阅程序提取出所需要的数据以及key
  4. 另起一段非业务代码,获得该信息
  5. 尝试删除缓存操作,发现删除失败
  6. 将这些信息发送至消息队列
  7. 重新从消息队列中获得该数据,重试操作。

read/write through 缓存代理

Read/Write Through套路是把更新数据库(Repository)的操作由缓存自己代理了,所以,对于应用层来说,就简单很多了。可以理解为,应用认为后端就是一个单一的存储,而存储自己维护自己的Cache。数据库由缓存代理,缓存未命中时由缓存加载数据库数据然后应用从缓存读,写数据时更新完缓存后同步写数据库。应用只感知缓存而不感知数据库。

缓存代理

写回

这种模式是指在更新数据的时候,只更新缓存,不更新数据库,而我们的缓存会异步地批量更新数据库。

写回

参考资料

分布式系统关注点——「缓存穿透」和「缓存雪崩」到底啥区别?

缓存:无底洞优化。

分布式之数据库和缓存双写一致性方案解析

阿里P8架构师谈:分布式缓存的应用场景、选型比较、问题和挑战

常见的缓存更新策略剖析

0%