Redis多级缓存架构、缓存设计、性能优化

  • 多级缓存架构
  • 缓存设计
  • 缓存穿透
  • 解决方法
  • 缓存雪崩
  • 解决方案
  • 缓存击穿
  • 解决方法
  • 热点key重建
  • 缓存与数据库双写不一致
  • 解决方案
  • 布隆过滤器


多级缓存架构

redis层级存储 redis 多级缓存_redis

  • Nginx层:Lua动态渲染模板
    一些静态资源、例如HTML、CSS、JS、图片资源等都可以独立部署在一台服务器上、加载进Redis缓存中。用户请求经过Nginx时、判断是否为静态资源、是则直接从静态资源服务器里面获取、不用经过后端的Web层和Redis集群;
  • Web层缓存
    Web层里面会有一些Ehcache缓存、可以使用HashMap、ConcurrentHashMap、ArrayList等数据结构缓存数据,当请求经过Nginx发送到Web服务器时、判断Web服务器缓存中是否已经缓存请求数据,是就从服务器的缓存中获取并返回、否则去Redis集群中查询
  • Redis集群
    如果前两层缓存都没有查找到数据,则将Web层服务器发送请求到Redis集群里取查询数据,如果查询到,就将数据返回,如果查询不到,返回null.

如果多级缓存都没有查找到数据,那就从MySQL数据库中查询,查到之后将数据放入Redis集群中。

缓存设计

缓存穿透

  • 缓存穿透指查询一个根本不存在的数据,缓存层和存储层都不存在,通常处于容错的考虑,如果从存储层查询不到的数据则不写入缓存层;
  • 缓存穿透将导致不存在的数据每次请求都要到存储层去查询, 失去了缓存保护后端存储的意义。
  • 原因有两个
  • 自身业务代码或者数据出现问题
  • 一些恶意攻击

例如:我们的用户ID一般都不可能是负数、如果一直查询ID=-1的数据,肯定查不到,如果同时发送大量请求(上万)会导致存储层压力瞬间变大。

解决方法
  1. 缓存空对象
Map<String,Object> DB=new HashMap<String,Object>();

    @GetMapping("/getCache")
    public Object get(String key){
        String cache=stringRedisTemplate.opsForValue().get(key);
        if (StringUtils.isEmpty(cache)){
            //从数据库读取数据操作--假设DB为数据库
            String dbVal= (String) DB.get(key);
            stringRedisTemplate.opsForValue().set(key,dbVal);
            if (StringUtils.isEmpty(dbVal)){
                Boolean expire = stringRedisTemplate.expire(key,300,TimeUnit.SECONDS);
            }
            return dbVal;
        }else {
            //...正常业务逻辑
            return cache;
        }
    }
  1. 布隆过滤器拦截
    在访问缓存层和存储层之前,将存在的key用布隆过滤器提前保存起来,做第一层拦截,当收到一个对key请求时先用布隆过滤器验证是key否存在,如果存在在进入缓存层、存储层。可以使用bitmap做布隆过滤器。这种方法适用于数据命中不高、数据相对固定、实时性低的应用场景,代码维护较为复杂,但是缓存空间占用少
  2. 接口层进行进行数据校验
    把一些逻辑上不可能存在的数据进行拦截。如果ID最小为0,那么-1就会带来缓存穿透问题,可以验证ID<0时,拦截掉。

缓存雪崩

由于缓存层承载着大量请求,有效保护了存储层,但是如果缓存层由于某些原因不可用(宕机)或者大量热点缓存key由于超时时间相同,在同一时间段失效,大量请求直接到达存储数据库,数据库承受不住导致系统雪崩。

解决方案
  1. 缓存的过期时间用随机值,尽量让不同的key的过期时间不同。
  2. 依赖隔离组件为使用后端限流熔断并降级。
    比如使用Sentinel或Hystrix限流降级组件。
  3. 提前演练。
    在项目上线前, 演练缓存层宕掉后, 应用以及后端的负载情况以及可能出现的问题, 在4此基础上做一些预案设定。
  4. 设置key永不过期

缓存击穿

指的是缓存中没有,但数据库中有的数据,(一般缓存时间到期),当前key是一个热点key(例如一个秒杀活动),并发量非常大。突然,该热点key失效,导致大量的线程透过缓存层到达数据库层,数据库承受不住,导致崩溃。

解决方法
  1. 分布式锁
    只允许一个线程重建缓存,其他线程等待线程执行完,重新获取缓存数据。当key不存在,获取写锁、当key存在,获取写锁;
  2. 设置key永不过期
    不设置过期时间,不会出现key过期的问题。
  3. 接口限流,熔断与降级
    重要的接口一定要做好限流策略,防止用户恶意刷接口,同时要降级准备,当接口中的某些 服务 不可用时候,进行熔断,失败快速返回机制。

热点key重建

  • 当前key是一个热点key(例如一个热门的娱乐新闻)失效,并发量非常大,有大量的线程来重建缓存,造成后端负载太大,甚至奔溃。
  • 因为重建缓存不能再短时间内完成,可能是一个复杂计算,会有复杂的SQL,多次IO等;
  • 这种情况下,要避免大量线程同时重建缓存
  • 通过互斥锁,只允许一个线程重建缓存,其他线程等待线程执行完,重新获取缓存数据。
