分布式缓存常见的几种问题
为什么使用分布式缓存和分布式缓存的应用场景就先不说了。我们着重看看,分布式缓存的若干问题
缓存选型
当前主流的做缓存的开源组件有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
因为线程调度的不确定性和网络等原因,存在下面这种时序:
- A更新了数据库,值设置为1
- B更新了数据库,值设置为2
- B更新了缓存,值设置为2
- A更新了缓存,值设置为1
此时就产生了脏数据
先更新数据库,再删除缓存
这种情况也会存在并发问题。线程A和B,线程A做更新操作,线程B做查询操作。
- 缓存刚好失效
- 线程B查询数据库,获取一个旧值
- 线程A将新值写入数据库
- 线程A删除缓存
- 线程B将查询的旧值写入缓存
这种情况下就会产生脏数据。但是概率极低。因为,要在步骤3耗费的时间小于步骤2的情况下,步骤4才有可能比步骤5先发生。但数据库肯定是读操作远远快于写操作的,所以这一情形很难出现。
如果非要解决这个问题的话,采用下文说的延时双删策略。
先删除缓存,再更新数据库
还是线程A和B,线程A负责写入新值,线程B负责查询。这两个线程同时执行的话,存在下面这种场景:
- 线程A先删除缓存
- 线程B查询缓存,发现缓存不存在
- 线程B查询数据库得到旧值
- 线程B将旧值写入缓存
- 线程A将新值写入缓存
这个时候又出现了脏数据的问题,并且脏数据也有可能长时间存在。
延时双删
这个时候可以采用延时双删策略,以达到最终一致性的目的。
意思就是,先删除缓存,在更新数据库,隔特定时间,再删除缓存一次。就是在上文的场景中把步骤5改为,等待1S(举例,实际时间需更具业务确定,但不能太短,防止线程B还没写入就已经开始删数据),再次删除缓存。
这种策略下,如果数据库做了读写分离,还会存在问题。睡眠时间就得加上主从同步的延时时间。
等待的这个时间也可以改为异步线程执行,以免减低系统的吞吐量。
第二次删除也存在删除缓存失败的可能,这个时候可以借助消息队列做重试,直到成功为止。
其实延时双删也只是个最终一致性策略,更新完数据库第二次删除的那段时间,数据还是不一致的。
延时双删的补偿策略
本章节所讲的策略,可以说是延时双删的补偿策略,确保数据最后一致行的机制。
策略1:
采用消息队列
- 更新数据库数据;
- 缓存因为种种问题删除失败
- 将需要删除的key发送至消息队列
- 自己消费消息,获得需要删除的key
- 继续重试删除操作,直到成功
策略2:
策略1的缺陷在于对业务代码的大量侵入,为此,有人提出了订阅数据库的binlog的方案
启动一个订阅程序去订阅数据库的binlog,获得需要操作的数据。在应用程序中,另起一段程序,获得这个订阅程序传来的信息,进行删除缓存操作。
- 更新数据库数据
- 数据库会将操作信息写入binlog日志当中
- 订阅程序提取出所需要的数据以及key
- 另起一段非业务代码,获得该信息
- 尝试删除缓存操作,发现删除失败
- 将这些信息发送至消息队列
- 重新从消息队列中获得该数据,重试操作。
read/write through 缓存代理
Read/Write Through套路是把更新数据库(Repository)的操作由缓存自己代理了,所以,对于应用层来说,就简单很多了。可以理解为,应用认为后端就是一个单一的存储,而存储自己维护自己的Cache。数据库由缓存代理,缓存未命中时由缓存加载数据库数据然后应用从缓存读,写数据时更新完缓存后同步写数据库。应用只感知缓存而不感知数据库。
写回
这种模式是指在更新数据的时候,只更新缓存,不更新数据库,而我们的缓存会异步地批量更新数据库。