5 redis

缓存篇

https://blog.csdn.net/weixin_40205234/article/details/124614720

https://blog.csdn.net/hello_list/article/details/124893755 整合命令

redis应用 https://blog.csdn.net/zzti_erlie/article/details/85213880

1. Redis 数据类型

要求

  • 掌握常见数据类型的底层结构

概述

回答

redisObject就好像是java中的object,它里面有类型(String,list,set,hash,zset),编码(编码有很多,就是我们学习的底层数据结构,比如sds、dict、ziplist、quicklist、skiplist,intset),编码实际数据结构存储地址的指针(就是使用数据结构存储的数据的地址)

数据类型实际描述的是 value 的类型,key 都是 string,常见数据类型(value)有

  1. string(embstr、raw、int)

    三种实现方式

    存储一些简单的kv键值对

    回答:string根据存放的数据类型不同,会使用不同的编码来存放数据,如果是整数型,则底层编码为int,实际使用long来存储。对于字符串类型,如果大小大于44使用raw,小于等于44使用embstr来进行存储。raw和embstr的主要区别为:RedisObject和实际存储数据的sdshdr是否在一起。embstr是Redisobject和sdshdr存放在一起,而raw则是两者分开存储,使用指针相连。

    sdshdr:简单动态字符串的意思,有点像java中的StringBuilder,是可变长的。这里为什么要使用sds而不是使用原生的char?

    因为原生的char是不存储字符串长度的,在获取长度时需要遍历。在空间方面,sds会预留一些空间,减少内存分配与释放次数。二进制安全,c语言中的char是以\0结尾,不能存储图片啥的二进制,sds以长度来读取,防止提前结束。支持动态扩容,方便拼接字符串。

    embstr:底层由sds实现

    raw:底层也由sds实现

  2. list(quicklist,由多个 ziplist 双向链表组成)

    list底层是quicklist加zipList。quicklist每个节点的数据结构为ziplist。ziplist是内存上连续的,这样使空间更加紧凑,减少内存碎片,查找效率高。每个entry还存储了前一个entry的长度,用来反向遍历,存储长度大小不一定, 小于253使用1个字节,大于253使用5个字节来保存。

  3. hash(ziplist、dict)

    采用ziplist作为他的编码。当大小过大大于64或者数量过多时大于512转换为HashTable又名dict(数组加链表,和java的hashmap很像,只不过这里不会进化为红黑树,使用头插法,dict有两个数组,一个用来扩容)来存储。

    rehash时机:1、数据量小于数组长度时不扩容。2、数据量大于5倍的数组长度时进行扩容,3、在两者之间,如果没有进行aof和rdb时进行扩容。4、当容量小于数组长度的十分之一时缩容。

    rehash操作:是渐进式的,不是一次将所有数据全部放在新数组中,而是每次crud的时候迁移一个桶(一个链表),每个字典有两个哈希表。有主动扩容的时机。

    image-20220721225820836

  4. set(intset、dict)

    两种实现方式

    无序,不重复。作用:求交集、并集

    dict:当集合中出现非整数的类型时,就使用这个作为底层数据结构

    intset:动态升级每个节点的编码大小,有序,二分查找,不重复,可看成动态可变整型数组。当集合全是整数时,就使用这个底层数据结构来存放集合。

  5. sorted set(ziplist、skiplist&dict)

    两种实现方式

    选择底层数据结构理由:有序、唯一、能够根据key(member)快速获取分数,因此排序我们想到skiplist,唯一选择dict,所以使用两种数据结构来实现zset,skiplist和dict都分别存储了一份数据,根据命令来选择从哪一份中取数据,要排序则使用skiplist,取数使用dict

    但是这样就会导致浪费空间,所以我们在数据量不大<=128个的时候,就是用ziplist来存储,通过业务来排序。相邻的两个entry一个存放键,一个存放value

    在数据量较小的时候使用ziplist,当见或者值大小大于64或者个数多余128时转为skiplist&dict,

    skiplist是使用空间换时间,使用像书的多级目录那样有多层索引, 头结点不存储任何数据,其他节点结构包括:分数、节点值、下一个节点指针、lever(包括间距和上一个节点指针)

    在查找时,顺着头结点顶层往下查找,第一个如果值大于目标值则或者为null下一层,如果小于则继续往右查找,等于表示找到了。

    image-20220721225445298

  6. bitmap

  7. hyperloglog

  8. 地理位置。

每一种类型都用 redisObject 结构体来表示,每种类型根据情况不同,有不同的编码 encoding(即底层数据结构)

实际存储的是编码方式。

image-20220702163051957

image-20220702163102954

String

  1. 如果字符串保存的是整数值,则底层编码为 int,实际使用 long 来存储

  2. 如果字符串保存的是非整数值(浮点数字或其它字符)又分两种情况

    1. 长度 <= 39 字节,使用 embstr 编码来保存,即将 redisObject 和 sdshdr 结构体保存在一起,分配内存只需一次
    2. 长度 > 39 字节,使用 raw 编码来保存,即 redisObject 结构体分配一次内存,sdshdr 结构体分配一次内存,用指针相连
  3. sdshdr 称为简单动态字符串,实现上有点类似于 java 中的 StringBuilder,有如下特性

    1. 单独存储字符长度,相比 char* 获取长度效率高(char* 是 C 语言原生字符串表示)
    2. 支持动态扩容,方便字符串拼接操作
    3. 预留空间,减少内存分配、释放次数(< 1M 时容量是字符串实际长度 2 倍,>= 1M 时容量是原有容量 + 1M)
    4. 二进制安全,例如传统 char* 以 \0 作为结束字符,这样就不能保存视频、图片等二进制数据,而 sds 以长度来进行读取

List

  1. 3.2 开始,Redis 采用 quicklist 作为其编码方式,它是一个双向链表,节点元素是 ziplist

    1. 由于是链表,内存上不连续
    2. 操作头尾效率高,时间复杂度 O(1)
    3. 链表中 ziplist 的大小和元素个数都可以设置,其中大小默认 8kb
  2. ziplist 用一块连续的内存存储数据,设计目标是让数据存储更紧凑,减少碎片开销,节约内存,它的结构如下

    1. zlbytes – 记录整个 ziplist 占用字节数
    2. zltail-offset – 记录尾节点偏移量
    3. zllength – 记录节点数量
    4. entry – 节点,1 ~ N 个,每个 entry 记录了前一 entry 长度,本 entry 的编码、长度、实际数据,为了节省内存,根据实际数据长度不同,用于记录长度的字节数也不同,例如前一 entry 长度是 253 时,需要用 1 个字节,但超过了 253,需要用 5 个字节
    5. zlend – 结束标记
  3. ziplist 适合存储少量元素,否则查询效率不高,并且长度可变的设计会带来连锁更新问题

Hash

  1. 在数据量较小时,采用 ziplist 作为其编码,当键或值长度过大(64)或个数过多(512)时,转为 hashtable 编码

  2. hashtable 编码

    • hash 函数,Redis 5.0 采用了 SipHash 算法

    • 采用拉链法解决 key 冲突

    • rehash 时机

      ① 当元素数 < 1 * 桶个数时,不扩容

      ② 当元素数 > 5 * 桶个数时,一定扩容

      ③ 当 1 * 桶个数 <= 元素数 <= 5 * 桶个数时,如果此时没有进行 AOF 或 RDB 操作时

      ④ 当元素数 < 桶个数 / 10 时,缩容

    • rehash 要点

      ① 每个字典有两个哈希表,桶个数为 2 n 2^n 2n,平时使用 ht[0],ht[1] 开始为 null,扩容时新数组大小为元素个数 * 2

      渐进式 rehash,即不是一次将所有桶都迁移过去,每次对这张表 CRUD 仅迁移一个桶

      active rehash,server 的主循环中,每 100 ms 里留出 1s 进行主动迁移

      ④ rehash 过程中,新增操作 ht[1] ,其它操作先操作 ht[0],若没有,再操作 ht[1]

      ⑤ redis 所有 CRUD 都是单线程,因此 rehash 一定是线程安全的

