Redis精讲

Redis是一个内存中的数据结构存储系统,用作数据库、缓存和消息中间件。它支持多种数据结构如字符串、哈希、列表、集合和有序集合。Redis通过RDB和AOF两种方式进行持久化,确保数据安全。此外,Redis提供了主从复制和哨兵模式来实现高可用性,以及红锁和分布式锁来解决并发问题。Redis还支持通过List数据结构实现简单的消息队列功能。
摘要由CSDN通过智能技术生成

当今互联网应用程序中,高效地处理数据是至关重要的。Redis是一种快速、开源的NoSQL数据库,可以轻松地处理各种类型的数据。在本篇技术博客中,我们将深入了解Redis的工作原理、常见用例以及如何使用Redis来提高应用程序的性能和可伸缩性。

目录

Redis能做什么

1.分布式缓存:挡在MySQL身前的带刀护卫

1.从硬件上比MySQL更强:

2.查询比MySQL快:

3.关系的处理:

2.内存存储和持久化(RDB+AOF):

RDB

RDB的执行原理: 

AOF

RDB与AOF对比 

3.高可用架构搭配:

4.分布式锁:

使用场景

实现原理

​编辑

redisson实现的分布式锁

redisson分布式锁的代码实现

redisson分布式锁的可重入性

redisson实现的分布式锁能够保证主从数据的一致

5.消息队列:

Redis的内部机制

1.数据过期策略

惰性删除:

定期删除

2.数据淘汰策略

数据淘汰策略-使用建议:

Redis的集群方案

1.主从复制

主从数据同步原理

主从全量同步:

主从增量同步:

2.哨兵模式(保证高并发高可用)

服务状态监控

​编辑

哨兵选主规则

redis集群(哨兵模式)脑裂

3.分片集群结构

分片集群的数据读写

Redis网络模型 

Redis是单线程的,为什么这么快?

I/O多路复用模型

用户空间和内核空间

阻塞IO

非阻塞 IO 

IO多路复用

redis的网络模型

Redis常见问题

缓存穿透

解决方案:

布隆过滤器

缓存击穿

解决方案:

1.添加互斥锁:

2.逻辑过期:

缓存雪崩

解决方案

双写一致性

延时双删

异步通知:保证数据的最终一致性


Redis 是一个开源(BSD许可)的,内存中的数据结构存储系统,它可以用作数据库、缓存和消息中间件。 它支持多种类型的数据结构,如 字符串(strings), 散列(hashes), 列表(lists), 集合(sets), 有序集合(sorted sets) 与范围查询, bitmaps, hyperloglogs 和 地理空间(geospatial) 索引半径查询。 Redis 内置了 复制(replication)LUA脚本(Lua scripting), LRU驱动事件(LRU eviction)事务(transactions) 和不同级别的 磁盘持久化(persistence), 并通过 Redis哨兵(Sentinel)和自动 分区(Cluster)提供高可用性(high availability)。

                                                                                                           源自CRUG网站

也就是说redis是一种基于内存的K-V键值对的数据库 

Redis支持以下数据结构:

  • 字符串(Strings)
  • 哈希表(Hashes)
  • 列表(Lists)
  • 集合(Sets)
  • 有序集合(Sorted Sets)

redis与memcached一样,都是支持键值对数据结构的开源内存数据库存储系统,redis出现之后,反而在渐渐取代memcached的生存地位,这是因为memcached当中的value没有类型的概念,这是redis和memcached最本质的一个区别。通过这一点我们可以推断redis的如下优点:

json存储:Redis比Memcached更适合存储和处理JSON数据。因为Redis支持多种数据类型,其中包括字符串、哈希表和有序集合等数据结构,这些数据结构都可以用来存储和操作JSON数据。

memcached的value没有类型看似可以将整个json作为value,好像无伤大雅。那么value的意义在哪里?

我们假设一种场景:

如果我的客户端想通过一个K-V的缓存系统取回value当中的某一个元素,假设此时memcahced的value当中存了一个数组,redis当中存了一个list,这时如果要从memcached取回json当中的某一个元素,和直接取回list当中的某一个元素,那么成本就不一样了。

对于memcached来说,需要返回value所有的数据到client端。memcached的服务器如果大量通过这种方式传输,网卡的IO就是最大的瓶颈。而且要求client端有代码去解码我们的json数据。

而在Redis中,可以使用列表(List)数据类型来存储JSON数组,客户端可以直接请求列表中的某一个元素,Redis只会返回所需的元素,这样可以避免返回整个JSON数组给客户端。规避了上述memcached的问题。这是因为redis的server对每种类型都有自己的方法,可以直接对数据进行操作,而不需要进行序列化和反序列化。这使得Redis比Memcached更加灵活和高效。

 在上述场景中,Redis相比Memcached更符合“计算向数据移动”的思想,避免返回整个数组或对象给客户端,减少了网络带宽和IO的占用。

Redis能做什么

如果没有redis,我们的主流数据库大多使用的是MySQL随着我们高性能、高并发的业务诉求,MySQL慢慢的会到达一个瓶颈,此时就需要一个“秘书”来帮助mysql处理业务。

1.分布式缓存:挡在MySQL身前的带刀护卫

要为MySQL分担压力,不是什么阿猫阿狗都能胜任的,首先从业务上,主流的业务按照二八原则的诉求,百分之八十的业务都是查询,百分之二十时写入操作。我们希望能够达到一种平衡,也就是所谓的读写分离。MySQL主要做存储,保证一致性,数据存储在硬盘。

所以我们需要保证,如果MySQL扛不住了,这个带刀侍卫需要帮我分担,所以对同致化的要求非常高。而redis正好满足了这血要求:

1.从硬件上比MySQL更强:

        MySQL的硬件层时硬盘,什么地方比硬盘更快呢?答案就是我们的内存。内存的访问从IO上的人要比硬盘快。

2.查询比MySQL快:

        Redis采用经典的K-V查询。说到K-V模型,我们总是很容易联想到HashMap,redis原理与之一样,可以直接通过get(key)获取数据。

3.关系的处理:

        MySQL属于传统的关系型数据库(RDBMS),会有主键,外键等等等约束,而redis作为一种“NO SQL”数据库,没有这些约束,所以可以说redis是对MySQL的一种加强和补充。

       

在redis出现之前,所有的业务统统去找MySQL,现在MySQL已经不堪重负,于是redis挡在了MySQL面前,大部分的业务操作都是查询操作,这种业务就会不直接去查询MySQL,而是先去查询redis,如果缓存redis当中命中了就直接返回,这样MySQL的访问量就会大大降低。如果在redis当中没有找到再去访问MySQL,如果在MySQL当中找到了,MySQL再把这个数据回写进redis,下一次来的时候,这条数据就可以直接在redis当中查询到了。

此时对于数据库层,我们可以理解为这两个数据库合二为一了。

2.内存存储和持久化(RDB+AOF):

redis支持异步的将内存中的数据写道硬盘上,同时不影响继续服务。redis运行的时候所有数据都是在内存当中操作,但是redis作为服务器,如果服务器断电了,内存中的数据库就会全部丢失,此时我们又要将MySQL当中的数据重新加载进redis,很繁琐。此时就要引入redis的一项功能:持久化。如果redis断电了,在内存当中的数据可以写进硬盘,当redis恢复之后,又可以把硬盘当中的数据重新读回到redis,不用麻烦MySQL。

