Redis 开发与运维缓存设计

Posted 木兮同学

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Redis 开发与运维缓存设计相关的知识,希望对你有一定的参考价值。

文章目录


一、缓存的收益与成本

收益

  • 加速读写
  • 降低后端负载,例如使用 redis 缓存降低 mysql 负载等。

成本

  • 数据不一致:缓存层和存储层的数据存在着一定时间窗口的不一致性,和更新策略有关。
  • 代码维护成本:加入缓存后,需要同时处理缓存层和存储层的逻辑。
  • 运维成本:例如 Redis Cluster,加入后无形中增加了运维成本。

使用场景

  • 降低后端负载,对高消耗的 SQL 比如 JOIN 结果集、分组统计结果缓存。
  • 加速请求响应,利用 Redis/Memcache 优化 IO 响应时间。
  • 大量写合并为批量写,如计数器先在 Redis 累加再批量写 DB。

二、缓存更新策略

Redis 过期键的删除策略?

  • 定时删除:超时时间到达时,删除
  • 惰性删除:再次访问过期数据时,删除
  • 定期删除:每隔一定周期,删除
  • 对于定时删除:由于数据库可能同时接受成千上万个用户的访问,那么可能有大量的 key 需要删除,如果我们为每一个 key 的超时时间都设置一个定时器,每次超时就进行删除操作,那么会导致系统性能非常低。
  • 对于惰性删除:如果一个过期 key 长期没有被访问,那么该 key-value 对将会一直存储在数据库中,会一直占有内存。而 redis 又是一个基于内存的数据库,这样很容易导致内存被耗尽。
  • 对于定期删除 :redis 难以确定执行删除操作的时长和频率
  • 因此 redis 采用惰性删除和定期删除相结合的方式,来删除系统中的过期键

Redis 内存淘汰机制?

  • Redis v4.0 前提供 6 种数据淘汰策略:
    • volatile-lru:利用 LRU 算法移除设置过期时间的 key(LRU:最近最少使用,Least Recently Used )
    • volatile-ttl:从已设置过期时间的数据集中挑选将要过期的数据淘汰
    • volatile-random:从已设置过期时间的数据集中任意选择数据淘汰
    • allkeys-lru:当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的 key(这个是最常用的)
    • allkeys-random:当内存不足以容纳新写入数据时,从数据集中任意选择数据淘汰
    • no-eviction:禁止驱逐数据,也就是说当内存不足以容纳新写入数据时,新写入操作会报错。
  • Redis v4.0 后增加以下 2 种:
    • volatile-lfu:从已设置过期时间的数据集中挑选最不经常使用的数据淘汰(LFU,Least Frequently Used)算法,也就是最频繁被访问的数据将来最有可能被访问到。
    • allkeys-lfu:当内存不足以容纳新写入数据时,在键空间中,移除最不经常使用的 key。

三、缓存粒度控制

粒度问题

  • 比如要缓存一个 mysql 中的用户信息:select * from user where id = id
  • 设置用户缓存:set user:id '(上面sql语句的结果)'
  • 缓存粒度-全部属性:即缓存 select *的结果
  • 缓存粒度-部分重要属性:比如缓存 select user_name, phone部分字段结果

三个角度分析

  • 通用性:缓存全部数据比部分数据更加通用,但从实际经验看,很长时间内应用只需要几个重要的属性
  • 空间占用:缓存全部数据要比部分数据占用更多的空间,可能存在以下问题:
    • 全部数据会造成内存浪费
    • 每次传输产生的网络流量会比较大,耗时相对较大,极端情况下会阻塞网络
    • 全部数据的序列化和反序列化的 CPU 开销更大
  • 代码维护:全部数据的优势更加明显,而部分数据一旦要加新字段需要修改业务代码,而且修改后通常还需要刷新缓存数据

四、缓存穿透问题

问题描述

  • 缓存穿透
    • 缓存穿透是指查询一个根本不存在的数据,缓存层和存储层都不会命中,通常出于容错的考虑,如果从缓存层查不到数据则不写入缓存层。
    • 缓存穿透将导致不存在的数据每次请求都要到存储层去查询,失去了缓存保护后端存储的意义。
  • 原因
    • 自身业务代码或数据出现问题。
    • 恶意攻击或者爬虫等等造成大量空命中,比如根据一堆不存在的 id 进行查询,每次都是穿透缓存直接查询数据库。
  • 发现
    • 根据业务的响应时间,审查业务本身代码问题
    • 可以在程序中分别统计总调用数、缓存层命中数、存储层命中数,如果发现大量空命中,可能就是出现了缓存穿透。