Sorted Set

  1. 在数据量较小时,采用 ziplist 作为其编码,按 score 有序,当键或值长度过大(64)或个数过多(128)时,转为 skiplist + hashtable 编码,同时采用的理由是

    • 只用 hashtable,CRUD 是 O(1),但要执行有序操作,需要排序,带来额外时间空间复杂度
    • 只用 skiplist,虽然范围操作优点保留,但时间复杂度上升
    • 虽然同时采用了两种结构,但由于采用了指针,元素并不会占用双份内存
  2. skiplist 要点:多层链表、排序规则、 backward、level(span,forward)

image-20210902094835557

  • score 存储分数、member 存储数据、按 score 排序,如果 score 相同再按 member 排序
  • backward 存储上一个节点指针
  • 每个节点中会存储层级信息(level),同一个节点可能会有多层,每个 level 有属性:
    • foward 同层中下一个节点指针
    • span 跨度,用于计算排名,不是所有跳表都实现了跨度,Redis 实现特有
  1. 多层链表可以加速查询,规则为,从顶层开始

    1. 大于同层右边的,继续在同层向右找

    2. 相等找到了

    3. 小于同层右边的或右边为 NULL,下一层,重复 1、2 步骤

image-20210902094835557

  • 以查找【崔八】为例
    1. 从顶层(4)层向右找到【王五】节点,22 > 7 继续向右找,但右侧是 NULL,下一层
    2. 在【王五】节点的第 3 层向右找到【孙二】节点,22 < 37,下一层
    3. 在【王五】节点的第 2 层向右找到【赵六】节点,22 > 19,继续向右找到【孙二】节点,22 < 37,下一层
    4. 在【赵六】节点的第 1 层向右找到【崔八】节点,22 = 22,返回

注意

  • 数据量较小时,不能体现跳表的性能提升,跳表查询的时间复杂度是 l o g 2 ( N ) log_2(N) log2(N),与二叉树性能相当

4、初始化

在插入节点的时候使用随机函数,来确定所在层数,每个节点都有50%的概率变成索引节点。

5、为什么不使用B树,而是用跳表?

因为跳跃表比较简单,却能达到类似快速查询的效果。

详细解答

1、简单数据类型SDS

image-20220702005537774

优点:

1、获取字符串长度为O(1),因为直接读取头中的len

2、内存预分配,在申请新内存的时候,如果新字符大小小于1M,则申请拓展后字符串长度的两倍+1,大于则拓展后的字符串加1M+1,这样能提高效率,因为申请内存很耗费时间。

3、支持动态扩容

4、二进制安全,因为有了长度,不会遇到\0就不读了。

2、intset

特点:底层是一个数组,且保持唯一有序,这样在查找的时候就够使用二分查找

数组中的类型大小可以动态升级。

image-20220702011654745

intsetcoding升级

image-20220702011820577

3、dict

数组加链表,扩容与缩容

image-20220702095112622

image-20220702095122612

image-20220702095129760

4、ziplist

内存连续的数组,节点记录了上一个节点的长度。

image-20220702095202887

image-20220702095215303

5、quicklist

链表加ziplist而dict是数组加链表

list的底层是quicklist

image-20220702095527823

image-20220702095516605

6、skipList

2. keys 命令问题

回答

会,因为redis是单线程的,一个命令执行太慢会阻塞其他命令的执行,可以使用scan命令进行替代,这个有点像mysql中的limit。他维护了一个游标,每次读取完当前端之后,会返回下一个游标的起始,因为可以继续读取。

要求

  • 理解低效命令对单线程的 Redis 影响

问题描述

  • redis有一亿个 key,使用 keys 命令是否会影响线上服务?

解答

  • keys 命令时间复杂度是 O ( n ) O(n) O(n),n 即总的 key 数量,n 如果很大,性能非常低
  • redis 执行命令是单线程执行,一个命令执行太慢会阻塞其它命令,阻塞时间长甚至会让 redis 发生故障切换

改进方案

  • 可以使用 scan 命令替换 keys 命令,语法 scan 起始游标 match 匹配规则 count 提示数目,返回值代表下次的起点
    1. 虽然 scan 命令的时间复杂度仍是 O ( n ) O(n) O(n),但它是通过游标分步执行,不会导致长时间阻塞
    2. 可以用 count 参数提示返回 key 的个数
    3. 弱状态,客户端仅需维护游标
    4. scan 能保证在 rehash 也正常工作
    5. 缺点是可能会重复遍历 key(缩容时)、应用应自己处理重复 key

3. 过期 key 的删除策略

回答

https://blog.csdn.net/panguangyuu/article/details/124347862

主要有两种,1、是定期删除,redis有一个定时任务处理器serverCron (相当于mysql中的主循环,这里是10ms一次),他会周期性的去清除过期字典里的数据,每次随机抽取20个key检查是否过期,如果过期数大于5,则继续抽取。

redis会定期执行清理过期key的任务,运行频率由redis.conf中的hz参数决定,取值范围1~500,默认是10,代表每秒运行10次。

2、惰性删除,在读写数据库的命令时,会去检查key是否过期,过期则删除。

要求

  • 了解 Redis 如何记录 key 的过期时间
  • 掌握 Redis 对过期 key 的删除策略

记录 key 过期时间

  • 每个库中都包含了 expires 过期字典
    • hashtable结构,键为指针,指向真正 key,值为 long 类型的时间戳,毫秒精度
  • 当设置某个 key 有过期时间时,就会向过期字典中添加此 key 的指针和时间戳

过期 key 删除策略

  • 惰性删除

    • 在执行读写数据库的命令时,执行命令前会检查 key 是否过期,如果已过期,则删除 key
  • 定期删除

    • redis 有一个定时任务处理器 serverCron,负责周期性任务处理,默认 100 ms 执行一次(hz 参数控制)包括:① 处理过期 key、② hash 表 rehash、③ 更新统计结果、④ 持久化、⑤ 清理过期客户端

    • 对于处理过期 key 会:依次遍历库,在规定时间内运行如下操作

      ① 从每个库的 expires 过期字典中随机选择 20 个 key 检查,如果过期则删除

      ② 如果删除达到 5 个,重复 ① 步骤,没有达到,遍历至下一个库

      ③ 规定时间没有做完,等待下一轮 serverCron 运行

4. Redis 持久化

回答:分为aof(有点像mysql的statement)和rdb(内存快照)

aof是将所有的redis命令记录在日志文件中,redis的日志是在命令执行完之后才会写入缓冲区中,这是因为redis在写日志的时候不会检查命令的格式是否正确(为了性能放弃检查),所以先执行操作在写入日志,防止日志出现错误的命令。

aof是以追加的形式将写命令加入aof文件中的,重启的时候会执行aof中的命令来重建数据库。

aof的写磁盘策略:

1、always:日志写完磁盘在将操作结果返回,性能相对不高。基本不会丢失数据,因为每次aof写都是将上一次aof缓冲区的数据写入磁盘,最多会丢失100ms的数据,因为循环是100ms一次

2、everySec每秒写一次:每秒将缓冲区的日志写会文件中,最多丢失1s数据

3、no操作系统写:由操作系统来决定合适将数据刷盘。

