一、为什么使用Redis

1.性能

我们在碰到需要执行耗时很久,且结果不频繁变动的SQL时,就特别适合将运行结果放入缓存。

2.并发

在大并发的情况下,所有的请求直接访问数据库,数据库会出现连接异常。这时候就需要用Redis做一个缓冲操作,让请求先访问到Redis,而不是直接访问数据库。
Redis查漏补缺 - 图1

二、使用Redis有什么缺点

常见的主要问题

  1. 缓存和数据库双写一致性问题
  2. 缓存雪崩问题
  3. 缓存击穿问题
  4. 缓存的并发竞争问题

    三、单线程的Redis为什么这么快

  5. 纯内存操作

  6. 单线程操作,避免了频繁上下文切换
  7. 采用了非阻塞I/O 多路复用机制

我们现在仔细地说一说I/O多路复用机制,因为这个说法实在是太通俗了,通俗到一般人都不懂是什么意思。打一个比方:小曲在S城开了一家快递店,负责同城快送服务。小曲因为资金限制,雇佣了一批快递员,然后小曲发现资金不够了,只够买一辆车送快递。
经营方式一:
客户每送来一份快递,小曲就让一个快递员盯着,然后快递员开车去送快递。慢慢的小曲就发现了这种经营方式存在很多问题,几十个快递员基本上时间都花在了抢车上了,大部分快递员都处在闲置状态,谁抢到了车,谁就能去送快递。
随着快递的增多,快递员也越来越多,小曲发现快递店里越来越挤,没办法雇佣新的快递员了,快递员之间的协调很花时间,大部分时间花在抢车上。综合上述缺点,小曲痛定思痛,提出了下面的经营方式↓
经营方式二:
小曲只雇佣一个快递员,客户送来的快递,小曲按送达地点标注好,然后依次放在一个地方。最后,那个快递员依次去取快递,一次拿一个,开着车去送快递,送好了就回来拿下一个快递。
上述两种经营方式对比,是不是明显觉得第二种,效率更高、更好呢?在上述比喻中:

  • 每个快递员→每个线程
  • 每个快递→每个Socket(I/O流)
  • 快递的送达地点→Socket的不同状态
  • 客户送快递请求→来自客户端的请求
  • 小曲的经营方式→服务端运行的代码
  • 一辆车→CPU的核数

于是我们有了如下结论:

  1. 经营方式一 就是传统的并发模型,每个 I/O 流(快递)都有一个新的线程(快递员)管理
  2. 经营方式二 就是I/O多路复用。只有单个线程(一个快递员),通过跟踪每个I/O 流的状态(每个快递的送达地点),来管理多个 I/O 流

下面类比到真实的Redis线程模型,如图所示
Redis查漏补缺 - 图2
参照上图,简单来说就是,我们的Redis-client 在操作的时候,会产生具有不同事件类型的 Socket。在服务端,有一段 I/O 多路复用程序,将其置入队列之中。然后文件事件分派器依次去队列中取,转发到不同的事件处理器中。

需要说明的是,这个 I/O 多路复用机制,Redis 还提供了 Select、Epoll、Evport、Kqueue 等多路复用函数库。

四、Redis的数据类型及各自使用场景

1.String

2.Hash

模拟Session

3.List

简单的消息队列
做基于Redis的分页功能,性能极佳

4.Set

可以做全局去重
利用交集、并集、差集等操作,可以计算共同喜好、自己独有喜好等

5.Sorted Set

可以做排行榜应用,取TOP N操作。还可以用来做延时任务。最后一个应用就是可以做范围查找

五、Redis的过期策略及内存淘汰机制

定时删除

用一个定时器来负责监视Key,过期自动删除。十分消耗CPU资源

定期删除

Redis 默认每隔 100ms 检查是否有过期的 Key,有则删除(随机抽取检查,会导致很多Key到时间未被删除)

惰性删除

获取Key值时,Redis会检查下,如果Key已过期,会删除

内存淘汰策略

Noeviction

新写入会报错

Allkeys-lru(推荐使用)

移除最近使用最少的

Allkeys-random

随机删除某个key

Volatile-lru

在设置了过期时间的key中,移除最近使用最少的

Allkeys-lfu

移除使用频率最少的

六、Redis和数据库双写一致性问题
一致性问题是分布式常见问题,还可以再分为最终一致性和强一致性。数据库和缓存双写,就必然会导致不一致问题。

方案从根本上来说,只能降低不一致发生的概率,无法完全避免。因此,有强一致性要求的数据不能放缓存

Redis查漏补缺 - 图3

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

原因1(线程安全角度)
同时有A 和 B 进行更新操作,会出现

  1. 线程A更新了数据库
  2. 线程B 更新了数据库
  3. 线程B 更新了缓存
  4. 线程A 更新了缓存

产生脏数据,不考虑

原因2(业务场景角度)

  1. 如果一个写数据库场景比较多,读比较少的业务需求,采用这种会导致,数据还没读到,缓存就被频繁更新,浪费性能
  2. 如果你写入数据库的值,并不是直接写入缓存的,而是需要经过一系列复杂计算再写入。那么,每次写入数据库后,都再次计算写入缓存的值,无疑是浪费性能的。

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

该方案会导致不一致的原因是,同时有一个请求A进行更新操作,另一个请求B进行查询操作。那么会出现如下情形:

  1. 请求A进行写操作,删除缓存;
  2. 请求B查询发现缓存不存在;
  3. 请求B去数据库查询得到旧值;
  4. 请求B将旧值写入缓存;
  5. 请求A将新值写入数据库。

上述情况就会导致不一致的情形出现。而且,如果不采用给缓存设置过期时间策略,该数据永远都是脏数据。
那么,如何解决呢?采用延时双删策略。

  1. 先淘汰缓存
  2. 在写入数据库
  3. 休眠1S,再次淘汰缓存

