第09课:缓存设计与优化

第09课:缓存设计与优化

我们将在本达人课的最后一篇讨论以下七个问题。

  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. 代码维护上:表面上全量属性会更好。我们真的需要全量吗?其实我们在使用缓存的时候,优先考虑的是内存而不单单只是保证代码的扩展性。

缓存穿透问题

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

enter image description here

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

原因有两点:

  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:布隆过滤器拦截

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

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

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

无底洞问题优化

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

enter image description here

当客户端增加一个缓存的时候,只需要 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 多次网络时间。

enter image description here

串行 IO

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

enter image description here

并行IO

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

enter image description here

hash_tag

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

enter image description here

总之

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

热点 Key 重建优化

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

enter image description here

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

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

三个目标为:

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

两个解决方案为

  • 互斥锁
  • 永不过期

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

互斥锁(mutex key)

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

enter image description here

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

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 添加逻辑过期时间,当发现超过逻辑过期时间后,会使用单独的线程去构建缓存。这样的好处就是不需要线程的等待过程。见下图。

enter image description here

如上图所示,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 的重建问题,但缺点是不保证一致性,逻辑过期时间增加了维护成本和内存成本。

上一篇
下一篇
目录