redis持久化有两种方式:RDB和AOF

RDB

RDB是Redis数据库的一种持久化方式,全称为Redis DataBase Backup file(redis数据备份文件),也叫做redis的数据快照。简单来说就是吧内存中的所有数据都记录到磁盘中,当redis实例故障重启之后,我们就可以从磁盘中读取快照文件,把数据恢复到内存中。

最基本的操作如下:

命令有两个,save和bgsave,都可以手动执行RDB文件的生成,save是用主进程来执行RDB,bgsave使用子进程来进行RDB。如果我们使用主进程的话,加入文件比较大,就会阻塞其他进程的命令,所以一般都是用bgsave来执行RDB操作。

除此之外,redis还提供了更方便的备份方式,无需人工执行,只需要触发条件就可以自动由子进程执行RDB。redis内部有触发RDB的机制,可以在redis.config文件中找到,格式如下:

 

我们可以依照此格式根据自己的需要进行适当的设置。

RDB的执行原理: 

bgsave开始时会fork主进程得到子进程,子进程共享主进程的内存数据。完成fork后读取内存数据并写入RDB文件。这是一个异步过程,对主进程几乎没有阻塞,只有开启新进程的时候会产生纳秒级的阻塞,可以忽略不计。

原理图大致如下:

下面的物理内存可以理解为计算机的内存条,左上方的主进程即为redis的主进程,如果要实现对redis的读写操作肯定是要在内存中去操作,在Linux系统中,所有的进程都无法操作物理内存的,主进程只能操作虚拟内存。不过没关系,操作系统会维护一个虚拟内存和物理内存的映射关系表,这个表被称为页表,主要记录的是虚拟地址和物理地址的映射关系。主进程去操作虚拟内存,虚拟内存经过页表的映射关联到物理内存真正的存储数据的位置,这样就能实现对物理内存的读写操作了。

接下来我们看看主进程与子进程是如何同步数据的:首先我们知道,当我们去执行bgsave的时候,redis会开启一个子进程去执行RDB,在内部会fofork一个子进程(就相当于克隆了一个子进程),子进程并不是对数据进行拷贝,而是仅仅将页表进行拷贝,此时子进程就有了和主进程相同的映射关系,子进程在操作自己的虚拟内存时,最终可以映射到相同的物理内存当中。这样就实现了主进程和子进程内存空间的共享,我们就无需拷贝内存中的数据量,直接实现内存共享,最高速度时非常快的,时纳秒级别的,可以忽略不计。子进程拿到页表之后已经可以读到页表中的数据了,然后可以把独到的数据写入到磁盘中作为新的RDB文件替换旧的RDB文件。到这里redis的异步持久化就基本实现了,然而,子进程在写RDB文件的过程中,主进程是可以接收用户的请求来修改内存中的数据的,如果子进程在读写的时候主进程同时在读写,是不是会发生冲突呢?为了避免这个问题的发生,在fork底层,会采用一种copy-on-write的技术,也就是说:当我们在fork的时候会将共享的内存标记为read-only(只读),任何一个进程只能来读数据,而不能写。如果此时主进程接收到了写的请求,它就必须将数据拷贝一份出来,再去完成它的写操作。在拷贝之后,主进程在进行读操作的时候也会从拷贝份去读了,也表达映射关系也会映射到新的数据。这样就避免了脏写的问题。

AOF

AOF是Redis数据库的一种持久化方式,全称为Append Only File。AOF持久化方式是指将Redis执行的每一条写命令追加到AOF文件中,可以看作是命令日志文件,以便在Redis重启后可以重新执行AOF文件中的写命令进行数据恢复。

注意AOF默认是关闭的,我们需要通过修改redis.conf配置文件来开启它。

AOF的命令记录频率也可以通过redis.conf文件来配:

 

 一般来说我们都采用everysec,最多丢失一秒的数据,风险最低,性能也还好。

因为是记录命令,AOF文件会比RDB文件大得多。而且AOF会记录对同一个key的多次写操作,但只有最后一次写操作才有意义。执行bgwriteaof命令,可以让AOF文件执行重写功能,用最少的命令达到相同效果。

redis也会在触发阈值时自动取重写AOF文件,也可以在redis.conf配置文件中配置:

RDB与AOF对比 

RDB与AOF各有自己的优缺点,如果对数据安全性要求更高,在世纪开发中往往会结合二者来使用

3.高可用架构搭配:

假设有一天redis挂了,我们的架构就有编程类之前MySQL裸奔的形式,所有的请求都会直接打在MySQL上,所有我们尽量要避免这种情况出现。此时我们就需要多搞几台redis挡在MySQL前面,即使某一台redis挂了,依旧不影响我们整个数据库的运行。所以redis就会支持从单机到主从,哨兵,集群的模式,这也是redis作为高可用架构的落地保障。

4.分布式锁:

一提到锁,学过Java的小伙伴都会联想到synchronized,lock等等,我们大家都清楚,redis和JVM是两回事,synchronized只对JVM起效,对于跨服务器的加锁需求,就需要redis来实现了。

在分布式系统中,由于多个节点之间的并发访问,可能会出现资源竞争的情况,为了避免这种情况,通常需要使用分布式锁来控制对共享资源的访问。

使用场景

我们先来看一个抢券的场景:

    /**
     * 抢购优惠券
     * @throws InterruptedException
     */
    public void rushToPurchase() throws InterruptedException {
        //获取优惠劵数量
        Integer num = (Integer) redisTemplate.opsForValue().get("num");
        //判断是否抢完
        if (num == null || num <= 0) {
            throw new RuntimeException("优惠劵已抢完");
        }
        //优惠券数量减一,抢到
        num--;
        // 重新设置优惠券数量
        redisTemplate.opsForValue().set("num",num);
    }

代码的流程图如下:

 

现在我们来分析这个代码会不会出现问题。

我们模拟量两个线程,首先线程1先去查询优惠券,再去查询库存是否充足 ,线程2进行同样的操作,这是正常的情况:

因为线程之间的并发运行,可能还会发生这种情况:

 

假设库存只剩下一个,线程1查询了优惠券,还没来得及判断库存是否充足的时候,线程2也运行,查询优惠券,此时这两个线程查询到的优惠券数量都是1,线程1继续执行,判断库存还有,最终扣减库存,将库存量改为0 .此时线程2由于查询到的优惠劵数量也是1,也会扣减库存,此时库存变为-1,此时就会出现”超卖“现象。为了解决这个问题,我们可以通过加锁来实现。

首先我们尝试使用多线程编程当中的synchronized锁:

    /**
     * 抢购优惠券
     * @throws InterruptedException
     */
    public void rushToPurchase() throws InterruptedException {
        synchronized (this){
            //获取优惠劵数量
            Integer num = (Integer) redisTemplate.opsForValue().get("num");
            //判断是否抢完
            if (num == null || num <= 0) {
                throw new RuntimeException("优惠劵已抢完");
            }
            //优惠券数量减一,抢到
            num--;
            // 重新设置优惠券数量
            redisTemplate.opsForValue().set("num",num);
        }
    }

我们用synchronized将之前的代码包起来,就算是加锁成功了,再来看看目前线程的执行原理:

