redis系列(九)—缓存

redis系列(九)—缓存

前言

大家好,牧码心今天给大家推荐一篇redis系列(九)—缓存的文章,在实际工作中有很多应用场景,希望对你有所帮助。内容如下:

  • 缓存概述
  • 缓存优劣
  • 缓存更新策略
  • 缓存常见问题

缓存概述

随着互联网的普及,内容信息越来越复杂,用户数和访问量越来越大,我们的应用需要支撑更多的并发量,同时我们的应用服务器和数据库服务器所做的计算也越来越多。但是往往我们的应用服务器资源是有限的,数据库每秒能接受的请求次数也是有限的,如何能够有效利用有限的资源来提供尽可能大的吞吐量?一个有效的办法就是引入缓存,那什么是缓存?

缓存是一个高速数据存储层,其中存储了数据子集,且通常是短暂性存储,这样日后再次请求该数据时,速度要比访问数据的主存储位置快。

为了更直观说明,我们看下使用缓存的流程图:
使用缓存的流程图

缓存的优劣势

  • 缓存的优势

    • 提升应用程序性能:因为内存比磁盘或 SSD 快几个数量级,所以从内存中缓存读取数据非常快(亚毫秒级)。这大大加快了数据访问速度,从而提升了应用程序的整体性能。
    • 减少后端负载:通过将读取负载的重要部分从后端数据库重定向到内存层,缓存可以减少数据库上的负载,防止其在负载情况下性能降低,甚至可以防止其在高峰期崩溃;
    • 提高读取吞吐量 (IOPS):相对于同等的基于磁盘的数据库,除了更低的延迟之外,内存中系统还可以实现更高的请求速度 (IOPS)。用作分布式端缓存的单个实例每秒可以处理数十万个请求。
  • 缓存的劣势

    • 数据不一致性:缓存层和存储层的数据存在着一定时间窗口的不一致性,时间窗口与更新策略有关;

    • 维护成本:加入缓存后,需要同时处理缓存层和存储层的逻辑,加大了开发者维护的成本;

  • 缓存的使用场景

    • 开销大的复杂计算:

    • 加速请求响应:使用redis做缓存,每秒可以完成数万次读写,并且提供的批量操作可以优化整个IO链的响应时间。

缓存的更新策略

缓存中的数据通常都是有生命周期的,需要在指定时间后被删除或更新,这样可以保证缓存空间在一个可控的范围。但是缓存中的数据会和数据源中的真实数据有一段时间窗口的不一致,需要利用某些策略进行更新。
在介绍更新策略前,我们先介绍下缓存的几个概念
缓存命中率
命中率=返回正确结果数/请求缓存次数,命中率越高,表明缓存的使用率越高。
最大元素(或最大空间)
缓存中可以存放的最大元素的数量,一旦缓存中元素数量超过这个值(或者缓存数据所占空间超过其最大支持空间),那么将会触发缓存启动清空策略根据不同的场景合理的设置最大元素值往往可以一定程度上提高缓存的命中率,从而更有效的时候缓存。
清空策略
设计合适的清空策略可有效提升缓存命中率,常见一般策略有:

  • 先进先出策略(FIFO)
    先进先出策略是最先进入缓存的数据在缓存空间不够的情况下(超出最大元素限制)会被优先被清除掉,以腾出新的空间接受新的数据。
    策略算法主要比较缓存元素的创建时间。在数据实效性要求场景下可选择该类策略,优先保障最新数据可用。
  • 最近最少使用策略(LRU)
    最近最少使用策略,无论数据是否过期,根据最后一次被使用的时间戳,清除最远使用时间戳的元素释放空间。
    策略算法主要比较元素最近一次被使用时间。在热点数据场景下较适用,优先保证热点数据的有效性。
  • 最少使用策略(LFU)
    该策略是无论数据是否过期,根据数据的被使用次数判断,清除使用次数较少的元素释放空间。策略算法主要比较数据的命中次数。在保证高频数据有效性场景下,可选择这类策略。
    除此之外,还有一些简单的策略,如:
    • 根据过期时间判断,清理过期时间最长的元素;
    • 根据过期时间判断,清理最近要过期的元素;
    • 从数据集中任意选择数据淘汰;

