本文为redis学习笔记的第九篇文章。介绍redis缓存中 一些重要的问题。

1. 缓存收益和成本

1.1 收益

  • 加速读写
  • 降低后端负载(降低mysql负载)

1.2 成本

  • 数据不一致:缓存层和数据层有时间窗口不一致,和更新策略有关
  • 代码维护成本:多了一层缓存逻辑
  • 运维成本:例如redis cluster

1.3 使用场景

  • 降低后端负载:对于高消耗的SQL:join结果集、分组统计结果;对这些结果进行缓存。
  • 加速请求响应
  • 大量写合并为批量写:如计数器先redis累加再批量写入DB

2. 缓存的更新策略

  • LRU/LFU/FIFO算法剔除:例如maxmemory-policy

FIFO(first in first out)

先进先出策略,最先进入缓存的数据在缓存空间不够的情况下(超出最大元素限制)会被优先被清除掉,以腾出新的空间接受新的数据。策略算法主要比较缓存元素的创建时间。在数据实效性要求场景下可选择该类策略,优先保障最新数据可用。

LFU(less frequently used)

最少使用策略,无论是否过期,根据元素的被使用次数判断,清除使用次数较少的元素释放空间。策略算法主要比较元素的hitCount(命中次数)。在保证高频数据有效性场景下,可选择这类策略。

LRU(least recently used)

最近最少使用策略,无论是否过期,根据元素最后一次被使用的时间戳,清除最远使用时间戳的元素释放空间。策略算法主要比较元素最近一次被get使用时间。在热点数据场景下较适用,优先保证热点数据的有效性。

  • 超时剔除:例如expire
  • 主动更新:开发控制生命周期(最终一致性,时间间隔比较短)

image

  • 低一致性:最大内存和淘汰策略
  • 高一致性:超时剔除和主动更新结合,最大内存和淘汰策略兜底。

3. 缓存粒度控制

image

3.1 缓存粒度控制三个角度

  • 通用性:全量属性更好(添加删除属性不需要改东西)
  • 占用空间:部分属性更好
  • 代码维护:表面上全量属性更好(添加删除属性不需要改东西)

4. 缓存穿透优化

4.1 定义

大量请求不命中,缓存已经没有存在的意义了:

image

4.2 产生原因

  • 业务代码自身问题
  • 恶意攻击、爬虫等

4.3 如何发现

  • 业务响应时间
  • 业务本身问题
  • 相关指标:总调用数、缓存层命中数、存储层命中数

4.4 解决方案

  • 方案一:缓存空对象

image

存在的问题

需要更多的键:恶意攻击、爬虫会有很多乱七八糟的键,当量很大时,会有风险,所以会对这种空对象设置缓存时间控制风险

缓存层和存储层数据“短期”不一致:缓存了空对象,但是当业务恢复了,真实数据又存在于DB中了,那么在这个空对象过期时间内,取到的仍然是空对象,造成短期内数据不一致的问题。解决:可以订阅消息,当恢复正常后接受到消息,然后刷新缓存。

image

  • 方案二:布隆过滤器拦截

image

什么是Bloom Filter

布隆过滤器(Bloom Filter)是1970年由布隆提出的, “a space-efficient probabilistic data structure”。它实际上是一个很长的二进制矢量和一系列随机映射函数。布隆过滤器可以用于检索一个元素是否在一个集合中。它的优点是空间效率和查询时间都远远超过一般的算法,缺点是有一定的误识别率和删除困难。

如果想判断一个元素是不是在一个集合里,一般想到的是将集合中所有元素保存起来,然后通过比较确定。链表、树、散列表(又叫哈希表,Hash table)等等数据结构都是这种思路。但是随着集合中元素的增加,我们需要的存储空间越来越大。同时检索速度也越来越慢,上述三种结构的检索时间复杂度分别为O(n),O(log n),O(n/k)

布隆过滤器的原理是,当一个元素被加入集合时,通过K个散列函数将这个元素映射成一个位数组中的K个点,把它们置为1

优点:相比于其它的数据结构,布隆过滤器在空间和时间方面都有巨大的优势。布隆过滤器存储空间和插入/查询时间都是常数(O(k))。另外, 散列函数相互之间没有关系,方便由硬件并行实现。布隆过滤器不需要存储元素本身,在某些对保密要求非常严格的场合有优势。布隆过滤器可以表示全集,其它任何数据结构都不能;k和m相同,使用同一组散列函数的两个布隆过滤器的交并差运算可以使用位操作进行。

缺点:但是布隆过滤器的缺点和优点一样明显。误算率是其中之一。随着存入的元素数量增加,误算率随之增加。但是如果元素数量太少,则使用散列表足矣。另外,一般情况下不能从布隆过滤器中删除元素。我们很容易想到把位数组变成整数数组,每插入一个元素相应的计数器加1,这样删除元素时将计数器减掉就可以了。然而要保证安全地删除元素并非如此简单。首先我们必须保证删除的元素的确在布隆过滤器里面。这一点单凭这个过滤器是无法保证的。另外计数器回绕也会造成问题。在降低误算率方面,有不少工作,使得出现了很多布隆过滤器的变种。

image

检索时,我们只要看看这些点是不是都是1就(大约)知道集合中有没有它了:如果这些点有任何一个0,则被检元素一定不在;如果都是1,则被检元素很可能在。这就是布隆过滤器的基本思想。

Bloom Filter应用场景?

image

image

RedisBitmap作为位数组构建起来的可扩展的布隆过滤器。