首先线程1先拿到互斥锁,然后去查询优惠劵,此时线程2获取锁失败,会不断地自旋尝试,当线程1把锁释放之后线程2再进行查询。这样就解决了超卖的问题。如果我们的项目是一个单体项目,并且之启动了一台服务器,这个代码时没有问题的,但是往往我们的项目为了能够值成·更多的并发请求,会把我们的服务进行集群部署。

如图,这里我们布置了三台服务器,当用户请求队时候,我们可以使用Nginx作反向代理,负载均衡到各个请求去访问各个服务,如果我们的服务器这样部署,刚刚的代码就会出现问题。

 左边是8080服务器的进程,右边是8081服务器的进程,左边的线程1先拿到了本地的互斥锁去查询优惠券,右边的线程1也拿到了互斥锁去查询优惠券,两者都能查到,都是1。这是因为线程拿到的锁是sychronized锁,属于本地的锁,目前这个锁是属于JVM的,然而每个服务都有各自的JVM,它只能解决同一个JVM下线程的互斥,解决不了多个JVM下线程的互斥,所以在集群的情况下我们就不能使用本地的锁来解决了。我们需要使用外部的锁来解决,也就是所谓的分布式锁。

如图,我们在线程之外加了一个锁,比如说redis就可以作为分布式锁。它的流程是这样的。流程大致如下:左边的线程1先去尝试加分布式锁,加锁成功的话,在分布式锁中就会添加一条记录,证明这个线程已经持有锁,假如8081的线程1再想获取分布式锁,那么它就会加锁失败,因为里面已经有线程持有锁了,此时右边的线程会进入阻塞状态。而左边持有锁的线程就开始执行自己的业务了,之后再把锁释放,释放之后右边的线程锁才能正常的去获取锁,执行自己的业务逻辑。在线程1持有锁的时间内,其他线程想要执行自己的业务都会持有锁失败。这样就解决了刚刚库存超卖的问题。

如果我们的项目中有秒杀抢购的业务,并且项目是在集群架构的情况下,我们必须要使用分布式锁来解决服务之间的互斥性。

实现原理

redis实现分布式锁主要利用redis的setnx命令,setnx是SET if not exists(如果不存在,则设置值)的简写。

流程图如下: 

Q:redis实现分布式锁如何合理的控制有效时长?

A: 1.根据业务的执行时间进行预估,来设置锁的过期时间(不太靠谱,因为即使我们加长了锁的有效时间,万一出现卡顿,网络抖动等现象,都会导致业务执行时间变慢,所以这个时间是不好控制的。)

        2.给锁续期:我们设置了锁定过期时间之后,再开另外一个线程做监控,来判断业务到底执行力多久,如果业务执行时间比较长,就增加线程持有锁的时长。这个放完听起来很合理,实现起来又太麻烦,不过这个技术市面上已经有实现的了,也就是我们的redisson实现的分布式锁

redisson实现的分布式锁

首先,redisson实现的分布式锁,也是基于redis的setnx命令来实现的,做了很多的增强和优化。我们来看看redisson的执行流程:

线程想要获取锁,假设加锁成功以后,就可以直接操作redis,这里和刚刚redis实现的分布式锁是一样的,不一样的是,加锁成功异或会另开一个线程进行监控(watch dog),不断地去监听持有锁的线程,给线程增加锁的持有时间,也就是续期。每隔release time/3的时间就做一次续期,release time就是锁定过期时间,默认是30秒。手动释放锁之后,需要通知一下对应线程的watch dog就不需要再做监听了,因为key已经被删除了。

如果此时来了一个新的线程,新线程也会尝试去加锁,假如加锁失败,redisson是智利一个while循环来让这个线程不断地尝试加锁,如果在很短的时间内,线程1释放了锁,线程2就可以直接获取锁。如果线程1一直没有释放锁,线程2也不会一直循环下去,redisson在这里设置了一个阈值,只要循环到了一定次数,也会直接结束,线程2就获取锁失败了。一般情况下业务执行都是非常快的,通常几十毫秒执行完成,所以一般也不会让线程2等太久,这种机制的好处就是:在高并发的情况下,可以很大程度的增加分布式锁的使用性能,这就是redisson新增的特性,也叫重试机制。

redisson分布式锁的代码实现

代码实现大致如下:

public void redisLock() throws InterruptedException{
        //获取锁(重入锁),执行锁的名称
        RLock lock =redissonClient.getLock("lock1");
        //尝试获取锁,参数:获取锁的最大等待时间(期间会重试),锁自动施放时间,时间单位
        //boolean isLock=lock.tryLock(10,30, TimeUnit.SECONDS);
        boolean isLock=lock.tryLock(10,TimeUnit.SECONDS);
        //判断是否获取成功
        if(isLock){
            try {
                System.out.println("执行业务");
            }finally {
                //释放锁
                lock.unlock();
            }
        }
    }

这里有一个小细节,如果我们设置了锁的失效时间,就没有watchdog监听了,因为redisson认为我们可以自己控制锁的失效时间,如果我们传的是-1获知没有传值,redisson就会使用watchdog进行监听续期。

这里面所有的加锁,解锁,设置过期时间,给锁续期等操作,都是基于Lua脚本来完成的 ,是可以保证命令执行的原子性的。Lua脚本最大的作用就是能够调用redis命令来保证多条命令执行的原子性。

redisson分布式锁的可重入性

我们先来分析一段代码:

    public void add1(){
        RLock lock=redissonClient.getLock("lock1");
        //执行业务
        add2();
        //释放锁
        lock.unlock();
    }
    public void add2(){
        RLock lock=redissonClient.getLock("lock1");
        //执行业务
        //释放锁
        lock.unlock();
    }

这里有两个方法,add1先创建了一个锁并获取,获取之后调用add2方法,add2方法在也创建了一个锁,名字相同,也尝试去获取这个锁,那么add2能获取成功吗?我们之前讲的redis实现的分布式锁是不可重入的,但是redisson实现的分布式锁是可以重入的,也就是说这段代码是可以成功获取锁的。

add1方法调用add2方法,两个方法在同一个线程,每个线程在执行的时候都有一个唯一的线程id来做标识,redisson就是根据这个线程id的标识来做判断的,在执行代码的时候来判断一下之前获取的锁的线程与当前线程是不是同一个线程,如果是就可以获取锁成功,这就是可重入锁的逻辑,与java当中的ReentrantLock的逻辑是一样的。当业务比较复杂的时候,锁的粒度比较细的时候,我们就可以用到锁的重入,也可以避免多个锁之间产生死锁的问题。

在redisson中实现逻辑是这样的:

首先在存储锁数据的时候都采用hash结构,当前的key可以根据自己的业务进行命名,field记录的是持有锁的线程的唯一的标识,value存储当前线程重入的次数

redisson实现的分布式锁能够保证主从数据的一致

企业开发过程中通常都会搭建redis的主从集群架构,比如图纸有一个主节点和两个从节点,主节点主要负责写数据(增删改),从节点主要负责读操作。当主节点发生了写数据之后就要把数据同步给从节点,来实现主从数据的同步,假如现在又Java应用创建了分布式锁,如果要写数据到主节点中,正常情况下主节点需要把数据同步到从节点。但是假如还没来得及同步数据主节点就宕机了,依据redis提供的哨兵模式,会在两个从节点当中选出一个节点当作主节点当有,假设下面的节点成为了主节点。当有新的线程来了之后就会直接请求新的主节点,也会尝试去获取锁,因为之前的数据没同步过来,所以新的线程也能够加锁成功,这时就出现了两个线程同时持有了一把锁,丧失了锁的互斥性,可能会出现脏数据。

