Redis 缓存设计(各种问题,优缺点,穿透雪崩等)

文章目录

  • 缓存的收益和成本
  • 缓存更新策略(简单介绍)
  • 缓存粒度控制(简单介绍)
  • 穿透优化
    • 1. 缓存空对象
    • 2. 布隆过滤器拦截
  • 雪崩优化
  • 热点key重建优化
    • 互斥锁(mutex key)
    • 永远不过期

缓存的收益和成本

收益如下:

  • 加速读写:因为缓存通常都是全内存的(例如redis,memcache),而存储层通常读写性能不够强悍(例如MySQL),通过缓存的使用可以有效地加速读写,优化用户体验。
  • 降低后端负载:帮助后端减少访问量和复杂计算(例如很复杂的SQL语句),在很大程度降低了后端的负载。

成本如下:

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

缓存的使用场景基本包含如下两种:

  • 开销大的复杂计算:以MySQL为例,一些复杂的操作或者计算(例如大量联表操作、一些分组计算),如果不加缓存,不但无法满足高并发量,同时也会给MySQL带来巨大的负担。
  • 加速请求响应:即使查询单条后端数据足够快(例如 select * from table where id=?),那么依然可以使用缓存,以Redis为例,每秒可以完成数万次读写,并且提供的批量操作可以优化整个IO链的响应时间。

缓存更新策略(简单介绍)

缓存中的数据都是有生命周期的,需要在指定时间后被删除或更新,这样可以保证缓存空间在一个可控的范围。但是缓存中的数据会和数据源中的真实数据有一段时间窗口的不一致,需要利用某些策略进行更新。

  1. LRU/LFU/FIFO 算法剔除
  2. 超时删除
  3. 主动更新

建议:

  • 低一致性业务建议配置最大内存和淘汰策略的方式使用。
  • 高一致性业务可以结合使用超时剔除和主动更新,这样即使主动更新出了问题,也能保证数据过期时间后删除脏数据。

缓存粒度控制(简单介绍)

使用缓存时,例如对于一条数据库中的数据,究竟是缓存全部属性还是只缓存部分重要属性呢?下面从通用性、空间占用、代码维护三个角度进行说明:

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

穿透优化

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

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

缓存穿透问题可能会导致后端存储负载加大,由于很多后端存储不具备高并发性,甚至可能造成后端存储宕掉。通常可以在程序中分别统计总调用数、缓存层命中数、存储层命中数,如果发现大量存储层空命中,可能就是出现了缓存穿透问题。

造成缓存穿透的基本原因有两个。第一,自身业务代码或者数据出现问题,第二,一些恶意攻击、爬虫等造成大量空命中。下面我们来看一下如何解决缓存穿透问题。

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. 布隆过滤器拦截

Redis 缓存设计(各种问题,优缺点,穿透雪崩等)_第1张图片
如上图所示,在访问缓存层和存储层之前,将存在的key用布隆过滤器提前保存起来,做一层拦截。将非法的一定不存在的请求拦截掉,过滤掉非法请求。

方案对比:

解决缓存穿透 适用场景 维护成本
缓存空对象 数据命中不高,数据频繁变化实时性高 代码维护简单,需要过多的存储空间,数据不一致
布隆过滤器 数据命中不高,数据相对固定实时性低 代码维护复杂,缓存空间占用少

雪崩优化

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

预防和解决缓存雪崩问题,可以从以下三个方面进行着手。
1)保证缓存层服务高可用性:可以从 Redis Sentinel 和 Redis Cluster 都实现了高可用。
2)依赖隔离组件为后端限流并降级:无论是缓存层还是存储层都会有出错的概率,可以将他们视同为资源。作为并发量较大的系统,加入有一个资源不可用,可能会造成线程全部阻塞在这个资源上,造成整个系统不可用。这种情况我们可以采用服务降级,可以通过使用 Hystrix 实现。
3)提前演练

热点key重建优化