解决方案

  • 缓存空对象,当存储层不命中后,仍然将空对象保留到缓存层。可能存在的问题

    • 空值做了缓存,缓存层就需要存更多的键,比如说恶意攻击,需要存一堆无用的 id 键,值为 null。这种情况可以设置一个较短的过期时间,让其自动剔除。
    • 缓存层和存储层数据“短期”不一致。比如查询 mysql,这时候 mysql 出现问题了,导致返回的是 null,然后又把这个空数据缓存到了redis,假如有 5 分钟过期时间,那这 5 分钟内都是错误数据,这个时候就是数据不一致了。此时可以利用消息系统或者其他方式清除掉缓存层中的空对象。
  • 布隆过滤器拦截(后面文章详细介绍)

    • 在访问缓存层和存储层之前,将存在的 key 用布隆过滤器提前保存起来,做第一层拦截。
    • 这种方法适用于数据命中不高、数据相对固定、实时性低的应用场景,代码维护较为复杂,但缓存空间占用少。

五、无底洞优化

问题描述

  • 场景描述
    • 2010年,Facebook 已经有了 3000 个 Memcache 个节点,为了满足需求需要添加大量新 Memcache 节点,发现 加机器性能并没有提升,反而下降
  • 问题分析
    • 客户端一次批量操作会涉及多次网络操作,也就意味着批量操作会随着节点的增多,耗时会不断增大
    • 网络连接数变多,对节点的性能也有一定影响。
  • 问题关键点
    • 更多的节点不代表更高的性能,所谓“无底洞”就是说投入越多不一定产出越多。
    • 一般出现在批量接口的需求,比如 mget、mset 等。

分布式条件下优化批量操作

  • 常见的 IO 优化思路
    • 命令本身优化,例如优化 SQL 语句等
    • 减少网络通信的次数
    • 降低接入成本,例如客户端长连/连接池、NIO 等
  • 四种批量优化的方法(具体代码实现,请参考书籍)
    • 串行命令:由于 n 个 key 是比较均匀地分布在 Redis Cluster 的各个节点上,因此无法使用 mget 命令一次性获取,所以通常来讲要获取 n 个 key 的值,最简单的方法就是逐次执行 n 个 get 命令,操作时间 = n 次网络时间 + n 次命令时间
    • 串行 IO:Redis Cluster 使用 CRC16 算法计算出散列值,再取对 16383 的余数就可以算出 slot 值,同时 Smart 客户端会保存 slot 和节点的对应关系,有了这两个数据就可以将属于同一个节点的 key 进行归档得到每个节点的 key 子列表,之后对每个节点执行 mget 或者 Pipeline 操作,操作时间 = node 次网络时间 + n 次命令时间
    • 并行 IO:此方案是上面方案最后一步改为多线程执行,网络次数虽然还是节点个数,但由于使用多线程,网络时间变为 O(1) ,这种方案会增加编程的复杂度,它的操作时间 = max_slow(node 网络时间) + n 次命令时间
    • hash_tag 实现:Redis Cluster 的 hash_tag 功能,它可以将多个 key 强制分配到一个节点上,操作时间 = 1 次网络时间 + n 次命令时间

六、缓存雪崩优化

问题描述

  • 由于缓存层承载着大量请求,有效地保护了存储层,但是如果缓存层由于某些原因不能提供服务,于是所有请求都会到达存储层,存储层的调用量会暴增,造成存储层也会级联宕机的情况

优化方案

  • 保证缓存高可用性。个别节点、个别机器或者个别机房出现问题时能保证高可用,可以使用 Redis Sentinel 和 Redis Cluster。
  • 依赖隔离组件为后端限流并降级。例如线程池、信号量隔离组件,或一些比如阿里的 Sentinel 限流组件。
  • 提前演练。例如压力测试。

七、热点 key 重建优化