为了解决这个问题,redisson提供了另外一种锁,叫做RedLock(红锁):不能只在一个redis实例上创建锁,应该在多个redis实例上创建锁(n/2+1),避免在一个redis实例上加锁

 

但是在项目中其实很少使用到红锁,则会是因为加了红锁以后实现起来会变得特别复杂,特别是在高并发的情况下性能就会很差,因为需要提供多个独立的redis节点,运维也比较繁琐。

那如果项目中一定要解决主从一致性的问题该如何解决呢?首先我们要知道,这种情况出现的概率是极低的,而且redis集群整体的思想是AP思想(高可用性),我们可以想办法做到最终一致性,如果某些业务一定要求数据的强一致性,建议采用基于CP思想的zookeeper来实现分布式锁,它可以保证数据的强一致性。 

5.消息队列:

Redis可以通过List数据结构来实现消息队列。具体实现方法如下:

  1. 使用LPUSH命令将消息推入队列中。

  2. 使用BRPOP命令从队列中取出消息,如果队列中没有消息,则会阻塞等待。

  3. 处理完消息后,重复执行步骤1和步骤2。

需要注意的是,使用Redis作为消息队列时,需要考虑以下问题:

  1. 消息的顺序问题:Redis的List数据结构是一个有序列表,可以保证消息的顺序。但是,在多个生产者和消费者的情况下,可能无法保证消息的全局顺序。

  2. 消息的持久化问题:Redis默认情况下只会将数据保存在内存中,如果Redis宕机或者重启,可能会导致消息丢失。为了解决这个问题,可以将消息保存到磁盘中,或者使用Redis的AOF(Append-Only File)功能。

  3. 消息的确认问题:在使用Redis作为消息队列时,需要考虑消费者处理消息失败的情况。为了避免消息丢失,可以在消费者处理完消息后,向Redis发送确认消息,从而将已经处理过的消息从队列中删除。

Redis的内部机制

1.数据过期策略

数据过期策略是指在缓存系统中,为了避免缓存数据占用过多内存,需要对缓存数据进行过期处理的一种策略。

在缓存系统中,为了提高数据访问的性能,通常会将一部分数据缓存在内存中。但是,由于内存资源是有限的,如果缓存数据占用过多内存,就会导致系统的性能下降。因此,需要对缓存数据进行过期处理,及时清理不再使用的数据,以释放内存资源。

redis对数据设置数据的有效时间,数据过期以后,就需要将数据从内存中删除掉,可以按照不同的规则进行删除,这种删除的规则就被称之为数据过期策略。

Redis的数据过期策略主要有两种:惰性删除和过期删除

惰性删除:

设置该key过期时间后我们不去管他,当需要该key时,我们再检查其是否过期,如果国企就直接删除,反之返回key。

优点:对CPU友好,只会再使用该key时才会进行过期检查,对于很多用不到的key不用浪费时间进行过期检查。

缺点:对内存不友好,如果一个key已经过期,但是一直没有使用,那么该key就会一致存在内存中,内存永远不会释放。

定期删除

每隔一段时间,我们就对一些key进行检查,删除里面过期的key(从一定数量的数据库中取出一定数量的随机key进行检查,并删除其中过期的key)

定期清理有两种模式:

  • SLOW模式是定时任务,执行频率默认为10hz,每次不超过25ms,可以通过修改配置文件redis.conf的hz选项来调整这个次数。
  • FAST模式执行频率不固定,但两次间隔不低于2ms,每次耗时不超过1ms

优点:可以通过限制删除操作执行的时长和频率来减少删除操作对CPU的影响。另外定期删除也能有效释放过期键占用的内存。

缺点:难以确定删除操作执行的时长和频率。(如果执行太频繁对CPU不友好,如果执行太少又和惰性删除一样了)

redis的过期删除策略:惰性删除+定期删除两种策略进行配合使用

2.数据淘汰策略

数据淘汰策略是指在缓存系统中,为了释放缓存空间,需要对缓存数据进行淘汰的一种策略。

在缓存系统中,由于内存资源是有限的,如果缓存数据占用过多内存,就会导致系统的性能下降。因此,需要对缓存数据进行淘汰处理,及时清理不再使用的数据,以释放内存资源。

当redis内存不够用时,此时再向redis中添加新的key,那么redis就会按照某一种规则将内存中的数据删除掉,这种数据的删除规则被称之为内存的淘汰策略。

redis支持8种不同策略来选择要删除的key:

  • noeviction:不淘汰任何key,但是内存满时不允许写入新数据,默认就是这种策略
  • volatile-ttl:对设置了TTL(过期时间)的key,比较key的剩余TTL值,TTL越小越先被淘汰
  • all-keys random:对全体key,随机进行淘汰。
  • volatile-random:对设置了TTL的key随机进行淘汰
  • allkeys-lru:对全体key,基于LRU算法进行淘汰
  • volatile-lru:对设置了过期时间的key,基于LRU算法进行淘汰
  • allkeys-lfu:对全体key,基于LFU算法进行淘汰
  • volatile-lfu:对设置了过期时间的key,基于LFU算法进行淘汰

LRU算法是Least Recently Used的缩写,即最近最少使用算法。它是一种常用的数据淘汰策略,用于淘汰最近最少使用的数据。

LRU(Least Recently Used)最近最少使用。当前时间减去最后一次访问时间,这个值越大则淘汰优先级越高。

LFU(Least Frequently Used)最少频率使用。会统计每个key的访问频率,值越小淘汰优先级越高。

数据淘汰策略-使用建议:

1.优先使用allkeys-lru策略。充分利用LRU算法优势,吧最近最常访问的数据留在缓存中。如果业务有明显的冷热数据区分,建议使用。

为什么不优先选择LFU呢?这是因为在实际应用中,访问频率并不一定能准确地反映数据的热度,比如某个数据在某个时间段内被访问量很多次,其他时间段内很少被访问,此时如果仅仅按照访问频率来判断数据热度,就可能会出现误判。

2.如果而又无助数据访问频率差别不大,没有明显的冷热数据区分,建议使用all-keys random,随机选择淘汰。

3.如果业务中有置顶的需求,可以使用volatile-lru策略,同时指定数据不设置过期时间,这些数据就一直不被删除,会淘汰其他设置过期时间的数据。

4.如果业务中有短时高频访问的数据,可以使用allkeys-lfu或volatile-lfu策略。

Q:数据库有1000w数据,redis只能缓存20w数据,如何保证redis在的数据都是热点数据?

A:使用allkeys-lru,挑选最近最少使用的数据淘汰,留下来的都是经常访问的热点数据

Q:redis的内存用完了会发生什么?

A:主要看数据淘汰策略是什么,如果是默认配置(noeviction)会直接报错。

Redis的集群方案

1.主从复制

Redis主从复制是一种数据复制方式,它通过将一个Redis实例(即主节点)的数据复制到其他Redis实例(即从节点)上,以实现数据的备份、负载均衡和高可用性等目的。在主从复制中,主节点负责写入数据,从节点则负责读取数据,从而分担了主节点的读写压力。