Redis实现的布隆过滤器如何快速有效删除数据?:EXPIRE “bitmap的key值” 0

4.5 解决方案对比

image

5. 无底洞问题优化

5.1 问题描述

  • 2010年,facebook有了3000个Memcache节点
  • 发现问题:"加"机器性能没能提升,反而下降

5.2 问题原因

当存在的节点异常多的时候,IO的代价已经超过数据传输,上文提到的facebook的节点已经超过3000个,在这种情况下再增加节点已经没法再提高效率了。

image

5.3 问题解决—优化IO

  • 命令本身的效率:例如sql优化,命令优化
  • 网络次数:减少通信次数
  • 降低接入成本:长连/连接池,NIO等。
  • IO访问合并:O(n)到O(1)过程:批量接口(mget),就是上一篇文章中介绍的对于mget的四个方案。

6. 缓存雪崩优化

6.1 什么是缓存雪崩?

从下图可以很清晰出什么是缓存雪崩:由于缓存层承载着大量请求,有效的保护了存储层,但是如果缓存层由于某些原因整体不能提供服务,于是所有的请求都会达到存储层,存储层的调用量会暴增,造成存储层也会挂掉的情况。 缓存雪崩的英文原意是 stampeding herd(奔逃的野牛),指的是缓存层宕掉后,流量会像奔逃的野牛一样,打向后端存储。

image

6.2 如何防止缓存雪崩?

  • 保证缓存层服务高可用性。

和飞机都有多个引擎一样,如果缓存层设计成高可用的,即使个别节点、个别机器、甚至是机房宕掉,依然可以提供服务,例如前面介绍过的 Redis SentinelRedis Cluster 都实现了高可用。

  • 依赖隔离组件为后端限流并降级

无论是缓存层还是存储层都会有出错的概率,可以将它们视同为资源。作为并发量较大的系统,假如有一个资源不可用,可能会造成线程全部 hang 在这个资源上,造成整个系统不可用。降级在高并发系统中是非常正常的:比如推荐服务中,如果个性化推荐服务不可用,可以降级补充热点数据,不至于造成前端页面是开天窗。

在实际项目中,我们需要对重要的资源 ( 例如 RedisMySQLHbase、外部接口 ) 都进行隔离,让每种资源都单独运行在自己的线程池中,即使个别资源出现了问题,对其他服务没有影响。但是线程池如何管理,比如如何关闭资源池,开启资源池,资源池阀值管理,这些做起来还是相当复杂的,这里推荐一个 Java 依赖隔离工具 Hystrix。超出范围了。不再赘述。

7. 热点key重建优化

7.1 问题

热点key( 例如一个热门的娱乐新闻)+较长的重建时间(可能是一个复杂计算,例如复杂的 SQL、多次 IO、多个依赖等)

就是说在高并发的情况下,某个key在缓存中重建时间太长,以至于高并发下缓存查不到,都去DB进行查询。对于DB压力很大,并且响应时间长。

image

三个目标:要减少缓存重建次数、数据尽可能一致、减少潜在危险

两个解决:互斥锁、永远不过期

7.2 互斥锁—setex,setnx

image

存在问题:有等待时间。

伪代码:

image

(1) 从 Redis 获取数据,如果值不为空,则直接返回值,否则执行 (2.1) 和 (2.2)。

(2) 如果 set(nx 和 ex) 结果为 true,说明此时没有其他线程重建缓存,那么当前线程执行缓存构建逻辑。

(2.2) 如果 setnx(nx 和 ex) 结果为 false,说明此时已经有其他线程正在执行构建缓存的工作,那么当前线程将休息指定时间 ( 例如这里是 50 毫秒,取决于构建缓存的速度 ) 后,重新执行函数,直到获取到数据。

7.3 永远不过期

这里我想了很久到底是什么意思,,,我感觉这是一个场景:保证数据的定期更新。对于热点key,无非是并发特别大并且重建缓存时间比较长,如果直接设置过期时间,那么时间到的时候,巨大的访问量会压迫到数据库上,所以我们实际上,是不给他设置过期时间,但是不设置过期时间,怎么做到定时更新呢?这里的方案是给热点key的val增加一个逻辑过期时间字段,并发访问的时候,判断这个逻辑字段的时间值是否大于当前时间,大于了说明要对缓存进行更新了,那么这个时候,依然让所有线程访问老的缓存,因为缓存并没有设置过期,但是另开一个线程对缓存进行重构。等重构成功,即执行了redis set操作之后,所有的线程就可以访问到重构后的缓存中的新的内容了。不知道我的理解是不是正确。

“永远不过期”包含两层意思:

从缓存层面来看,确实没有设置过期时间,所以不会出现热点 key 过期后产生的问题,也就是“物理”不过期。

从功能层面来看,为每个 value 设置一个逻辑过期时间,当发现超过逻辑过期时间后,会使用单独的线程去构建缓存。

2018/6/19 号补充:物理上缓存确实是不过期的,保证所有线程都能访问到,但是有可能是老的数据;逻辑上给 value 增加过期时间,如果当过期时间超过当前时间(每一个线程拿缓存数据的时候都会判断一下,也就是说这里仍然使用互斥锁,其中一个线程发现过期时间超过当前时间了,那么锁住,另开一个线程去完成数据重建),新开一个线程去构建缓存,构建成功之后,设置新内容到缓存中并且删除老缓存,就完成了热点 key 的重建。

image

伪代码实现:

image

两种方案对比

image