暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

如何解决缓存的热 key、大 key、雪崩、击穿、穿透问题?

阿东编程之路 2023-01-19
407

本文出现代码均为阿东的简单实现,仅供参考,不可直接用于生产
为了提高性能和保护数据库,我们会在数据库层之上加层缓存;使用缓存也可能会引入一些问题:热 key、大 key、雪崩、击穿、穿透,本文就基于 Redis 分析和总结下这五大问题的出现原因及解决方案。

一. hot key


在业务中总有些数据的访问非常频繁,比如热门网站首页、热搜新闻等,这些就叫做热 key(hot key);单机的 Redis 官网号称可以扛住 10 W 的读并发,可当并发高于 10 W,单机的 Redis 就没有办法支持。我们 Redis 在线上一般采用数据分片思想的分片集群模式,数据存取通过一致性哈希算法路由到对应 Redis 节点上来提升整体并发度。


热 key 的解决方案一般是使用多级缓存


多级缓存


对于热点数据,网络开销已经不能忽虑,我们可以将热点数据在本地存一份,当有读请求时,直接从本地获取省去网络开销,并发度也会大幅提升;但由于本地内存空间是有限的,所以只能存部分最热的数据,我们可以使用支持 LRU/LFU 内存淘汰策略的 GuavaCaffeine 缓存框架。


本地缓存的一致性问题


缓存的不一致分为「不同级之间」「同级之间」,我们这里只讨论同级缓存之间的不一致不同级不一致的问题可以看阿东之前的文章《浅谈缓存一致性》),同级缓存之间一致性的重点就在于各个节点需要进行通信,比较好的方式是利用消息队列的「生产者/消费者」模型,当有缓存数据变更时发出一条删除消息,所有节点消费到删除消息后将对应的本地缓存失效掉,这样就能保证各个节点缓存的一致性


热 key 检测


本地缓存是为了热 key 准备的,很多情况下热 key 是不固定的(比如一些突然爆火的视频文章和促销商品),所以上面的方案在一些场景下不是很灵活,如果有条件可以在 Redis 入口处做个代理服务,专门检测热 key(统计 key 的访问频率等),当达到热 key 标准就通知系统把该 key 加到本地缓存中


写热点


对于写热点,其实没什么特别好的方案,就是限流(快速失败)、排队、合并请求这些。

二. big key


存储 big key 除了会使用过多内存之外,还对 Redis 的性能也有很大影响:

  • 内存分配耗时:由于 Redis 处理命令是单线程的,当应用写入一个big key 时,更多的时间将消耗在内存分配上,操作的耗时就会增加,删除 big key 也一样;

  • 网络传输耗时在 Redis 6.0 之前,从处理网络请求到执行命令都是单线程完成(Redis 6.0 采用多线程处理网络请求),而读取 big key 时,在网络数据传输上也会花费更多时间,后面请求就会排队等待,进而影响整体吞吐量。

big key 的标准是什么?

一般认为 String 类型大于 10 KB、集合类型元素数量大于一万就算 big key。

解决方案目前主流的有两种:压缩 value 大 key 拆成小 key

压缩 value

String 类型的 big key 无法进行拆分可以用压缩算法进行压缩,当然也会有压缩和解压的时间消耗需要自行权衡

