Redis 实战应用篇 — 缓存雪崩、缓存击穿、缓存穿透和数据一致性

2,037 阅读12分钟

前言

其实缓存雪崩、缓存击穿、缓存穿透、Redis 和 MySQL 数据一致性,网上已经很多人都写过相关文章,不过博主还是决定再写一篇,想把自己的理解写出来,希望可以帮助到一些人。如果你是开发新手,对这些名词一点都不了解也千万不要害怕,这些名词看起来高大上,其实非常简单。就是开发中使用 Redis 可能会发生一些没有考虑到的问题,只不过换了个高大上的名字而已。

缓存穿透

在说缓存穿透之前,我们先来看一个业务场景,以我们公司 APP 为例,我们现在有一个查询物流信息的业务

    /**
     * 通过发货单查询物流信息
     */
    public ExpressInfo findByDeliveryOrderId(Long id) {
        //从 Redis 查询物流信息
        Object obj = redisTemplate.opsForValue().get("hosjoy-b2b-express:express-info:" + id);
        if (obj != null) {
            return (ExpressInfo) obj;
        } else {
            ExpressInfo expressInfo = expressMapper.selectByDeliveryOrderId(id);//数据库查询
            if (expressInfo != null) {
                redisTemplate.opsForValue().set("hosjoy-b2b-express:express-info:" + id, expressInfo, expressInfo,Duration.ofHours(2l));
                return expressInfo;
            } else {
                throw new ClientException("发货单:{} 的物流信息不存在", id);
            }
        }
    }

你看这段伪代码,如果缓存中有就直接返回,如果没有我就去数据库查询,然后更新到 Redis 并设置过期时间再返回。乍一看挺好的,但其实会有一个潜在的问题,如果我传进来的 id 是负数呢?那是不是一直缓存查询不到,数据库也查询不到?不过上一篇我们已经说过了hibernate-validator 参数校验,@Positive 注解其实可以很轻易的避免负数参数。那我故意传一个随机生成的 long 类型的 id,这样你参数校验就防止不住了吧,而且我随机生成的,你数据库里面肯定没有。那么按照上面的代码,每次都会查不到缓存走数据库,而且数据库也查不到。

我们用 Redis 是把它作为缓存中间件,将请求走到 Redis ,当 Redis 数据过期后再访问 MySQL 去更新 Redis

image.png

上面的场景每次都会访问 MySQL ,这很明显就违背了 Redis 作为缓存中间件的初衷。查询某一个不存在的资源,导致每次请求都访问 MySQL,缓存失去了作用。这就是缓存穿透

如何避免这个问题呢?其实我个人认为缓存穿透解决的意义并不是很大,相较于缓存击穿和缓存雪崩来说,毕竟是个小概率的事情。也没有哪个憨憨一定要花费精力恶搞我们的服务器吧?如果一定要解决的话,办法也不是没有。最常用的就是参数校验做好,起码能防止负数id这种明显不合法的参数。其次还记得我们在 Redis 基础面试篇 提到过一个高级数据结构 — 布隆过滤器。我们可以将资源 id 全部放进布隆过滤器里面,在代码中先判断,由于布隆过滤器能百分之百校验出资源不存在,所以就可以直接返回,这里使用 Redisson 作为布隆过滤器的具体实现,在业务代码前面加上校验

public ExpressInfo findByDeliveryOrderId(Long id) {
        RBloomFilter<Long> bloomFilter = redissonClient.getBloomFilter("hosjoy-b2b-product:bloom-filter:express-info");
        if(!bloomFilter.contains(id)){
            throw new ClientException("发货单:{} 的物流信息不存在", id);
        }
}

缓存击穿

同样我们先不管什么叫做缓存击穿,先来看一段伪代码,以我们公司 APP 为例,现在有一个查询商品分类的业务

    /**
     * 查询商品分类信息
     */
    @SuppressWarnings("unchecked")
    public List<ProductCategory> findProductCategory() {
        Object obj = redisTemplate.opsForValue().get("hosjoy-b2b-product:product-category");
        if (obj == null) {
            //查数据库
            List<ProductCategory> categoryList = productCategoryMapper.selectProductCategory();
            //写入到 Redis,过期时间 2 小时
            redisTemplate.opsForValue().set("hosjoy-b2b-product:product-category", categoryList, Duration.ofHours(2L));
            return categoryList;
        } else {
            return (List<ProductCategory>) obj;
        }
    }

同样,我先从 Redis 查商品,如果没有,再去数据库查询,并且更新到 Redis 然后返回。但是我们要知道商品分类这样的数据时高频访问的,否则也不用存 Redis 中了,只要用户浏览商品就一定会访问。上述代码看似没什么问题,实则问题非常大,本来一切正常的,但是由于我们刚刚设置了商品分类在 Redis 中的过期时间是两小时,两小时过后过期的这一瞬间,用户量大的情况下,几万甚至几十万请求(对于淘宝京东这样的量级其实已经很小了)在 Redis 中都没查到数据,然后都去访问数据库了,那毋庸置疑,数据库根本顶不住这么大的并发请求压力,瞬间就被击垮了。这种由于 Redis 中数据过期导致一瞬间大量请求直接访问 MySQL 导致 MySQL 被打挂的情况就叫做缓存击穿。