aof重写:这个是为了解决aof文件太大的问题。aof重写是只保留最新的结果,丢掉中间的内容,只关注结果。

实现原理:根据主进程创建子进程,根据子进程的内存,将相应的数据操作命令写入一个新的文件中,将子进程中所有的key处理完成之后,将重写缓冲区(就是主进程中对数据修改了的操作)写入子进程的重写文件中,之后在将aof日志文件替换即可。

rdb:有点像vm中的拍摄快照方式,将整个内存中的数据以二进制的形式写入磁盘,速度快。但是写入周期不好控制。有两个相关命令:save,这个是在主进程中进行写入,会阻塞其他命令。一个是bgsave,使用子进程来写入磁盘,子进程的创建会阻塞主进程,创建后的写内存不会阻塞主进程。在rdb的bgsave不会将新增的数据写入磁盘,所以通常用来做全量备份。

redis通常采用aof和rdb两者结合的形式俩备份,但是默认是关闭的,需要手动开启,使用rdb来创建全量备份,使用aof来进行增量备份。

要求

  • 掌握 AOF 持久化和 AOF 重写
  • 掌握 RDB 持久化
  • 了解混合持久化

AOF 持久化

  • AOF - 将每条写命令追加至 aof 文件,当重启时会执行 aof 文件中每条命令来重建内存数据
  • AOF 日志是写后日志,即先执行命令,再记录日志
    • Redis 为了性能,向 aof 记录日志时没有对命令进行语法检查,如果要先记录日志,那么日志里就会记录语法错误的命令
  • 记录 AOF 日志时,有三种同步策略
    • Always 同步写,日志写入磁盘再返回,可以做到基本不丢数据,性能不高
      • 为什么说基本不丢呢,因为 aof 是在 serverCron 事件循环中执行 aof 写入的,并且这次写入的是上一次循环暂存在 aof 缓冲中的数据,因此最多还是可能丢失一个循环的数据
    • Everysec 每秒写,日志写入 AOF 文件的内存缓冲区,每隔一秒将内存缓冲区数据刷入磁盘,最多丢一秒的数据
    • No 操作系统写,日志写入AOF 文件的内存缓冲区,由操作系统决定何时将数据刷入磁盘

AOF 重写

  • AOF 文件太大引起的问题
    1. 文件大小受操作系统限制
    2. 文件太大,写入效率变低
    3. 文件太大,恢复时非常慢
  • 重写就是对同一个 key 的多次操作进行瘦身
    1. 例如一个 key 我改了 100 遍,aof 里记录了100 条修改日志,但实际上只有最后一次有效
    2. 重写无需操作现有 aof 日志,只需要根据当前内存数据的状态,生成相应的命令,记入一个新的日志文件即可
    3. 重写过程是由另一个后台子进程完成的,不会阻塞主进程
  • AOF 重写过程
    1. 创建子进程时会根据主进程生成内存快照,只需要对子进程的内存进行遍历,把每个 key 对应的命令写入新的日志文件(即重写日志)
    2. 此时如果有新的命令执行,修改的是主进程内存,不会影响子进程内存,并且新命令会记录到 重写缓冲区
    3. 等子进程所有的 key 处理完毕,再将 重写缓冲区 记录的增量指令写入重写日志
    4. 在此期间旧的 AOF 日志仍然在工作,待到重写完毕,用重写日志替换掉旧的 AOF 日志

RDB 持久化

  • RDB - 是把整个内存数据以二进制方式写入磁盘
    • 对应数据文件为 dump.rdb
    • 好处是恢复速度快
  • 相关命令有两个
    • save - 在主进程执行,会阻塞其它命令
    • bgsave - 创建子进程执行,避免阻塞,是默认方式
      • 子进程不会阻塞主进程,但创建子进程的期间,仍会阻塞,内存越大,阻塞时间越长
      • bgsave 也是利用了快照机制,执行 RDB 持久化期间如果有新数据写入,新的数据修改发生在主进程,子进程向 RDB 文件中写入还是旧的数据,这样新的修改不会影响到 RDB 操作
      • 但这些新数据不会补充至 RDB 文件
  • 缺点: 可以通过调整 redis.conf 中的 save 参数来控制 rdb 的执行周期,但这个周期不好把握
    • 频繁执行的话,会影响性能
    • 偶尔执行的话,如果宕机又容易丢失较多数据

混合持久化

  • 从 4.0 开始,Redis 支持混合持久化,即使用 RDB 作为全量备份,两次 RDB 之间使用 AOF 作为增量备份
    • 配置项 aof-use-rdb-preamble 用来控制是否启用混合持久化,默认值 no
    • 持久化时将数据都存入 AOF 日志,日志前半部分为二进制的 RDB 格式,后半部分是 AOF 命令日志
    • 下一次 RDB 时,会覆盖之前的日志文件
  • 优缺点
    • 结合了 RDB 与 AOF 的优点,恢复速度快,增量用 AOF 表示,数据更完整(取决于同步策略)、也无需 AOF 重写
    • 与旧版本的 redis 文件格式不兼容

5. 缓存问题

要求

  • 掌握缓存击穿

    缓存击穿是指因为某个key失效,但是在此时有多个客户端同时查询这个key导致数据库压力过大,压垮数据库。

    解决方法:1、对热点数据设置不过期。

    2、对【查询缓存没有 查询数据库 将缓存放入缓存】进行加锁,让别的线程查询的时候被阻塞,这样就等待第一个线程查询数据库并将其放入缓存中。但这样会影响吞吐量。

  • 掌握缓存雪崩

    情况1:雪崩的意思是同一时间大量的key过期,这时又有大量的用户进行访问,导致同时去访问数据库,从而导致数据库宕机。

    解决方法:

    1、将过期时间分散开来,从根源上解决问题。

    2、服务降级:暂停非核心数据查询缓存,返回预定义错误信息。

    情况2:Redis服务器宕机,导致大量请求访问数据库

    解决方法:

    1、事前预防,搭建高性能集群

    2、多级缓存,但是实现难度较高

    3、熔断(降级):通过监控,一旦发现,暂停缓存访问等待实例恢复,返回预定信息

    4、限流:通过监控,发现访问数据库达到阈值,限制访问数据库的请求数。

  • 掌握缓存穿透

    缓存击穿是指查询缓存的时候,发现缓存没有,之后访问数据库,发现也没有,这时缓存中依旧是空的,普通用户一般就不访问了,但是一些恶意请求就会不断访问导致数据库宕机。

    与击穿雪崩区别:穿透是数据库中没有,击穿和雪崩会自然恢复。

    解决方法:

    1、第一次查询时,发现没有,则将缓存该key值设置为null,这样别人访问的时候就会发现他是空的了。

    2、使用布隆过滤器,布隆过滤器会将所有key的存在与否加载到过滤器中,当请求来到时,首先经过布隆过滤器的过滤,如果没有则直接返回null,有则放行到缓存。

    布隆过滤器是一个数组,对一个key的存在与否使用多个哈希函数进行计算并标记位为1,如果查找某个key时,遇到的全部为1,但是可能是别的数给他变成的1,不是这个key,这就是可能不存在,但是发现为0,则表示一定不存在。

    https://blog.csdn.net/qq_40124555/article/details/122810154

    布隆过滤器的缺点:无法删除,因为布隆过滤器底层数据结果为一个数组,这个数组被所有key公用,删除会导致出现错误。这时查询删除了的数据一定会导致穿透。

  • 掌握旁路缓存与缓存一致性

    旁路缓存就是先读缓存,如果命中直接返回,如果缺失则查询数据库并将结果缓存并返回。

    新增数据是直接插入数据库,不放在缓存中,修改删除数据则一定要先进行更新数据库,在删除缓存。

    如果先删除缓存会导致缓存中的数据长时间不一致,因为先删除缓存时,还未进行完成数据库更新,这时有个线程来查询数据库,此时会将未更新的数据放在缓存中,这个数据是脏数据。

    如果先更新了数据库,别的线程只会读到很短时间内的脏数据,在高并发下是可接收的。

    还有一种可能,就是一个线程查询时数据过期了

    image-20220630164335276