@GetMapping("/getProduct")
    public Object getProduct(String key) throws InterruptedException {
        String cache=stringRedisTemplate.opsForValue().get(key);
        if (StringUtils.isEmpty(cache)){
            String mutexKey="mutext:key:"+key;
            Boolean aBoolean = stringRedisTemplate.opsForValue().setIfAbsent(mutexKey, "1", 180, TimeUnit.MILLISECONDS);
            if (aBoolean){
                cache= (String) DB.get(key);
                stringRedisTemplate.opsForValue().set(key,cache,300,TimeUnit.SECONDS);
                stringRedisTemplate.delete(mutexKey);
            }else {
                Thread.sleep(50);
                getProduct(key);
            }
        }
        return cache;
    }

缓存与数据库双写不一致

在大并发下,同时操作数据库与缓存会存在数据不一致性问题。

redis层级存储 redis 多级缓存_数据库_02


线程1先写数据库、再准备写入缓存期间,线程也写了数据库、又更新了缓存;

这就造成redis写覆盖;

redis层级存储 redis 多级缓存_java_03


线程1写完数据后,删除缓存数据、然后线程3查询这条缓存数据,由于被删除了,查找不到,因此会从数据库中获取,获取到后,在准备更新缓存期间,线程2写了同一条记录,又删除了缓存,最后线程3Web层查出来的数据其实和数据库的不一致,如果把Web层查出来的数据stock更新缓存,那么得到的是脏数据。

解决方案
  • 并发很高,如果业务上能容忍短时间的缓存数据不一致(如商品名称,商品分类菜单等),缓存加上过期时间依然可以解决大部分业务对于缓存的要求。
  • 如果不能容忍缓存数据不一致、可以通过读写锁保证并发读写或写写的时候是串行化的,读读操作相当于无锁;
  • 用阿里开源的canal通过监听数据库的binlog日志及时的去修改缓存,但是引入了新的中间件,增加了系统的复杂度。
    我们针对读多写少的情况加入缓存可以提高性能,如果写多读多又不能容忍数据不一致,那就没必要加缓存了,直接从Web层查询MySQL,不经过Redis,可以保证一致性;
    放入缓存的数据应该是对实时性、一致性要求不高的数据。

布隆过滤器

对于恶意攻击,向服务器请求大量不存在的数据造成的缓存穿透,还可以用布隆过滤器先做一次过滤,对于不存在的数据布隆过滤器一般都能够过滤掉,不让请求再往后端发送。当布隆过滤器说某个值存在时,这个值可能不存在;当它说不存在时,那就肯定不存在。

redis层级存储 redis 多级缓存_数据库_04

  • 布隆过滤器就是一个大型位数组和几个不一样的无偏hash函数。
  • 向布隆过滤器添加key时、会使用多个hash函数进行Hash算出整数值,再对维数组进行取模得到一个位置,每个hash函数会得到一个不同的位置,再将这几个位置置为1,完成Add操作。
  • 向布隆过滤器询问key是否存在时,跟add一样,通过hash算出几个位置,判断这些位置是不是都为1,只要有一个是0,则说明这个key不存在。如果都是1,则该key极有可能存在,因为这些位置为1,可能是其他的key存在所致。
    如果这个数组比较稀疏,这个概率就很大,如果这个位数比较拥挤,这个概率就会降低。
  • 这种方法适用于数据命中不高、 数据相对固定、 实时性低(通常是数据集较大) 的应用场景, 代码维护较为复杂, 但是缓存空间占用很少。
<dependency>
   <groupId>org.redisson</groupId>
   <artifactId>redisson</artifactId>
   <version>3.6.5</version>
</dependency>
package com.redisson;

import org.redisson.Redisson;
import org.redisson.api.RBloomFilter;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;

public class RedissonBloomFilter {

    public static void main(String[] args) {
        Config config = new Config();
        config.useSingleServer().setAddress("redis://localhost:6379");
        //构造Redisson
        RedissonClient redisson = Redisson.create(config);

        RBloomFilter<String> bloomFilter = redisson.getBloomFilter("nameList");
        //初始化布隆过滤器:预计元素为100000000L,误差率为3%,根据这两个参数会计算出底层的bit数组大小
        bloomFilter.tryInit(100000000L,0.03);
        //将zhuge插入到布隆过滤器中
        bloomFilter.add("zhuge");

        //判断下面号码是否在布隆过滤器中
        System.out.println(bloomFilter.contains("guojia"));//false
        System.out.println(bloomFilter.contains("baiqi"));//false
        System.out.println(bloomFilter.contains("zhuge"));//true
    }
}

使用布隆过滤器需要把所有数据提前放入布隆过滤器,并且在增加数据时也要往布隆过滤器里放,布隆过滤器缓存过滤伪代码:

//初始化布隆过滤器
RBloomFilter<String> bloomFilter = redisson.getBloomFilter("nameList");
//初始化布隆过滤器:预计元素为100000000L,误差率为3%
bloomFilter.tryInit(100000000L,0.03);
        
//把所有数据存入布隆过滤器
void init(){
    for (String key: keys) {
        bloomFilter.put(key);
    }
}

String get(String key) {
    // 从布隆过滤器这一级缓存判断下key是否存在
    Boolean exist = bloomFilter.contains(key);
    if(!exist){
        return "";
    }
    // 从缓存中获取数据
    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;
    }
}

布隆过滤器不能删除数据,如果要删除得重新初始化数据