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

Redis缓存设计与优化

并发编程之美 2020-09-21
700

介绍

缓存带来了加速读写,降低后端负载的好处外,同时也存在一定的成本,比如数据不一致,缓存层和数据层有时间窗口不一致,和更新策略有关;代码维护成本多了一层缓存逻辑;以及运维成本,例如Redis Cluster等。所以在实际的使用中,我们需要区分场景合理使用缓存逻辑。同时缓存对粒度控制分缓存全部数据和部分重要数据:

  • 通用性:全量属性更好

  • 占用空间:部分属性更好

  • 代码维护上:表面上全量属性更好


一、缓存适用场景

缓存的适用场景示例:

  • 对高消耗的SQL:join结果集/分组统计结果缓存

  • 加速请求响应:利用Redis/Memcache优化IO响应时间

  • 大量写合并为批量写:如计数器先Redis累加再批量写DB


二、缓存更新策略

缓存的更新策略:

  • 控制最大内存情况下,LRU/LFU/FIFO算法剔除:例如maxmemory-policy

  • 超时剔除:例如expire

  • 主动更新:开发控制生命周期

三种缓存更新策略对比:

策略
一致性
维护成本
LRU/LIRS算法剔除
最差

超时剔除
较差

主动更新


使用建议:

  • 低一致性:最大内存和淘汰策略

  • 高一致性:超时剔除和主动更新结合,最打内存和淘汰策略兜底


除了缓存服务器自带的缓存失效策略之外,我们还可以根据具体的业务需求进行自定义的缓存淘汰,常见的策略有两种:

  • 定时去清理过期的缓存

  • 当有用户请求过来时,再判断这个请求所用到的缓存是否过期,过期的话就去底层系统得到新数据并更新缓存

两者各有优劣,第一种的缺点是维护大量缓存的key是比较麻烦的,第二种的缺点就是每次用户请求过来都要判断缓存失效,逻辑相对比较复杂。


二、缓存穿透优化
缓存穿透最常见的场景就是访问根本就不存在的数据。一般是黑客故意去请求缓存中不存在的数据,导致所有的请求都落到数据库上,造成数据库短时间内承受大量请求而崩掉。

原因:

  • 业务代码自身问题,空变量

  • 恶意攻击、爬虫等

解决:

1. 缓存空对象+过期时间

存在的问题:

  • 需要更多的键

  • 缓存层和存储层数据短期不一致