缓存击穿

  • 缓存击穿是指:某一热点 key 在缓存和数据库中都存在,它过期时,这时由于并发用户特别多,同时读缓存没读到,又同时去数据库去读,压垮数据库

  • 解决方法

    1. 热点数据不过期
    2. 对【查询缓存没有,查询数据库,结果放入缓存】这三步进行加锁,这时只有一个客户端能获得锁,其它客户端会被阻塞,等锁释放开,缓存已有了数据,其它客户端就不必访问数据库了。但会影响吞吐量(有损方案)

    image-20210902105842482

缓存雪崩

  • 情况1:由于大量 key 设置了相同的过期时间(数据在缓存和数据库都存在),一旦到达过期时间点,这些 key 集体失效,造成访问这些 key 的请求全部进入数据库。

  • 解决方法:

    1. 错开过期时间:在过期时间上加上随机值(比如 1~5 分钟)

    2. 服务降级:暂停非核心数据查询缓存,返回预定义信息(错误页面,空值等)

  • 情况2:Redis 实例宕机,大量请求进入数据库

  • 解决方法:

    1. 事前预防:搭建高可用集群

    2. 多级缓存:缺点是实现复杂度高

    3. 熔断:通过监控一旦雪崩出现,暂停缓存访问待实例恢复,返回预定义信息(有损方案)

    4. 限流:通过监控一旦发现数据库访问量超过阈值,限制访问数据库的请求数(有损方案)

缓存穿透

  • 缓存穿透是指:如果一个 key 在缓存和数据库都不存在,那么访问这个 key 每次都会进入数据库

    • 很可能被恶意请求利用
    • 缓存雪崩与缓存击穿都是数据库中有,但缓存暂时缺失
    • 缓存雪崩与缓存击穿都能自然恢复,但缓存穿透则不能
  • 解决方法

    1. 如果数据库没有,也将此不存在的 key 关联 null 值放入缓存,缺点是这样的 key 没有任何业务作用,白占空间

    2. 布隆过滤器

    image-20210902110302668

    ① 过滤器可以用来判定 key 不存在,发现这些不存在的 key,把它们过滤掉就好

    ② 需要将所有的 key 都预先加载至布隆过滤器

    ③ 布隆过滤器不能删除,因此查询删除的数据一定会发生穿透

旁路缓存

  • 旁路缓存(Cache Aside),是一种常见的使用缓存的策略
  • 查询规则
    • 先读缓存
    • 如果命中,直接返回
    • 如果缺失,查 DB 并将结果放入缓存,再返回
  • 增、删、改规则
    • 新增数据,直接存 DB
    • 修改、删除数据,先更新DB,再删缓存

为什么要先操作库,再操作缓存?

  • 假设操作库和缓存均能成功,如果先操作缓存,会大几率出现数据库与缓存不一致的情况

一致性分析 - 先清缓存,再更新库

image-20210902111258034

一致性分析 - 先更新库,再清缓存

image-20210902111337037
  • 会有短暂不一致,但最终会一致
image-20210902111414597
  • 假设查询线程 A 查询数据时恰好缓存数据由于时间到期失效,或是第一次查询,会如上图所示出现不一致
  • 但这种几率出现机会很小

用锁解决一致性

image-20210902111615554
  • 缺点:影响吞吐量、分布式锁设计较为复杂

6. 缓存原子性

要求

  • 掌握 Redis 事务的局限性

    redis使用multi来开启一个事务,使用exec来执行事务,但是redis中的事务并不支持回滚, 当其中有一条操作出错了,他并不会回滚,而是继续执行所有命令。

    当开启一个事务并进行更新操作a(还未执行),此时另一个线程抢先修改了a,此时去执行事务,这时会导致丢失更新,即另一个线程的更新丢了,因为是set操作是直接赋值的,但是使用自增操作,在事务内他也能拿到最新的值进行更新。

  • 理解用乐观锁保证原子性

    为了保证事务内的数据保持最新值,此时需要使用watch key1 key2来监控要修改的值,当监控的值被修改之后事务就会修改失败,watch要在事务开启前使用。利用这个特性,我们可以再应用层使用循环来实现乐观锁。

    image-20220630194407130

  • 理解用 lua 脚本保证原子性

    lua是一个向js一样的脚本,能够操作redis,且lua脚本是原子性的,这样就能够保证原子性。

Redis 事务局限性

  • 单条命令是原子性,这是由 redis 单线程保障的
  • 多条命令能否用 multi + exec 来保证其原子性呢?

Redis 中 multi + exec 并不支持回滚,例如有初始数据如下

set a 1000
set b 1000
set c a

执行

multi
decr a
incr b
incr c
exec

执行 incr c 时,由于字符串不支持自增导致此条命令失败,但之前的两条命令并不会回滚

更为重要的是,multi + exec 中的读操作没有意义,因为读的结果并不能赋值给临时变量,用于后续的写操作,既然 multi + exec 中读没有意义,就无法保证读 + 写的原子性,例如有初始数据如下

set a 1000
set b 1000

假设 a 和 b 代表的是两个账户余额,现在获取旧值,执行转账 500 的操作:

get a /* 存入客户端临时变量 */
get b /* 存入客户端临时变量 */
/* 客户端计算出 a 和 b 更新后的值 */
multi
set a 500
set b 1500
exec

但如果在 get 与 multi 之间其它客户端修改了 a 或 b,会造成丢失更新

乐观锁保证原子性

watch 命令,用来盯住 key(一到多个),如果这些 key 在事务期间:

  • 没有被别的客户端修改,则 exec 才会成功

  • 被别的客户端改了,则 exec 返回 nil

还是上一个例子

get a /* 存入客户端临时变量 */
get b /* 存入客户端临时变量 */
/* 客户端计算出 a 和 b 更新后的值 */
watch a b /* 盯住 a 和 b */
multi
set a 500
set b 1500
exec

此时,如果其他客户端修改了 a 和 b 的值,那么 exec 就会返回 nil,并不会执行两条 set 命令,此时客户端可以进行重试

lua 脚本保证原子性

Redis 支持 lua 脚本,能保证 lua 脚本执行的原子性,可以取代 multi + exec

例如要解决上面的问题,可以执行如下命令

eval "local a = tonumber(redis.call('GET',KEYS[1]));local b = tonumber(redis.call('GET',KEYS[2]));local c = tonumber(ARGV[1]); if(a >= c) then redis.call('SET', KEYS[1], a-c); redis.call('SET', KEYS[2], b+c); return 1;else return 0; end" 2 a b 500
  • eval 用来执行 lua 脚本
  • 2 表示后面用空格分隔的参数中,前两个是 key,剩下的是普通参数
  • 脚本中可以用 keys[n] 来引用第 n 个 key,用 argv[n] 来引用第 n 个普通参数
  • 其中双引号内部的即为 lua 脚本,格式化如下
local a = tonumber(redis.call('GET',KEYS[1]));
local b = tonumber(redis.call('GET',KEYS[2]));
local c = tonumber(ARGV[1]); 
if(a >= c) then 
    redis.call('SET', KEYS[1], a-c); 
    redis.call('SET', KEYS[2], b+c); 
    return 1;
