看完这篇Redis缓存三大问题,保你面试能造火箭,工做能拧螺丝。

前言

平常的开发中,无不都是使用数据库来进行数据的存储,因为通常的系统任务中一般不会存在高并发的状况,因此这样看起来并无什么问题。web

一旦涉及大数据量的需求,如一些商品抢购的情景,或者主页访问量瞬间较大的时候,单一使用数据库来保存数据的系统会由于面向磁盘磁盘读/写速度问题有严重的性能弊端,详细的磁盘读写原理请参考这一片[]。面试

在这一瞬间成千上万的请求到来,须要系统在极短的时间内完成成千上万次的读/写操做,这个时候每每不是数据库可以承受的,极其容易形成数据库系统瘫痪,最终致使服务宕机的严重生产问题。redis

为了克服上述的问题,项目一般会引入NoSQL技术,这是一种基于内存数据库,而且提供必定的持久化功能。数据库

Redis技术就是NoSQL技术中的一种。Redis缓存的使用,极大的提高了应用程序的性能和效率,特别是数据查询方面。api

但同时,它也带来了一些问题。其中,最要害的问题,就是数据的一致性问题,从严格意义上讲,这个问题无解。若是对数据的一致性要求很高,那么就不能使用缓存数组

另外的一些典型问题就是,缓存穿透缓存击穿缓存雪崩。本篇文章从实际代码操做,来提出解决这三个缓存问题的方案,毕竟Redis的缓存问题是实际面试中高频问点,理论和实操要兼得。缓存

缓存穿透

缓存穿透是指查询一条数据库和缓存都没有的一条数据,就会一直查询数据库,对数据库的访问压力就会增大,缓存穿透的解决方案,有如下两种:安全

  1. 缓存空对象:代码维护较简单,可是效果很差。
  2. 布隆过滤器:代码维护复杂,效果很好。

缓存空对象

缓存空对象是指当一个请求过来缓存中和数据库中都不存在该请求的数据,第一次请求就会跳过缓存进行数据库的访问,而且访问数据库后返回为空,此时也将该空对象进行缓存。数据结构

如果再次进行访问该空对象的时候,就会直接击中缓存,而不是再次数据库,缓存空对象实现的原理图以下: 缓存空对象的实现代码以下:并发

