Redis - 04 缓存设计与性能优化

113 阅读11分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

Redis - 缓存设计与性能优化

多级缓存架构

  1. Nginx 缓存
  2. JVM内存(Map)缓存
  3. Redis集群 缓存

image.png

缓存设计

缓存穿透

  • 查询一个根本不存在的数据,缓存层和存储层都不会命中,通常出于容错的考虑, 如果从存储层查不到数据则不写入缓存层。

  • 缓存穿透将导致不存在的数据每次请求都要到存储层去查询, 失去了缓存保护后端存储的意义。

  • 造成缓存穿透的基本原因有两个:

    1. 自身业务代码或者数据出现问题
    2. 一些恶意攻击、爬虫等造成大量空命中
  • 缓存穿透问题解决方案:

    1. 缓存空对象,并设置过期时间

      
      String get(String key) {
          // 从缓存中获取数据
          String cacheValue = cache.get(key);
          // 缓存为空
          if (StringUtils.isBlank(cacheValue)) {
              // 从存储中获取
              String storageValue = storage.get(key);
              cache.set(key, storageValue);
              // 如果存储数据为空, 需要设置一个过期时间(300秒)
              if (storageValue == null) {
                  cache.expire(key, 60 * 5);
              }
              return storageValue;
          } else {
              // 缓存非空
              return cacheValue;
          }
      }
      
      
    2. 布隆过滤器

      对于恶意攻击,向服务器请求大量不存在的数据造成的缓存穿透,还==可以用布隆过滤器先做一次过滤==,对于不存在的数据布隆过滤器一般都能够过滤掉,不让请求再往后端发送。==当布隆过滤器说某个值存在时,这个值可能不存在;当它说不存在时,那就肯定不存在。==

image.png

布隆过滤器就是一个大型的位数组和几个不一样的无偏 hash 函数。所谓无偏就是能够把元素的 hash 值算得比较均匀。

向布隆过滤器中添加 key 时,会使用多个 hash 函数对 key 进行 hash 算得一个整数索引值然后对位数组长度进行取模运算得到一个位置,每个 hash 函数都会算得一个不同的位置。再把位数组的这几个位置都置为 1 就完成了 add 操作。

向布隆过滤器询问 key 是否存在时,跟 add 一样,也会把 hash 的几个位置都算出来,看看位数组中这几个位置是否都为 1,只要有一个位为 0,那么说明布隆过滤器中这个key 不存在。如果都是 1,这并不能说明这个 key 就一定存在,只是极有可能存在,因为这些位被置为 1 可能是因为其它的 key 存在所致。如果这个位数组比较稀疏,这个概率就会很大,如果这个位数组比较拥挤,这个概率就会降低。

这种方法适用于数据命中不高、 数据相对固定、 实时性低(通常是数据集较大) 的应用场景, 代码维护较为复杂, 但是缓存空间占用很少

  • 可以用redisson实现布隆过滤器,引入依赖:

    <dependency>
       <groupId>org.redisson</groupId>
       <artifactId>redisson</artifactId>
       <version>3.6.5</version>
    </dependency>
    
  • 示例伪代码:

    package com.redisson;
    
    import org.redisson.Redisson;
    import org.redisson.api.RBloomFilter;
    import org.redisson.api.RedissonClient;
    import org.redisson.config.Config;
    
    public class RedissonBloomFilter {
    
        public static void main(String[] args) {
            Config config = new Config();
            config.useSingleServer().setAddress("redis://localhost:6379");
            //构造Redisson
            RedissonClient redisson = Redisson.create(config);
    
            RBloomFilter<String> bloomFilter = redisson.getBloomFilter("nameList");
            //初始化布隆过滤器:预计元素为100000000L,误差率为3%,根据这两个参数会计算出底层的bit数组大小
            bloomFilter.tryInit(100000000L,0.03);
            //将zhuge插入到布隆过滤器中
            bloomFilter.add("zhuge");
    
            //判断下面号码是否在布隆过滤器中
            System.out.println(bloomFilter.contains("guojia"));//false
            System.out.println(bloomFilter.contains("baiqi"));//false
            System.out.println(bloomFilter.contains("zhuge"));//true
        }
    }
    
  • 使用布隆过滤器需要把所有数据提前放入布隆过滤器,并且在增加数据时也要往布隆过滤器里放,布隆过滤器缓存过滤伪代码:

  • 注意:布隆过滤器不能删除数据,如果要删除得重新初始化数据。

    //初始化布隆过滤器
    RBloomFilter<String> bloomFilter = redisson.getBloomFilter("nameList");
    //初始化布隆过滤器:预计元素为100000000L,误差率为3%
    bloomFilter.tryInit(100000000L,0.03);
            
    //把所有数据存入布隆过滤器
    void init(){
        for (String key: keys) {
            bloomFilter.put(key);
        }
    }
    
    String get(String key) {
        // 从布隆过滤器这一级缓存判断下key是否存在
        Boolean exist = bloomFilter.contains(key);
        if(!exist){
            return "";
        }
        // 从缓存中获取数据
        String cacheValue = cache.get(key);
        // 缓存为空
        if (StringUtils.isBlank(cacheValue)) {
            // 从存储中获取
            String storageValue = storage.get(key);
            cache.set(key, storageValue);
            // 如果存储数据为空, 需要设置一个过期时间(300秒)
            if (storageValue == null) {
                cache.expire(key, 60 * 5);
            }
            return storageValue;
        } else {
            // 缓存非空
            return cacheValue;
        }
    }
    