else 
    return 0; 
end

7. 内存淘汰机制

回答:主要有三种:主要区别在于数据集大小不同。

1、从已经设置过期数据中选择最近最少使用,随机选择,选择将要过期的。以及LFU(最不经常使用),random,LRU,LFU

2、从所有数据中选择LRU,所有数据中随机选择,以及最不经常使用。random,LRU,LFU

3、禁止写入新数据。

LRU和LFU的区别在于选择的时间不同,LFU是可以自定义起始时间,而LRU 是固定的。

image-20220702224451960


Redis 提供 6 种数据淘汰策略:

  1. volatile-lru(least recently used):从已设置过期时间的数据集(server.db[i].expires)中挑选最近最少使用的数据淘汰
  2. volatile-ttl:从已设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数据淘汰
  3. volatile-random:从已设置过期时间的数据集(server.db[i].expires)中任意选择数据淘汰
  4. allkeys-lru(least recently used):当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的 key(这个是最常用的)
  5. allkeys-random:从数据集(server.db[i].dict)中任意选择数据淘汰
  6. no-eviction:禁止驱逐数据,也就是说当内存不足以容纳新写入数据时,新写入操作会报错。这个应该没人使用吧!

4.0 版本后增加以下两种:

  1. volatile-lfu(least frequently used):从已设置过期时间的数据集(server.db[i].expires)中挑选最不经常使用的数据淘汰
  2. allkeys-lfu(least frequently used):当内存不足以容纳新写入数据时,在键空间中,移除最不经常使用的 key

要求

  • 掌握基于链表的 LRU Cache 实现
  • 了解 Redis 在 LRU Cache 实现上的变化

LRU Cache 淘汰规则

Least Recently Used,将最近最少使用的 key 从缓存中淘汰掉。以链表法为例,最近访问的 key 移动到链表头,不常访问的自然靠近链表尾,如果超过容量、个数限制,移除尾部的

  • 例如有原始数据如下,容量规定为 3

    image-20210902141534470
  • 时间上,新的留下,老的淘汰,比如 put d,那么最老的 a 被淘汰

    image-20210902141720278
  • 如果访问了某个 key,则它就变成最新的,例如 get b,则 b 被移动到链表头

    image-20210902141912068

LRU Cache 链表实现

  • 如何断开节点链接
image-20210902141247148
  • 如何链入头节点
image-20210902141320849

参考代码一

package day06;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class LruCache1 {
    static class Node {
        Node prev;
        Node next;
        String key;
        Object value;

        public Node(String key, Object value) {
            this.key = key;
            this.value = value;
        }

        // (prev <- node -> next)
        public String toString() {
            StringBuilder sb = new StringBuilder(128);
            sb.append("(");
            sb.append(this.prev == null ? null : this.prev.key);
            sb.append("<-");
            sb.append(this.key);
            sb.append("->");
            sb.append(this.next == null ? null : this.next.key);
            sb.append(")");
            return sb.toString();
        }
    }

    public void unlink(Node node) {
        node.prev.next = node.next;
        node.next.prev = node.prev;
    }

    public void toHead(Node node) {
        node.prev = this.head;
        node.next = this.head.next;
        this.head.next.prev = node;
        this.head.next = node;
    }

    int limit;
    Node head;
    Node tail;
    Map<String, Node> map;
    public LruCache1(int limit) {
        this.limit = Math.max(limit, 2);
        this.head = new Node("Head", null);
        this.tail = new Node("Tail", null);
        head.next = tail;
        tail.prev = head;
        this.map = new HashMap<>();
    }

    public void remove(String key) {
        Node old = this.map.remove(key);
        unlink(old);
    }

    public Object get(String key) {
        Node node = this.map.get(key);
        if (node == null) {
            return null;
        }
        unlink(node);
        toHead(node);
        return node.value;
    }

    public void put(String key, Object value) {
        Node node = this.map.get(key);
        if (node == null) {
            node = new Node(key, value);
            this.map.put(key, node);
        } else {
            node.value = value;
            unlink(node);
        }
        toHead(node);
        if(map.size() > limit) {
            Node last = this.tail.prev;
            this.map.remove(last.key);
            unlink(last);
        }
    }

    @Override
    public String toString() {
        StringBuilder sb = new StringBuilder();
        sb.append(this.head);
        Node node = this.head;
        while ((node = node.next) != null) {
            sb.append(node);
        }
        return sb.toString();
    }

    public static void main(String[] args) {
        LruCache1 cache = new LruCache1(5);
        System.out.println(cache);
        cache.put("1", 1);
        System.out.println(cache);
        cache.put("2", 1);
        System.out.println(cache);
        cache.put("3", 1);
        System.out.println(cache);
        cache.put("4", 1);
        System.out.println(cache);
        cache.put("5", 1);
        System.out.println(cache);
        cache.put("6", 1);
        System.out.println(cache);
        cache.get("2");
        System.out.println(cache);
        cache.put("7", 1);
        System.out.println(cache);
    }

}

参考代码二

package day06;

import java.util.LinkedHashMap;
import java.util.Map;

public class LruCache2 extends LinkedHashMap<String, Object> {

    private int limit;

    public LruCache2(int limit) {
        // 1 2 3 4 false
        // 1 3 4 2 true
        super(limit * 4 /3, 0.75f, true);
        this.limit = limit;
    }

    @Override
    protected boolean removeEldestEntry(Map.Entry<String, Object> eldest) {
        if (this.size() > this.limit) {
            return true;
        }
        return false;
    }

    public static void main(String[] args) {
        LruCache2 cache = new LruCache2(5);
        System.out.println(cache);
        cache.put("1", 1);
        System.out.println(cache);
        cache.put("2", 1);
        System.out.println(cache);
        cache.put("3", 1);
        System.out.println(cache);
        cache.put("4", 1);
        System.out.println(cache);
        cache.put("5", 1);
        System.out.println(cache);
        cache.put("6", 1);
        System.out.println(cache);
        cache.get("2");
        System.out.println(cache);
        cache.put("7", 1);
        System.out.println(cache);
    }
}

Redis LRU Cache 实现

Redis 采用了随机取样法,较之链表法占用内存更少,每次只抽 5 个 key,每个 key 记录了它们的最近访问时间,在这 5 个里挑出最老的移除

  • 例如有原始数据如下,容量规定为 160,put 新 key a

    image-20210902142353705

  • 每个 key 记录了放入 LRU 时的时间,随机挑到的 5 个 key(16,78,90,133,156),会挑时间最老的移除(16)

  • 再 put b 时,会使用上轮剩下的 4 个(78,90,133,156),外加一个随机的 key(125),这里面挑最老的(78)

    image-20210902142644518

  • 如果 get 了某个 key,它的访问时间会被更新(下图中 90)这样就避免了它下一轮被移除

    image-20210902143122234

8、Redis的消息队列

1、1:1

要实现1:1的消息队列,我们可以选用list作为数据结构,服务端使用Lpush生产消息,客户端使用Rpop消费消息。

2、1:n

要实现一对多的关系,可以使用redis提供的订阅发布模式,订阅发布模式分为两种情况

1、基于通道的发布订阅模式

image-20220630225839635

底层数据结果使用的是hash表,每个频道对应hash表中的key,每个key的值对应一个链表,链表的节点对应订阅该频道的客户端

注意:如果是先发布消息,再订阅频道,不会收到订阅之前就发布到该频道的消息!

subscribe 通道名 publish 通道名 信息

注意:进入订阅状态的客户端,不能使用除了subscribe、unsubscribe、psubscribe 和 punsubscribe 这四个属于"发布/订阅"之外的命令,否则会报错!