如果我们使用的redis是单节点,它的并发能力是有上限的,想提高并发能力最常见的做法就是搭建主从集群,实现读写分离。此时就需要有多台redis节点。其中有一个主节点叫做master,其余的从节点叫做slave/replica

主节点负责客户端的写操作(增删改),从节点负责客户端的读操作,因为redis通常情况下都是读多写少,让多个从节点负责读操作,这样就增加了redis的并发能力。假设一台redis的并发读能力是10w,两台就是20w,增强了并发能力。

当主节点进行写操作的时候,需要把数据同步给从节点,来保证主节点与从节点的数据同步,这样才真正实现了读写分离。 

主从数据同步原理

单节点的redis并发能力是有上限的,要进一步提高redis并发能力,就需要搭建主从集群,实现读写分离,一般都是一主多从,主节点负责写数据,从节点负责读数据。

主从全量同步:

这里我们以两个节点为例,左边是主节点,右边是从节点。同步数据首先是从节点执行建立连接的操作,然后向主节点发处同步数据的请求。主节点master接收到以后先判断这个节点是否是第一次同步,如果是,就把master数据的版本信息发送给从节点从节点将数据保存到本地,此时主从节点的版本信息就保持一致了,在同时主节点master会执行一次bgsave,生成RDB文件,将RDB文件发送给从节点,从节点接收到之后就会清空本地的数据,来执行RDB文件,此时主从的数据就同步完成了。但是如果主节点再生成RDB文件的过程中又去接收了客户端的其他请求,那么刚刚发过去的RDB文件就不是最新的数据了吗?不用担心,主节点在记录RDB文件的过程中,他会把新的请求记录到一个日志文件中(repl_backlog),随后再把日志文件发送给从节点,从节点执行完日志文件之后就完成了主从数据的完全同步

Q:master如何判断从节点是不是第一次请求?

A:每个主节点都有唯一的replid(Replication id)从节点会继承主节点的replid,当从节点发起请求数据同步的时候就会把自己的replid发送给主节点,主节点会用这个replid和自己的replid进行比对,如果不一样就证明这个从节点是第一次进行数据同步,就把master自己的replid发送给从节点,从节点就把这个信息记录到本地,此时他们两个的replid就一致了。

Q:如何确保master发送的日志文件正好就是从节点需要的那部分数据?

A:当从节点第二次进行数据同步的时候,master就不会再生成RDB文件里,而是根据日志文件来进行同步,那么到底要从日志文件中读取多少数据呢?此时要引入一个概念:offset

offset(偏移量):可以理解为一个自增的整数值,日志文件记录的数据越多这个值就越大,在主从进行版本信息数据同步的时候也会携带这个offset。假如现在slave记录的offset值为50,主节点的offset是80,也就是说50到80的数据还没有同步完成,所以主节点再次与从节点同步数据的时候就会把50到80这部分数据同步给从节点。

总结:

  1. 从节点请求主节点数据
  2. 主节点判断是不是第一次请求,是第一次就从节点同步版本信息。
  3. 主节点执行bgsave,生成rdb文件后,发送给从节点去执行
  4. 在执行rdb生成执行期间,主节点会以命令的方式记录到缓冲区(一个日志文件)
  5. 把生成之后的命令文件发送给从节点进行同步。
主从增量同步:

增量同步出发的时机主要是slave重启或后期数据变化。

从节点重启之后会给主节点master发送同步数据请求,带来两个值:replid和offset,如何主节点去判断这个replid是否一致,因为不是第一次同步,这个id肯定是一致的,下一步master会从日志文件在获取offset之后的数据,发送给从节点。

总结:

  1. 从节点请求主节点同步数据,主节点判断是不是第一次请求,不是第一次就获取从节点offset值。
  2. 主节点从命令日志中获取offset值之后的数据,发送给从节点进行数据同步。

2.哨兵模式(保证高并发高可用)

主从模式有一个缺点:无法保证redis的高可用,假设主节点宕机,redis就失去了写数据的能力,为了保证redis集群的高可用性,redis提供了哨兵模式,它可以实现redis集群的自动的故障恢复。

 哨兵也是一个redis节点,但是它是由多台redis节点组成的,一般情况下我们至少要部署三台哨兵。

  • 监控:sentinel会不断的检查主从结构中的节点是否按照预期去工作
  • 自动故障恢复:假如主节点宕机了,哨兵会选取一个从节点当作主节点继续工作,这样就能保证主从架构的高可用,如果从节点挂了,哨兵也会将其自动去除。
  • 通知:sentinel充当redis客户端的服务发现来源,当集群发生故障转移时会将最新信息推送给redis的客户端。

我们写java来连接redis时通常使用spring框架封装好的rediTemplate,rediTemplate就相当于一个redis客户端,当一个主节点挂了之后,哨兵选了一个从节点当作主节点,对于一个客户端来说,在写操作的时候就需要找到新的主节点。有了哨兵的话,客户端不需要做任何改动,哨兵会自动挡通知到客户端,客户端会自动挡连接上新的主节点,继续工作。

如果真的有主节点宕机,哨兵将会如何选择主节点呢?

服务状态监控

哨兵sentinel基于心跳机制检测redis服务状态,(默认)每隔1s向集群的每个实例发送一个ping命令,如果实例返回了一个pong就证明这个实例是正常的,但是假如在规定的时间内有一个实例没有响应,此时就认为打哪个签到实例主观下线了。

  • 主观下线:如果某sentinel节点发现某实例未在规定时间响应,则认为该实例主观下线。
  • 客观下线:若超过指定数量(quorum)的sentinel都认为该实例主观下线,则该实例客观下线。quorum值最好超过sentinel实例数量的一半                                                                    

哨兵选主规则

  • 首先判断旧主节点与从节点断开时间长短,如超过指定值就会排除从节点
  • 如何判断从节点的slave-priority值(优先级),值越小优先级越高
  • 如果slave-priority一样,则判断offset值,越大优先级越高(重点)
  • 最后是判断slave节点运行id大小,越小优先级越高(无所谓)

有了哨兵模式,redis的主从集群也有了高可用的支撑,但是哨兵模式也有可能出现其他的问题,比如脑裂问题。

redis集群(哨兵模式)脑裂

图中是一个正常的主从架构,配合哨兵模式。假设由于网络原因,主节点master和哨兵都处于不同的网络分区,哨兵只能监测从节点,不能监测主节点,此时哨兵就会按照选主的规则在从节点中选出一个节点当作主节点,图中右侧上方的节点被选作主节点,但是此时老的主节点并没有挂,只是网络出现问题,此时客户端还可以正常连接,这就出现了两个master,这就是脑裂现象。

目前的客户端现在连接的还是老master,它会持续的向老master中写入数据,新的节点因为网络原因还不能同步数据,假如网络恢复,哨兵会将老master强制降为slave,此时这个slave会从master当中同步数据,会将自己的数据清空,这样之前客户端写进的数据就丢失了。这就是脑裂问题出现之后导致数据丢失的问题。

为了解决这个问题,我们可以修改两个配置:

  • min-replica-to-write 1 表示最少的slave节点为1个,当客户端连接主节点写入数据的时候,这个主节点必须要有至少一个从节点才能接收客户端的数据,否则直接拒绝请求。
  • min-replica-max0lag 5 表示数据负责和同步到延迟不能超过5秒。