缓存击穿(失效)

  • 大批量缓存在同一时间失效,可能导致大量请求同时击穿缓存直达数据库,可能会造成数据库瞬间压力过大甚至挂掉
  • 解决: 对于这种情况我们在批量增加缓存时最好 ==将这一批数据的缓存过期时间设置为一个时间段内的不同时间。==让过期时间更加均匀的分布

示例伪代码:

String get(String key) {
    // 从缓存中获取数据
    String cacheValue = cache.get(key);
    // 缓存为空
    if (StringUtils.isBlank(cacheValue)) {
        // 从存储中获取
        String storageValue = storage.get(key);
        cache.set(key, storageValue);
        //设置一个过期时间(300到600之间的一个随机数)
        int expireTime = new Random().nextInt(300)  + 300;
        if (storageValue == null) {
            cache.expire(key, expireTime);
        }
        return storageValue;
    } else {
        // 缓存非空
        return cacheValue;
    }
}

缓存雪崩

  • 缓存雪崩指的是缓存层支撑不住或宕掉后, 流量会像奔逃的野牛一样, 打向后端存储层。
  • 由于缓存层承载着大量请求, 有效地保护了存储层, 但是如果缓存层由于某些原因不能提供服务(比如超大并发过来,缓存层支撑不住,或者由于缓存设计不好,类似大量请求访问bigkey,导致缓存能支撑的并发急剧下降)
    • 于是大量请求都会打到存储层, 存储层的调用量会暴增, 造成存储层也会级联宕机的情况。

预防和解决缓存雪崩问题, 可以从以下三个方面进行着手。

  1. ==保证Redis服务高可用性,比如使用Redis Sentinel或Redis Cluster。集群和哨兵==
  2. 依赖隔离组件为后端限流熔断并降级。比如使用Sentinel或Hystrix限流降级组件。 限流:使用MQ队列削峰,也是一个措施
  3. ==使用redission分布式锁,保证相同key只能有一个线程访问数据库,其它线程延时阻塞;== 待第一个线程读到值,写入redis后;其它阻塞线程直接重新查询redis即可
  4. 提前演练。准确预估流量

热点缓存key相关

==开发人员使用“缓存+过期时间”的策略既可以加速数据读写, 又保证数据的定期更新, 这种模式基本能够满足绝大部分需求。==

1、热点缓存key 重建优化

  • 有两个问题如果同时出现, 可能就会对应用造成致命的危害:
    • 当前key是一个热点key(例如一个热门的娱乐新闻),并发量非常大。
    • 重建缓存不能在短时间完成, 可能是一个复杂计算, 例如复杂的SQL、 多次IO、 多个依赖等。
  • 在缓存失效的瞬间, 有大量线程来重建缓存, 造成后端负载加大, 甚至可能会让应用崩溃。

要解决这个问题主要就是要避免大量线程同时重建缓存。

==我们可以利用互斥锁来解决,此方法只允许一个线程重建缓存, 其他线程等待重建缓存的线程执行完, 重新从缓存获取数据即可。==

示例伪代码:

String get(String key) {
    // 从Redis中获取数据
    String value = redis.get(key);
    // 如果value为空, 则开始重构缓存
    if (value == null) {
        // 只允许一个线程重建缓存, 使用nx, 并设置过期时间ex
        String mutexKey = "mutext:key:" + key;
        if (redis.set(mutexKey, "1", "ex 180", "nx")) {
             // 从数据源获取数据
            value = db.get(key);
            // 回写Redis, 并设置过期时间
            redis.setex(key, timeout, value);
            // 删除key_mutex
            redis.delete(mutexKey);
        }// 其他线程休息50毫秒后重试
        else {
            Thread.sleep(50);
            get(key);
        }
    }
    return value;
}