——这里的客户端指的是 jedis、lettuce的客户端,redis-cli是无法退出订阅状态的!

2、基于模式的发布订阅

image-20220630230632080

实现原理:使用的是节点为pubsubPattern的链表。

struct redisServer {
    //...
    list *pubsub_patterns; 
    // ...
}
 
// 1303行订阅模式列表结构:
typedef struct pubsubPattern {
    client *client;  -- 订阅模式客户端
    robj *pattern;   -- 被订阅的模式
} pubsubPattern;

订阅者使用* 或者?占位符来模糊匹配通道名。每个节点的client中保存了该模式下的订阅者

两者区别

模式匹配使的客户端匹配的通道更广,而通道匹配必须要给出具体的通道名。

3、发布订阅模式的缺点

在消费者下线的情况下,生产的消息会丢失,必须使用专业消息队列来实现例如卡夫卡,rabbitmq

1、消息无法持久化,存在丢失风险

2、没有应答机制,发布方不会确保订阅方收到

4、发布订阅使用场景

1.对于消息处理可靠性要求不强
2.消费能力无需通过增加消费方进行增强

9、Redis网络模型

Redis到底是单线程还是多线程?

  • 如果仅仅聊Redis的核心业务部分(命令处理),答案是单线程
  • 如果是聊整个Redis,那么答案就是多线程

在Redis版本迭代过程中,在两个重要的时间节点上引入了多线程的支持:

  • Redis v4.0:引入多线程异步处理一些耗时较旧的任务,例如异步删除命令unlink
  • Redis v6.0:在核心网络模型中引入 多线程,进一步提高对于多核CPU的利用率,Redis的多线程部分只是用来处理网络数据的读写和协议解析,执行命令仍然是单线程顺序执行

因此,对于Redis的核心网络模型,在Redis 6.0之前确实都是单线程。是利用epoll(Linux系统)这样的IO多路复用技术在事件循环中不断处理客户端情况。

为什么Redis要选择单线程?

  • 抛开持久化不谈,Redis是纯 内存操作,执行速度非常快,它的性能瓶颈是网络延迟而不是执行速度,因此多线程并不会带来巨大的性能提升。
  • 多线程会导致过多的上下文切换,带来不必要的开销
  • 引入多线程会面临线程安全问题,必然要引入线程锁这样的安全手段,实现复杂度增高,而且性能也会大打折扣

回答

为什么 Redis 单线程模型效率也能那么高?

  1. C语言实现,效率高
  2. 纯内存操作
  3. 基于非阻塞的IO复用模型机制
  4. 单线程的话就能避免多线程的频繁上下文切换问题
  5. 丰富的数据结构

说说 Redis 的线程模型

回答:IO多路复用简单来说就是使用一个线程去监听多个fd(文件描述符),在这些文件描述符可读或可写时发送通知给用户线程,避免无效等待,高效利用CPU。主要实现有select poll epoll。主要区别就是在

redis网络模型使用的就是epoll。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-l2Bgv3IY-1668352260129)(file://G:/%E7%BE%8E%E5%89%A7/%E6%9C%88%E5%85%89%E9%AA%91%E5%A3%AB/%E9%98%B6%E6%AE%B510%E4%BB%A3%E7%A0%81%E8%B5%84%E6%96%99/day06-%E7%BC%93%E5%AD%98%E7%AF%87/%E8%AE%B2%E4%B9%89/%E7%BC%93%E5%AD%98%E7%AF%87%E8%AE%B2%E4%B9%89/1653982278727.png?lastModify=1656651665)]

image-20220722005758486

image-20220722005842060

这个图流程:首先创建serversocket(此时他还未就绪),并将他注册到红黑树上面去,当他就绪(即serversocket可读,什么时候可读,就是客户端连上来的时候,给serversocket发送了serversocket readable事件(使用中断?))时,就会调用在初始化服务时为serversocket设置的回调函数——tcpAccepthandler(主要任务为:1、接收客户端连接,并返回fd,并将客户端fd绑定到红黑树上面去(此时,客户端还未就绪) 2、为客户端设置就绪回调函数readQueryFromClient)

当客户端有命令请求过来时,即客户端就绪了,这时就会调用在将客户端绑到树上去时为他绑定的回调函数readQueryFromClient,这个函数会去执行命令解析执行,之后将返回结果存放在一个队列中。

在监听到客户端事件时,处理事件时,首先会对客户端事件绑定一个命令恢复处理器sendReplyToClient,这个操作是在beforesleep是做的,这样每个客户端事件在处理redis处理完成之后,都能够收到处理结果。

image-20220702135923048

image-20220702140059392

虚线是指绑定的回调函数

当我们的客户端想要去连接我们服务器,会去先到IO多路复用模型去进行排队,会有一个连接应答处理器,他会去接受读请求,然后又把读请求注册到具体模型中去,此时这些建立起来的连接,如果是客户端请求处理器去进行执行命令时,他会去把数据读取出来,然后把数据放入到client中, clinet去解析当前的命令转化为redis认识的命令,接下来就开始处理这些命令,从redis中的command中找到这些命令,然后就真正的去操作对应的数据了,当数据操作完成后,会去找到命令回复处理器,再由他将数据写出。

Redis 内部使用文件事件处理器 file event handler ,这个文件事件处理器是单线程的,所以
Redis 才叫做单线程的模型。它采用 IO 多路复用机制同时监听多个 socket ,根据 socket 上的事
件来选择对应的事件处理器进行处理。
文件事件处理器的结构包含 4 个部分:

  1. 多个 socket 。
  2. IO 多路复用程序。
  3. 文件事件分派器。
  4. 事件处理器(连接应答处理器、命令请求处理器、命令回复处理器)。

多个 socket 可能会并发产生不同的操作,每个操作对应不同的文件事件,但是 IO 多路复用程序会
监听多个 socket,会将 socket 产生的事件放入队列中排队,事件分派器每次从队列中取出一个事
件,把该事件交给对应的事件处理器进行处理。

如果他问为什么io复用块,就去比较阻塞io,和非阻塞io,以及多线程的弊端(上下文切换),基于内存的操作,瓶颈在于io。

以下是原码

image-20220702131945234


1、网络模型-阻塞IO

在《UNIX网络编程》一书中,总结归纳了5种IO模型:

  • 阻塞IO(Blocking IO)
  • 非阻塞IO(Nonblocking IO)
  • IO多路复用(IO Multiplexing)
  • 信号驱动IO(Signal Driven IO)
  • 异步IO(Asynchronous IO)

应用程序想要去读取数据,他是无法直接去读取磁盘数据的,他需要先到内核里边去等待内核操作硬件拿到数据,这个过程就是1,是需要等待的,等到内核从磁盘上把数据加载出来之后,再把这个数据写给用户的缓存区,这个过程是2,如果是阻塞IO,那么整个过程中,用户从发起读请求开始,一直到读取到数据,都是一个阻塞状态。

1653897115346

具体流程如下图:

用户去读取数据时,会去先发起recvform一个命令,去尝试从内核上加载数据,如果内核没有数据,那么用户就会等待,此时内核会去从硬件上读取数据,内核读取数据之后,会把数据拷贝到用户态,并且返回ok,整个过程,都是阻塞等待的,这就是阻塞IO

总结如下:

顾名思义,阻塞IO就是两个阶段都必须阻塞等待:

阶段一:

  • 用户进程尝试读取数据(比如网卡数据)
  • 此时数据尚未到达,内核需要等待数据
  • 此时用户进程也处于阻塞状态

阶段二:

  • 数据到达并拷贝到内核缓冲区,代表已就绪
  • 将内核数据拷贝到用户缓冲区
  • 拷贝过程中,用户进程依然阻塞等待
  • 拷贝完成,用户进程解除阻塞,处理数据

可以看到,阻塞IO模型中,用户进程在两个阶段都是阻塞状态。

1653897270074

2、 网络模型-非阻塞IO

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

阶段一:

  • 用户进程尝试读取数据(比如网卡数据)
  • 此时数据尚未到达,内核需要等待数据
  • 返回异常给用户进程
  • 用户进程拿到error后,再次尝试读取
  • 循环往复,直到数据就绪

阶段二:

  • 将内核数据拷贝到用户缓冲区
  • 拷贝过程中,用户进程依然阻塞等待
  • 拷贝完成,用户进程解除阻塞,处理数据
  • 可以看到,非阻塞IO模型中,用户进程在第一个阶段是非阻塞,第二个阶段是阻塞状态。虽然是非阻塞,但性能并没有得到提高。而且忙等机制会导致CPU空转,CPU使用率暴增。

1653897490116

3、 网络模型-IO多路复用

无论是阻塞IO还是非阻塞IO,用户应用在一阶段都需要调用recvfrom来获取数据,差别在于无数据时的处理方案:

如果调用recvfrom时,恰好没有数据,阻塞IO会使CPU阻塞,非阻塞IO使CPU空转,都不能充分发挥CPU的作用。
如果调用recvfrom时,恰好有数据,则用户进程可以直接进入第二阶段,读取并处理数据

所以怎么看起来以上两种方式性能都不好

而在单线程情况下,只能依次处理IO事件,如果正在处理的IO事件恰好未就绪(数据不可读或不可写),线程就会被阻塞,所有IO事件都必须等待,性能自然会很差。

就比如服务员给顾客点餐,分两步

  • 顾客思考要吃什么(等待数据就绪)
  • 顾客想好了,开始点餐(读取数据)

要提高效率有几种办法?

方案一:增加更多服务员(多线程)
方案二:不排队,谁想好了吃什么(数据就绪了),服务员就给谁点餐(用户应用就去读取数据)

那么问题来了:用户进程如何知道内核中数据是否就绪呢?

所以接下来就需要详细的来解决多路复用模型是如何知道到底怎么知道内核数据是否就绪的问题了

这个问题的解决依赖于提出的

文件描述符(File Descriptor):简称FD,是一个从0 开始的无符号整数,用来关联Linux中的一个文件。在Linux中,一切皆文件,例如常规文件、视频、硬件设备等,当然也包括网络套接字(Socket)。

通过FD,我们的网络模型可以利用一个线程监听多个FD,并在某个FD可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源。

阶段一:

  • 用户进程调用select,指定要监听的FD集合
  • 核监听FD对应的多个socket
  • 任意一个或多个socket数据就绪则返回readable
  • 此过程中用户进程阻塞

阶段二:

  • 用户进程找到就绪的socket
  • 依次调用recvfrom读取数据
  • 内核将数据拷贝到用户空间
  • 用户进程处理数据

当用户去读取数据的时候,不再去直接调用recvfrom了,而是调用select的函数,select函数会将需要监听的数据交给内核,由内核去检查这些数据是否就绪了,如果说这个数据就绪了,就会通知应用程序数据就绪,然后来读取数据,再从内核中把数据拷贝给用户态,完成数据处理,如果N多个FD一个都没处理完,此时就进行等待。

用IO复用模式,可以确保去读数据的时候,数据是一定存在的,他的效率比原来的阻塞IO和非阻塞IO性能都要高

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-GZncQRSe-1668352260132)(…/…/…/讲义/原理篇.assets/1653898691736.png)]