这个问题相对于缓存穿透还是比较重要的,一定要解决。解决的方案其实也很简单,刚刚我们已经知道是由于 Redis 中数据过期那一瞬间大量请求直接打到 MySQL,那我在去 MySQL 查询这加个锁就行了。如果 Redis 中没有,先让一个请求去数据库查询,把值更新到 Redis,其他请求再从 Redis 中查询即可。

    /**
     * 查询商品分类信息
     */
    @SuppressWarnings("unchecked")
    public List<ProductCategory> findProductCategory() {
        Object obj = redisTemplate.opsForValue().get("hosjoy-b2b-product:product-category");
        if (obj == null) {
            synchronized (this){
                //进入 synchronized 一定要先再查询一次 Redis,防止上一个抢到锁的线程已经更新过了
                obj = redisTemplate.opsForValue().get("hosjoy-b2b-product:product-category");
                if(obj != null){
                    return (List<ProductCategory>) obj;
                }
                List<ProductCategory> categoryList = productCategoryMapper.selectProductCategory();
                redisTemplate.opsForValue().set("hosjoy-b2b-product:product-category", categoryList, Duration.ofHours(2L));
            }
            return categoryList;
        } else {
            return (List<ProductCategory>) obj;
        }
    }

这样同一时间只有一个请求能访问 MySQL ,当它查询到数据更新到 Redis 之后释放锁,此时其他并发线程进入 synchronized 代码块首先查 Redis ,发现 Redis 已经有了,就不会再访问 MySQL

这里有几个细节点:

  • 由于 Spring 容器默认是单例的,这里我们在当前 Service 类,使用 synchronized(this) 对于当前应用是安全的
  • 进入 synchronized 代码块必须先查一次 Redis ,防止上一个抢到锁的线程已经更新过 Redis 了
  • 这里的场景没有必要一定使用分布式锁

你可能会奇怪,为什么这里不用分布式锁,毕竟我们生产环境的商品服务实例肯定是集群,使用 synchronized(this) 只能保证当前应用实例同时只有一个请求执行这段代码,不能保证集群中其他实例。值得注意的是我们这里并不是要对数据进行安全修改,我们仅仅是想要防止大量请求访问到 MySQL ,假设现在商品服务是 10 个实例组成的集群,那么这里的代码最坏的情况也就是 10 个请求同时访问 MySQL 查询,问题不大~~ 当然使用分布式锁肯定也没问题

缓存雪崩

缓存雪崩和缓存击穿比较类似,由于电商系统中使用 Redis 存储的数据特别多,而一般我们又会给缓存数据设置过期时间。有这样一种场景,当大量的 key 在同一时刻失效,那么大量请求过来查询 Redis 中没有,就会去访问 MySQL ,可能会造成 MySQL 压力过大挂掉。这种由于 Redis 中大量 key 失效,导致大量请求访问数据库,从而导致数据库压力过大挂掉的情况叫做缓存雪崩

缓存雪崩的解决方案比较简单,我们只要让 Redis 中的 key 尽量不要在同一时间失效即可,存入 Redis 的时候给过期时间加上随机数

    Duration expire = Duration.ofHours(2L).plus(Duration.ofSeconds((int) (Math.random() * 100)));
    redisTemplate.opsForValue().set("key","value", expire);

Redis 和 MySQL 数据一致性

这个问题也是面试中经常会问的问题,首先什么是数据一致性?

我们知道 Redis 作为缓存中间件来使用,当我们 MySQL 中数据被更新后再写到 Redis 这是两步操作,可能会存在多个线程这两步操作顺序混乱,从而导致 MySQL 和 Redis 数据不一致,从而导致业务问题,下面让我们来具体分析会有什么问题

双写模式

所谓的双写模式也就是我们上面的代码示例,先修改 MySQL ,然后更新 Redis。看下面这段伪代码

    /**
     * 双写模式
     * */
    @Transactional
    public void update(Object obj){
        xxxMapper.update(obj);//更新数据库
        redisTemplate.opsForValue().set("key",obj);//更新缓存
    }

其实大多数业务规模一般的公司这种方式是够用的,只会在并发请求较大的情况下才会出现问题。它可能会有这样一种场景,线程 A 更新完了数据库,线程 B 第二次更新完了数据库,但是 线程 A 对于 Redis 服务网络抖动了一下,导致 线程 A 的修改结果它还没有写到 Redis,线程 B 的修改结果先写入到 Redis 了,然后线程 A 在线程 B 后面更新 Redis。这样一来就会出现脏数据问题,因为线程 B 是最新修改的数据库,但是它在 Redis 中的值被线程 A 修改数据库的值给覆盖了,图示:

image.png