示例代码:

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


    2. 布隆过滤器拦截

    最常见的则是采用布隆过滤器,将所有可能存在的数据哈希到一个足够大的bitmap中,一个一定不存在的数据会被 这个bitmap拦截掉,从而避免了对底层存储系统的查询压力。

    比如10亿电话本判断电话在不在电话本中,使用很少的内存解决这个问题。在cache层之前增加了布隆过滤器,如果布隆过滤器过滤掉了则说明这个key是无效的,直接返回,如果没被过滤,则从cache层去拿数据。

    三、缓存无底洞问题优化

    有这么一个场景,已经存在了很多Redis或者Memcache服务节点,发现加机器性能没提示反而下降:http://highscalability.com/blog/2009/10/26/facebooks-memcached-multiget-hole-more-machines-more-capacit.html

    问题关键点:

    • 更多的机器!=更高的性能

    • 批量接口需求(mget、mset等)

    • 数据增长与水平扩展需求

    所以原因就是批量操作的变化,当只有一个节点是,一个mget操作是有一次网络IO,当阶段扩大到3个时候,使用顺序IO方式的话,一次mget的操作会随着机器节点的个数增加而网络传输次数也越来越多,对客户端执行效率带来很大的下降。实际上IO由于扩容从原来的o(1)增加到了o(node)。


    优化IO的几种方法:

    • 命令本身优化:例如慢查询keys、hgetall bigkey

    • 减少网络通信次数

    • 降低接入成本:例如客户端长链接/连接池、NIO等

    • 串行mget

    • 串行io

    • 并行io

    • hash_tag

    串行mget、串行io、并行io以及hash_tag介绍详见【Redis Cluster高可用集群模式】


    四种方案优缺点对比:

    方案
    优点
    缺点
    网络IO
    串行mget
    少量keys满足需求大量keys请求延迟严重
    o(keys)
    串行IO
    少量节点满足需求
    大量nodes延迟严重
    o(nodes)
    并行IO
    延迟取决于最慢的节点
    超时定位问题复杂
    o(max_slow(node))
    hash_tag
    性能最高
    读写增加tag维护成本,tag分布容易出现数据倾斜
    o(1)


    四、缓存雪崩问题优化

    当流量洪峰到达时,缓存同一时间大面积的失效,所以,后面的请求都会落到数据库上,造成数据库短时间内承受大量请求而崩掉,就是缓存雪崩。

    解决方法:

    • 事前:尽量保证整个 redis 集群的高可用性,如采用Redis Cluster架构,发现机器宕机尽快补上。选择合适的内存淘汰策略

    • 事中:本地cache缓存 + hystrix限流&降级,避免MySQL崩掉

    • 事后:利用 redis 持久化机制保存的数据尽快恢复缓存

    • 对缓存进行实时监控,当请求访问的慢速度比超过阈值,及时报警,通过自动故障转移,服务降级,停止部分非核心接口的访问

    • 提前压测预估系统处理能力,做好限流与服务降级


    五、缓存预热优化

    缓存预热就是系统上线后,将相关的缓存数据直接加载到缓存系统。这样就可以避免在用户请求的时候,先查询数据库,然后再将数据缓存的问题,用户直接查询事先被预热的缓存数据。解决思路:

    • 直接写个缓存刷新页面,上线时手工操作下

    • 数据量不大,可以在项目启动的时候自动进行加载

    • 定时刷新缓存


    六、热点key重建优化

    热key重建指的是开发人员设置好的缓存过期时间过了,需要重新构建缓存。热key说明当前可能有大量的请求,同时访问同一个key,而且这个并发量特别大,缓存失效的瞬间可能会有大量的线程来重建缓存,造成后端数据库压力暴增。

    问题描述:热点key+较长的重建时间。

    存在问题:大量的线程都会做缓存重建和查询数据源。

    解决方法:

    1. 互斥锁(mutex key)

    通过设置互斥锁,统一时间只允许一个请求进行热key的重建。如基于redis的setnx命令实现

    存在问题:不需要大量重建工作,但是存在大量线程等待的问题。

    示例代码:

      String get(String key) {
        String value = redis.get(key);
        if (value == null) {
          String mutexKey = "mutex:key:" + key;
          if (redis.set(mutexKey, "1", "ex 180", "nx")) {
          value = db.get(key);
          redis.set(key,value);
             redis.delete(mutexKey);
          } else {
             //其他线程休息50毫秒后重试
             Thread.sleep(50);
             get(key);
          }
        }
        return value;
      }


      2. 永不过期

      为每个value添加逻辑过期时间,发现超过逻辑过期时间后,会使用单独的线程去构建缓存,但是存在缓存不一致情况。示例代码:

        String get(final String key) {
          V v = redis.get(key);
        String value = v.getValue();
          long logicTimeout = v.getLogicTimeout();
          if (logicTimeout >= System.currentTimeMills()) {
        String mutexKey = "mutex:key:" + key;
        if (redis.set(mutexKey, "1", "ex 180", "nx")) {
        异步更新后台异步执行
        threadPool.execute(() -> {
        String dbValue = db.get(key);
                 redis.set(key,dbValue, newLogicTimeout());
        redis.delete(mutexKey);
               });
        }
        }
        return value;
        }


        3. 方案对比

        方案
        优点缺点
        互斥锁
        保证一致性
        代码复杂,存在死锁风险
        永远不过期
        基本杜绝热点key重建问题
        不保证一致性,逻辑过期时间增加维护成本和内存成本


        4. 缓存降级

        与热点key相对立的策略就是缓存降级了,服务降级的目的,是为了防止Redis服务故障,导致数据库跟着一起发生雪崩问题。因此,对于不重要的缓存数据,可以采取服务降级策略,例如一个比较常见的做法就是,Redis出现问题,不去数据库查询,而是直接返回默认值给用户。


        推荐阅读

        1. Redis云平台CacheCloud:https://github.com/sohutv/cachecloud

        2. Redis数据结构与内部编码,你知道多少?

        3. Redis持久化机制

        4. Redis Sentinel哨兵模式

        5. Redis Cluster高可用集群模式

        看完本文有收获?请转发分享给更多人

        关注「并发编程之美」,一起交流Java学习心得


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

        评论