IO多路复用是利用单个线程来同时监听多个FD,并在某个FD可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源。不过监听FD的方式、通知的方式又有多种实现,常见的有:

  • select
  • poll
  • epoll

其中select和pool相当于是当被监听的数据准备好之后,他会把你监听的FD整个数据都发给你,你需要到整个FD中去找,哪些是处理好了的,需要通过遍历的方式,所以性能也并不是那么好

而epoll,则相当于内核准备好了之后,他会把准备好的数据,直接发给你,咱们就省去了遍历的动作。

image-20220701135347631

4、网络模型-Redis是单线程的吗?为什么使用单线程

Redis到底是单线程还是多线程?

  • 如果仅仅聊Redis的核心业务部分(命令处理),答案是单线程
  • 如果是聊整个Redis,那么答案就是多线程

在Redis版本迭代过程中,在两个重要的时间节点上引入了多线程的支持:

  • Redis v4.0:引入多线程异步处理一些耗时较旧的任务,例如异步删除命令unlink
  • Redis v6.0:在核心网络模型中引入 多线程,进一步提高对于多核CPU的利用率

因此,对于Redis的核心网络模型,在Redis 6.0之前确实都是单线程。是利用epoll(Linux系统)这样的IO多路复用技术在事件循环中不断处理客户端情况。

为什么Redis要选择单线程?

  • 抛开持久化不谈,Redis是纯 内存操作,执行速度非常快,它的性能瓶颈是网络延迟而不是执行速度,因此多线程并不会带来巨大的性能提升。
  • 多线程会导致过多的上下文切换,带来不必要的开销
  • 引入多线程会面临线程安全问题,必然要引入线程锁这样的安全手段,实现复杂度增高,而且性能也会大打折扣

5、Redis的单线程模型-Redis单线程和多线程网络模型变更

1653982278727

当我们的客户端想要去连接我们服务器,会去先到IO多路复用模型去进行排队,会有一个连接应答处理器,他会去接受读请求,然后又把读请求注册到具体模型中去,此时这些建立起来的连接,如果是客户端请求处理器去进行执行命令时,他会去把数据读取出来,然后把数据放入到client中, clinet去解析当前的命令转化为redis认识的命令,接下来就开始处理这些命令,从redis中的command中找到这些命令,然后就真正的去操作对应的数据了,当数据操作完成后,会去找到命令回复处理器,再由他将数据写出。

10、redis集群