public class UserServiceImpl { @Autowired UserDAO userDAO; @Autowired RedisCache redisCache;
 public User findUser(Integer id) {
      Object object = redisCache.get(Integer.toString(id));
      // 缓存中存在,直接返回
      if(object != null) {
           // 检验该对象是否为缓存空对象,是则直接返回null
           if(object instanceof NullValueResultDO) {
                return null;
           }
           return (User)object;
      } else {  
           // 缓存中不存在,查询数据库
           User user = userDAO.getUser(id);
           // 存入缓存
           if(user != null) {
                redisCache.put(Integer.toString(id),user);
           } else {
                // 将空对象存进缓存
                redisCache.put(Integer.toString(id), new NullValueResultDO());
           }
           return user;
      }
 }          
复制代码
复制代码public User findUser(Integer id) { Object object = redisCache.get(Integer.toString(id)); // 缓存中存在,直接返回 if(object != null) { // 检验该对象是否为缓存空对象,是则直接返回null if(object instanceof NullValueResultDO) { return null; } return (User)object; } else { // 缓存中不存在,查询数据库 User user = userDAO.getUser(id); // 存入缓存 if(user != null) { redisCache.put(Integer.toString(id),user); } else { // 将空对象存进缓存 redisCache.put(Integer.toString(id), new NullValueResultDO()); } return user; } } 复制代码} 复制代码

缓存空对象的实现代码很简单,可是缓存空对象会带来比较大的问题,就是缓存中会存在不少空对象,占用内存的空间,浪费资源,一个解决的办法就是设置空对象的较短的过时时间,代码以下:

// 再缓存的时候,添加多一个该空对象的过时时间60秒
redisCache.put(Integer.toString(id), new NullValueResultDO(),60);
复制代码

布隆过滤器

布隆过滤器是一种基于几率数据结构,主要用来判断某个元素是否在集合内,它具备运行速度快(时间效率),占用内存小的优势(空间效率),可是有必定的误识别率删除困难的问题。它只能告诉你某个元素必定不在集合内或可能在集合内。

在计算机科学中有一种思想:空间换时间,时间换空间。通常二者是不可兼得,而布隆过滤器运行效率和空间大小都兼得,它是怎么作到的呢?

在布隆过滤器中引用了一个误判率的概念,即它可能会把不属于这个集合的元素认为可能属于这个集合,可是不会把属于这个集合的认为不属于这个集合,布隆过滤器的特色以下:

  1. 一个很是大 的二进制位数组 (数组里只有0和1)
  2. 若干个 哈希函数
  3. 空间效率查询效率高
  4. 不存在 漏报(False Negative):某个元素在某个集合中,确定能报出来。
  5. 可能存在 误报(False Positive):某个元素不在某个集合中,可能也被爆出来。
  6. 不提供删除方法,代码维护困难。
  7. 位数组初始化都为0,它不存元素的具体值,当元素通过哈希函数哈希后的值(也就是数组下标)对应的数组位置值改成1。

实际布隆过滤器存储数据和查询数据的原理图以下: 可能不少读者看完上面的特色和原理图,仍是看不懂,别急下面经过图解一步一步的讲解布隆过滤器,总而言之一句简单的话归纳就是布隆过滤器是一个很大二进制位数组,数组里面只存0和1

初始化的布隆过滤器的结构图以下: 以上只是画了布隆过滤器的很小很小的一部分,实际布隆过滤器是很是大的数组(这里的大是指它的长度大,并非指它所占的内存空间大)。

那么一个数据是怎么存进布隆过滤器的呢?

当一个数据进行存入布隆过滤器的时候,会通过如干个哈希函数进行哈希(如果对哈希函数还不懂的请参考这一片[]),获得对应的哈希值做为数组的下标,而后将初始化的位数组对应的下标的值修改成1,结果图以下:

当再次进行存入第二个值的时候,修改后的结果的原理图以下: 因此每次存入一个数据,就会哈希函数的计算,计算的结果就会做为下标,在布隆过滤器中有多少个哈希函数就会计算出多少个下标,布隆过滤器插入的流程以下:

  1. 将要添加的元素给m个哈希函数
  2. 获得对应于位数组上的m个位置
  3. 将这m个位置设为1

那么为何会有误判率呢?

假设在咱们屡次存入值后,在布隆过滤器中存在x、y、z这三个值,布隆过滤器的存储结构图以下所示: 当咱们要查询的时候,好比查询a这个数,实际中a这个数是不存在布隆过滤器中的,通过2哥哈希函数计算后获得a的哈希值分别为2和13,结构原理图以下: 通过查询后,发现2和13位置所存储的值都为1,可是2和13的下标分别是x和z通过计算后的下标位置的修改,该布隆过滤器中实际不存在a,那么布隆过滤器就会误判改值可能存在,由于布隆过滤器不存元素值,因此存在误判率

那么具体布隆过布隆过滤的判断的准确率和一下两个因素有关:

  1. 布隆过滤器大小:越大,误判率就越小,因此说布隆过滤器通常长度都是很是大的。
  2. 哈希函数的个数:哈希函数的个数越多,那么误判率就越小。

那么为何不能删除元素呢?

缘由很简单,由于删除元素后,将对应元素的下标设置为零,可能别的元素的下标也引用改下标,这样别的元素的判断就会收到影响,原理图以下: 当你删除z元素以后,将对应的下标10和13设置为0,这样致使x和y元素的下标受到影响,致使数据的判断不许确,因此直接不提供删除元素的api。

以上说的都是布隆过滤器的原理,只有理解了原理,在实际的运用才能如鱼得水,下面就来实操代码,手写一个简单的布隆过滤器。

对于要手写一个布隆过滤器,首先要明确布隆过滤器的核心:

  • 若干哈希函数
  • 存值得Api
  • 判断值得Api

实现得代码以下:

public class MyBloomFilter { // 布隆过滤器长度 private static final int SIZE = 2 << 10; // 模拟实现不一样的哈希函数 private static final int[] num= new int[] {5, 19, 23, 31,47, 71}; // 初始化位数组 private BitSet bits = new BitSet(SIZE); // 用于存储哈希函数 private MyHash[] function = new MyHash[num.length];
// 初始化哈希函数
public MyBloomFilter() {
    for (int i = 0; i &lt; num.length; i++) {
        function [i] = new MyHash(SIZE, num[i]);
    }
}

// 存值Api 
public void add(String value) {
    // 对存入得值进行哈希计算
    for (MyHash f: function) {
        // 将为数组对应的哈希下标得位置得值改成1
        bits.set(f.hash(value), true);
    }
}

// 判断是否存在该值得Api 
public boolean contains(String value) {
    if (value == null) {
        return false;
    }
    boolean result= true;
    for (MyHash f : func) {
        result= result&amp;&amp; bits.get(f.hash(value));
    }
    return result;
}
复制代码
复制代码// 初始化哈希函数 public MyBloomFilter() { for (int i = 0; i &lt; num.length; i++) { function [i] = new MyHash(SIZE, num[i]); } } // 存值Api public void add(String value) { // 对存入得值进行哈希计算 for (MyHash f: function) { // 将为数组对应的哈希下标得位置得值改成1 bits.set(f.hash(value), true); } } // 判断是否存在该值得Api public boolean contains(String value) { if (value == null) { return false; } boolean result= true; for (MyHash f : func) { result= result&amp;&amp; bits.get(f.hash(value)); } return result; } 复制代码} 复制代码

哈希函数代码以下:

public static class MyHash {
        private int cap;
        private int seed;
        // 初始化数据
        public MyHash(int cap, int seed) {
            this.cap = cap;
            this.seed = seed;
        }
        // 哈希函数
        public int hash(String value) {
            int result = 0;
            int len = value.length();
            for (int i = 0; i < len; i++) {
                result = seed * result + value.charAt(i);
            }
            return (cap - 1) & result;
        }
    }
复制代码

布隆过滤器测试代码以下:

public static void test {
        String value = "4243212355312";
        MyBloomFilter filter = new MyBloomFilter();
        System.out.println(filter.contains(value));
        filter.add(value);
        System.out.println(filter.contains(value));
    }
复制代码

以上就是手写了一个很是简单得布隆过滤器,可是实际项目中可能事由牛人或者大公司已经帮你写好的,如谷歌的Google Guava,只须要在项目中引入一下依赖:

<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>27.0.1-jre</version>
</dependency>
复制代码

实际项目中具体的操做代码以下:

public static void MyBloomFilterSysConfig {
 @Autowired
 OrderMapper orderMapper
 
// 1.建立布隆过滤器  第二个参数为预期数据量10000000,第三个参数为错误率0.00001
BloomFilter&lt;CharSequence&gt; bloomFilter =  BloomFilter.create(Funnels.stringFunnel(Charset.forName("utf-8")),10000000, 0.00001);
// 2.获取全部的订单,并将订单的id放进布隆过滤器里面
List&lt;Order&gt; orderList = orderMapper.findAll()
for (Order order;orderList ) {
    Long id = order.getId();
    bloomFilter.put("" + id);
}
复制代码
复制代码@Autowired OrderMapper orderMapper // 1.建立布隆过滤器 第二个参数为预期数据量10000000,第三个参数为错误率0.00001 BloomFilter&lt;CharSequence&gt; bloomFilter = BloomFilter.create(Funnels.stringFunnel(Charset.forName("utf-8")),10000000, 0.00001); // 2.获取全部的订单,并将订单的id放进布隆过滤器里面 List&lt;Order&gt; orderList = orderMapper.findAll() for (Order order;orderList ) { Long id = order.getId(); bloomFilter.put("" + id); } 复制代码} 复制代码

在实际项目中会启动一个系统任务或者定时任务,来初始化布隆过滤器,将热点查询数据的id放进布隆过滤器里面,当用户再次请求的时候,使用布隆过滤器进行判断,改订单的id是否在布隆过滤器中存在,不存在直接返回null,具体操做代码:

// 判断订单id是否在布隆过滤器中存在
bloomFilter.mightContain("" + id)
复制代码

布隆过滤器的缺点就是要维持容器中的数据,由于订单数据确定是频繁变化的,实时的要更新布隆过滤器中的数据为最新。

缓存击穿

缓存击穿是指一个key很是热点,在不停的扛着大并发,大并发集中对这一个点进行访问,当这个key在失效的瞬间,持续的大并发就穿破缓存,直接请求数据库,瞬间对数据库的访问压力增大。

缓存击穿这里强调的是并发,形成缓存击穿的缘由有如下两个:

  1. 该数据没有人查询过 ,第一次就大并发的访问。(冷门数据)
  2. 添加到了缓存,reids有设置数据失效的时间 ,这条数据恰好失效,大并发访问(热点数据)

对于缓存击穿的解决方案就是加锁,具体实现的原理图以下: 当用户出现大并发访问的时候,在查询缓存的时候和查询数据库的过程加锁,只能第一个进来的请求进行执行,当第一个请求把该数据放进缓存中,接下来的访问就会直接集中缓存,防止了缓存击穿

业界比价广泛的一种作法,即根据key获取value值为空时,锁上,从数据库中load数据后再释放锁。若其它线程获取锁失败,则等待一段时间后重试。这里要注意,分布式环境中要使用分布式锁单机的话用普通的锁(synchronizedLock)就够了。

下面以一个获取商品库存的案例进行代码的演示,单机版的锁实现具体实现的代码以下:

// 获取库存数量 public String getProduceNum(String key) { try { synchronized (this) { //加锁 // 缓存中取数据,并存入缓存中 int num= Integer.parseInt(redisTemplate.opsForValue().get(key));
        if (num&gt; 0) {
            //没查一次库存-1
            redisTemplate.opsForValue().set(key, (num- 1) + "");
            System.out.println("剩余的库存为num:" + (num- 1));
        } else {
            System.out.println("库存为0");
        }
    }
} catch (NumberFormatException e) {
    e.printStackTrace();
} finally {
}
return "OK";
复制代码
复制代码if (num&gt; 0) { //没查一次库存-1 redisTemplate.opsForValue().set(key, (num- 1) + ""); System.out.println("剩余的库存为num:" + (num- 1)); } else { System.out.println("库存为0"); } } } catch (NumberFormatException e) { e.printStackTrace(); } finally { } return "OK"; 复制代码} 复制代码

分布式的锁实现具体实现的代码以下:

public String getProduceNum(String key) {
    // 获取分布式锁
    RLock lock = redissonClient.getLock(key);
    try {
        // 获取库存数
        int num= Integer.parseInt(redisTemplate.opsForValue().get(key));  
        // 上锁           
        lock.lock();
        if (num> 0) {
            //减小库存,并存入缓存中
            redisTemplate.opsForValue().set(key, (num - 1) + "");
            System.out.println("剩余库存为num:" + (num- 1));
        } else {
            System.out.println("库存已经为0");
        }
    } catch (NumberFormatException e) {
        e.printStackTrace();
    } finally {
        //解锁
        lock.unlock();
    }
    return "OK";
}
复制代码

缓存雪崩

缓存雪崩 是指在某一个时间段,缓存集中过时失效。此刻无数的请求直接绕开缓存,直接请求数据库。

形成缓存雪崩的缘由,有如下两种:

  1. reids宕机
  2. 大部分数据失效

好比天猫双11,立刻就要到双11零点,很快就会迎来一波抢购,这波商品在23点集中的放入了缓存,假设缓存一个小时,那么到了凌晨24点的时候,这批商品的缓存就都过时了。

而对这批商品的访问查询,都落到了数据库上,对于数据库而言,就会产生周期性的压力波峰,对数据库形成压力,甚至压垮数据库。

缓存雪崩的原理图以下,当正常的状况下,key没有大量失效的用户访问原理图以下: 当某一时间点,key大量失效,形成的缓存雪崩的原理图以下: 对于缓存雪崩的解决方案有如下两种:

  1. 搭建高可用的集群,防止单机的redis宕机。
  2. 设置不一样的过时时间,防止赞成之间内大量的key失效。

针对业务系统,永远都是具体状况具体分析,没有最好,只有最合适。于缓存其它问题,缓存满了和数据丢失等问题,咱们后面继续深刻的学习。最后也提一下三个词LRU、RDB、AOF,一般咱们采用LRU策略处理溢出,Redis的RDB和AOF持久化策略来保证必定状况下的数据安全。

相关文章
相关标签/搜索