我们将在这篇讨论以下七个问题。

  1. 缓存收益与成本的问题
  2. 缓存更新的策略
  3. 缓存颗粒的控制
  4. 缓存穿透的优化
  5. 无底洞问题的优化
  6. 缓存雪崩的优化
  7. 热点key的重建优化

缓存收益与成本的问题

关于缓存收益与成本主要分为三个方面的讲解,第一个是什么是收益;第二个是什么是成本;第三个是有哪些使用场景。

收益

主要有以下两大收益。

  1. 加速读写:通过缓存加速读写,如 CPU L1/L2/L3 的缓存、Linux Page Cache 的读写、游览器缓存、Ehchache 缓存数据库结果。
  2. 降低后端负载:后端服务器通过前端缓存来降低负载,业务端使用 Redis 来降低后端 MySQL 等数据库的负载。

成本

产生的成本主要有以下三项。

  1. 数据不一致:这是因为缓存层和数据层有时间窗口是不一致的,这和更新策略有关的。
  2. 代码维护成本:这里多了一层缓存逻辑,就会增加成本。
  3. 运维费用的成本:如 Redis Cluster,甚至是现在最流行的各种云,都是成本。

使用场景

使用场景主要有以下三种。

  1. 降低后端负载:这是对高消耗的 SQL,join 结果集和分组统计结果缓存。
  2. 加速请求响应:这是利用 Redis 或者 Memcache 优化 IO 响应时间。
  3. 大量写合并为批量写:比如一些计数器先 Redis 累加以后再批量写入数据库。

缓存的更新策略

主要有以下三种策略。

  1. LRU、LFU、FIFO 算法策略。例如 maxmemory-policy,这是最大内存的策略,当 maxmemory 最大时,会优先删除过期数据。我们在控制最大内存,让它帮我们去删除数据。
  2. 过期时间剔除,例如 expire。设置过期时间可以保证其性能,如果用户更新了重要信息,应该怎么办。所以这个时候就不适用了。
  3. 主动更新,例如开发控制生命周期。

这三个策略中,一致性最好的就是主动更新。能够根据代码实时的更新数据,但是维护成本也是最高的;算法剔除和超时剔除一致性都做的不够好,但是维护成本却非常低。

根据缓存的使用场景,我们会采用不同的更新策略。

实际开发中我给大家以下两个建议。

  1. 低一致性:最大内存和淘汰策略,数据库有些数据是不需要马上更新的,这个时候就可以用低一致性来操作。
  2. 高一致性:超时剔除和主动更新的结合,最大内存和淘汰策略兜底。你没办法确保内存不会增加,从而使服务不可用了。

缓存粒度问题

我们知道,用户第一次访问客户端,客户端访问 Redis 肯定是没有的,这个时候只能从数据库 DB 那里获取信息,代码如下



select * from t_teacher where id= {id}



在 Redis 设置用户信息缓存,代码如下



set teacher:{id} select * from t_teacher where id= {id}



这个时候我们来看看缓存粒度问题。

因为我们要更新全部属性。到底我们是采用 select * 还是仅仅只是更新你需要更新的那些字段呢?如下两段代码



set key1 = ? from select * from t_teacher
set key1 = ? from select key1 from t_teacher



缓存粒度控制可以从以下三个角度来观察,通过这三点来决定如何选择。

  1. 通用性:全量属性更好。上面一个对比 * 和某个字段的查询,最好是通过全量属性,这样的话,select * 具有很好的通用性,因为如果你 select 某个字段的话,未来如果一旦业务改变,代码也要随之改变才可以。
  2. 占用空间:部分属性会更好。因为这样占用的空间是最小的。
  3. 代码维护上:表面上全量属性会更好。我们真的需要全量吗?其实我们在使用缓存的时候,优先考虑的是内存而不单单只是保证代码的扩展性。

缓存穿透问题

首先大家看下下面这张图。



redistemplate 缓存列表数据 redis 列表 缓存设计_Redis

当请求发送给服务器的时候,缓存找不到,然后都堆到数据库里。这个时候,缓存相当于穿透了,不起作用了。

