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

2,357 阅读13分钟

前言

其实缓存雪崩、缓存击穿、缓存穿透、RedisMySQL 数据一致性,网上已经很多人都写过相关文章,不过博主还是决定再写一篇,想把自己的理解写出来,希望可以帮助到一些人。如果你是开发新手,对这些名词一点都不了解也千万不要害怕,这些名词看起来高大上,其实非常简单。就是开发中使用 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 这是两步操作,可能会存在多个线程这两步操作顺序混乱,从而导致 MySQLRedis 数据不一致,从而导致业务问题,下面让我们来具体分析会有什么问题

双写模式

所谓的双写模式也就是我们上面的代码示例,先修改 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 不是原子操作,那我们就加锁让它成为原子操作,注意这里不能用本地锁(synchronizedLock)了,得用分布式锁。既然是读和写相关,最适合的就是读写锁,写锁没释放就不能读,写操作需要排队,这样就不会出现读到的数据不一致问题

    /**
     * 双写模式 - 加读写锁
     */
    @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 ,此时 BMySQL 的修改还未提交,那么 C 读到的就是 A 写入到 MySQL 的值。然后 B 修改的 MySQL 提交了事务,并且网络正常删除了 RedisC 由于网络问题,更新 Redis 比较慢,在 B 删除 Redis 之后,那么 C 更新到 Redis 的值其实是 A 写入到 MySQL 的,而此时最新的 MySQL 值应该是 B 修改的结果,那么就产生了脏数据问题

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

使用中间件 canal 订阅 binary log

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

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

image.png

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

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

扯淡的缓存数据一致性

没错,上一段介绍了很多花里胡哨的数据一致性方案,其实说难听点都是扯淡的。

  • 前提条件不成立,网络抖动属于小概率事件
  • 想要兼顾性能抛弃锁,又要保证 数据强一致性 的方案基本不可能

首先网络抖动导致数据不一致本身就是小概率事件,其次,不使用分布式锁的方案,想要实现数据强一致性基本就是扯淡。另外熟悉计算机内功知识的同学应该了解 Windows 操作系统中的 MESI 缓存一致性协议。连操作系统花费一大堆功夫整出来的 MESI 都只能追求数据的最终一致性,现在后端的面试题整天说什么缓存数据一致性,我真的想吐槽是不是吃饱了撑的找事干。有这个精力不如去做好服务扩容,请求熔断降级,不比这个扯淡问题实际的多。

结语

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

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

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