Q:如何选择redis集群方案?

A:主从模式一般1主1从+哨兵就可以了,单节点一般不超过10G内存。这样一听好像内存并不多,不过大家注意,redis是作为缓存来使用的,我们不应该把海量数据都存储到redis之中,更应该把一些热点的数据存在redis,一般项目中数据量不会太大,一主一从就够了,10G可以实现大部分项目的高并发和高可用。主节点负责写数据,redis单点写操作的并发是在8W左右,单节点读的并发大概是10W左右,一般项目是达不到这个数据量的。

如果数据量特别大,可以给不同的服务分配当前的主从。比如订单的服务分配一主一从,秒杀的服务也分配一主一从,搭建多套redis集群,也能解决数据存储的问题。

3.分片集群结构

主从和哨兵可以解决高可用,高并发读的问题。但是仍然有两个问题没有解决:海量数据存储问题,高并发写的问题。

使用分片集群可以解决上述问题,分片集群特征:

  • 集群中有多个master,每个master保存不同数据。这样数据存储的上限就取决于master节点的数量,这样从理论上讲就可以面对海量数据的存储和高并发写的问题。
  • 每个master支持多个slave节点,每一个master本身还可以具备主从关系,可以解决高并发读的问题。
  • master与master之间还可以通过心跳来做监测,大家互相监控,就不需要哨兵了,如果多个master认为某一个master下线了,也可以给它标记为客观下线,也可以做主从的切换。
  • 客户端可以访问集群的任意节点,最终都会被转发到正确节点。

分片集群的数据读写

redis分片集群引入了哈希槽的概念,redis集群有16384给哈希槽。在我们创建分片集群的时候,可以给每个主节点分配相对平均的哈希槽。当我们写入数据的时候,redis会通过CRC16的哈希算法来计算当前key的哈希值,假设得到的值是66666,redis会用66666与16384取模,得到的值必然是16384之内的值,所以就将数据存到了下面的节点上,在使用分片集群的时候,我们可以设置key的有效部分,也就是说,我们可以按照一定规则来选择key存储到哪一个节点中。假如你的set命令是右侧的样子,计算hash值的时候就会按照aaa来计算哈希值。

Redis网络模型 

Redis 的网络模型被称为 "单线程模型",但实际上是一种 "单线程加异步IO" 的混合模型。

Redis是单线程的,为什么这么快?

1.redis是基于内存的,操作起来非常的快,这也是redis执行速度快最主要的原因

2.采用了单线程,避免了不必要的上下文切换。在多线程的环境下,CPU核数是一定的,这就必然会涉及到上下文的切换,这也是损耗性能的地方。而且我们使用了多线程以后,还要考虑线程安全的问题,可能会大量使用并发锁的技术,所以使用多线程未必那么友好。

3.redis底层采用I/O多路复用和非阻塞IO,所以性能会比较好。

I/O多路复用模型

redis是纯内存操作,执行速度非常快,它的性能瓶颈是网络延迟而不是执行速度,I/O多路复用主要是实现了搞笑的网络请求。

用户空间和内核空间

Linux系统中一个进程使用的内存情况划分为两部分:内核空间,用户空间。

用户空间的权限是比较低的,如果我们想要调用系统的一些资源就需要调用内核空间。内核空间可以调用一切系统资源。

比如我们在电脑上打开微信,给张三发消息说下午要去钓鱼,我们来分析一下这一过程。

打开微信就相当于开启了一个进程,编辑的聊天信息就相当于是在用户空间操作的。内核空间操作网卡将编辑完的信息发送出去,简单来说用户空间没有权限去直接操控网卡,用户空间需要调用内核空间的接口,才能把消息发送出去。

在消息发送的时候,Linux系统为了提高IO的效率,会在用户空间和内核空间之间假如缓冲区,当我们去写数据到时候,需要从用户空间的缓冲区把数据拷贝到内核空间的缓冲区,然后再写入到设备(磁盘,网卡等),读取数据到时候也是先从设备读取到内核缓冲区,再从内核缓冲区拷贝到用缓冲区。

可以看到,在整个过程中会涉及到各种状态的切换,影响IO效率的主要有两个原因:1.在用户空间需要数据的时候需要从内核中获取。假设现在内核中没有数据,用户进程只能等待数据,这是一个效率低浪费2.数据的拷贝:读数据的时候需要从内核拷贝到用户,写数据的时候需要从用户拷贝到内核,这种来回的拷贝也是非常也是非常影响性能的。

所以说想要提升IO效率就要从这两点出发,1.减少无效等待 2.减少用户空间与内核空间数据的拷贝

我们所说的IO模型也就这针对这一部分进行了优化,我们来一次看:

阻塞IO

左边是用户空间,右边是内核空间,假如现在用户想要从内核中获取数据,流程是这样的:用户进程尝试读取数据的时候会调用一个recvfrom函数,直接去调用内核空间所提供的接口,如果此时内核中还没有我们想要的数据,就在内核这里进行等待。此时用户进程就处于阻塞状态。如果数据已经准备好了,我们就需要把数据从内核缓冲区拷贝到用户缓冲区,在这个拷贝到过程中用户的进程也是出于阻塞状态的,拷贝完成后,用户进程拿到了数据,这个阻塞状态才会解除。

 可以看到,用户进程在两个阶段都属于阻塞状态,都需要等待数据的拷贝完成,这个相对来讲就比较耗时了,也会影响效率。

非阻塞 IO 

顾名思义,非阻塞IO的recvfrom操作会立即返回结果而不是阻塞用户进程。

当用户进程尝试去读取数据的时候,依旧会调用recvfrom方法,如果内核中目前还没有数据,此时内核需要等待数据,但也会同时把异常返回给用户进程,那用户进程就不会处于阻塞状态,但是用户进程拿到异常之后,会再次尝试读取,就这样循环知道数据准备就绪,此时用户进程也需要等待数据的拷贝完成,从内核缓冲区拷贝到用户缓冲区,拷贝完成之后用户进程才会解除阻塞状态。

总的来说,用户进程在第一个阶段是非阻塞的,但是在第二个阶段(数据拷贝阶段)依然是阻塞的 。在第一个阶段虽然没有阻塞,但是性能并未提高,因为这一过程中用户进程会不断的询问内核有没有准备就绪,这回造成忙等,会导致CPU控制,导致CPU使用率暴增。所以说这种方式的效率也不是很高。

IO多路复用

IO多路复用是利用单个线程来同时监听多个Socket(主要指客户端的连接),并在某个Socket可读、可写时得到通知,从而避免无效等待,充分利用CPU资源。Linux系统采用的就是这种IO模型。

 用户进程需要获取数据的时候就不再去调用recvfrom方法了,而是先去调用select方法,select方法可以监听一个socket集合,里面包含多个socket,只要有任意一个或多个socket就许多时候就返回一个可用的状态,在监听的过程中用户进程是阻塞的。

用户进程收到了准备就绪的信息之后,再去调用recvfrom读取数据,因为这里可能会有多个socket,这里会使用一个循环依次进行调用。等待内核中的数据拷贝到用户缓冲区,整个流程就结束了。