2、如何保证缓存里都是热点数据?

  1. 我们主动去刷新缓存key的过期时间:缓存key每命中一次,就会增加一段过期时间。
  2. 如果是频繁查询的数据,他的过期时间就会比较长
  3. 如果是冷数据,他到了初始化过期时间后,就会被淘汰掉

缓存与数据库双写不一致-解决方案

在大并发下,同时操作数据库与缓存会存在数据不一致性问题:

  1. 双写不一致情况

image.png

  1. 读写并发不一致

image.png

解决方案:

  1. 最重要:==给缓存数据加上过期时间,每隔一段时间触发读的主动更新==
  2. 如果不能容忍数据不一致,==可以通过加 读写锁(Redisson分布式锁) 保证并发;== 读写或写写的时候。按顺序排好队,读读的时候相当于无锁;
  3. 也可以用阿里开源的 canal ==通过监听数据库的binlog日志及时的去修改缓存,== 但是引入了新的中间件,增加了系统的复杂度。
  4. 如果数据库抗不住压力,还可以把缓存作为数据读写的主存储, 异步将数据同步到数据库,数据库只是作为数据的备份。

以上我们针对的都是读多写少的情况加入缓存提高性能, 如果写多读多的情况又不能容忍缓存数据不一致,那就没必要加缓存了,可以直接操作数据库

企业实战解决方案:

  • ==更新数据库时,双删Redis 缓存值==
    • 第一次删:A线程更新数据时,先将Redis中缓存的值删除;
      • 然后进行更新数据库操作;
      • 如果A事务没提交前,B线程进来读Redis中缓存值,发现没有,查不到;则去查数据库,此时读到的是 旧数据(脏数据),因为A事务还没提交;并且 B事务会将脏数据重写到Redis中;
    • 第二次删:A事务提交后,延时几毫秒,再次将Redis中的值删除;
    • 以此来保证 Redis与数据库数据一致性;
  • 更新数据库双删缓存 结合 Key的到期时间使用,效果更佳!

Redis开发规范与性能优化

1.键值设计

  • key 设计

    1. 键值设计

      【建议】: 可读性和可管理性 以业务名(或数据库名)为前缀(防止key冲突),用冒号分隔,比如业务名:表名:id

    2. 命令使用

      【建议】:简洁性 保证语义的前提下,控制key的长度

    3. 客户端使用

      【强制】:不要包含特殊字符 反例:包含空格、换行、单双引号以及其他转义字符

  • value设计 - 拒绝bigkey

    • bigkey的产生:

      • (1) 社交类:粉丝列表,如果某些明星或者大v 不精心设计下,必是bigkey。
      • (2) 统计类:例如按天存储某项功能或者网站的用户集合,除非没几个人用,否则必是bigkey。
      • ==(3) 缓存类:将数据从数据库load出来序列化放到Redis里,这个方式非常常用;== 需要注意:有的同学为了图方便把相关数据都存一个key下,产生bigkey。
    • bigkey的危害:

      • 1.导致redis阻塞

      • 2.网络拥塞

      • 3. 过期删除时,如果没有使用过期异步删除策略,就会存在阻塞Redis的可能性

    • 如何优化bigkey

      • 1.拆

        • big hash:可以讲数据分段存储; big list: list1、list2、...listN
        • 如果bigkey不可避免,不要每次把所有元素都取出来
      • (2).选择适合的数据类型。

  • 控制key的生命周期,redis不是垃圾桶。

    • ==设置过期时间==

2.命令使用

  • 3.【推荐】合理使用select 多业务用多数据库实际还是单线程处理,会有干扰。
  • 4.【推荐】使用批量操作提高效率; 注意控制一次批量操作的元素个数(例如500以内,实际也和元素字节数有关)。
  • 5.【建议】Redis事务功能较弱,不建议过多使用,可以用lua替代

3.客户端使用

  1. 避免多个应用使用一个Redis实例
    1. ==正例:不相干的业务拆分,公共数据做服务化。==
  2. 【推荐】 使用带有连接池的数据库,可以有效控制连接,同时提高效率,标准使用方式:
  3. 【建议】 高并发下建议客户端添加熔断功能(例如sentinel、hystrix)
  4. 【推荐】 设置合理的密码,如有必要可以使用SSL加密访问
  5. 【建议】 Redis对于过期键有三种清除策略:
  6. 如果不设置最大内存, 当 Redis 内存超出物理内存限制时,内存的数据会开始和磁盘产生频繁的交换 (swap),会让 Redis 的性能急剧下降。
  • 如果不设置最大内存, 当 Redis 内存超出物理内存限制时,内存的数据会开始和磁盘产生频繁的交换 (swap),会让 Redis 的性能急剧下降。

  • ==当Redis运行在主从模式时,只有主结点才会执行过期删除策略,然后把删除操作”del key”同步到从结点删除数据。==