redis (1) - 缓存: 击穿 & 穿透 & 雪崩

原创
2019/02/19 22:10
阅读数 4.1W

缓存应用思考

从几个方面来考量:

根据实际需要来选择缓存方案,Redis、Zookeeper 亦或 单机Local。当选择第三方组件时, 组件需要考虑保证一致性及有效性的服务(CAP)(对于锁的场景来说更复杂些,eg. redis-master刚新建了锁key,但还未同步到redis-slave,此时发生了主从切换,这种情况如何处理锁key丢失带来的问题(数据库乐观锁兜底保证业务不被重复执行)), 当发生应用与缓存服务器断开时,是否有PlanB (是否启动本地缓存、或熔断...)来保证整个系统的正确稳定运行。 

2、对于缓存的KEY

  • 首先KEY的值需规范设计。对于框架的自动处理过程需要了解清晰,保证缓存服务上key的唯一性。(redis通常使用 “:”冒号来体现层次, eg. Spring-data-redis 提供的RedisCacheManager支持配置时设置CacheKeyPrefix

  • 合理分配失效时间。是物理上有失效时间 亦或: 在缓存对象里增加一个虚拟失效时间,而物理上是没有过期时长或一个过期较长的时间,用其他特定处理方式来更新。

  • 综合考虑缓存服务集群上 大量热点KEY的槽点分布及失效时间分布(TTL可增加一个随机数),避免发生雪崩及服务器单机流量不均。

3、对于缓存的Value

  •  首先要确定存储数据结构。  Redis所提供的不同数据结构是否更适合业务处理。

  •  适合用哪种 (反)序列化方式。

  • 合理规划Key、Value的值大小,更综合的说是预估缓存服务的数据规模。(redis对大Key、大Value在同步场景的处理会影响性能)

4、在使用层面而言    

  • 缓存的加载。

    • 提前预加载。 可能是一般简单的静态配置项,也可能是热点或计算复杂的缓存,通过提前加载处理来提升访问性能。

    • 执行时按需加载。此时需要控制并发查DB过程,合理加锁控制单线程操作(甚至于考虑是否有必要控制全局唯一单例处理);  至于没有获取到锁的线程是 阻塞等待(retry 次数和超时处理),还是当前返回历史数据,依业务容忍度而定;获取到锁后,还需要再查一次缓存,确实没有值才访问DB。

  • 缓存更新。 定时任务后台更新、获取缓存的过程里,异步通过缓存对象定义的过期时间判定是否需要更新。两种方式都可能有缓存与数据库的不一致情况, 但起码保证了热点key不会直接打到DB层。

缓存与数据库的双写一致性

通常数据发生了变更,先删除了缓存,然后再去修改数据库。 因为: 先修改数据库后删除缓存,如果删除缓存失败了,那么会导致数据库中是新数据,缓存中是旧数据,数据就出现了不一致

为什么选择删除而不是重构缓存,是因为: 对于对象、文本类型或需要额外复杂逻辑多数据源加工的,修改缓存value的成本较高。   

当出现不一致的情况要考虑: 

  1.  业务可以接受一定时间内的不一致(缓存带过期时间),系统不优化
  2. 当读写分离时,因为msql主从同步延时期间不一致导致的缓存不是最新数据 
    1. 高可用主库,用缓存提高读性能, 强制读主,读和写都落到主库上
    2. 选择性读主。 在cache里记录哪些记录发生过写请求,来路由读主还是读从。

即使引入缓存有不一致, 但不会比[不引入缓存]更糟 这是更为实际的优化目标。

场景解析

1、在这个先删缓存后修改数据库的过程中,此时还没修改完毕;一个请求过来,去读缓存,发现缓存空了,去查询数据库,查到了修改前的旧数据,放到了缓存中;随后数据变更的程序完成了数据库的修改,此时依然出现了不一致。

这种情况无法完全避免,譬如:类似全局读写锁的控制方式, 但这又严重影响了整体性能,并增加了系统的复杂度。

所以说折中的方案是:延时双删除。当更新成功数据库后,延时一定时间,再次删除缓存。这个延时时间需要评估系统读数据业务逻辑的耗时,在此基础上加几百ms即可,主要是保证这个删除操作一定是在 [旧数据被重新加载回缓存] 操作之后。

2、如果用了mysql的读写分离架构,主从延时无法避免。写完到主库还未同步到读库的期间,读请求已经从读库中把原数据写到了缓存里, 又不一致了。

  1.  解决这种场景的思路转化为: [ 在从库同步完成之后,如果有旧数据入缓存,应该及时把它淘汰掉 ]。所以:延时双删除在主从同步的延时时间基础上,加几百ms,删除掉缓存。通过工具( canal 订阅从库的binlog,这里能够最准确的知道从库数据同步完成的时间。
  2. 选择性读主当写请求发生时, 将 [库+表+主键三个信息拼装一个key设置到cache里,这条记录的超时时间,设置为预估的“主从同步时延” 。当读请求发生时:到cache里去查询 [库+表+主键],如果,cache里有这个key,说明主从时延内刚发生过写请求,数据库主从同步可能还没有完成,此时就应该去主库查询 ;否则 说明最近没有发生过写请求,此时就可以去从库查询。或者通过工具( canal )订阅从库的binlog,当确认同步完成时,删除cache。

缓存穿透

在处理缓存时,一般是先从缓存查询,如果缓存没有这个key获取为null,则会从DB中查询,若结果不为空则回写到缓存中去。

按这种做法,查询一个不存在于缓存和数据库中的值,由于缓存一定不命中,需要从数据库查询,查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到数据库去查询,造成缓存穿透。

解决办法

  1. 对每一个缓存key都有一定的规范约束,这样在程序中对不符合要求的请求可以拒绝。(通过一些接入层的判定拒绝不符合规则的请求)
  2. 如果对应在数据库中的数据都不存在,我们将此key对应的value设置为一个默认的值,比如“NULL”,并设置一个缓存的失效时间,当然这个key的时效比正常的时效要小的多。
  3. 将可能出现的缓存key的组合方式的所有数值以hash形式存储在一个很大的Bitmap中<布隆过滤器> , 需要考虑如何将这个可能出现的数据的hash值同步到bitmap中( eg. 后端每次新增一个可能的组合就同步一次 或者 穷举),一个一定不存在的数据会被这个bitmap拦截掉,从而避免了对底层存储系统的查询压力。

缓存雪崩 & 缓存击穿

缓存雪崩:  大量缓存集中在一段时间内失效数据量巨大查询都落在数据库上,造成了缓存雪崩。

缓存击穿: 指的是某些个热点key在某个特殊的场景时间内恰好失效了,恰好有针对这个Key的大量并发请求过来了,造成DB压力。

解决办法

  1. 分析用户行为,尽量让失效时间点均匀分布,合理分配不同的过期时间;
    setRediskey, value, time+Math.random()*10000
  2. 控制单线程处理回写缓存过程。(是否需要控制全局唯一单例处理)至于没有获取到锁的线程是 阻塞等待(retry 次数和超时处理),还是当前返回历史数据,依业务容忍度而定。获得锁的线程需要再次访问下缓存,确实没有值才访问DB。 
    (在Spring-Cache提供的实现中,synchronized是加在整个RedisCache.get过程上的,给整个[查询-更新]过程上锁, 个人感觉略不合理。)
  3.  在业务认可的情况下,容忍一定时间内的缓存更新延时。方案是:Redis无过期时间 , 由应用服务定时触发刷新,或者 设置过期时间较长,在缓存对象上增加一个属性来标识过期截止时间, 当获取到数据后,检测到数据标记时间快超时了则异步发起一个线程去主动更新该缓存。 这里需要加锁控制好并发。

布隆过滤器

将一个key通过n个不同的hash函数定位成n个整数,然后将这n个整数定位在一个长度在M的初始数值为0的数组对应下标上,设置该n个下标的数值为1。判定某个key是否在集合就是判定:这n个下标的数值都为1。但这种方式只能标识一定不在集合内,并不能代表一定在!

优点就是: 不用存储具体的某个key值,占用内存极少。

  1. 需要一个长度为N的比特数组,每个下标位置的比特初始化为0。
  2. 某个key加入集合时,用k个hash函数计算出k个散列值来定位数组的K个下标,把数组中对应下标的比特位置为1
  3. 判断某个key是否在集合时,用k个hash函数计算出k个散列值,并查询数组中对应的下标的值,如果都是1,认为在集合中。

哈希函数个数 和 布隆过滤器长度 都会影响到错误概率及过滤效率:

  1. 布隆过滤器越长,其误报率越小。过小的长度,所有bit位值很快都为1, 起不到太大过滤的效果
  2. 哈希函数的个数过多,容易加快  bit 位置位 1 的速度,降低布隆过滤器的效率 。太小的话,误报率就高。
 k 为哈希函数个数,m 为布隆过滤器长度,n 为插入的元素个数,p 为误报率

Guava里的布隆过滤器

com.google.common.hash.BloomFilter

// 可能存在
public boolean mightContain(T object) {
  return strategy.mightContain(object, funnel, numHashFunctions, bits);
}

// 放入值
public boolean put(T object) {
  return strategy.put(object, funnel, numHashFunctions, bits);
}

  static <T> BloomFilter<T> create(
      Funnel<? super T> funnel, int expectedInsertions /* n */, double fpp, Strategy strategy) {
    checkNotNull(funnel);
    checkArgument(expectedInsertions >= 0, "Expected insertions (%s) must be >= 0",
        expectedInsertions);
    checkArgument(fpp > 0.0, "False positive probability (%s) must be > 0.0", fpp);
    checkArgument(fpp < 1.0, "False positive probability (%s) must be < 1.0", fpp);
    checkNotNull(strategy);

    if (expectedInsertions == 0) {
      expectedInsertions = 1;
    }
 
    long numBits = optimalNumOfBits(expectedInsertions, fpp);
    int numHashFunctions = optimalNumOfHashFunctions(expectedInsertions, numBits);
    try {
      return new BloomFilter<T>(new BitArray(numBits), numHashFunctions, funnel, strategy);
    } catch (IllegalArgumentException e) {
      throw new IllegalArgumentException("Could not create BloomFilter of " + numBits + " bits", e);
    }
  }

  static long optimalNumOfBits(long n, double p) {
    if (p == 0) {
      p = Double.MIN_VALUE;
    }
    return (long) (-n * Math.log(p) / (Math.log(2) * Math.log(2)));
  }

  static int optimalNumOfHashFunctions(long n, long m) {
    // (m / n) * log(2), but avoid truncation due to division!
    return Math.max(1, (int) Math.round((double) m / n * Math.log(2)));
  }

类BloomFilterStrategies中定义了hash算法MURMUR128_MITZ_32、MURMUR128_MITZ_64。

静态创建BloomFilter实例的方法逻辑中,通过传入的误报率、预期插入的数据量,会按规则确定BitArray的大小和hash定位的个数,与上图一致。

Redis-BloomFilter

Redis4.0版本推出了 module 的形式,可以将 module 作为插件额外实现Redis的一些功能。官网推荐了一个 RedisBloom 作为 Redis 布隆过滤器的 Module

RedisBloom需要先进行安装,推荐使用Docker进行安装,简单方便:

docker pull redislabs/rebloom:latest
docker run -p 6379:6379 --name redis-redisbloom redislabs/rebloom:latest
docker exec -it redis-redisbloom bash
# redis-cli
# 127.0.0.1:6379> bf.add noob hello

基本指令:

bf.add      添加一个元素到布隆过滤器
bf.exists   判断元素是否在布隆过滤器
bf.madd     添加多个元素到布隆过滤器
bf.mexists  判断多个元素是否在布隆过滤器
127.0.0.1:6379> bf.add noob tc01
(integer) 1
127.0.0.1:6379> bf.add noob tc02
(integer) 1
127.0.0.1:6379> bf.add noob tc03
(integer) 1
127.0.0.1:6379> bf.exists noob tc01
(integer) 1
127.0.0.1:6379> bf.exists noob tc02
(integer) 1
127.0.0.1:6379> bf.exists noob tc03
(integer) 1
127.0.0.1:6379> bf.exists noob tc04
(integer) 0
127.0.0.1:6379> bf.madd noob tc05 tc06 tc07
1) (integer) 1
2) (integer) 1
3) (integer) 1
127.0.0.1:6379> bf.mexists noob tc05 tc06 tc07 tc08
1) (integer) 1
2) (integer) 1
3) (integer) 1
4) (integer) 0