比如使用 Gzip 对 String 进行压缩:

    public static byte[] compress(Serializable value) {

    try (ByteArrayOutputStream buf = new ByteArrayOutputStream(512);
    ObjectOutputStream output = new ObjectOutputStream(buf);
    // 压缩后的数据
    ByteArrayOutputStream gzipBuf = new ByteArrayOutputStream();
    GZIPOutputStream gzip = new GZIPOutputStream(gzipBuf)) {
    output.writeObject(value);
    // 序列化之后的数据
    byte[] ba = buf.toByteArray();
    gzip.write(ba);
    return gzipBuf.toByteArray();
    } catch (Exception e) {
    log.error("compress error", e);
    throw new UnsupportedOperationException(e);
    }
    }

    测试了下压缩后的字节数组长度能减少将近一半:

      public static void main(String[] args) {

      StringBuilder sb = new StringBuilder();
      for(int i = 0; i < 10000; i++) {
      sb.append(UUID.randomUUID().toString());
      }
      String value = sb.toString();
      byte[] base = value.getBytes();
      System.out.println("压缩前 " + base.length);
      byte[] compress = compress(value);
      System.out.println("压缩后 " + compress.length);
      }


      大 key 拆成 小 key

      对于集合类型,如果元素过多,可以根据一定的规则进行拆分这种方案阿东没有在线上用过,仅仅是提供一种思路,阿东在下面简单实现了分片存取的代码

      集合分片代码:

        /**
        * 将集合按照 size 分片
        */
        public static <T> List<List<T>> partition(List<T> list, int size) {

        List<List<T>> res;
        // 如果待分片集合元素数量大于size则进行分片
        if(list.size() > size) {
        int len = list.size() size + 1;
        res = new ArrayList<>(len);
        for(int i = 0; i < list.size(); i += size) {
        int limit = i + size;
        res.add(list.subList(i, limit > list.size() ?
        list.size() : limit));
        }
        } else {
        res = new ArrayList<>(1);
        res.add(list);
        }
        return res;
        }

        分片设置缓存代码:

          // setList 方法为将集合设置进缓存
          public <T> void shardingSet(String key, List<T> list, long seconds, Jedis jedis) {
          // 将集合按照 size 分片
          List<List<T>> partitions = partition(list, 1000);
          // 收集分片 key
          List<String> subKeys = new ArrayList<>(partitions.size());
          for(int i = 0; i < partitions.size(); i++) {
          String subKey = key + i;
          subKeys.add(subKey);
          // 设置分片 List 缓存
          setList(subKey, partitions.get(i), seconds, jedis);
          }
          // 设置分片 key 集合缓存
          setList(key, subKeys, seconds, jedis);
          }

          分片获取缓存代码:

            // getList 方法为从缓存获取集合
            public <T> List<T> shardingGet(String key, Jedis jedis) {
            // 获取分片 key 集合
            List<String> subKeys = getList(key, jedis);
            List<T> res = new ArrayList<>();
            if (CollectionUtils.isEmpty(subKeys)) {
            return res;
            }
            // 组装各个分片缓存集合
            for(String subKey: subKeys) {
            res.addAll(getList(subKey, jedis));
            }
            return res;
            }


            三. 缓存雪崩


            缓存雪崩是指大量的请求无法在Redis(缓存层)中进行处理,接着被应用送到了数据库层,导致数据库的压力激增


            一般发生缓存雪崩有两种原因:缓存中大批量数据同时过期 和 缓存服务宕机。


            1. 缓存中大批量数据同时过期。


            有些业务场景,会在同一时刻塞入多个缓存,在某一时刻,这些缓存同时失效,此时,当应用对这些数据进行访问时就会发生缓存缺失,从而去访问数据库层,对数据库造成压力,甚至可能造成数据库宕机影响整个系统。


            解决方案


            随机过期时间:我们可以在设置过期时间时在原基础上随机加上一小段时间(比如随机 0 ~ 6 分钟),可以减小缓存数据同时过期的概率。


            代码简单实现如下:


              private static final long RANDOM_EXPIRE_SECONDS = 60 * 6;


              public boolean setex(String key, String value, long seconds, Jedis jedis) {

              try {
              jedis.setex(key, seconds + ThreadLocalRandom.current()
              .nextLong(RANDOM_EXPIRE_SECONDS), value);
              return true;
              } catch (Throwable t) {
              log.error("setex error, key={}", key, t);
              return false;
              }
              }


              2. Redis 宕机


              当 Redis 发生宕机后,大量请求会直接打到数据库,对数据库造成较大压力,对于这个问题有两种方案,分为「事前」和「事后」:


              第一种方案(事前):事前预防,做好 Redis 的高可用(主从节点)


              第二种方案(事后):在业务系统中实现服务熔断、降级、限流


              • 当我们监测到 Redis 服务宕机后(长时间 ping 不通或收到公司监测平台通知后),可以启动服务熔断机制,一段时间内直接访问数据库而不再访问 Redis ,同时需要开启对数据库访问的排队限流或只保留核心业务访问,剩余业务提示“业务繁忙”等字样。

              四. 缓存击穿


              缓存击穿是指:某个热点数据无法在缓存中被处理,大量请求都打到数据库,导致数据库压力激增。缓存击穿一般发生在热点数据过期。


              缓存击穿的解决方案主要有两种:


              1. 有种简单粗暴的方案,就是把热点的数据设置永不过期;


              2. 设置永不过期的方式虽然简单,但是不够灵活,很多情况下热点 key 是不固定的(比如一些突然爆火的视频文章和促销商品),当发生了缓存击穿(热点数据过期),比较好的方式就是使用互斥锁对数据库的访问进行限流,由于只是限流访问,使用本地锁即可,比较适合的就是高性能的双检锁模式(不了解双检锁的可以去看阿东之前的文章《单例模式那些事儿)。


              双检锁模式:


                public String doubleCheckLock(String key) {
                // 一检
                String value = getCache(key);
                if(!Strings.isNullOrEmpty(value)) {
                return value;
                }
                synchronized (key.intern()) {
                // 二检
                value = getCache(key);
                if(!Strings.isNullOrEmpty(value)) {
                return value;
                }
                // 从数据库中查并写回缓存
                value = getDB(key);
                setCache(key, value);
                }
                return value;
                }


                这里有个点:synchronized 里不直接锁 key,是因为传入的 String 都是不同的对象,而 synchronized 锁是建立在对象的基础上,只有是同一个对象 synchronized 锁才会生效(synchronized 相关的知识不了解可以看阿东之前的文章深入理解 Java 线程与锁),我们可以使用 String.intern() 方法:如果字符串常量池中含有该字符串(equals),直接将常量池的地址返回,如果没有,创建一个字符串对象放入到常量池中并返回地址;这样就能保证同样 key 内容的请求争抢的是同一个锁对象(网上很多文章都说 String.intern() 方法会导致内存泄漏,阿东做了实验并不是这样,字符串常量池中的字符串对象是可以被回收的,回收策略同样是 GC ROOT 引用链的判断,感兴趣的同学可以自己写个程序试试)。

                五. 缓存穿透


                缓存穿透是指要访问的数据既不在缓存中,也不在数据库中,导致请求在访问缓存时,发生缓存缺失,再去访问数据库时也没有值;这样的情况下,缓存就成了摆设,如果访问这些缺失数据的请求持续高并发,就会给缓存和数据库同时带来压力


                造成缓存穿透有两种原因:


                • 业务层操作:数据被删。

                • 恶意攻击:专门攻击数据库没有的数据。


                解决方案同样分为「事前」和「事后」。


                事前方案


                前端过滤:在系统请求入口处对请求进行检测,将恶意请求过滤(请求参数不合理,非法值)。


                事后方案


                设置空值:当发生缓存穿透时,我可以设置一个空值(或是业务统一的缺省值)到缓存中。当后续还有请求打过来,就可以在缓存层直接过滤返回,避免大量请求打到数据库。使用该方案需要注意一点,当新增或删除该数据时需要删除缓存,否则可能会造成缓存与数据库不一致


                伪代码如下:


                  // 从缓存中查
                  String value = getCache(key);
                  if(value != null) {
                  return value;
                  }
                  // 从数据库中查
                  value = getByDB(key);
                  if(value == null) {
                  // 数据库中也为空就将空值写回缓存
                  setCache(key, "NULL", 120);
                  } else {
                  setCache(key, value, 1200);
                  }
                  return value;


                  布隆过滤器布隆过滤器由一个初始值都为 0 的 bit 数组和 N 个哈希函数组成,主要分为「标记」「判断存在」两种操作。


                  当我们想将标记值为存在时,布隆过滤器的工作流程如下:


                  1. 使用 N 个哈希函数对待标记数据计算出 N 个哈希值;

                  2. 将这 N 个哈希值对 bit 数组的长度进行取模,得到每个哈希值在数组中的位置;

                  3. 把数组中对应位置设置为 1。


                  当我们想判断某个值是否存在,布隆过滤器的工作流程如下:


                  1. 使用 N 个哈希函数对待标记数据计算出 N 个哈希值;

                  2. 将这 N 个哈希值对 bit 数组的长度进行取模,得到每个哈希值在数组中的位置;

                  3. 判断数组中每个位置是否都为 1:如果是,则存在;如果有一个位置的值不为 1,则不存在。


                  bit 数组长度越大、哈希函数越多准确度越高。


                  我们可以在新增或编辑时将数据加进布隆过滤器(历史数据可以在上线前通过脚本加入),后续在查询缓存和数据库之前先到布隆过滤器中快速判断数据是否存在,不存在的数据快速过滤掉。




                  如果觉得文章不错可以点个赞和关注


                  参考:

                  《Redis 核心技术与实战》作者:蒋德钧

                  https://mp.weixin.qq.com/s/oDV-2IkX16EffLcStT0bSg

                  https://redis.io/docs


                  文章转载自阿东编程之路,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

                  评论