总结一句话就先修改 MySQL 的线程却最后执行了 Redis 的更新。这种问题我们都知道,加锁就可以了,既然写 MySQL 和写 Redis 不是原子操作,那我们就加锁让它成为原子操作,注意这里不能用本地锁(synchronized 和 lock)了,得用分布式锁。既然是读和写相关,最适合的就是读写锁,写锁没释放就不能读,写操作需要排队,这样就不会出现读到的数据不一致问题

    /**
     * 双写模式 - 加读写锁
     */
    @Transactional
    public void update(Object obj) {
        RReadWriteLock lock = redissonClient.getReadWriteLock("lock");
        RLock writeLock = lock.writeLock();
        try {
            if (writeLock.tryLock(waitTime, leaseTime, TimeUnit.SECONDS)) {
                xxxMapper.update(obj);//更新数据库
                redisTemplate.opsForValue().set("key", obj);//更新缓存
            }
        } finally {
            writeLock.unlock();
        }
    }

失效模式

所谓的失效模式就是我们更新完数据库之后,把 Redis 中的数据给删掉,由于我们业务代码从 Redis 查不到的时候会去查询 MySQL 然后更新到 Redis,所以下次有用户请求过来的时候,就自动被更新为最新值

    /**
     * 失效模式
     */
    @Transactional
    public void update(Object obj) {
        xxxMapper.update(obj);//更新数据库
        redisTemplate.delete("key");//删除缓存
    }

其实上面这段代码也会存在一些问题,首先由于我们要删除 Redis ,所以要在业务查询时防止缓存击穿的问题,缓存击穿上面已经说过了。其次,它也会产生脏数据问题。如下图:

image.png

这种场景是线程 A 正常的执行了写 MySQL 和删 Redis ,然后线程 B 在它之后进行写 MySQL,与此同时另一个业务线程 C 在读这份数据,线程 C 发现 Redis 中没有数据(因为已经被 A 删掉了),它去读 MySQL ,此时 B 对 MySQL 的修改还未提交,那么 C 读到的就是 A 写入到 MySQL 的值。然后 B 修改的 MySQL 提交了事务,并且网络正常删除了 Redis , C 由于网络问题,更新 Redis 比较慢,在 B 删除 Redis 之后,那么 C 更新到 Redis 的值其实是 A 写入到 MySQL 的,而此时最新的 MySQL 值应该是 B 修改的结果,那么就产生了脏数据问题

如果文字难以理解就看图,这个情况和上面的双写模式类似,都是产生数据不一致的情况。对于这非原子的两个操作之间夹杂其他操作产生的一致问题,我们都可以加锁来解决,同样我们只要在这里加读写锁,保证更新数据库和删除缓存是一个原子操作即可,写锁不释放无法读,就能避免这个场景。加锁代码和双写模式几乎一样,这里就不贴了。

使用中间件 canal 订阅 binary log

可以看到上面两种模式都需要加锁来避免数据不一致的情况。要知道加锁是会影响系统吞吐量的,如果我们不想加锁并且要实时保证 MySQL 和 Redis 数据一致。有一个很好的方案是使用中间件 canal 来订阅 MySQL 的 binary log 日志解决这个问题。

canal 是阿里开源的一个中间件,它的原理是把自己作为 MySQL 的从节点,向 MySQL 发送 dump 协议订阅 MySQL 的 binary log 日志,当 MySQL 中有任何数据变化时,例如数据的增删改、创建索引、新建表等,canal 都会监听到。这样一来问题就变得简单了,我们只需要写一个客户端程序来接受 canal server 发过来的事件类型,发一个消息到 MQ ,再用一个应用去接受 MQ 的消息,根据 binlog 数据的变化类型,同步数据到 Redis 就可以了,这样就没有上面两种模式涉及到的数据不一致问题了。如下图:

image.png

值得注意的是,这里引入 MQ (为什么使用消息队列?) 来实现异步、削峰、解耦,所以我们得保证一下 MQ 的顺序消费,否则 Redis 数据还是会有不一致性。

这种方案相对比较好的是一旦做成之后,我们甚至不需要在业务代码里面去更新 Redis,而且这种方案也是相对来说可以比较完美的解决 MySQL 和 Redis 数据一致性。缺点是我们额外引入了中间件得去维护,而且还是一下引入了两个,为了防止单节点高可用问题,还得去做集群......成本不小啊

结语

对于 MySQL 和 Redis 数据一致性,网上也有人提出一些其他方案比如延时双删,感兴趣的可以去研究下,其实解决不了问题……也有说把缓存失效时间变短,这个其实治标不治本。其实我最欣赏的是这种方案:和业务沟通好,让他们接受短时间内的数据不一致性,等 Redis 数据过期之后自然就变为最新的了,这样连加锁都不需要了。 这种才是彰显开发在公司地位的方案......

上面是开玩笑的,其实我觉得加锁解决数据一致性就挺好的

看在我端午都不休息的份上,如果这篇文章对你有帮助,记得点赞加关注。你的支持就是我继续创作的动力!