在这两个阶段,用户进程都处于阻塞的状态,看起来性能没怎么提升。然而,当调用select函数的时候,它可以一次性的监听多个socket,不会再像之前的IO模型直接调用recvfrom方法。

如果现在来了很多socket请求,我们目前是一个单线程的情况。这么多客户端的socket就需要排队等候,因为阻塞IO一次只能处理一个socket,假如正在处理的socket没有数据,就需要等待数据的就绪,整个进程都要处于阻塞状态,队列中其他的socket即使已经就绪也需要一个个去等,所以说性能较差。

而select方法可以一次传递多个socket,只要有一个就绪就可以立马知道结果了,我们就可以立刻调用recvfrom方法去处理数据,这种模式效率显然是要高很多的。、

这只是IO多路复用的基本思想,在真正的Linux系统中,实现IO多路复用的方式有三种:select,poll,epoll。

在这里我们举个点餐的案例:

现在有很多顾客需要点餐,现在有两种模式:

监听模式:每个顾客都坐在自己的桌子面前,每个桌子上都装了一个开关,这个开关会连接到服务员的灯泡上,任意一个人需要点餐都可以按下这个开关,此时服务员面前的等就会亮起,服务员就会得知,有顾客准备就绪,需要点餐,但是服务员不知道到底是谁就绪,只能去一个一个询问,直到找到了真正点餐的顾客,这就是早期IO多路复用的实现方案,也是select和poll的实现方案。这种方案的弊端显而易见。

epoll的思路也很简单:顾客面前的按钮不再控制服务生面前的灯泡,而是控制服务生面前的计算机,直接在计算机上显示出是几号桌就绪了,这样一来,我们的服务员就能快速找到就绪的顾客,给他点餐,这就是epoll的实现方案。

差异:

  • select和poll指挥通知用户进程有socket就绪,但不确定具体是哪个socket,需要用户进程逐个遍历socket来确认
  • epoll则会在通知用户进程socket已就绪的同时把已就绪的socket写入用户空间。

讲到这里,我们已经阐述了IO多路复用模型的实现原理,下面我们来看看redis的网络模型:

redis的网络模型

redis通过IO多路复用来提高网络性能,并且支持各种不同的多路复用实现,并且将这些实现封装,也提供了统一的高性能事件库。

在redis当中,IO多路复用主要用作监听,监听客户端的连接,也就是每一个socket连接。但是每一个连接有可能会处理不同的事件,比如客户端需要有写请求,也有客户端需要有读请求。这个IO多路复用只是负责已经就绪的连接,把这些不同的时间进行派发。在redis中提供了多个事件处理器,这些处理器分别负责实现不同的网络通信请求,比如:

  • 连接应答处理器,可以处理客户端请求的应答。
  • 命令回复处理器可以客户端响应。
  • 命令请求处理器可以接收客户端的参数,还要转换redis指令,输出结果。结果有需要使用命令回复处理器来响应客户端。

总的来说整个redis的网络模型就是IO多路复用+事件派发机制。以上说的都是单线程的模型,在redis6.0之后又引入了多线程的模型来进一步的提升redis的性能,在传统的单线程的模型下,真正影响redis性能的并不是我们命令的执行,也不是IO多路复用这种事件的监听机制,而是IO。

往往最影响性能的都是IO,比如MySQL当中最影响性能的就是磁盘读写,在redis这里就是网络的读写,redis为了解决这个问题引入了多线程。其实只有两块内容引入了多线程:

  1. 命令的解析:假设有很多客户端都来读数据,那每个客户端都会携带自己的命令,在单线程的情况下可能会忙不过来,于是redis在接收网络请求的模块开启了多线程。解析成redis可以执行的命令。注意:在真正去执行命令的时候还是redis主线程去执行的,这个还是串行执行的,是线程安全的,这些命令的都是基于内存操作,并不会影响性能,真正影响性能的就是接收网络请求,指令转换的过程,所以我们在这里加入了多线程。当命令执行完之后就会把结果放入到缓冲区就可以了。
  2. 响应结果的输出:当出发了写事件,在往外写数据的时候,这块内容也是比较耗时的,因为这块内容也会涉及到网络响应问题,所以也开启了多线程。

在这两块引入多线程之后可以说大幅度提高了redisd对于客户端处理的速度,主要是减少了网络IO导致了性能变慢的影响。

Redis常见问题

缓存穿透

Redis的缓存穿透是指在使用Redis作为缓存数据库时,恶意或非法的请求导致缓存无法命中,进而绕过缓存直接查询后端数据源。这种情况下,大量无效的查询请求直接访问后端数据库,导致数据库性能下降,甚至可能引发数据库宕机。

举个例子:这里面有一个get请求:api/news/getById/1 

要根据文章的id查询文章的详情,如果文章的数据已经加载到了redis缓存,查询的流程是这样的。

首先,根据id查询文章,先到redis中查询这个文章是否有数据,如果命中了数据就直接返回结果。如果redis查询不到就会再查询数据库,再返回数据。在返回之前,我们需要把在数据库中查到的数据存储到redis。这是一个正常的流程。

当查询一个不存在的数据,MySQL中也查询不到这个数据,这个数据也不会写到缓存中,就会导致每次请求都会区查询数据库。

如果有人恶意通过你的请求路径造用假id制造请求(比如id为0或负数)疯狂的冲击数据库,由于数据库的并发不高,请求到了一定数量就会击垮数据库导致宕机。

解决方案:

  1. 缓存空数据,查询返回的数据为空,仍把这个空结果进行缓存  比如{key:1,value:null}。这种方式的有点事简单,缺点是消耗内存,会发生不一致的问题。比如一开始id为1没有数据,我们缓存为null,但是后面这个数据添加上了,就会发生缓存不一致的问题。

  2. 布隆过滤器:查询文章数据的时候,首先经过布隆过滤器,如果说布隆过滤器中存在这个数据再哦出现redis。如果不存在,布隆过滤器就会直接返回,不用经过redis。这里的前提是,在缓存预热的时候需要将布隆过滤器给初始化

布隆过滤器

在理解布隆过滤器之前,我们需要了解一个概念:

bitmap(位图):相当于是一个以(bit)位为单位的数组,数组中每个单元只能存储二进制数0或1

布隆过滤器主要实现就是依赖于bitmap检索元素是否存在于一个集合中。比如我们添加一个id为1的数据,我们会把这个数据进行多个哈希计算,得到下标为1,3,7的值,这三个值就代表了id为1的数据,当我们去查询的时候,就会使用相同的哈希函数去计算哈希值,判断对应的位置是否都为1,如果都为1就代表这个数据是存在的。

位图的优点是,存储到压力是比较小的,因为只需要存储0和1。我们在缓存预热的时候,可以把缓存中所有的id经过哈希计算存储在位图中,当有id为空的时候就可以使用布隆过滤器来过滤那些不存在的请求。但是这之中可能会存在误判。

在这里我有两个id值,id分别为1和2,都经过了三次哈希,找到了数组中对应的位置并且把元素都改为了1。

现在来了一个查询id为3的请求,但是id为3的数据是不存在的,它也经过了三次哈希,得到的值正好被覆盖,此时就产生了误判。