开发人员使用”缓存+过期时间“的策略既可以加速数据读写,又保证数据的定时更新,这种模式基本满足绝大部分需求。但是有两个问题如果同时出现,可能就会对应用造成致命的危害:

  • 当前key是一个热点key(例如一个热门的娱乐新闻),并发量非常大。
  • 重建缓存不能在短时间完成,可能是一个复杂计算,例如复杂的SQL、多次IO、多个依赖等。

在缓存失效的瞬间,有大量线程来重建缓存(如下图所示),造成后端负载加大,甚至可能会让应用奔溃。
Redis 缓存设计(各种问题,优缺点,穿透雪崩等)_第2张图片
要解决这个问题也不是很复杂,但是不能为了解决这个问题给系统带来更多的麻烦,所以需要制定如下目标:

  • 减少重建缓存的次数。
  • 数据尽可能一致。
  • 较少的潜在危险。

互斥锁(mutex key)

此方法只允许一个线程重建缓存,其他线程等待重建缓存的线程执行完,重新从缓存获取数据即可,整个过程如下图所示。
Redis 缓存设计(各种问题,优缺点,穿透雪崩等)_第3张图片
下面代码使用Redis的setnx命令实现上述功能:

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(mutex_key);
        }
        // 其他线程休息50毫秒后重试
        else {
            Thread.sleep(50);
            get(key);
        }
    }
    return value;
}
  • 从Redis获取数据,如果值不为空,则直接返回值;否则执行下面2和3步骤。
  • 如果set(nx和ex)结果为true,说明此时没有其他线程重建缓存,那么当前线程执行缓存构建逻辑。
  • 如果set(nx和ex)结果为false,说明此时已经有其他线程正在执行构建缓存的工作,那么当前线程将休息指定时间(例如这里是50毫秒,取决于构建缓存的速度)后,重新执行函数,直到获取到数据。

永远不过期

“永远不过期”包含两层意思:

  • 从缓存层面来看,确实没有设置过期时间,所以不会出现热点key过期后产生的问题,也就是“物理”不过期。
  • 从功能层面来看,为每个value设置一个逻辑过期时间,当发现超过逻辑过期时间后,会使用单独的线程去构建缓存。

整个过程如下图所示。
Redis 缓存设计(各种问题,优缺点,穿透雪崩等)_第4张图片
从实战看,此方法有效地杜绝了热点key产生的问题,但唯一不足的就是重构缓存期间,会出现数据不一致的情况,这取决于应用方是否容忍这种不一致。下面代码使用Redis进行模拟:

String get(final String key) {
    V v = redis.get(key);
    String value = v.getValue();
    // 逻辑过期时间
    long logicTimeout = v.getLogicTimeout();
    // 如果逻辑过期时间小于当前时间,开始后台构建
    if(v.logicTimeout <= System.currentTimeMillis()) {
        String mutexKey = "mutex:key:" + key;
        if(redis.set(mutexKey, "1", "ex 180", "nx")) {
            // 重构缓存
            threadPool.execute(new Runnable(){
                public void run() {
                    String dbValue = db.get(key);
                    redis.set(key, dbvalue, newLogicTimeout);
                    redis.delete(mutexKey);
                }
            });
        }
    }
    return value;
}

作为一个并发量较大的应用,在使用缓存时有三个目标:

  • 加快用户访问速度,提高用户体验。
  • 降低后端负载,减少潜在的风险,保证系统平稳。
  • 保证数据“尽可能”及时更新。

下面将按照这三个维度对上述两种解决方案进行分析。

  • 互斥锁(mutex key):这种方案思路比较简单,但是存在一定的隐患,如果构建过程出现问题或者时间较长,可能会存在死锁和线程池阻塞的风险,但是这种方法能够较好的降低后端存储负载,并在一致性上做的比较好。
  • “永远不过期”:这种方案由于没有设置真正的过期时间,实际上已经不存在热点key产生的一系列危害,但是会存在数据不一致的情况,同时代码复杂度会增大。

两种解决方法对比:

解决方案 优点 缺点
简单分布式锁 思路简单,保证一致性 代码复杂度增大,存在死锁的风险,存在线程池阻塞的风险
“永远不过期” 基本杜绝热点 key 问题 不保证一致性,逻辑过期时间增加代码维护成本和内存成本

你可能感兴趣的:(redis)