[toc]

一、问题的来源

传统关系型数据库由于是面向磁盘存储的,因此在大量请求突然到来的情况下可能会导致数据库瘫痪,甚至是服务器宕机等严重的生产环境问题

为了克服面向磁盘存储的问题,通常会将关系型数据库与 NoSQL 结合使用

Redis 引入作为缓存可以解决这类问题,但是同时又会带来缓存穿透,缓存击穿,缓存雪崩等问题

缓存是一层用户与数据库之间的中间层,主要用于存储经常被查询的数据,以提高查询效率
image.png

但是这样的缓存方式同样会存在问题

二、缓存穿透

1. 什么是缓存穿透?

一个 key 对应的数据在数据库和缓存中都不存在,因此每次针对此 key 的请求从缓存中都无法获取,更无法从数据库中进行获取,如果有大量的该请求则可能使得数据库瘫痪

2. 缓存穿透的解决方案

由于缓存是不命中时被动写入的一种机制,如果从数据库中查不到数据就不写入缓存的话就会导致缓存穿透这个问题,为了解决这个问题,常用的解决方式有

  1. 空结果缓存:缓存从数据库中查询出来的空结果,并为其设置短时的 TTL
  2. 布隆过滤器:将所有可能存在的数据 hash 到一个足够大的 bitmap 中,而一定不存在的数据就会被这个 bitmap 拦截,从而避免了对数据库的压力
    一个简单的布隆过滤器实现如下:
public class MyBloomFilter {

    /**
     * 位数组的大小
     */
    private static final int DEFAULT_SIZE = 2 << 24;
    /**
     * 通过这个数组可以创建 6 个不同的哈希函数
     */
    private static final int[] SEEDS = new int[]{3, 13, 46, 71, 91, 134};

    /**
     * 位数组。数组中的元素只能是 0 或者 1
     */
    private BitSet bits = new BitSet(DEFAULT_SIZE);

    /**
     * 存放包含 hash 函数的类的数组
     */
    private SimpleHash[] func = new SimpleHash[SEEDS.length];

    /**
     * 初始化多个包含 hash 函数的类的数组,每个类中的 hash 函数都不一样
     */
    public MyBloomFilter() {
        // 初始化多个不同的 Hash 函数
        for (int i = 0; i < SEEDS.length; i++) {
            func[i] = new SimpleHash(DEFAULT_SIZE, SEEDS[i]);
        }
    }

    /**
     * 添加元素到位数组
     */
    public void add(Object value) {
        for (SimpleHash f : func) {
            bits.set(f.hash(value), true);
        }
    }

    /**
     * 判断指定元素是否存在于位数组
     */
    public boolean contains(Object value) {
        boolean ret = true;
        for (SimpleHash f : func) {
            ret = ret && bits.get(f.hash(value));
        }
        return ret;
    }

    /**
     * 静态内部类。用于 hash 操作!
     */
    public static class SimpleHash {

        private int cap;
        private int seed;

        public SimpleHash(int cap, int seed) {
            this.cap = cap;
            this.seed = seed;
        }

        /**
         * 计算 hash 值
         */
        public int hash(Object value) {
            int h;
            return (value == null) ? 0 : Math.abs(seed * (cap - 1) & ((h = value.hashCode()) ^ (h >>> 16)));
        }
    }

    public static void main(String[] args){

        Integer value1 = 13423;
        Integer value2 = 22131;
        MyBloomFilter filter = new MyBloomFilter();
        System.out.println(filter.contains(value1));
        System.out.println(filter.contains(value2));
        filter.add(value1);
        filter.add(value2);
        System.out.println(filter.contains(value1));
        System.out.println(filter.contains(value2));
    }
}

三、缓存击穿

1. 什么是缓存击穿?

当一个 key 对应的数据在缓存中不存在而在数据库中存在 ( 一般是因为缓存时间到期 ),而这个 key 又是热点 key 时,因为并发用户的数量很多,同时缓存中因为没数据导致高并发用户只能去数据库读取数据从而引起数据库压力瞬间增大,就像缓存这一层中被凿出来一个洞一样,这种情况称为缓存击穿

2. 缓存击穿的解决方案

缓存击穿的常用解决方案有如下几种

  1. 使用互斥锁:在缓存失效的时候 ( 即判断拿出来的值为空 ),不是立即去查询底层存储数据库,而是先使用缓存数据库的某些 API ( 比如 Redis 的 setnx key expire_time ) 设置一个短期内过期的临时 key,只有设置了并有返回值的请求线程才能去查询底层存储数据库,而其余请求线程则只能等待 ,查询后将 key 及其数据缓存
    Q:这种情况下其余的请求只能等待?那如果发生了死锁或者线程池阻塞怎么办?
  2. 高热点 key,写定时器更新 key 的过期时间,更新时机最好在并发量最小的时候
  3. 为热点 key 设置其缓存永不过期

四、缓存雪崩

1. 什么是缓存雪崩?

缓存雪崩针对的是一堆缓存中失效 ( 同时 ) 数据库中依然存在的 key,与缓存击穿的区别在于针对的数量级不同

缓存雪崩对底层存储数据库的影响非常大,因为大量 key 的同时失效也会导致大量的请求,从而可能造成底层存储数据库瘫痪

更致命的缓存雪崩是因为某个服务器节点宕机或者是断网而无法分担读写压力而导致的

2. 缓存雪崩的解决方案

  1. 对缓存过期时间使用随机值
  2. 异地多活
  3. 网上还有一种加锁队列的处理方式,通过对请求进行排队来减轻服务器的压力,但是这样同时会造成系统的吞吐量下降,假设有一个请求被阻塞了,其余的所有线程就都会死锁

Q.E.D.


只有创造,才是真正的享受,只有拚搏,才是充实的生活。