介绍完缓存更新策略的几个关键概念后,下面分别从使用场景,一致性和维护成本等几个方面介绍缓存更新策略

LRU/LFU/FIFO算法剔除

  • 使用场景:剔除算法通常用于缓存是用量超过预设的最大值时候,如何对现有的数据进行剔除。例如Redis使用maxmemory-policy这个配置作为内存最大值后对于数据的剔除策略
  • 一致性:剔除数据由缓存自身算法决定,一致性方面差;
  • 剔除算法不需要再次实现,只需要配置配置最大maxmemory和对应的策略即可,维护简单;

超时剔除

  • 使用场景:该策略是通过给缓存数据设置过期时间,让其在过期时间后自动删除,例如Redis提供的expire命令。如果业务可以容忍一段时间内,缓存层数据和存储层数据不一致, 那么可以为其设置过期时间。 在数据过期后,再从真实数据源获取数据,重新放到缓存并设置过期时间。例如一个视频的描述信息,可以容忍几分钟内数据不一致,但是涉及交易方面的业务,后果可想而知。
  • 一致性:存在时间窗口内的不一致性问题;
  • 维护成本:维护成本不是很高, 只需设置expire过期时间即可;

主动更新

  • 使用场景:业务需要做到强一致性,需要更新数据库数据后立即更新缓存,可以利用消息触发或者回调等方式更新缓存;
  • 一致性:一致性高,但主动更新程序出问题,会存在很长时间无法更新问题,可以结合超时剔除策略使用;
  • 维护成本:维护成本高,需要开发程序,并保证数据的准确性;
    总的来说,上面几种策略都是围绕一致性展开的业务场景,在实际使用中要结合具体的业务分析一致性要求的强弱程度来选择对应的策略。

常见问题

缓存雪崩
缓存雪崩是缓存层崩掉后,所有的并发请求都会达到数据库,数据库的短期IO压力暴增,直至造成数据库也出现宕机,如图所示:
缓存雪崩

  • 解决方案:
    (1)、保证缓存层服务的高可用:保证缓存高可用可以用多个节点配置成集群,做到个别节点宕机后,可以负载到其他可用节点,如采用redis cluster配置
    (2)、后端采用限流组件降级请求:使用类似hystrix的组件做限流&降级,资源的隔离等

缓存穿透
缓存穿透是指大量请求的 key 根本不存在于缓存中,导致请求直接到了数据库上,根本没有经过缓存这一层。此现象可能会使后端存储负载加大,由于很多后端存储不具备高并发性,甚至可能造成后端存储宕掉。通常可以在程序中分别统计总调用数、缓存层命中数、存储层命中数,如果发现大量存储层空命中,可能就是出现了缓存穿透问题。

产生缓存穿透的原因:

  • 自身业务代码或者数据出现问题;
  • 一些恶意攻击、 爬虫等造成大量空命中;

解决方案:

  • 基本的参数校验,可以拦截不合规则的请求;
  • 缓存空对象:将缓存和数据库都查不到某个 key 的数据写入到缓存中,之后再访问这个key,可以从缓存中获取,此方案也存在几个问题:
    1.空值做了缓存, 意味着缓存层中存了更多的键,需要更多的内存空间(如果是攻击,问题更严重);
    2.存在一定时间窗口的数据不一致性问题;

下面代码实现方式如下:

