Redis 缓存问题(13)

断了今生、忘了曾经 提交于 2020-04-07 23:58:32

缓存使用场景

针对读多写少的高并发场景,我们可以使用缓存来提升查询速度。

当我们使用Redis作为缓存的时候,一般流程是这样的:

因为这些数据是很少修改的,所以在绝大部分的情况下可以命中缓存。但是,一旦被缓存的数据发生变化的时候,我们既要操作数据库的数据,也要操作Redis的数据,所以问题来了。现在我们有两种选择:

  1. 先操作Redis的数据再操作数据库的数据
  2. 先操作数据库的数据再操作Redis的数据到

底选哪一种?首先需要明确的是,不管选择哪一种方案,我们肯定是希望两个操作要么都成功,要么都一个都不成功。不然就会发生Redis跟数据库的数据不一致的问题。

但是,Redis的数据和数据库的数据是不可能通过事务达到统一的,我们只能根据相应的场景和所需要付出的代价来采取一些措施降低数据不一致的问题出现的概率,在数据一致性和性能之间取得一个权衡。

对于数据库的实时性一致性要求不是特别高的场合,比如T+1的报表,可以采用定时任务查询数据库数据同步到Redis的方案。

由于我们是以数据库的数据为准的,所以给缓存设置一个过期时间,是保证最终一致性的解决方案。

选择方案

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

正常情况:

 更新数据库,成功。
 删除缓存,成功。

异常情况:

1.更新数据库失败,程序捕获异常,不会走到下一步,所以数据不会出现不一致。

2.更新数据库成功,删除缓存失败。数据库是新数据,缓存是旧数据,发生了不一致的情况。

这种情况我可以提供一个“重试机制”来解决。比如:如果删除缓存失败,我们捕获这个异常,把需要删除的 key 发送到消息队列。然后自己创建一个消费者消费,尝试再次删除这个 key。这种方式会对业务代码造成入侵。

所以我们使用另外一种方案“异步更新缓存” 因为更新数据库时会产生binlog日志,所以我们可以通过一个服务来监听binlog的变化(如:maxwell 或 canal ),然后在客户端完成删除 key 的操作。如果删除失败的话,再发送到消息队列。

总之,对于后删除缓存失败的情况,我们的做法是不断地重试删除,直到成功。无论是重试还是异步删除,都是最终一致性的思想。

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

正常情况:

 删除缓存,成功。
 更新数据库,成功。

异常情况: 1、删除缓存,程序捕获异常,不会走到下一步,所以数据不会出现不一致。

2、删除缓存成功,更新数据库失败。 因为以数据库的数据为准,所以不存在数据不一致的情况。看起来好像没问题,但是如果有程序并发操作的情况下:

 a.  线程 A 需要更新数据,首先删除了 Redis 缓存
 b.  线程 B 查询数据,发现缓存不存在,到数据库查询旧值,写入 Redis,返回
 c.  线程 A 更新了数据库

这个时候,Redis 是旧的值,数据库是新的值,发生了数据不一致的情况。

那问题就变成了:能不能让对同一条数据的访问串行化呢?代码肯定保证不了,因为有多个线程,即使做了任务队列也可能有多个服务实例。数据库也保证不了,因为会有多个数据库的连接。只有一个数据库只提供一个连接的情况下,才能保证读写的操作是串行的,或者我们把所有的读写请求放到同一个内存队列当中,但是这种情况吞吐量太低了。

所以我们可以使用“延时双删”的策略,在写入数据之后,再删除一次缓存。

A 线程:
  1)删除缓存
  2)更新数据库
  3)休眠 500ms(这个时间,依据读取数据的耗时而定)
  4)再次删除缓存

高并发问题

在 Redis 存储的所有数据中,有一部分是被频繁访问的。有两种情况可能会导致热点问题的产生,一个是用户集中访问的数据,比如抢购的商品,明星结婚和明星出轨的微博。还有一种就是在数据进行分片的情况下,负载不均衡,超过了单个服务器的承受能力。热点问题可能引起缓存服务的不可用,最终造成压力堆积到数据库。

出于存储和流量优化的角度,我们必须要找到这些热点数据。

热点数据发现

除了自动的缓存淘汰机制之外,怎么找出那些访问频率高的 key 呢?或者说,我们可以在哪里记录 key 被访问的情况呢?

1. 客户端