Redis Bitmap 实现简单的布隆过滤器

BitmapRedis中并不是一个单独的数据类型,而是由字符串类型(Redis内部称Simple Dynamic String,SDS)之上定义的与比特相关的操作实现的,此时SDS就被当做位数组了。

下面是在redis-cli中使用getbitsetbit指令的操作示例。

# 字符串"meow"的二进制表示:01101101 01100101 01101111 01110111
100.98.1.108:15> set bitmap_cat "meow"OK
# 最低位下标为0。取得第3位的比特(0)
100.98.1.108:15> getbit bitmap_cat 3(integer) 0
# 取得第23位的比特(1)
100.98.1.108:15> getbit bitmap_cat 23(integer) 1
# 将第7位设为0 返回的是原值
100.98.1.108:15> setbit bitmap_cat 7 0(integer) 1  
# 将第14位设为1
100.98.1.108:15> setbit bitmap_cat 14 1(integer) 0
# 修改过后的字符串变成了"lgow"
100.98.1.108:15> get bitmap_cat"lgow"

RedisBitmap是自动扩容的,即get/set到高位时,就会主动填充0。此外,还有bitcount指令用于计算特定字节范围内1的个数,bitop指令用来执行位运算(支持andorxornot)。

具体实现可以仿Guava的BloomFilter处理, 只是将存储结构由数组改成Redis的Bitmap .

展开阅读全文
打赏
2
15 收藏
分享
加载中
更多评论
打赏
0 评论
15 收藏
2
分享
返回顶部
顶部