Object getObjectInclNullById(Integer id) {
    // 从缓存中获取数据
    Object cacheValue = cache.get(id);
    // 缓存为空
    if (cacheValue == null) {
        // 从数据库中获取
        Object storageValue = storage.get(key);
        // 缓存空对象
        cache.set(key, storageValue);
        // 如果存储数据为空,需要设置一个过期时间(300秒)
        if (storageValue == null) {
            // 必须设置过期时间,否则有被攻击的风险
            cache.expire(key, 60 * 5);
        }
        return storageValue;
    }
    return cacheValue;
}
  • 布隆过滤器
    在访问缓存层和存储层之前,将存在的key用布隆过滤器提前保存起来,做第一层拦截。例如:一个推荐系统有4亿个用户id,每个小时算法工程师会根据每个用户之前历史行为计算出推荐数据放到存储层中,但是最新的用户由于没有历史行为,就会发生缓存穿透的行为,为此可以将所有推荐数据的用户做成布隆过滤器。 如果布隆过滤器认为该用户id不存在,那么就不会访问存储层,在一定程度保护了存储层。如图所示:
    布隆过滤器的缓存穿透优化
    此方案适用于数据命中不高、数据相对固定、实时性低(通常是数据集较大)的应用场景,代码维护较为复杂,但是缓存空间占用少。

热点key并发竞争
开发人员使用“缓存+过期时间”的策略既可以加速数据读写,又保证数据的定期更新,这种模式基本能够满足绝大部分需求。但是有两个问题如果同时出现,可能就会对应用造成致命的危害:
1.当前请求的key,并发量很大;
2.重写此key对应缓存数据无法在短时间内完成,此时在缓存失效期间会造成造成大量线程重新写缓存,造成后端负载过大。

  • 解决方案:

1.利用分布式锁:此方法只允许一个线程重建缓存,其他线程等待重建缓存的线程执行完,重新从缓存获取数据即可。下面是代码实现方式:

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);
}
}r
eturn value;
}

2.永不过期方式:
所谓永不过期,从缓存层面理解:没有设置过期时间, 所以不会出现热点key过期后产生的问题。从逻辑层面理解:为每个value设置一个逻辑过期时间, 当发现超过逻辑过期时间后, 会使用单独的线程去构建缓存。此方法有效杜绝了热点key产生的问题, 但唯一不足的就是重构缓存期间, 会出现数据不一致的情况。

下面将按照这三个维度对上述两种解决方案进行分析。
1.互斥锁(mutex key) : 这种方案思路比较简单, 但是存在一定的隐患,如果构建缓存过程出现问题或者时间较长,可能会存在死锁和线程池阻塞的风险,但是这种方法能够较好地降低后端存储负载,并在一致性上做得比较好。
2.永远不过期: 这种方案由于没有设置真正的过期时间,实际上已经不存在热点key产生的一系列危害,但是会存在数据不一致的情况。

缓存和数据库一致性
(1) 先写数据库,后更新缓存
此方式适合并发低的情况,也是常规方案,但此方式存在问题是更新数据库后,若遇到缓存宕机,则会出现更新缓存失败,造成数据不一致。
(2) 先删除缓存,后更新数据库
此方式可以避免上述1中写入redis失败问题,将缓存删除可以让请求去查询数据库,但此方式不适合高并发场景。
比如多线程情况下,另一个读线程优先读取数据库数据后更新缓存,会造成缓存和数据库数据不一致。
(3)直接操作缓存,后定时或消息触发更新数据库
此方式是将所有请求全部读写缓存,以mysql数据库作为备份,然后定期写入mysql。适合高并发,但这种高并发往往会因为业务对读、写的顺序等等可能有不同要求,可能还要借助消息队列以及锁完成针对业务上对数据和顺序可能会因为高并发、多线程带来的不确定性和不稳定性。

总之,在一个并发量较大的应用,做好缓存设计时应考虑的几个目标:
第一,加快用户访问速度 提高用户体验。
第二, 降低后端负载,减少潜在的风险,保证系统平稳。
第三,保证数据“尽可能”及时更新。

参考

  • 《redis开发与运维》
发布了91 篇原创文章 · 获赞 27 · 访问量 9万+

猜你喜欢

转载自blog.csdn.net/xhwwc110/article/details/105439379
今日推荐