问题描述

  • 开发人员使用“缓存 + 过期时间”的策略即可以加速数据读写,又保证数据的定期更新,这种模式基本能够满足绝大部分需求。但是有两个问题如果同时出现,可能就会对应用造成致命的危害:
    • 当前 key 是一个热点 key,并发量特别大
    • 重建缓存不能在短时间完成,可能是一个复杂计算,例如复杂的 SQL、多次 IO、多个依赖等。
  • 缓存失效的瞬间,有大量线程来重建缓存,造成后端负载加大,甚至可能会让应用崩溃。
  • 可以指定如下三个目标去解决这个问题,又不给系统带来更多麻烦:
    • 减少重建缓存的次数
    • 数据尽可能一致
    • 减少潜在的风险

解决思路

  • 互斥锁,当检测到缓存需要重建的时候加上一把锁,缓存成功了就把锁释放,在这个期间如果有其他线程访问缓存时会一直等待。
  • 代码示例
    	String get(String key) throws InterruptedException 
            String value = redis.get(key);
            // 如果 value 为空,开始重构缓存
            if (value == null) 
                String mutexKey = "mutex:key:" + key;
                // 加锁:如果 key 不存在,则 set ,且设置 180 秒的过期时间,如果能设置成功返回 true
                if (redis.set(mutexKey, "1", "ex 180", "nx")) 
                    value = db.get(key);
                    redis.set(key, value);
                    redis.delete(mutexKey);
                    
                // 其他线程休息 50 毫秒后重试
                 else 
                    Thread.sleep(50);
                    get(key);
                
            
            return value;
        
    
  • 永远不过期
    • 从缓存层面来看,确实没有设置过期时间,不会出现热点 key 过期后产生的问题
    • 从功能层面来看,为每个 value 设置逻辑过期时间,当发现超过逻辑过期时间后,使用单独的线程去构建缓存
    • 代码示例:
    String get(final String key) throws InterruptedException 
        V v = redis.get(key);
        String value = v.getValue();
        // 逻辑过期时间
        Long logicTimeout = v.getLogicTimeout();
        // 如果逻辑过期时间小于当前时间,开始后台构建
        if (logicTimeout <= System.currentTimeMillis()) 
            String mutexKey = "mutex:key:" + key;
            // 加锁:如果key不存在,则set,且设置180秒的过期时间,如果能设置成功返回true
            if (redis.set(mutexKey, "1", "ex 180", "nx")) 
                // 重构缓存
                threadPool.execute(new Runnable() 
                    @Override
                    public void run() 
                        String dbValue = db.get(key);
                        redis.set(key, (dbValue, newLogicTimeout));
                        redis.delete(mutexKey);
                    
                );
             else 
                // 其他线程休息50毫秒后重试
                Thread.sleep(50);
                get(key);
            
        
        return value;
    
    

两种方案对比

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

八、缓存降级

问题描述

  • 当访问量剧增、服务出现问题(如响应时间慢或不响应)或非核心服务影响到核心流程的性能时,仍然需要保证核心服务还是可用的,即使是有损服务。系统可以根据一些关键数据进行自动降级,也可以配置开关实现人工降级。
  • 降级的最终目的是保证核心服务可用,即使是有损的。而且有些服务是无法降级的(如加入购物车、结算)。

处理方案

  • 在进行降级之前要对系统进行梳理,看看系统是不是可以丢卒保帅,从而梳理出哪些必须誓死保护,哪些可降级,比如可以参考日志级别设置预案:
    • 一般:比如有些服务偶尔因为网络抖动或者服务正在上线而超时,可以自动降级。
    • 警告:有些服务在一段时间内成功率有波动(如在 95~100% 之间),可以自动降级或人工降级,并发送告警。
    • 错误:比如可用率低于 90%,或者数据库连接池被打爆了,或者访问量突然猛增到系统能承受的最大阀值,此时可以根据情况自动降级或者人工降级。
    • 严重错误:比如因为特殊原因数据错误了,此时需要紧急人工降级。

来源:《Redis 开发与运维》第 11 章 缓存设计

以上是关于Redis 开发与运维缓存设计的主要内容,如果未能解决你的问题,请参考以下文章

Redis 开发与运维开发运维的“陷阱”

Redis 开发与运维开发运维的“陷阱”

Redis 开发与运维开发运维的“陷阱”

Redis(开发与运维):14---列表对象

Redis 开发与运维Redis Sentinel 哨兵

Redis 开发与运维Redis Sentinel 哨兵