这么做,可以将1秒内所造成的缓存脏数据,再次删除。
那么,这个1秒是怎么确定的,具体该休眠多久呢?
针对上面的情形,读者应该自行评估自己的项目的读数据业务逻辑的耗时。然后写数据的休眠时间则在读数据业务逻辑的耗时基础上,加几百ms即可。这么做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。

如果你用了MySQL的读写分离架构怎么办?
在这种情况下,造成数据不一致的原因如下,还是两个请求,一个请求A进行更新操作,另一个请求B进行查询操作。

  1. 请求A进行写操作,删除缓存;
  2. 请求A将数据写入数据库了;
  3. 请求B查询缓存发现,缓存没有值;
  4. 请求B去从库查询,这时,还没有完成主从同步,因此查询到的是旧值;
  5. 请求B将旧值写入缓存;
  6. 数据库完成主从同步,从库变为新值。

上述情形,就是数据不一致的原因。还是使用双删延时策略。只是,睡眠时间修改为在主从同步的延时时间基础上,加几百ms。
采用这种同步淘汰策略,吞吐量降低怎么办?
那就将第二次删除作为异步的。自己起一个线程,异步删除。这样,写的请求就不用沉睡一段时间再返回。这么做,加大吞吐量。
第二次删除,如果删除失败怎么办?
这是个非常好的问题,因为第二次删除失败,就会出现如下情形。还是有两个请求,一个请求A进行更新操作,另一个请求B进行查询操作,为了方便,假设是单库:

  1. 请求A进行写操作,删除缓存;
  2. 请求B查询发现缓存不存在;
  3. 请求B去数据库查询得到旧值;
  4. 请求B将旧值写入缓存;
  5. 请求A将新值写入数据库;
  6. 请求A试图去删除请求B写入对缓存值,结果失败了。

这也就是说,如果第二次删除缓存失败,会再次出现缓存和数据库不一致的问题。

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

国外提出了一个缓存更新套路,名为《Cache-Aside pattern》[1],其中就指出:

  • 失效:应用程序先从cache取数据,没有得到,则从数据库中取数据,成功后,放到缓存中;
  • 命中:应用程序从cache中取数据,取到后返回;
  • 更新:先把数据存到数据库中,成功后,再让缓存失效。

另外, Facebook也在论文《Scaling Memcache at Facebook》[2]中提出,他们用的也是先更新数据库,再删缓存的策略。
这种情况不存在并发问题么?
不是的。假设这会有两个请求,一个请求A做查询操作,一个请求B做更新操作,那么会有如下情形产生:

  1. 缓存刚好失效;
  2. 请求A查询数据库,得一个旧值;
  3. 请求B将新值写入数据库;
  4. 请求B删除缓存;
  5. 请求A将查到的旧值写入缓存。

如果发生上述情况,确实是会发生脏数据。
然而,发生这种情况的概率又有多少?
发生上述情况有一个先天性条件,就是步骤3的写数据库操作比步骤2的读数据库操作耗时更短,才有可能使得步骤4先于步骤5。可是,大家想想,数据库的读操作的速度远快于写操作的(不然做读写分离干嘛,做读写分离的意义就是因为读操作比较快,耗资源少),因此步骤3耗时比步骤2更短,这一情形很难出现。
假设,有人非要抬杠,有强迫症,一定要解决怎么办?
如何解决上述并发问题?
首先,给缓存设有效时间是一种方案。其次,采用策略二里给出的异步延时删除策略,保证读请求完成以后,再进行删除操作。
还有其他造成不一致的原因么?
有的,这也是缓存更新策略二和缓存更新策略三都存在的一个问题,如果删缓存失败了怎么办,那不是会有不一致的情况出现么。比如一个写数据请求,然后写入数据库了,删缓存失败了,这会就出现不一致的情况了。这也是缓存更新策略二里留下的最后一个疑问。
如何解决?
提供一个保障的重试机制即可,这里给出两套方案。

  • 方案一:

如下图所示:
Redis查漏补缺 - 图4
流程如下所示:

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

然而,该方案有一个缺点,对业务线代码造成大量的侵入。于是有了方案二,在方案二中,启动一个订阅程序去订阅数据库的binlog,获得需要操作的数据。在应用程序中,另起一段程序,获得这个订阅程序传来的信息,进行删除缓存操作。

  • 方案二:

Redis查漏补缺 - 图5
流程如下图所示:

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

备注说明:上述的订阅binlog程序在MySQL中有现成的中间件叫Canal,可以完成订阅binlog日志的功能。至于Oracle中,笔者目前不清楚有没有现成中间件可以使用。另外,重试机制,笔者采用的是消息队列的方式。如果对一致性要求不是很高,直接在程序中另起一个线程,每隔一段时间去重试即可,这些大家可以灵活自由发挥,只是提供一个思路。
本文其实是对目前互联网中已有的一致性方案,进行了一个总结。对于先删缓存、再更新数据库的更新策略,还有方案提出维护一个内存队列的方式,笔者看了一下,觉得实现异常复杂,没有必要,因此没有在文中给出。最后,希望大家有所收获。

7.应对缓存穿透和缓存雪崩问题

1.应对缓存穿透

缓存穿透,即黑客故意去请求缓存中不存在的数据,导致所有的请求都怼到数据库上,从而数据库连接异常

解决方案:

  1. 利用互斥锁,缓存失效的时候,先去获得锁,得到锁,再去请求数据库,没有得到锁,则休眠一段时间
  2. Key是否取到值,都直接返回。Value值中维护一个缓存失效时间。
  3. 布隆过滤器

2.缓存雪崩

  1. 给缓存的失效时间加一个随机值,避免集体失效
  2. 双缓存