原因有两点:

  1. 业务代码自身的问题。很多实际开发的时候,如果是一个不熟练的程序员,由于缺乏必要的大数据的意识,很多代码在第一次写的时候是 OK 的,但是当需要修改业务代码的时候,常常会出现问题。
  2. 恶意攻击和爬虫问题。网络上充斥着各种攻击和各种爬虫模仿着人为请求来访问你的数据。如果恶意访问穿透你的数据库,将会导致你的服务器瞬间产生大量的请求导致服务中止。

那我们去如何发现这些问题呢?

  1. 业务的相应时间:一般请求的时间都是稳定的,但是如果出现类似穿透现象,必然在短时间内有一个体现。
  2. 业务本身的问题。产品的功能出现问题。
  3. 对缓存层命中数、存储层的命中数这些值的采集。

解决方案1:缓存空对象

当缓存中不存在,访问数据库的时候,又找不到数据,需要设置给 cache 的值为 null,这样下次再次访问该 id 的时候,就会直接访问缓存中的 null 了。

但是可能存在的两个问题。首先是需要更多的键,但是如果这个量非常大的话,对业务也是有影响的,所以需要设置过期时间;其次是缓存层和存储层数据“短期”不一致。当缓存层过期时间到了以后,可能会产生和存储层数据不一致的情况。这个时候需要使用一些消息队列等方式,来确保这个值的一致性。

下面的代码用 Java 来实现简单的缓存空对象