我们可以在所有调用get、set方法的地方加上Key的计数。但这样的话但是这样的话,每一个地方都要修改,重复的代码也多。如果我们用的是 Jedis 的客户端,我们可以在 Jedis 的 Connection 类sendCommand()里面,用一个 HashMap 进行 key 的计数。

但有这种方式有几个问题:

 1.不知道要存多少个Key,如果数量较大,可能会发生内存泄漏的问题。
 2.会对客户端代码造成侵入。
 3.只能统计当前客户端的热点。

2. 代理层

第二种方式就是在代理端实现,比如 TwemProxy 或者 Codis,但是不是所有的项目都使用了代理的架构。

3. 服务端

第三种就是在服务端统计,Redis 有一个 monitor 的命令,可以监控到所有 Redis执行的命令。如:

jedis.monitor(new JedisMonitor() {
	@Override
	public void onCommand(String command) {
		System.out.println("#monitor: " + command);
	}
});

Facebook 的 开 源 项 目 redis-faina就是基于这个原理实现的。它是一个 python 脚本,可以分析 monitor 的数据。

redis-cli -p 6379 monitor | head -n 100000 | ./redis-faina.py

这种方法也会有两个问题:

 1)monitor 命令在高并发的场景下,会影响性能,所以不适合长时间使用。
 2)只能统计一个 Redis 节点的热点 key。

4.机器层面

还有一种方法就是机器层面的,通过对 TCP 协议进行抓包,也有一些开源的方案,比如 ELK 的 packetbeat 插件。

当我们发现了热点 key 之后,我们来看下热点数据在高并发的场景下可能会出现的问题,以及怎么去解决。

缓存雪崩

缓存雪崩就是 Redis 的大量热点数据同时过期(失效),因为设置了相同的过期时间,刚好这个时候 Redis 请求的并发量又很大,就会导致所有的请求落到数据库。

解决办法:

 1. 加互斥锁或者使用队列,针对同一个 key 只允许一个线程到数据库查询
 2. 缓存定时预先更新,避免同时失效
 3. 通过加随机数,使 key 在不同的时间过期
 4. 缓存永不过期

缓存穿透

还有一种情况,数据在数据库和 Redis 里面都不存在,可能是一次条件错误的查询。在这种情况下,因为数据库值不存在,所以肯定不会写入 Redis,那么下一次查询相同的key 的时候,肯定还是会再到数据库查一次。那么这种循环查询数据库中不存在的值,并且每次使用的是相同的 key 的情况,我们有没有什么办法避免应用到数据库查询呢?

(1)缓存空数据 
(2)缓存特殊字符串,比如&&

我们可以在数据库缓存一个空字符串,或者缓存一个特殊的字符串,那么在应用里面拿到这个特殊字符串的时候,就知道数据库没有值了,也没有必要再到数据库查询了。

但是这里需要设置一个过期时间,不然的话数据库已经新增了这一条记录,应用也还是拿不到值。

这个是应用重复查询同一个不存在的值的情况,如果应用每一次查询的不存在的值是不一样的呢?即使你每次都缓存特殊字符串也没用,因为它的值不一样,比如我们的用户系统登录的场景,如果是恶意的请求,它每次都生成了一个符合 ID 规则的账号,但是这个账号在我们的数据库是不存在的,那 Redis 就完全失去了作用。

这种因为每次查询的值都不存在导致的 Redis 失效的情况,我们就把它叫做缓存穿透。这个问题我们应该怎么去解决呢?

经典面试题

其实它也是一个通用的问题,关键就在于我们怎么知道请求的 key 在我们的数据库里面是否存在,如果数据量特别大的话,我们怎么去快速判断。

如何在海量元素中(例如 10 亿无序、不定长、不重复)快速判断一个元素是否存在?

如果是缓存穿透的这个问题,我们要避免到数据库查询不存的数据,肯定要把这 10亿放在别的地方。这些数据在 Redis 里面也是没有的,为了加快检索速度,我们要把数据放到内存里面来判断,问题来了:

如果我们直接把这些元素的值放到基本的数据结构(List、Map、Tree)里面,比如一个元素 1 字节的字段,10 亿的数据大概需要 900G 的内存空间,这个对于普通的服务器来说是承受不了的。

所以,我们存储这几十亿个元素,不能直接存值,我们应该找到一种最简单的最节省空间的数据结构,用来标记这个元素有没有出现。

这个东西我们就把它叫做位图,他是一个有序的数组,只有两个值,0 和 1。0 代表不存在,1 代表存在。

具体的关于BitMap与布隆过滤器的内容,可以转到另外一片文章: 《布隆过滤器》

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!