这种误判的误判率和数组大小有关,数组越大误判率就越低,但同时也带来了更多的内存消耗。其实在具体的实现方案中,我们是可以控制误判率的,布隆过滤器具体的实现方案有:Redisson。Guava等,它们都提供了对布隆过滤器的具体实现。在我们去添加数据的时候我们可以去手动设置误判率。

这个误判是不可能不存在的,一般项目中都会设置在百分之五以内,这个误判率一般项目也是能接受的,不至于在高并发下压倒数据库。

缓存击穿

当给某一个热点的key设置了过期时间,当key过期的时候,恰好这个时间点有对这个key的大量并发请求过来,这些请求发现redis没有数据,就直接请求到了数据库,这些请求可能会瞬间将数据库压垮。 

 我们在查询的时候,已经把数据同步到了redis,为什么还会压垮数据库呢?

如果在缓存重建的过程中花费了过多的时间,比如50ms,此时有大量请求过来数据库是承受不住的。之所以重建这么慢是因为有的情况下我们存如缓存的数据可能是多个表汇总的结果,需要分头统计,可能会花费更久的时间。

这种现象就是缓存击穿,解决方案有两种。

解决方案:

1.添加互斥锁:

互斥锁的流程大致如下:线程一来了之后先去查询缓存,但是缓存中没有数据,于是就添加互斥锁,接下来查询数据库来进行缓存重建,拿到数据之后把新的数据写入到缓存中再把锁释放。

假设于此同时又来了一个线程二,依旧查询缓存,此时缓存中依旧没有数据,因为目前线程1还没有重建缓存成功,他也想要获取锁。因为线程1目前已经获取锁正在进行缓存重建,于是线程2便获取锁失败了,休眠一会再试。在线程1缓存重建之前,线程2只能不断的重试。等到线程1将数据写入到缓存中,线程2就可以直接获取数据了。

这就是互斥锁解决缓存击穿的方案。

2.逻辑过期:

这种方案的逻辑是,对于热点的key,在redis中不设置过期时间。我们在存储数据的时候新增一个过期时间的字段,比如

KEY:1

VALUE:{"id":"123","title":"杏花烟雨湘兰","expire":"153213455"}

其中id和title是正常的的业务数据,我们新增了一个expire字段来描述这条i数据什么时候过期。当我们新增数据到缓存的时候,直接为维护这个过期时间就行了。

具体流程如下:

有一个线程1,拆线呢缓存发现逻辑时间已过期,此时线程1会获取互斥锁(用于进行缓存重建),此时会新开一个线程去重建缓存数据,这个新线程首先从数据库中查询数据,然后把数据写入到缓存,重置过期时间,最终释放锁。线程一也不需要等待新线程重建完毕,可以直接返回数据。假设来了一个线程3,也发现数据已经过期,他想去获取锁构建缓存,此时他会获取锁失败,也会直接把数据返回。

使用互斥锁方案可以保证数据的强一致性,但是性能相对较差,而逻辑过期方案优先保证的是高可用,性能比较好。这两种方案要根据不同的业务进行选择,并不是一定要一分高下。

缓存雪崩

缓存雪崩是指在同一时段有大量的key同时失效或者redis服务宕机。导致大量的请求达到数据库,带来巨大压力。

解决方案
  • 给不同的key的TTL添加随机值,可以给一道五分钟随机,这样每个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件了。
  • 利用Redis集群提高服务的可用性,如哨兵模式,集群模式等等。
  • 在设计业务系统的时候添加降级限流的策略 (nginx,springcloud gateway等等)
  • 给业务添加多级缓存,预防大量key过期。比如Guava或Caffeine可以做一级缓存,使用Redis作为二级缓存,预防雪崩

双写一致性

双写一致性:当修改了数据库的数据也要同时更新缓存数据,缓存和数据库的数据要保持一致。

当我们使用redis作为缓存的时候,MySQL数据如何与redis进行同步呢?

这个问题要根据我们的业务场景进行综合考量,对于对一致性要求高的业务和允许一定延迟一致的业务是有不同的方案的。 

对于要求强一致性的业务,我们采用的是延迟双删的策略:我们在写操作的时候先删除缓存,再修改数据库,延时一小会儿再一次删除缓存。根据正常逻辑只需要删除缓存然后修改数据库就可以了。这里面就有另外一个问题:是先生成缓存呢。还是先操作数据库呢?

答案是无论先做哪一个都会出问题,我们来分析一下:

如果先删除缓存:

假设一开始缓存和数据库都是10,线程1删除缓存之后去更新数据库。线程2来了之后发现没有,就会把数据库写入到缓存中,这是正常逻辑。

然而,如果再线程1删除缓存后,操作数据库之前,线程2进行查询,直接去查询数据库,写入缓存,此时线程1再去更新数据库,写入缓存的还是线程2修改之前的值。这就导致了缓存是10而数据库是20。

如果先操作数据库:

同样假设一开始缓存和数据库都是10,正常的流程是,线程2要进行写操作,先更新数据库在进行缓存,线程1查询缓存没有数据,就回去数据库拿数据,将数据写到缓存中。

然而会有这样一种特殊情况:线程1查询数据未命中,他将数据库当中的值拿出来(10),在线程1写入缓存之前,线程被切换,线程2更新数据库为20,删除缓存,此时线程1将10写入缓存,又发生了不一致的情况。

延时双删

为什么要删除两次缓存呢?这是为了防止刚刚两种情况产生脏数据,所以我们采用了双删的策略来降低脏数据的出现。

之所以要延迟一会儿是因为在一般情况下数据库是主从模式,是读写分离的我们要延时一会儿让主节点把数据同步给从节点,所以需要延时,但是由于延时时间不好控制,依然有可能会出现脏数据,所以说延时双删可以极大的控制脏数据的发生,但也无法根除。

如果硬要保证强一致性,我们还可以使用互斥锁来实现,或者读数据的时候添加分布式锁。

 但是这种方案由于枷锁的缘故,性能就比较低了。

其实我们可以稍微优化一下,在保证绝对一致性的前提下,性能也相对比较好。我们要强调一点,像这些存入缓存的数据,一定是读多写少,如果是写多读少的话,就不建议将数据存储在缓存中。

我们可以利用读写锁来进行控制。

共享锁:加锁之后,其他线程可以继续读数据,但是不能写数据。

排他锁:加锁之后会阻塞其他线程读和写操作。

当我们去读数据的时候,我们可以添加共享锁,写数据到时候添加排他锁,这样性能就能得到极大提升,也能保证数据的强一致性。代码实现如下:

 读写锁redisson已经帮我们提供,我们只需获取就可以了。

对于允许短暂不一致的业务,解决方案就有很多了:

异步通知:保证数据的最终一致性

 

当我们修改数据,写入到MySQL以后就会发一条消息给MQ,让缓存服务模块监听MQ,最终更新缓存就可以了。当然,消息发出以后,接收消息,修改缓存都是有延迟的,但是可以保证最终的一致性。

除了使用MQ,我们也可以使用canal进行异步通知,canal是阿里出的中间件,主要基于MySQL的主从实现同步的:

当有数据修改之后这些道理数据库,数据库一旦发生变化,就会把变化记录在BINLOG二进制日志文件中。canal可以通过BINLOG日志来获取数据的变化,当有我们需要的表数据发生变化之后,我们就可以在缓存服务模块获取变化之后的数据更新到缓存。这种方式的好处就是,对业务的代码几乎是0侵入的。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值