public String getCacheThrough(String key){
    String cacheValue = cache.get(key);
    if(StringUtils.isBlank(cacheValue)){ // 如存储数据为空
        String storageValue = storage.get(key);
        cache.set(key,storageValue);//需要设置一个过期时间
        if(StringUtils.isBlank(strageValue){
            cache.expire(key.60*10);
}    
    return storageValue;
    }else{
    return cacheValue;
 }
}



解决方案2:布隆过滤器拦截

布隆过滤器,实际上是一个很长的二进制向量和一系列随机映射函数。布隆过滤器可以用于检索一个元素是否在一个集合中。它的优点是空间效率和查询时间都远远超过一般的算法,缺点是有一定的误识别率和删除困难。

类似于一个字典,你查词典的时候不需要把所有单词都翻一遍,而是通过目录的方式,用户通过检索的形式在极小内存中可以找到对应的内容。

虽然布隆过滤器可以通过极小的内存来存储,但是免不了需要一部分代码来维护这个布隆过滤器,并且经常需要根据规则来调整,在选取是否使用布隆过滤器,还需要通过场景来选取。

无底洞问题优化

无底洞问题就是即使加机器,性能却没有提升,反而降低了。到底这是怎么回事呢?先看下面的图。



redistemplate 缓存列表数据 redis 列表 缓存设计_缓存_02

当客户端增加一个缓存的时候,只需要 mget 一次,但是如果增加到三台缓存,这个时候则需要 mget 三次了,每增加一台,客户端都需要做一次新的 mget,给服务器造成性能上的压力。

同时,mget 需要等待最慢的一台机器操作完成才能算是完成了 mget 操作。这还是并行的设计,如果是串行的设计就更加慢了。

通过上面这个实例可以总结出:更多的机器!=更高的性能

但是并不是没办法,一般在优化 IO 的时候可以采用以下几个方法。

  1. 命令的优化。例如慢查下 keys、hgetall bigkey。
  2. 我们需要减少网络通讯的次数。这个优化在实际应用中使用次数是最多的,我们尽量减少通讯次数。
  3. 降低接入成本。比如使用客户端长连接或者连接池、NIO 等等。

四种批量优化的方法

四种方法主要是:

  1. 串行 mget
  2. 串行 IO
  3. 并行 IO
  4. hash_tag

串行 mget

如下图所示,串行 mget 就是根据 Redis 增加的台数,来 mget 多次网络时间。



redistemplate 缓存列表数据 redis 列表 缓存设计_Redis_03

串行 IO

如下图所示,根据 key 的增加,先在客户端组装成各种 subkeys,然后一次性根据 pipeline 方式进行传输,这样能有效的减少网络时间。



redistemplate 缓存列表数据 redis 列表 缓存设计_数据库_04

并行IO

如下图所示,在串行 IO 的基础上,再根据并行打包,把请求一次性的传给 Redis 集群。



redistemplate 缓存列表数据 redis 列表 缓存设计_缓存_05

hash_tag

如下图所示,用最极端的方式进行哈希传送给 Redis 集群。



redistemplate 缓存列表数据 redis 列表 缓存设计_数据库_06

总之

实际使用过程中,我们根据特定的业务场景,选定对应的批量优化方式,可以有效的优化。

热点 Key 重建优化

我们知道,使用缓存,如果获取不到,才会去数据库里获取。但是如果是热点 key,访问量非常的大,数据库在重建缓存的时候,会出现很多线程同时重建的情况。



redistemplate 缓存列表数据 redis 列表 缓存设计_缓存_07

如上图,就是因为高并发导致的大量热点的 key 在重建还没完成的时候,不断被重建缓存的过程,由于大量线程都去做重建缓存工作,导致服务器拖慢的情况。只有最后一个是重建完成,命中缓存。

为了解决以上的问题,我们着重研究了三个目标和两个解决方案。

三个目标为:

  • 减少重建缓存的次数;
  • 数据尽可能保持一致;
  • 减少潜在的风险。

两个解决方案为

  • 互斥锁
  • 永不过期

我们根据三个目标,解释一下两个解决方案。

互斥锁(mutex key)

由下图所示,第一次获取缓存的时候,加一个锁,然后查询数据库,接着是重建缓存。这个时候,另外一个请求又过来获取缓存,发现有个锁,这个时候就去等待,之后都是一次等待的过程,直到重建完成以后,锁解除后再次获取缓存命中。



redistemplate 缓存列表数据 redis 列表 缓存设计_缓存_08

那么这个过程是怎么做到的呢?请见下面代码演示



public String getKey(String key){
    String value = redis.get(key);
    if(value == null){
        String mutexKey = "mutex:key:"+key; //设置互斥锁的key
        if(redis.set(mutexKey,"1","ex 180","nx")){ //给这个key上一把锁,ex表示只有一个线程能执行,过期时间为180秒
          value = db.get(key);
          redis.set(key,value);
          redis.delete(mutexKety);
  }else{
        // 其他的线程休息100毫秒后重试
        Thread.sleep(100);
        getKey(key);
  }
 }
 return value;
}



但是互斥锁也有一定的问题,就是大量线程在等待的问题。下面我们就来讲一下永远不过期

永远不过期

首先在缓存层面,并没有设置过期时间(过期时间使用 expire 命令)。但是功能层面,我们为每个 value 添加逻辑过期时间,当发现超过逻辑过期时间后,会使用单独的线程去构建缓存。这样的好处就是不需要线程的等待过程。见下图。



redistemplate 缓存列表数据 redis 列表 缓存设计_缓存_09

如上图所示,T1 时间无需等待,直接输出,到 T2 的时候,发现 value 已经到了过期时间,于是就开始构建缓存,还是输出旧值。到了 T3 已经是旧值,直到 T4 时间,构建缓存已经完成,直接输出新值。

这样就避免了上面互斥锁大量线程等待的问题。具体实现伪代码如下:



public String getKey(final String key){
    V v = redis.get(key);
    String value = v.getValue();
    long logicTimeout = v.getLogicTimeout();
    if(logicTimeout >= System.currentTimeMillis()){
      String mutexKey = "mutex:key:"+key; //设置互斥锁的key
      if(redis.set(mutexKey,"1","ex 180","nx")){ //给这个key上一把锁,ex表示只有一个线程能执行,过期时间为180秒
        threadPool.execute(new Runable(){
            public void run(){
            String dbValue = db.getKey(key);
            redis.set(key,(dbValue,newLogicTimeout));//缓存重建,需要一个新的过期时间
            redis.delete(keyMutex); //删除互斥锁
     }
   };
  }
 }
}



互斥锁的优点是思路非常简单,具有一致性,其缺点是代码复杂度高,存在死锁的可能性。

永不过期的优点是基本杜绝 key 的重建问题,但缺点是不保证一致性,逻辑过期时间增加了维护成本和内存成本。

从2012年开始使用Redis做开发以来,Redis经过这么多年不断完善已经成为非常成熟、稳定的Key-Value数据库,最近产品使用了Redis Cluster,所以把Redis重新学习了一下,受益匪浅。