1、主从复制(哨兵模式

1、全量同步

首先先认主,即从库设置自己的主库。这个命令会做以下几件事

1、从库发送增量同步给主库

2、主库判断是否是第一次同步,通过从库发来的replication id来判断是不是同一个数据集。如果不同,则拒绝,从库发送全量同步请求

3、主库使用rdb来生成rdb文件发送给从库,从库清空自己的数据,加载主库的rdb文件。

4、在主库生成rdb文件时,不会阻塞主库,因此此时还会产生命令,这些命令存放在repl_baklog中,等从库同步完成将repl_baklog中的命令发送给从库同步。

image-20220701161153078

2、增量同步

从库发送自己的offset去主库,主库根据自己的offset比较,将未同步的数据发送给从库

3、什么时候进行增量同步

在从库宕机恢复过来之后。

4、什么时候进行全量同步

第一次进行主从同步时

从库断开时间太久,导致发送的offset大于主库的offset时,即repl_baklog被覆盖时。

5、怎么优化redis主从集群

1、从优化全量备份方面来看

  • 使用无盘复制,避免全量同步时的磁盘io,直接使用网络io
  • 设置redis单节点的内存大小,让他不要这么大,这样就不用那么多时间来备份rdb文件了。

2、从优化从节点来看

  • 减少从库从故障中恢复的时间,尽量避免全量同步
  • 如果从库过多,让一些从库的主节点设置为从库,减轻主库的压力。
6、主库宕机之后该怎么办

redis提供了哨兵机制能够让故障进行自动恢复。

哨兵的作用:

1、监控,哨兵秒中向各个节点方心跳,如果超时没回应则认为该节点主观下线,当超过一半的节点认为他下线时,此时就认为他是客观下线的。

2、故障自动恢复:哨兵会选取一个从节点从新作为主库,选取规则为:1、看断开的时间,断开越久超过阈值直接淘汰 2、看数据是否是最新的,哨兵会从所有从节点中选取offset最大的也就是最新的节点作为主节点。3、如果都一样则选择id最小的,也就是随机选取

3、通知:因为我们使用java客户端并不知道哪个作为主节点,此时需要哨兵告诉java,设置哪个从新作为主节点。

哨兵故障恢复流程

选取完主节点之后,让该节点执行slaveof on one的命令,让其他节点执行认新的主节点作为主库的命令。修改故障节点作为从节点,并且认主。

2、集群

1、redis分片集群是什么

有点像mysql的分库分表,每个redis节点上存储的数据都不同。每个master都可以有从节点,每个master通过ping检测彼此的健康状态,客户端可以随意登录客户端进行访问,就好像是只有一个redis节点。

2、什么是散列插槽?

在集群方式下,key并不是和某个master节点绑定的而是绑定的插槽,插槽有点像逻辑上的位置,而master就好像是物理位置,通过计算key的有效部分的哈希值,使用哈希值%2^14(就是16384)来计算要映射在哪个哈希曹中。而哈希曹在创建集群的时候已经自动平均分配给了所有master。在我们去取值的时候,只要根据key的哈希值就能够自动的重定向到对应的master中去。

3、如何指定对应的key放在同一个master中?

在计算key的哈希值的时候,我们可以指定key计算hash值的有效部分,使用{有效部分}key的其他部分,这样计算的时候,就只根据括号里面的值来进行计算位置了,这时候我们可以指定key的前缀来让同类型的key放在同一个库中。

4、集群伸缩

这个是指新建一个新的master之后,为这个master分配插槽。我们可以使用redis-cli --cluster help 来获取相应的命令提示。

5、故障转移

当集群中的一个master宕机之后,他的从机会上线替代他。如果没有从机会导致相应的哈希槽存不进数据。导致整个集群不可用。

6、数据迁移

使用cluster failover命令可以手动让集群中的某个master宕机,切换到执行cluster failover命令的这个slave节点,实现无感知的数据迁移。

image-20220701203107395

11、分布式锁

1、使用redis实现分布式锁思路

首先,synchronized实现是靠的是jvm来实现的,这时如果使用的是两台机器,两个jvm,此时两个请求分别请求两个不同的jvm,所以这个时候jvm不会进行阻塞,导致数据错误。所以这时,我们就需要一个所有jvm都能看见的东西且唯一的来做锁。所以使用redis来实现。

redis实现分布式锁的优点:高性能、高并发、高可用,即使客户端宕机我们也可以通过设置key失效的时间来防止锁不被释放。

redis实现的关键:setnx,这个命令的意思的如果没有则返回添加成功,如果存在返回失败。且redis是单线程的,这个命令线程安全。这样,所有的jvm都来抢占这个锁,获得锁则进行业务。在java使用redis实现分布式锁的时候,还必须将线程表识作为value存入key中,这样防止别的线程删除。

解锁逻辑:就是使用del命令删除key。在删除的时候要检查锁的表识,看看是不是自己锁的,防止误删除现象(就是说,获取锁的线程以为某种原因被阻塞了,这时因为设置了超时释放锁,这时别的线程又来成功到了锁,此时,原来的线程被唤醒,同时删除了第二个线程加的锁,此时第三个线程又来获取锁,此时他看到没有人有锁,所以成功获取了锁,这就造成了误删除问题。)

同时还要使用lua脚本来确保删除和判断线程表识是否一致是原子操作,否则在判断完线程是一致后被阻塞还是会删错。

为了保证在加锁和设置失效时间为原子性,我们可以使用set命令的参数 set key value ex 过期时间 nx(nx表示不存在则加锁)

image-20220701221220739

https://www.nowcoder.com/exam/interview/detail?questionClassifyId=0&questionId=2413078&questionJobId=160&type=1

的时候,只要根据key的哈希值就能够自动的重定向到对应的master中去。

3、如何指定对应的key放在同一个master中?

在计算key的哈希值的时候,我们可以指定key计算hash值的有效部分,使用{有效部分}key的其他部分,这样计算的时候,就只根据括号里面的值来进行计算位置了,这时候我们可以指定key的前缀来让同类型的key放在同一个库中。

4、集群伸缩

这个是指新建一个新的master之后,为这个master分配插槽。我们可以使用redis-cli --cluster help 来获取相应的命令提示。

5、故障转移

当集群中的一个master宕机之后,他的从机会上线替代他。如果没有从机会导致相应的哈希槽存不进数据。导致整个集群不可用。

6、数据迁移

使用cluster failover命令可以手动让集群中的某个master宕机,切换到执行cluster failover命令的这个slave节点,实现无感知的数据迁移。

[外链图片转存中…(img-H3n35pw6-1668352260133)]

11、分布式锁

1、使用redis实现分布式锁思路

首先,synchronized实现是靠的是jvm来实现的,这时如果使用的是两台机器,两个jvm,此时两个请求分别请求两个不同的jvm,所以这个时候jvm不会进行阻塞,导致数据错误。所以这时,我们就需要一个所有jvm都能看见的东西且唯一的来做锁。所以使用redis来实现。

redis实现分布式锁的优点:高性能、高并发、高可用,即使客户端宕机我们也可以通过设置key失效的时间来防止锁不被释放。

redis实现的关键:setnx,这个命令的意思的如果没有则返回添加成功,如果存在返回失败。且redis是单线程的,这个命令线程安全。这样,所有的jvm都来抢占这个锁,获得锁则进行业务。在java使用redis实现分布式锁的时候,还必须将线程表识作为value存入key中,这样防止别的线程删除。

解锁逻辑:就是使用del命令删除key。在删除的时候要检查锁的表识,看看是不是自己锁的,防止误删除现象(就是说,获取锁的线程以为某种原因被阻塞了,这时因为设置了超时释放锁,这时别的线程又来成功到了锁,此时,原来的线程被唤醒,同时删除了第二个线程加的锁,此时第三个线程又来获取锁,此时他看到没有人有锁,所以成功获取了锁,这就造成了误删除问题。)

同时还要使用lua脚本来确保删除和判断线程表识是否一致是原子操作,否则在判断完线程是一致后被阻塞还是会删错。

为了保证在加锁和设置失效时间为原子性,我们可以使用set命令的参数 set key value ex 过期时间 nx(nx表示不存在则加锁)

[外链图片转存中…(img-PAYTed7E-1668352260133)]

https://www.nowcoder.com/exam/interview/detail?questionClassifyId=0&questionId=2413078&questionJobId=160&type=1

最简单redis分布式锁的实现方式:加锁:setnx(key,1),解锁:del(key),问题:如果客户忘记解锁,将会出现死锁。第二种分布式锁的实现方式:setnx(key,1)+expire(key,30),解锁:del(key).问题:,由于setnx和expire的非原子性,当第二步挂掉,仍然会出现死锁。第三种方式:加锁:将setnx和expire变成原子性操作,set(key,1,30,NX),解锁:del(key)。同时考虑到线程A还在执行,但是锁已经到期,当线程A执行结束时去释放锁时,可能就会释放别的线程锁,所以在解锁时要先判断一下value值,看是不是该锁,如果是,再进行删除。此时由于判断和释放锁不是原子性的,使用lua脚本来保证原子性操作。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值