Redis面试

基础

redis为什么快

基于内存实现

  • Redis 是基于内存的数据库
  • Redis 将数据存储在内存中,读写操作不会因为磁盘的 IO 速度限制

高效的数据结构

  • String:缓存、计数器、分布式锁等
  • List:链表、队列、微博关注人时间轴列表等
  • Hash:用户信息、Hash 表等
  • Set: 去重、赞、踩、共同好友等
  • Zset: 访问量排行榜、点击量排行榜等

在这里插入图片描述

单线程模型

  • Redis 的 单线程指的是 Redis 的网络 IO 以及键值对指令读写是由一个线程来执行的

单线程的好处:

  • 不会因为线程创建导致的性能消耗
  • 避免多线程上下文切换引起的 CPU 消耗
  • 避免了线程之间的竞争问题,比如添加锁、释放锁、死锁等,不需要考虑各种锁问题

单线程是否没有充分利用 CPU 资源呢?

  • Redis 是基于内存的操作,CPU 不是 Redis 的瓶颈,Redis 的瓶颈 最有可能是机器内存的大小或者网络带宽

I/O 多路复用模型

  • Redis 采用 I/O 多路复用技术,并发处理连接
  • 采用了 epoll + 自己实现的简单的事件框架。epoll 中的 读、写、关闭、连接都转化成了事件,然后利用 epoll 的多路复用特性

基本IO模型

一个基本的网络IO模型,当处理get请求

  • 和客户端建立连接、从socket中读取请求、解析客户端发送的请求、执行get指令、响应客户端数据(向socket写回数据)

  • 建立连接和读取请求会出现阻塞

    • Redis 监听到一个客户端有连接请求,但一直未能成功建立起连接时,会阻塞在 accept() 函数这里,导致其他客户端无法和 Redis 建立连接

    • 当 Redis 通过 recv() 从一个客户端读取数据时,如果数据一直没有到达,Redis 也会一直阻塞在 recv()

IO 多路复用

  • 多路指的是 多个 socket 连接复用指的是 复用一个线程
  • 多路复用主要有三种技术:select,poll,epoll
  • 基本原理是:内核不是监视应用程序本身的连接,而是监视应用程序的文件描述符

当客户端运行时,它将生成具有不同事件类型的套接字。在服务器端,I / O 多路复用程序(I / O 多路复用模块)会将消息放入队列(也就是 下图的 I/O 多路复用程序的 socket 队列),然后通过文件事件分派器将其转发到不同的事件处理器

简单来说:Redis 单线程情况下,内核会一直监听 socket 上的连接请求或者数据请求,一旦有请求到达就交给 Redis 线程处理,这就实现了一个 Redis 线程处理多个 IO 流的效果

select / epoll 提供了基于事件的回调机制,即针对不同事件的发生,调用相应的事件处理器。所以 Redis 一直在处理事件,提升 Redis 的响应性能

在这里插入图片描述
Redis 线程不会阻塞在某一个特定的监听或已连接套接字上,即不会阻塞在某一个特定的客户端请求处理上。正因为此,Redis 可以同时和多个客户端连接并处理请求,从而提升并发性

使用场景

String的使用场景

字符串类型的使用场景:缓存、计数器、分布式锁等等

常用命令:get/set/del/incr/decr/incrby/decrby

实战场景1:记录每一个用户的访问次数,或者记录每一个商品的浏览次数

  • 方案: 常用键名:userid:pageview 或者 pageview:userid,如果一个用户的id为123,那对应的redis key就为pageview:123,value就为用户的访问次数,增加次数可以使用命令:incr
  • 使用理由: 每一个用户访问次数或者商品浏览次数的修改是很频繁的,如果使用mysql这种文件系统频繁修改会造成mysql压力,效率也低。而使用redis的好处有二:使用内存,很快;单线程,所以无竞争,数据不会被改乱

实战场景2:缓存频繁读取,但是不常修改的信息,如用户信息,视频信息

  • 方案: 业务逻辑上:先从redis读取,有值就从redis读取,没有则从mysql读取,并写一份到redis中作为缓存,注意要设置过期时间
  • 键值设计上: 直接将用户一条mysql记录做序列化(通常序列化为json)作为值,userInfo:userid 作为key,键名如:userInfo:123,value存储对应用户信息的json串。如 key为:“user: id:name:1”, value为"{“name”:“leijia”,“age”:18}"

实战场景3:限定某个ip特定时间内的访问次数

  • 方案: 用key记录IP,value记录访问次数,同时key的过期时间设置为60秒,如果key过期了则重新设置,否则进行判断,当一分钟内访问超过100次,则禁止访问

实战场景4: 分布式session

  • 想要多个服务器共享一个session,可以将session存放在redis中,redis可以独立于所有负载均衡服务器,也可以放在其中一台负载均衡服务器上;但是所有应用所在的服务器连接的都是同一个redis服务器

Hash的使用场景

以购物车为例子,用户id设置为key,那么购物车里所有的商品就是用 户key对应的值了,每个商品有id和购买数量,对应hash的结构就是商品id为field,商品数量为value
在这里插入图片描述

  • 当对象的某个属性需要频繁修改时,不适合用string+json,因为它不够灵活,每次修改都需要重新将整个对象序列化并赋值;如果使用hash类型,则可以针对某个属性单独修改,没有序列化,也不需要修改整个对象。比如,商品的价格、销量、关注数、评价数等可能经常发生变化的属性,就适合存储在hash类型里

List的使用场景

列表本质是一个有序的,元素可重复的队列

实战场景: 定时排行榜

  • list类型的lrange命令可以分页查看队列中的数据。可将每隔一段时间计算一次的排行榜存储在list类型中,如QQ音乐内地排行榜,每周计算一次存储再list类型中,访问接口时通过page和size分页转化成lrange命令获取排行榜数据
  • 但是,并不是所有的排行榜都能用list类型实现,只有定时计算的排行榜才适合使用list类型存储,与定时计算的排行榜相对应的是实时计算的排行榜,list类型不能支持实时计算的排行榜,下面介绍有序集合sorted set的应用场景时会详细介绍实时计算的排行榜的实现

Set的使用场景

集合的特点是无序性和确定性(不重复)

实战场景:收藏夹

key为用户id,value为歌曲id的集合

Sorted Set的使用场景

有序集合的特点是有序,无重复值。与set不同的是sorted set每个元素都会关联一个score属性,redis正是通过score来为集合中的成员进行从小到大的排序

实战场景:实时排行榜
QQ音乐中有多种实时榜单,比如飙升榜、热歌榜、新歌榜,可以用redis key存储榜单类型,score为点击量,value为歌曲id,用户每点击一首歌曲会更新redis数据,sorted set会依据score即点击量将歌曲id排序

数据结构实现

字符串String

底层原理
  • Redis 中的字符串是一种 简单动态字符串

SDS的定义
在这里插入图片描述
在这里插入图片描述

  • len 属性的值为 5,表示这个 SDS保存了一个五字节长的字符串
  • free 属性的值为 0,表示这个SDS没有分配任何未使用空间
  • buf 属性是一个char类型的数组,数组的前五个字节分别保存了’R’、‘e’、‘d’、‘i’、's’五个字符,而 最后一个字节则保存了空字符’\0’

遵循空字符结尾这一惯例的好处是,SDS可以直接重用一部分C字符串函数库里面的函数

  • SDS遵循C字符串以空字符结尾的惯例,保存空字符的1字节空间 不计算在SDS的len属性里面,并且为空字符分配额外的1字节空间, 以及添加空字符到字符串末尾等操作,都是由SDS函数自动完成的
SDS与C字符串的区别
  • 常数复杂度获取字符串长度,和C字符串不同,因为SDS在len属性中记录了SDS本身的长度,所以获取一个SDS长度的复杂度仅为O(1)
  • 兼容部分C字符串函数
  • 杜绝缓冲区溢出
  • 减少修改字符串时带来的内存重分配次数
  • 二进制安全

杜绝缓冲区溢出

C字符串不记录自身长度带来的另一个问题是容易造成缓冲区溢出

  • 因为C字符串不记录自身的长度,所以 strcat 假定用户在执行这个函数时,已经为dest分配了足够多的内存,可以容纳src字符串中的所有内容,而一旦这个假定不成立时,就会产生缓冲区溢出

  • SDS API需要对SDS进行修改时,API 会先检查SDS的空间 是否满足修改所需的要求

    • 如果不满足的话,API会自动将SDS的空间扩展至执行修改所需的大小,然后才执行实际的修改操作,所以使用SDS既不需要手动修改SDS的空间大小,也不会出现缓冲区溢出问题

拼接后的问题:

sdscat不仅对这个SDS进行了拼接操作,它还为SDS分配了13字节的未使用空间,并且拼接之后的字符串也正好是13字节长,这种现象既不是bug也不是巧合,它和SDS的空间分配策略有关

减少修改字符串时带来的内存重分配次数

因为C字符串并不记录自身的长度,所以对于一个包含了N个字符的C字符串来说,这个C字符串的底层实现总是一个 N+1个字符长的数组(额外的一个字符空间用于保存空字符)

因为C字符串的长度和底层数组的长度之间存在着这种关联性,所以每次增长或者缩短一个C字符串,程序都总要对保存这个C字符串的数组进行一次内存重分配操作

  • 如果程序执行的是 增长字符串的操作,比如拼接操作(append),那么在执行这个操作之前,程序需要先 通过内存重分配来扩展底层数组的空间大小——如果忘了这一步就会产生 缓冲区溢出
  • 如果程序执行的是 缩短字符串的操作,比如截断操作(trim),那么在执行这个操作之后,程序需要 通过内存重分配来释放字符串不再使用的那部分空间——如果忘了这一步就会产生 内存泄漏

在一般程序中,如果修改字符串长度的情况不太常出现,那么每次修改都执行一次内存重分配是可以接受的
但是Redis作为数据库,经常被用于速度要求严苛、数据被频繁修改的场合,如果每次修改字符串的长度都需要执行一次内存重分配的话,那么光是执行内存重分配的时间就会占去修改字符串所用时间的一大部分,如果这种修改频繁地发生的话,可能还会对性能造成影响

为了避免C字符串的这种缺陷,SDS通过 未使用空间解除了字符串长度和底层数组长度之间的关联:在SDS中,buf数组的长度不一定就是字符数量加一,数组里面可以包含未使用的字节,而这些字节的数量就由SDS的free属性记录

通过未使用空间,SDS实现了 空间预分配惰性空间释放 两种优化策略

空间预分配:

  • 空间预分配 用于 优化SDS的字符串增长操作
  • 当SDS的API对一个SDS进行修改,并且需要对SDS进行空间扩展的时候,程序不仅会为SDS分配修改所必须要的空间,还会为SDS分配额外的未使用空间
  • 通过空间预分配策略,Redis可以减少连续执行字符串增长操作所需的内存重分配次数

惰性空间释放:

  • 惰性空间释放 用于 优化SDS的字符串缩短操作
  • 当SDS的API需要缩短SDS保存的字符串时,程序并 不立即使用内存重分配来回收缩短后多出来的字节,而是使用free属性将这些字节的数量记录起来,并等待将来使用
  • 通过惰性空间释放策略,SDS避免了缩短字符串时所需的内存重分配操作,并为将来可能有的增长操作提供了优化

二进制安全

  • C字符串中的字符必须符合某种编码(比如ASCII),并且除了字符串的末尾之外,字符串里面不能包含空字符,否则最先被程序读入的空字符将被误认为是字符串结尾,这些限制使得C字符串只能保存文本数据,而不能保存像图片、音频、视频、压缩文件这样的二进制数据
  • SDS的API都是二进制安全的(binary-safe),所有SDS API都会以处理二进制的方式来处理SDS存放在buf数组里的数据,程序不会对其中的数据做任何限制、过滤、或者假设,数据在写入时是什么样的,它被读取时就是什么样
  • 将SDS的buf属性称为字节数组的原因——Redis不是用这个数组来保存字符,而是用它来保存一系列二进制数据

列表 list

底层原理
  • Redis的链表实现是 双向链表,每个链表节点 由一个 listNode 结构 来表示,每个节点都有一个指向前置节点后置节点 的指针
  • 链表使用一个list 结构 来表示,这个结构带有 表头节点指针表尾节点指针,以及链表长度等信息,链表表头节点的前置节点和表尾节点的后置节点都指向NULL
  • 链表设置不同的类型特定函数,Redis的链表可以用于保存各种不同类型的值

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

  • 链表提供了高效的节点重排能力,以及顺序性的节点访问方式,并且可以通过增删节点来灵活地调整链表的长度
  • 每个链表节点使用一个 listNode 结构表示,多个listNode可以通过 prevnext 指针组成双向链表,使用 list 结构来操作链表

list结构

list 结构为 链表提供了 表头指针head、表尾指针tail,以及链表长度计数 len,而dup、free和match 成员函数 则是用于 实现多态链表所需的类型特定函数:

  • dup函数用于 复制链表节点所保存的值
  • free 函数用于 释放链表节点所保存的值
  • match 函数用于 对比链表节点所保存的值和另一个输入值是否相等
  • 在这里插入图片描述
Redis的链表实现的特性总结
  • 双向链表:链表节点带有 prevnext 指针,获取某个节点的前置节点和后置节点的复杂度都是O(1)
  • 无环表头节点的prev指针表尾节点的next指针 都指向NULL,对链表的访问以NULL为终点
  • 带表头指针和表尾指针:通过list结构的 head指针tail指针,程序获取链表的表头节点和表尾节点的复杂度为O(1)
  • 带链表长度计数:程序使用list结构的 len 属性 来对list持有的链表节点进行计数,程序获取链表中节点数量的复杂度为O(1)
  • 多态: 链表节点使用 void* 指针来保存节点值,并且可以通过list结构的dup、free、match三个属性为节点值 设置类型特定函数,所以链表可以用于保存各种不同类型的值

字典hash

底层原理
  • Redis中的字典使用哈希表作为底层实现,每个字典带有两个哈希表,一个平时使用,另一个仅在进行rehash时使用
  • 哈希表使用 链地址法 来解决键冲突,被分配到同一个索引上的多个键值对会连接成一个单向链表
  • 哈希表进行 扩展或者收缩 操作时,程序需要将现有哈希表包含的所有键值对 rehash 到新哈希表里面,并且这个rehash过程并不是一次性地完成的,而是渐进式地完成的

哈希表
在这里插入图片描述

  • Redis字典所使用的哈希表由 dict.h/dictht 结构定义
  • table 属性是一个 数组,数组中的每个元素都是一个指向 dict.h/dictEntry结构的 指针,每个dictEntry结构保存着 一个键值对
  • size 属性记录了 哈希表的大小table数组的大小,而 used 属性则记录了哈希表目前已有节点(键值对)的数量
  • sizemask 属性的值总是等于 size - 1 ,这个属性和哈希值一起决定 一个键应该被放到table数组的哪个索引上面
  • 在这里插入图片描述

哈希表节点
在这里插入图片描述

  • 哈希表节点使用dictEntry结构表示,每个dictEntry结构都保存着一个键值对
  • key 属性保存着 键值对 中的 v 属性则保存着 键值对 中的 ,其中键值对的值可以是一个指针,或者是一个uint64_t 整数,又或者是一个int64_t整数
  • next 属性是指向另一个哈希表节点 的指针,这个指针可以将 多个哈希值相同的键值对连接在一起,以此来解决 键冲突的问题
  • 在这里插入图片描述

字典
在这里插入图片描述
在这里插入图片描述

  • Redis中的字典由 dict.h/dict 结构表示
  • type 属性和 privdata 属性是针对 不同类型的键值对,为创建多态字典而设置的:
  • type 属性 是一个指向 dictType 结构的 指针,每个dictType结构保存了 一簇用于操作特定类型键值对的函数,Redis会为用途不同的字典设置不同的类型特定函数
  • privdata 属性则 保存了需要传给那些类型特定函数的可选参数
  • ht 属性是一个包含两个项的数组,数组中的每个项都是一个 dictht哈希表,一般情况下,字典只使用ht[0]哈希表,ht[1]哈希表只会在对ht[0]哈希表进行rehash时使用
  • rehashidx 也和 rehash有关,记录了rehash目前的进度,如果目前没有在进行rehash,那么它的值为 -1
  • 在这里插入图片描述
解决键冲突
  • 当有两个或以上数量的键被分配到了哈希表数组的同一个索引上面时,称这些键发生了冲突
  • Redis的哈希表使用 链地址法 来解决键冲突,每个哈希表节点都有一个next指针,多个哈希表节点可以用next指针构成一个单向链表,被分配到同一个索引上的多个节点可以用这个 单向链表 连接起来

示例

  • 假设程序要将键值对 k2 和 v2 添加到哈希表里面,并且计算得出 k2 的索引值为2,那么键k1和k2将产生冲突,而解决冲突的办法就是使用next指针将键k2和k1所在的节点连接起来
  • 因为 dictEntry 节点组成的链表没有指向链表表尾的指针,所以为了速度考虑,程序总是将新节点添加到链表的表头位置(复杂度为O(1)),排在其他已有节点的前面
  • 在这里插入图片描述
rehash

随着操作的不断执行,哈希表保存的键值对会逐渐地增多或者减少,为了让哈希表的负载因子(load factor)维持在一个合理的范围之内,当哈希表保存的键值对数量太多或者太少时,程序需要对哈希表的大小进行相应的扩展或者收缩

扩展和收缩 哈希表的工作可以通过执行 rehash 操作来完成,Redis对字典的哈希表执行rehash的步骤如下:

  • 为字典的 ht[1] 哈希表 分配空间,这个哈希表的空间大小取决于要执行的操作,以及ht[0]当前包含的键值对数量(也即是ht[0].used属性的值)
  • 如果执行的是 扩展操作,那么ht[1]的大小为第一个大于等于ht[0].used * 2 的 2^n
  • 如果执行的是 收缩操作,那么ht[1]的大小为第一个大于等于ht[0].used 的 2^n
  • 将保存在 ht[0] 中的 所有键值对 rehash到ht[1]上面:rehash指的是 重新计算键的哈希值和索引值,然后将键值对放置到ht[1]哈希表的指定位置上
  • 当 ht[0] 包含的所有键值对都迁移到了ht[1]之后(ht[0]变为空表),释放ht[0],将ht[1]设置为ht[0],并在ht[1]新创建一个空白哈希表,为下一次rehash做准备

哈希表的扩展与收缩

当以下条件中的任意一个被满足时,程序会自动开始对哈希表执行扩展操作:

  • 服务器目前 没有 在执行 BGSAVE 命令或者 BGREWRITEAOF 命令,并且哈希表的 负载因子 大于等于 1
  • 服务器目前 正在 执行 BGSAVE 命令或者 BGREWRITEAOF 命令,并且哈希表的 负载因子 大于等于5
  • 当哈希表的 负载因子小于0.1 时,程序自动开始对哈希表执行收缩操作

哈希表的负载因子可以通过公式:

load_factor = ht[0].used / ht[0].size

根据 BGSAVE 命令或 BGREWRITEAOF命令是否正在执行,服务器执行扩展操作所需的负载因子并不相同
这是因为在执行BGSAVE命令或BGREWRITEAOF命令的过程中,Redis需要创建当前服务器进程的子进程,而大多数操作系统都采用 写时复制(copy-on-write)技术来优化子进程的使用效率,所以在子进程存在期间,服务器会 提高执行扩展操作所需的负载因子,从而尽可能地 避免在子进程存在期间进行哈希表扩展 操作,这可以 避免不必要的内存写入操作,最大限度地节约内存

示例
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

举个例子,假设程序要对图4-8所示字典的ht[0]进行扩展操作,那么程序将执行以下步骤:

  • ht[0].used当前的值为4,4*2=8,而8(2 3)恰好是第一个大于等于4的2的n次方,所以程序会将ht[1]哈希表的大小设置为8。图4-9展示了ht[1]在分配空间之后,字典的样子
  • 将ht[0]包含的四个键值对都rehash到ht[1]
  • 释放ht[0],并将ht[1]设置为ht[0],然后为ht[1]分配一个空白哈希表,如图4-11所示。至此,对哈希表的扩展操作执行完毕,程序成功将哈希表的大小从原来的4改为了现在的8
渐进式rehash

渐进式rehash的好处:

  • 扩展 或 收缩 哈希表需要将ht[0]里面的所有键值对rehash到ht[1]里面。这个 rehash 动作 并不是一次性、集中式地完成的,而是分多次、渐进式地完成的
  • 采取 分而治之 的方式,将 rehash键值对 所需的计算工作均摊到对字典的每个添加、删除、查找和更新操作上,从而 避免了 集中式rehash 而带来的庞大计算量

哈希表渐进式rehash的详细步骤:

  • 为ht[1]分配空间,让字典同时持有ht[0]和ht[1]两个哈希表
  • 在字典中维持一个 索引计数器变量 rehashidx,并将它的值设置为 0,表示rehash工作正式开始
  • 在 rehash 进行期间,每次对字典执行添加、删除、查找或者更新操作时,程序除了执行指定的操作以外,还会顺带将 ht[0] 哈希表在 rehashidx 索引上的所有键值对rehash到 ht[1],当rehash工作 完成 之后,程序将 rehashidx属性的值 加 1
  • 随着字典操作的不断执行,最终在某个时间点上,ht[0]的所有键值对都会被rehash至ht[1],这时程序将rehashidx属性的值设为 -1,表示rehash操作已完成

渐进式rehash执行期间的哈希表操作:

  • 在进行渐进式rehash的过程中,字典会同时使用ht[0]和ht[1]两个哈希表,所以在渐进式rehash进行期间,字典的删除(delete)、查找(find)、更新(update)等操作会在两个哈希表上进行
  • 例如,要在字典里面查找一个键的话,程序会先在ht[0]里面进行查找,如果没找到的话,就会继续到ht[1]里面进行查找
  • 另外,在渐进式rehash执行期间,新添加到字典的键值对一律会被保存到ht[1]里面,而ht[0]则不再进行任何添加操作,保证了ht[0]包含的键值对数量会只减不增,并随着rehash操作的执行而最终变成空表

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

跳跃表

跳跃表简介

  • 跳跃表是有序集合的底层实现之一,跳跃表 是一种 有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的
  • Redis 的跳跃表实现由 zskiplistzskiplistNode 两个结构组成,其中 zskiplist 用于保存 跳跃表信息(比如表头节点、表尾节点、长度),而zskiplistNode则用于表示 跳跃表节点
  • 每个跳跃表节点的层高都是1至32之间的随机数
  • 在同一个跳跃表中,多个节点可以包含相同的分值,但每个节点的成员对象必须是唯一的
  • 跳跃表中的节点按照分值大小进行排序,当分值相同时,节点按照成员对象的大小进行排序

跳跃表的实现

在这里插入图片描述

  • Redis的跳跃表由 redis.h/zskiplistNode 和 redis.h/zskiplist两个结构定义
  • zskiplistNode 结构用于 表示跳跃表节点,而 zskiplist 结构则用于 保存跳跃表节点的相关信息,比如节点的数量,以及指向表头节点和表尾节点的指针

zskiplist结构:

  • header:指向跳跃表的 表头节点
  • tail:指向跳跃表的 表尾节点
  • level:记录目前跳跃表内 即 层数最大的那个节点的层数(表头节点的层数不计算在内)
  • length:记录跳跃表的长度 即 跳跃表目前包含节点的数量(表头节点不计算在内)

zskiplistNode结构:

  • 层(level):节点中用L1、L2、L3等字样标记节点的各个层,L1代表第一层,L2代表第二层,以此类推。每个层都带有两个属性:前进指针 和 跨度。前进指针 用于 访问位于表尾方向的其他节点,而 跨度 则 记录了前进指针所指向节点和当前节点的距离。图中 连线上带有数字的箭头就代表前进指针,而那个数字就是跨度。当程序从表头向表尾进行遍历时,访问会沿着层的前进指针进行
  • 后退(backward)指针:节点中用 BW 字样标记节点的后退指针,它指向位于当前节点的前一个节点。后退指针在程序 从表尾向表头遍历时使用
  • 成员对象(obj):各个节点中的o1、o2和o3是节点所保存的 成员对象

注意表头节点和其他节点的构造是一样的:表头节点也有后退指针、分值和成员对象,不过表头节点的这些属性都不会被用到,所以图中省略了这些部分,只显示了表头节点的各个层

跳跃表节点
在这里插入图片描述
层 :

  • 跳跃表节点的 level数组 可以包含多个元素,每个元素都包含一个指向其他节点的指针,程序可以通过这些层来 加快访问其他节点的速度,一般来说,层的数量越多,访问其他节点的速度就越快
  • 每次创建一个 新跳跃表节点 的时候,程序都根据 幂次定律(power law,越大的数出现的概率越小)随机生成一个介于 1 和 32 之间的值 作为 level数组 的大小,这个大小就是层的 “高度”

图5-2分别展示了三个高度为1层、3层和5层的节点,因为C语言的数组索引总是从0开始的,所以节点的第一层是level[0],而第二层是level[1],以此类推。

前进指针:
在这里插入图片描述

  • 每个层都有一个指向表尾方向的前进指针(level[i].forward属性),用于从表头向表尾方向访问节点。 (图5-3用 虚线 表示出了程序从表头向表尾方向,遍历跳跃表中所有节点的路径:)
  • 迭代程序首先访问跳跃表的第一个节点(表头),然后从第四层的前进指针移动到表中的第二个节点
  • 在第二个节点时,程序沿着第二层的前进指针移动到表中的第三个节点
  • 在第三个节点时,程序同样沿着第二层的前进指针移动到表中的第四个节点
  • 当程序再次沿着第四个节点的前进指针移动时,它碰到一个NULL,程序知道这时已经到达了跳跃表的表尾,于是结束这次遍历

跨度:

层的跨度(level[i].span属性)用于记录 两个节点之间的距离

  • 两个节点之间的跨度越大,它们相距得就越远
  • 指向NULL的所有前进指针的跨度都为0,因为它们没有连向任何节点

跨度和遍历操作无关,遍历操作只使用前进指针就可以完成了,跨度实际上是用来计算排位(rank)的:在查找某个节点的过程中,将沿途访问过的所有层的跨度累计起来,得到的结果就是目标节点在跳跃表中的排位

跨度示例
在这里插入图片描述
在这里插入图片描述

  • 图5-4用虚线标记了在跳跃表中查找分值为3.0、成员对象为o3的节点时,沿途经历的层:查找的过程只经过了一个层,并且层的跨度为3,所以目标节点在跳跃表中的排位为3
  • 图5-5用虚线标记了在跳跃表中查找分值为2.0、成员对象为o2的节点时,沿途经历的层:在查找节点的过程中,程序经过了两个跨度为1的节点,因此可以计算出,目标节点在跳跃表中的排位为2

后退指针:

  • 节点的后退指针(backward属性)用于从 表尾向表头方向访问节点: 跟可以一次跳过多个节点的前进指针不同,因为 每个节点只有一个后退指针,所以每次只能后退至前一个节点

图5-6用虚线展示了如果从表尾向表头遍历跳跃表中的所有节点:程序首先通过跳跃表的tail指针访问表尾节点,然后通过后退指针访问倒数第二个节点,之后再沿着后退指针访问倒数第三个节点,再之后遇到指向NULL的后退指针,于是访问结束

分值和成员:
在这里插入图片描述

  • 节点的 分值(score属性)是一个 double类型的浮点数,跳跃表中的所有节点都按分值从小到大来排序
  • 节点的 成员对象(obj属性)是一个 指针,它指向一个 字符串对象,而字符串对象则保存着一个SDS值
  • 同一个跳跃表中,各个节点保存的成员对象必须是唯一的,但是多个节点保存的分值却可以是相同的分值相同 的节点将按照 成员对象 在字典序中的大小来进行排序,成员对象较小的节点会排在前面(靠近表头的方向),而成员对象较大的节点则会排在后面(靠近表尾的方向)

分值和成员示例
在这里插入图片描述
在图5-7所示的跳跃表中,三个跳跃表节点都保存了相同的分值10086.0,但保存成员对象o1的节点却排在保存成员对象o2和o3的节点之前,而保存成员对象o2的节点又排在保存成员对象o3的节点之前,由此可见,o1、o2、o3三个成员对象在字典中的排序为o1<=o2<=o3

跳跃表
在这里插入图片描述
在这里插入图片描述

  • 使用一个 zskiplist结构 来持有这些节点,程序可以更方便地对整个跳跃表进行处理,比如快速访问跳跃表的表头节点和表尾节点,或者快速地获取跳跃表节点的数量(也即是跳跃表的长度)
  • headertail 指针分别指向跳跃表的表头和表尾节点,通过这两个指针,程序定位表头节点和表尾节点的复杂度为O(1)
  • 通过使用 length 属性来记录节点的数量,程序可以在O(1)复杂度内返回跳跃表的长度
  • level 属性则用于在O(1)复杂度内获取跳跃表中层高最大的那个节点的层数量,注意表头节点的层高并不计算在内

整数集合

整数集合的实现

  • 整数集合(intset)是 集合键 的底层实现之一,当一个集合只包含整数值元素,并且这个集合的元素数量不多时,Redis就会使用整数集合作为集合键的底层实现
  • 整数集合的底层实现为 数组,这个数组以 有序、无重复的方式保存集合元素,在有需要时,程序会根据新添加元素的类型,改变这个数组的类型
  • 升级操作为 整数集合带来了操作上的灵活性,并且尽可能地 节约了内存
  • 整数集合 只支持升级操作,不支持降级操作

整数集合的实现

在这里插入图片描述

  • 整数集合(intset)是Redis用于保存 整数值的集合 抽象数据结构,它可以保存类型为int16_t、int32_t或者int64_t的整数值,并且保证集合中不会出现重复元素
  • contents 数组 是整数集合的底层实现:整数集合的每个元素都是contents数组的一个数组项(item),各个项在数组中按值的大小 从小到大有序地排列,并且数组中 不包含任何重复项
  • length 属性 记录了 整数集合包含的元素数量,也即是contents数组的长度

虽然 intset 结构将 contents 属性声明为 int8_t类型 的数组,但实际上 contents数组并不保存任何int8_t类型的值,contents数组的真正类型取决于encoding属性的值:

  • 如果encoding属性的值为 INTSET_ENC_INT16 ,那么contents就是一个 int16_t 类型的数组,数组里的每个项都是一个int16_t类型的整数值(最小值为-32768,最大值为32767)
  • 如果encoding属性的值为 INTSET_ENC_INT32,那么contents就是一个 int32_t 类型的数组,数组里的每个项都是一个int32_t类型的整数值(最小值为-2147483648,最大值为2147483647)
  • 如果encoding属性的值为 INTSET_ENC_INT64,那么contents就是一个 int64_t 类型的数组,数组里的每个项都是一个int64_t类型的整数值(最小值为-9223372036854775808,最大值为9223372036854775807)

整数集合示例
在这里插入图片描述

  • encoding属性的值为INTSET_ENC_INT16,表示整数集合的底层实现为int16_t类型的数组,而集合保存的都是int16_t类型的整数值
  • length属性的值为5,表示整数集合包含五个元素
  • contents数组按从小到大的顺序保存着集合中的五个元素
  • 因为每个集合元素都是int16_t类型的整数值,所以contents数组的大小等于sizeof(int16_t)5=165=80位

升级

每当要将一个新元素添加到整数集合里面,并且 新元素的类型 比整数集合现有所有元素的类型 都要长 时,整数集合需要先进行 升级,然后才能将新元素添加到整数集合里面

升级整数集合并添加新元素共分为三步进行:

  • 根据新元素的类型扩展整数集合底层数组的空间大小,并为新元素 分配空间
  • 将底层数组现有的所有元素都转换成与新元素相同的类型,并将类型转换后的元素放置到正确的位上,而且在放置元素的过程中,需要继续 维持底层数组的有序性质不变
  • 将新元素添加到底层数组里面

升级的好处

  • 提升整数集合的灵活性

  • 尽可能地节约内存

提升灵活性

  • 因为 C语言是静态类型语言,为了避免类型错误,通常不会将两种不同类型的值放在同一个数据结构里面
  • 一般只使用int16_t类型的数组来保存int16_t类型的值,只使用int32_t类型的数组来保存int32_t类型的值
  • 整数集合可以通过 自动升级底层数组 来适应新元素,所以可以随意地将int16_t、int32_t或者int64_t类型的整数添加到集合中,而不必担心出现类型错误,这种做法非常灵活

节约内存

  • 一直只向整数集合添加int16_t类型的值,那么整数集合的底层实现就会一直是int16_t类型的数组,只有在要将int32_t类型或者int64_t类型的值添加到集合时,程序才会对数组进行升级

降级

整数集合不支持降级操作,一旦对数组进行了升级,编码就会一直保持升级后的状态

即使将集合里唯一一个真正需要使用int64_t类型来保存的元素4294967295删除了,整数集合的编码仍然会维持INTSET_ENC_INT64,底层数组也仍然会是int64_t类型的
在这里插入图片描述

压缩列表

底层实现

  • 压缩列表被用作列表键和哈希键的 底层实现之一,是一种为节约内存而开发的顺序型数据结构
  • (当列表键 只包含 少量列表项,并且每个列表项要么就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来做列表键的底层实现)
  • (当一个哈希键 只包含 少量键值对,比且每个键值对的 键和值要么就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来做哈希键的底层实现)
  • 压缩列表可以包含多个节点,每个节点可以保存一个字节数组或者整数值
  • 添加新节点到压缩列表,或者从压缩列表中删除节点,可能 会引发连锁更新操作,但这种操作出现的几率并不高

压缩列表的构成

压缩列表 是Redis为了 节约内存 而开发的,是由一系列特殊编码的连续内存块组成的顺序型数据结构。一个压缩列表可以包含 任意多个节点(entry),每个节点可以 保存一个字节数组或者一个整数值

压缩列表的各个组成部分:
在这里插入图片描述
压缩列表各个组成部分的详细说明:

在这里插入图片描述

压缩列表示例
在这里插入图片描述

  • zlbytes 属性的值为0x50(十进制80),表示 压缩列表的总长 为80字节
  • zltail 属性的值为0x3c(十进制60),这表示如果有 一个指向压缩列表起始地址的指针p,那么只要用指针p加上偏移量60,就可以计算出表尾节点entry3的地址
  • zllen 属性的值为0x3(十进制3),表示压缩列表包含三个节点

压缩列表节点的构成

  • 每个压缩列表节点可以保存一个字节数组或者一个整数值
  • 节点的 previous_entry_length 属性以 字节 为单位,记录了压缩列表中前一个节点的长度
  • 节点的 encoding 属性 记录了 节点的content属性所保存数据的类型以及长度
  • 节点的 content 属性 负责保存节点的值节点值可以是一个字节数组或者整数,值的类型和长度由节点的encoding属性决定

字节数组 可以是以下三种长度的其中一种:

  • 长度小于等于63(2^6 - 1)字节的字节数组
  • 长度小于等于16383(2^14 - 1)字节的字节数组
  • 长度小于等于4294967295(2^32 - 1)字节的字节数组

整数值 则可以是以下六种长度的其中一种:

  • 4位长,介于0至12之间的无符号整数
  • 1字节长的有符号整数
  • 3字节长的有符号整数
  • int16_t类型整数
  • int32_t类型整数
  • int64_t类型整数

每个压缩列表节点都由 previous_entry_length、encoding、content 三个部分组成

previous_entry_length

节点的 previous_entry_length 属性以 字节 为单位,记录了压缩列表中前一个节点的长度

previous_entry_length属性的长度可以是 1字节或者5字节

  • 如果前一节点的长度 小于254字节,那么previous_entry_length属性的长度为 1字节前一节点的长度就保存在这一个字节里面
  • 如果前一节点的长度 大于等于254字节,那么previous_entry_length属性的长度为 5字节:其中属性的第一字节会被设置为0xFE(十进制值254),而之后的 四个字节则用于保存前一节点的长度
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述

因为节点的previous_entry_length属性记录了前一个节点的长度,所以程序可以 通过指针运算,根据当前节点的起始地址来计算出前一个节点的起始地址

拥有了一个指向某个节点起始地址的指针,那么通过这个指针以及这个节点的previous_entry_length属性,程序就可以一直向前一个节点回溯,最终到达压缩列表的表头节点

从表尾节点向表头节点进行遍历的完整过程:
在这里插入图片描述

  • 首先,拥有指向压缩列表表尾节点entry4起始地址的指针p1(指向表尾节点的指针可以通过指向压缩列表起始地址的指针加上zltail属性的值得出)
  • 通过用p1减去entry4节点previous_entry_length属性的值,我们得到一个指向entry4前一节点entry3起始地址的指针p2
  • 通过用p2减去entry3节点previous_entry_length属性的值,我们得到一个指向entry3前一节点entry2起始地址的指针p3
  • 通过用p3减去entry2节点previous_entry_length属性的值,我们得到一个指向entry2前一节点entry1起始地址的指针p4,entry1为压缩列表的表头节点
  • 最终 从表尾节点向表头节点遍历了整个列表

encoding

节点的encoding属性记录了节点的content属性所保存数据的类型以及长度:

  • 一字节、两字节或者五字节长,值的最高位为00、01或者10的是字节数组编码:这种编码表示节点的content属性保存着字节数组,数组的长度由编码除去最高两位之后的其他位记录
  • 一字节长,值的最高位以11开头的是整数编码:这种编码表示节点的content属性保存着整数值,整数值的类型和长度由编码除去最高两位之后的其他位记录

**字节数组编码: **
在这里插入图片描述

**整数编码: **
在这里插入图片描述

content

节点的content属性负责保存节点的值,节点值可以是一个字节数组或者整数,值的类型和长度由节点的encoding属性决定

在这里插入图片描述

  • 展示了一个保存字节数组的节点示例
  • 编码的最高两位00表示节点保存的是一个字节数组
  • 编码的后六位001011记录了字节数组的长度11
  • content属性保存着节点的值"hello world"

在这里插入图片描述

  • 编码11000000表示节点保存的是一个int16_t类型的整数值
  • content属性保存着节点的值10086

连锁更新

BitMap

  • Bitmap 的底层数据结构用的是 String 类型的 SDS 数据结构来保存位数组,Redis 把每个字节数组的 8 个 bit 位利用起来,每个 bit 位 表示一个元素的二值状态(不是 0 就是 1)
  • 可以将 Bitmap 看成是一个 bit 为单位的数组,数组的每个单元只能存储 0 或者 1,数组的下标在 Bitmap 中叫做 offset 偏移量
  • 为了直观展示,可以理解成 buf 数组的每个字节用一行表示,每一行有 8 个 bit 位,8 个格子分别表示这个字节中的 8 个 bit 位
  • 8 个 bit 组成一个 Byte,所以 Bitmap 会极大地节省存储空间。 这就是 Bitmap 的优势

使用场景

遇到的统计场景只需要统计数据的二值状态,比如用户是否存在、 ip 是否是黑名单、以及签到打卡统计等场景就可以考虑使用 Bitmap

只需要一个 bit 位就能表示 0 和 1。在统计海量数据的时候将大大减少内存占用

判断用户登录状态

Bitmap 提供了 GETBIT、SETBIT 操作,通过一个偏移值 offset 对 bit 数组的 offset 位置的 bit 位进行读写操作,需要注意的是 offset 从 0 开始

只需要一个 key = login_status 表示存储用户登陆状态集合数据, 将用户 ID 作为 offset,在线就设置为 1,下线设置 0。通过 GETBIT判断对应的用户是否在线。50000 万 用户只需要 6 MB 的空间

// SETBIT 命令  设置或者清空 key 的 value 在 offset 处的 bit 值(只能是 0 或者 1)
SETBIT <key> <offset> <value>

// GETBIT 命令  获取 key 的 value 在 offset 处的 bit 位的值,当 key 不存在时,返回 0
GETBIT <key> <offset>

// 假如我们要判断 ID = 10086 的用户的登陆情况:
// 第一步,执行以下指令,表示用户已登录
SETBIT login_status 10086 1

// 第二步,检查该用户是否登陆,返回值 1 表示已登录
GETBIT login_status 10086

// 第三步,登出,将 offset 对应的 value 设置成 0
SETBIT login_status 10086 0    

用户每个月的签到情况

在签到统计中,每个用户每天的签到用 1 个 bit 位表示,一年的签到只需要 365 个 bit 位

比如统计编号 89757 的用户在 2021 年 5 月份的打卡情况要如何进行?

key 可以设计成 uid:sign:{userId}:{yyyyMM},月份的每一天的值 - 1 可以作为 offset(因为 offset 从 0 开始,所以 offset = 日期 - 1

// 第一步,执行下面指令表示记录用户在 2021 年 5 月 16 号打卡
SETBIT uid:sign:89757:202105 15 1

// 第二步,判断编号 89757 用户在 2021 年 5 月 16 号是否打卡
GETBIT uid:sign:89757:202105 15
    
// 第三步,统计该用户在 5 月份的打卡次数,使用 BITCOUNT 指令。
// 该指令用于统计给定的 bit 数组中,值 = 1 的 bit 位的数量 
BITCOUNT uid:sign:89757:202105    

如何统计这个月首次打卡时间呢?

Redis 提供了 BITPOS key bitValue [start] [end]指令,返回数据表示 Bitmap 中第一个值为 bitValue 的 offset 位置。

在默认情况下, 命令将检测整个位图, 用户可以通过可选的 start 参数和 end 参数指定要检测的范围

// 获取 userID = 89757 在 2021 年 5 月份首次打卡日期
// 需要注意的是,我们需要将返回的 value + 1 ,因为 offset 从 0 开始
BITPOS uid:sign:89757:202105 1

在记录了一个亿的用户连续 7 天的打卡数据,如何统计出这连续 7 天连续打卡用户总数呢?

把每天的日期作为 Bitmap 的 key,userId 作为 offset,若是打卡则将 offset 位置的 bit 设置成 1

一共有 7 个这样的 Bitmap,如果我们能对这 7 个 Bitmap 的对应的 bit 位做 『与』运算

同样的 UserID offset 都是一样的,当一个 userID 在 7 个 Bitmap 对应对应的 offset 位置的 bit = 1 就说明该用户 7 天连续打卡

结果保存到一个新 Bitmap 中,我们再通过 BITCOUNT 统计 bit = 1 的个数便得到了连续打卡 7 天的用户总数了

Redis 提供了 BITOP operation destkey key [key ...]这个指令用于对一个或者多个 键 = key 的 Bitmap 进行位元操作

opration 可以是 andORNOTXOR。当 BITOP 处理不同长度的字符串时,较短的那个字符串所缺少的部分会被看作 0 。空的 key 也被看作是包含 0 的字符串序列

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-BIb49sey-1649846560766)(D:\Work-Space\TyporaImage\image-20210831235951634.png)]

// 操作指令表示将 三个 bitmap 进行 AND 操作,并将结果保存到 destmap 中。接着对 destmap 执行 BITCOUNT 统计
// 与操作
BITOP AND destmap bitmap:01 bitmap:02 bitmap:03
// 统计 bit 位 =  1 的个数
BITCOUNT destmap

简单计算下 一个一亿个位的 Bitmap占用的内存开销,大约占 12 MB 的内存(10^8/8/1024/1024),7 天的 Bitmap 的内存开销约为 84 MB。同时我们最好给 Bitmap 设置过期时间,让 Redis 删除过期的打卡数据,节省内存

布隆过滤器

布隆过滤器专门用来检测集合中是否存在特定的元素

如果在平时我们要判断一个元素是否在一个集合中,通常会采用查找比较的方法

  • 采用线性表存储,查找时间复杂度为O(N)
  • 采用平衡二叉排序树(AVL、红黑树)存储,查找时间复杂度为O(logN)
  • 采用哈希表存储,考虑到哈希碰撞,整体时间复杂度也要O[log(n/m)]

当需要判断一个元素是否存在于海量数据集合中,不仅查找时间慢,还会占用大量存储空间

**底层原理 : **

布隆过滤器底层使用bit数组存储数据,该数组中的元素默认值是 0,布隆过滤器第一次初始化的时候,会把数据库中所有已存在的key,经过一些列的 hash 算法,每个key都会计算出多个位置,然后把这些位置上的元素值设置为 1 。之后,有用户key请求过来的时候,再通过相同的 hash 算法计算位置

  • 如果多个位置中的元素值都是1,则说明该key在数据库中已存在。允许继续往后面操作

  • 如果有1个以上的位置上的元素值是0,则说明该key在数据库中不存在。这时可以拒绝该请求,而直接返回

  • 布隆过滤器 存在误判的情况存在数据更新问题

  • 初始化数据针对每个key都是通过多次hash算法,计算出一些位置,然后把这些位置上的元素值设置为1,hash算法会出现hash冲突,可能不同的key会计算出相同的位置。如果有几千万或者上亿的数据,布隆过滤器中的hash冲突会非常明显

    • 如果布隆过滤器判断出某个key存在,可能出现误判。如果判断某个key不存在,则它在数据库中一定不存在
    • 如果想减少误判率,可以适当增加hash函数
  • 布隆过滤器最致命的问题是:如果数据库中的数据更新了,需要同步更新布隆过滤器。

布隆过滤器使用场景

  • 爬虫系统url去重
  • 垃圾邮件过滤
  • 黑名单

HyperLogLog

实现原理

给定一系列的随机整数,记录下低位连续零位的最大长度 K,这通过这个值可以估算出随机数的数量 N

pf 的内存占用为什么是 12KB

  • 在计数比较小时,它的存储空间采用稀疏矩阵存储,空间占用很小,仅仅在计数慢慢变大、稀疏矩阵占用空间渐渐超过了阎值时,才会一次性转变成稠密矩阵,才会占用 12KB 的空间

  • HyperLogLog 实现中用的是 16384 个桶,也就是2^14, 每个桶的 maxbits 需要 6个 bit 来存储,最大可以表示 maxbits=63,即总共占用内存 (2^14) * 6 / 8 = 12 KB

统计每个网页每天的 UV 数据, UV 不一样,它要去重,同一个用户一天之内的多次访问请求只能计数一次。这就要求每一个网页请求都需要带上用户的 ID ,无论是登录用户还是未登录用户都需要一个唯一 来标识。

应用场景

set

如果页面的访问量非常大,就需要一个很大的 set 集合去统计,这样就非常浪费空间,单纯为了一个去重功能耗费这么多的空间不值得

HyperLogLog

Redis 提供的 HyperLogLog 数据结构,提供不精确的去重计数方案,虽然不精确,但是也不是非常离谱,标准误差是 0.81%

API命令

  • pfadd 和 set 集合的 sadd 的用法是一样的
  • pfcount 和 scard 的用法是一样的,直接获取计数值
  • pfmerge 用于将多个 pf 计数值累加在一起形成一个新的 pf 值

大key问题

scan和keys

如何从海量的 key 中找出满足特定前缀的 key 列表?

keys 和 scan 的对比

  • keys 是遍历算法,复杂度是 O(n),如果实例中有千万级以上的 key ,就会导致 Redis 服务卡顿,读写Redis的指令都会被延后甚至会超时报错
  • scan复杂度也是O(n),但是通过 游标分布进行,不会阻塞线程。提供 limit参数,可以控制返回结果的最大条数

同keys 一样,它也提供模式匹配功能

服务器不需要为游标保存状态,游标的唯一状态就是 scan 返回给客户端的游标整数

返回的结果可能会有重复,需要客户端去重

遍历的过程中如果有数据修改,改动后的数据能不能遍历到是不确定的。

单次返回的结果是空的并不意昧着遍历结束,而要看返回的游标值是否为零

scan的基本使用

scan 提供了三个参数,第一个是 cursor 整数值,第二个是 key 的正则模式,第三个是遍历的 limit hint

第一次遍历时 cursor 值为 0,然后将返回结果中第一个整数值作为下一次遍历的 cursor ,一直遍历到返回的 cursor 值为 0 时结束

更多的指令

scan 指令是一系列指令,除了可以遍历所有的 key 之外,还可以对指定的容器集合进行遍历

比如 zscan 遍历 zset 集合元素, hscan 遍历 hash 字典的元素, sscan遍历 set 集合的元素

大key

  • 单个简单的key存储的value很大
  • hash, set,zset,list 结构中存储过多的元素
  • 一个集群存储了上亿的key

大key的危害

  • 读写bigkey会导致超时严重,甚至阻塞服务
  • 大key相关的删除或者自动过期时,会出现qps突降或者突升的情况,极端情况下,会造成主从复制异常,Redis服务阻塞无法响应请求

找到大key

  • redis-cli -h 127.0.0.1 -p 7001 --bigkeys
  • redis-cli -h 127.0.0.1 -p 7001 --bigkeys -i 0.1 每隔100条scan指令就会休眠0.1s,ops就不会剧烈提升,但是扫描的时间会变长

解决办法

单个简单的key存储的value很大
(1) 对象需要每次都整存整取

  • 可以尝试将对象分拆成几个key-value, 使用multiGet获取值,分拆单次操作的压力,将操作压力平摊到多个redis实例中,降低对单个redis的IO影响

(2)对象每次只需要存取部分数据

  • 分拆成几个key-value, 也可以将这个存储在一个hash中,每个field代表一个具体的属性,使用hget,hmget来获取部分的value,使用hset,hmset来更新部分属性

hash, set,zset,list 中存储过多的元素

  • 可以对存储元素按一定规则进行分类,分散存储到多个redis实例中
  • 对于一些榜单类的场景,用户一般只会访问前几百及后几百条数据,可以只缓存前几百条以及后几百条,即对用户经常访问的数据做缓存(正序倒序的前几页),而不是全部都做,对于获取中间的数据则可以直接从数据库获取

过期策略

设置过期时间

  • expire key time(以秒为单位) 最常用的方式
  • setex(String key, int seconds, String value)–字符串独有的方式

除了字符串自己独有设置过期时间的方法外,其他方法都需要依靠expire方法来设置时间 如果没有设置时间,那缓存就是永不过期
如果设置了过期时间,之后又想让缓存永不过期,使用persist key

三种过期策略

定时删除 和 定期删除主动删除策略惰性删除被动删除策略

定时删除

含义:在设置键的过期时间的同时,创建一个定时器(timer),让定时器在键的过期时间来临时执行对键的删除操作

优点:定时删除策略对内存是最友好的,通过使用定时器,定时删除策略可以保证过期键会尽可能快地被删除,并释放过期键所占用的内存

缺点:

  • 对CPU资源是不友好,在过期键比较多的情况下,删除过期键这一行为可能会占用相当一部分CPU资源,对服务器的响应时间和吞吐量造成影响
  • 创建一个 定时器 需要用到Redis服务器中的 时间事件,时间事件的实现方式——无序链表,查找一个事件的时间复杂度为O(N)——并不能高效地处理大量时间事件

惰性删除

含义:放任键过期不管,但是每次从键空间中获取键时,都检查 取得的键 是否过期如果过期的话,就删除该键;如果没有过期,就返回该键

优点:对CPU资源友好,程序只会在取出键时才对键进行过期检查并且删除的目标仅限于当前处理的键

缺点:对内存不友好,如果一个键已经过期,而这个键,又仍然保留在数据库中占用内存。如果有非常多的过期键,而这些过期键又恰好没有被访问到的话,它们永远也不会被删除(除非用户手动执行FLUSHDB),甚至可以将这种情况看作是一种 内存泄漏

定期删除

含义:每隔一段时间程序就对数据库进行一次检查,删除里面的过期键。至于要删除多少过期键,以及要检查多少个数据库,则由算法决定

定期删除策略是前两种策略的一种整合和折中:

  • 定期删除策略 每隔一段时间执行一次删除过期键操作,并通过 限制删除操作 执行的时长和频率来减少删除操作对CPU资源的影响
  • 通过定期删除过期键,定期删除策略 有效地减少了因为过期键而带来的内存浪费

定期删除策略的难点是确定删除操作执行的时长和频率:

  • 如果删除操作执行得太频繁,或者执行的时间太长,定期删除策略就会退化成定时删除策略,以至于将CPU资源过多地消耗在删除过期键上面
  • 如果删除操作执行得 太少,或者执行的时间太短,定期删除策略又会和惰性删除策略一样,出现浪费内存的情况

如果采用定期删除策略的话,服务器必须 根据情况,合理地设置删除操作的执行时长和执行频率

Redis采用的过期策略

定期删除 + 懒汉式删除

懒汉式删除流程:

  • 在进行get或setnx等操作时,先检查key是否过期
  • 若过期,删除key,然后执行相应操作
  • 若没过期,直接执行相应操作

定期删除流程

  • 遍历每个数据库(就是redis.conf中配置的”database”数量,默认为16)
  • 检查当前库中的指定个数个key(默认是每个库检查20个key,注意相当于该循环执行20次,循环体是下边的描述)
  • 如果当前库中没有一个key设置了过期时间,直接执行下一个库的遍历
  • 随机获取一个设置了过期时间的key,检查该key是否过期,如果过期,删除key
  • 判断定期删除操作是否已经达到指定时长,若已经达到,直接退出定期删除

对于定期删除,在程序中有一个全局变量current_db来记录下一个将要遍历的库,假设有16个库,我们这一次定期删除遍历了10个,那此时的current_db就是11,下一次定期删除就从第11个库开始遍历,假设current_db等于15了,那么之后遍历就再从0号库开始(此时current_db==0)

缓存问题

缓存穿透

  • 缓存穿透是指用户请求的数据在缓存中不存在,同时在数据库中也不存在
  • 如果有恶意攻击者不断请求系统中不存在的数据,会导致短时间大量请求落在数据库上,造成数据库压力过大,甚至击垮数据库系统

解决方案

校验参数

  • 对用户的id做校验。例如,用户的合法id是 12xxx,以12开头的。如果用户传入了13开头的id,则参数校验失败,直接把相关请求拦截掉。这样可以过滤掉部分恶意伪造的用户id

返回空对象

  • 当缓存未命中,查询DB也为空,可以将返回的空对象写到缓存中,这样下次请求该key时直接从缓存中查询返回空对象,请求不会落到DB。为了避免存储过多空对象,通常会给空对象设置一个过期时间

存在的两个问题:

  • 如果有大量的key穿透,缓存空对象会占用宝贵的内存空间
  • 空对象的key设置了过期时间,在这段时间可能会存在缓存和持久层数据不一致的场景

布隆过滤器

  • 布隆过滤器底层使用bit数组存储数据,该数组中的元素默认值是 0,布隆过滤器第一次初始化的时候,会把数据库中所有已存在的key,经过一些列的 hash 算法,每个key都会计算出多个位置,然后把这些位置上的元素值设置为 1 。之后,有用户key请求过来的时候,再通过相同的 hash 算法计算位置
    • 如果多个位置中的元素值都是1,则说明该key在数据库中已存在。允许继续往后面操作
    • 如果有1个以上的位置上的元素值是0,则说明该key在数据库中不存在。这时可以拒绝该请求,而直接返回
  • 布隆过滤器 存在误判的情况存在数据更新问题
  • 初始化数据针对每个key都是通过多次hash算法,计算出一些位置,然后把这些位置上的元素值设置为1,hash算法会出现hash冲突,可能不同的key会计算出相同的位置。如果有几千万或者上亿的数据,布隆过滤器中的hash冲突会非常明显
    • 如果布隆过滤器判断出某个key存在,可能出现误判。如果判断某个key不存在,则它在数据库中一定不存在
    • 如果想减少误判率,可以适当增加hash函数
  • 布隆过滤器最致命的问题是:如果数据库中的数据更新了,需要同步更新布隆过滤器。

缓存击穿

  • 一个热点缓存在某个时刻到了过期时间失效了,大量的请求直接到数据库,造成数据库压力过大

加锁

  • 在访问数据库的时候加锁,防止多个相同的请求同时访问数据库。然后把数据库查询到的结果重新放入缓存中

缓存不失效

  • 对于热点数据,不用设置过期时间
    • 例如参与秒杀活动的热门商品,先从数据库中查询出商品的数据,然后同步到缓存中,提前做预热。然后等秒杀活动结束一段时间之后,再 手动删除 这些缓存

缓存雪崩

  • 大量的热点缓存同时失效。导致大量的请求直接访问数据库,数据库压力过大
  • 缓存服务器宕机或者其他原因导致整个缓存不可用

过期时间加随机数

  • 避免大量缓存同时失效的情况发生,不要设置相同的过期时间。在设置的过期时间基础上,加上 1 ~ 60秒的随机数

高可用

  • 集群模式或者哨兵模式,避免出现单节点故障导致整个redis服务不可用的情况
  • 使用哨兵模式之后,当某个master服务下线时,自动将该master下的某个slave服务升级为master服务,替代已下线的master服务继续处理请求

分布式锁

为什么需要分布式锁

  • 分布式锁 相对应的是「单机锁」,避免多个线程同时操作一个共享变量产生数据问题,通常会使用一把锁来 「互斥」,以 保证共享变量的正确性,其使用范围是在「同一个进程」中
  • 要实现分布式锁,必须借助一个外部系统,所有进程都去这个系统上申请「加锁」。这个外部系统,必须要实现「互斥」的能力,即两个请求同时进来,只会给一个进程返回成功,另一个返回失败(或等待)
  • 这个外部系统,可以是 MySQL,也可以是 Redis 或 Zookeeper。但为了追求更好的性能,我们通常会选择使用 Redis 或 Zookeeper 来做

实现分布式锁

SETNX lock 1
(integer) 1     // 客户端1,加锁成功

SETNX lock 1
(integer) 0     // 客户端2,加锁失败

DEL lock // 释放锁
(integer) 1

它存在一个很大的问题,当客户端 1 拿到锁后,如果发生下面的场景,就会造成「死锁」:

  • 程序处理业务逻辑异常,没及时释放锁
  • 进程挂了,没机会释放锁

如何避免死锁

在 Redis 中实现时,就是给这个 key 设置一个**「过期时间」**

// 一条命令保证原子性执行
SET lock 1 EX 10 NX
OK

存在两个严重的问题:

  • 锁过期:客户端 1 操作共享资源耗时太久,导致锁被自动释放,之后被客户端 2 持有
  • 释放别人的锁:客户端 1 操作共享资源完成后,却又释放了客户端 2 的锁

锁过期: 可能是评估操作共享资源的时间不准确

  • 例如,操作共享资源的时间「最慢」可能需要 15s,而我们却只设置了 10s 过期,那这就存在锁提前过期的风险
  • 过期时间太短,那增大冗余时间,例如设置过期时间为 20s,这样确实可以「缓解」这个问题,降低出问题的概率,但依旧无法「彻底解决」问题
  • 原因在于,客户端在拿到锁之后,在操作共享资源时,遇到的场景有可能是很复杂的,例如,程序内部发生异常、网络请求超时等等。
  • 既然是「预估」时间,也只能是大致计算,除非你能预料并覆盖到所有导致耗时变长的场景,但这其实很难

释放别人的锁,一个客户端释放了其它客户端持有的锁

  • 每个客户端在释放锁时,都是「无脑」操作,并没有检查这把锁是否还「归自己持有」,所以就会发生释放别人锁的风险,这样的解锁流程,很不「严谨」

LUA脚本实现

锁被别人释放怎么办?

解决办法是:客户端在加锁时,设置一个 [唯一标识」进去

// 锁的VALUE设置为UUID
// 这里假设 20s 操作共享时间完全足够,先不考虑锁自动过期的问题。
SET lock $uuid EX 20 NX
OK

之后,在释放锁时,要先判断这把锁是否还归自己持有,伪代码可以这么写:

// 锁是自己的,才释放
if redis.get("lock") == $uuid:
    redis.del("lock")

// 安全释放锁的 Lua 脚本如下
// 判断锁是自己的,才释放
if redis.call("GET",KEYS[1]) == ARGV[1]
then
    return redis.call("DEL",KEYS[1])
else
    return 0
end

释放锁使用的是 GET + DEL 两条命令,使用 Lua 脚本 让 Redis 来执行

因为 Redis 处理每一个请求是「单线程」执行的,在执行一个 Lua 脚本时,其它请求必须等待,直到这个 Lua 脚本处理完成,这样一来,GET + DEL 之间就不会插入其它命令了

  • 加锁:SET lock_key $unique_id EX $expire_time NX
  • 操作共享资源
  • 释放锁:Lua 脚本,先 GET 判断锁是否归属自己,再 DEL 释放锁

评估锁过期时间

加锁时,先设置一个过期时间,然后我们一个**「守护线程」**,定时去检测这个锁的失效时间,如果锁快要过期了,操作共享资源还未完成,那么就自动对锁进行「续期」,重新设置过期时间

Redisson 是一个 Java 语言实现的 Redis SDK 客户端,在使用分布式锁时,它就采用了「自动续期」的方案来避免锁过期,这个守护线程我们一般也把它叫做「看门狗」线程

除此之外,这个 SDK 还封装了很多易用的功能:

  • 可重入锁
  • 乐观锁
  • 公平锁
  • 读写锁
  • Redlock

Redlock

使用 Redis 时,一般会采用 主从集群 + 哨兵 的模式部署,这样做的好处在于,当主库异常宕机时,哨兵可以实现「故障自动切换」,把从库提升为主库,继续提供服务,以此保证可用性

那当「主从发生切换」时,这个分布锁会依旧安全吗?

试想这样的场景:

  • 客户端 1 在主库上执行 SET 命令,加锁成功
  • 此时,主库异常宕机,SET 命令还未同步到从库上(主从复制是异步的)
  • 从库被哨兵提升为新主库,这个锁在新的主库上,丢失了!

Redlock 的方案基于 2 个前提:

  • 不再需要部署从库和哨兵实例,只部署主库
  • 但主库要部署多个,官方推荐 至少 5 个实例

Redlock 具体流程,一共分为 5 步:

  • 客户端先获取「当前时间戳T1」
  • 客户端依次向多个 Redis 实例发起加锁请求(用前面讲到的 SET 命令),且每个请求会设置超时时间(毫秒级,要远小于锁的有效时间),如果某一个实例加锁失败(包括网络超时、锁被其它人持有等各种异常情况),就立即向下一个 Redis 实例申请加锁
  • 如果客户端从 >=3 个(大多数)以上 Redis 实例加锁成功,则再次获取「当前时间戳T2」,如果 T2 - T1 < 锁的过期时间,此时,认为客户端加锁成功,否则认为加锁失败
  • 加锁成功,去操作共享资源(例如修改 MySQL 某一行,或发起一个 API 请求)
  • 加锁失败,向「全部节点」发起释放锁请求(前面讲到的 Lua 脚本释放锁)

4 个重点:

  • 客户端在多个 Redis 实例上申请加锁
  • 必须保证大多数节点加锁成功
  • 大多数节点加锁的总耗时,要小于锁设置的过期时间
  • 释放锁,要向全部节点发起释放锁请求

为什么要在多个实例上加锁?

  • 本质上是为了「容错」,部分实例异常宕机,剩余的实例加锁成功,整个锁服务依旧可用

为什么大多数加锁成功,才算成功?

  • 如果只存在「故障」节点,只要大多数节点正常,那么整个系统依旧是可以提供正确服务的

为什么步骤 3 加锁成功后,还要计算加锁的累计耗时?

  • 操作的是多个节点,所以耗时肯定会比操作单个实例耗时更久,而且,因为是网络请求,网络情况是复杂的,有可能存在延迟、丢包、超时等情况发生,网络请求越多,异常发生的概率就越大
  • 即使大多数节点加锁成功,但如果加锁的累计耗时已经「超过」了锁的过期时间,那此时有些实例上的锁可能已经失效了,这个锁就没有意义了

为什么释放锁,要操作所有节点?

  • 例如,客户端在一个 Redis 实例上加锁成功,但在读取响应结果时,网络问题导致读取失败,那这把锁其实已经在 Redis 上加锁成功了。所以,释放锁时,不管之前有没有加锁成功,需要释放「所有节点」的锁,以保证清理节点上「残留」的锁

缓存和数据库的一致性

一致性 01

写流程:

  • 先删除缓存再更新DB,之后再异步将数据刷回缓存

读流程:

  • 先读缓存,如果缓存没读到,则去读DB,之后再异步将数据刷回缓存

缺点

容灾不足

  • 如果删除缓存失败,流程是否还继续走? 如果继续执行从 更新完DB到异步刷新缓存,数据处于滞后状态。如果缓存处于不可写状态,那么异步刷新那步也可能会失败,那缓存就会长期处于旧数据

并发问题

  • 写写并发: 多个服务器的多个线程 更新DB ,更新DB完成就要进行异步刷缓存,多服务器的异步操作是无法保证顺序的。 存在先更新的DB操作,反而很晚才去刷新缓存,即 数据有误
  • 读写并发: 服务器A进行 读操作,服务器B进行 写操作,存在 更新前的老数据写入缓存,最终数据还是错的

方案总结

  • 比较大的缺陷在于 更新缓存有可能会失败,而失败之后缓存中数据就一直会处于错误状态,所以它并不能保证数据的最终一致性
  • 适合使用的场景:并发量、一致性要求都不是很高的情况

一致性 02

为了保证 数据最终一致性,我们引入 binlog,通过解析binlog来刷新缓存,这样即使刷新失败,依然可以进行日志回放,再次刷新缓存

写流程: 先删除缓存再更新DB,监听从库(资源少的话主库也ok)的binlog,通过分析 binlog 解析出需要刷新的数据,然后读主库把最新的数据写入缓存

这里需要提一下:最后刷新前的读主库或者读从库,甚至不读库直接通过binlog解析出需要的数据都是ok的,这由业务决定,比如刷新的数据只是表的一行,那直接通过binlog就完全能解析出来;然而如果需要刷新的数据来自多行,多张表,甚至多个库的话,那就需要读主库或是从库才行

读流程: 读缓存,如果缓存没读到,则去读DB,之后再异步将数据刷回缓存

缺点

只适合简单业务,复杂业务容易发生并发问题:

  • 针对单库多表单次更新的改进:利用事务
  • 当AB表的更新发生在一个事务内时,不管线程1、线程2如何读取,他们都能获取两张表的最新数据,所以刷新缓存的数据都是符合要求

所以这种方案只针对多表单次更新的情况

  • 针对多表多次更新的改进:增量更新
  • 每张表的更新,在同步缓存时,只获取该表的字段覆盖缓存
  • 这样,线程1,线程2总能获取对应表最新的字段,而且Databus对于同表同行会以串行的形式通知下游,所以能保证缓存的最终一致性

更新“某张表多行记录“时,这个操作要在一个事务内,不然并发问题依然存在,正如前面分析的

并发问题:

  • 数据更新的时候,缓存中的数据已失效,此时又发生了更新
  • 读的时候,缓存中的数据已失效,此时又发生了更新

总结

  • 适合使用的场景:业务简单,读写QPS比较低的情况
  • binlog用来刷新缓存是一个很棒的选择,它天然的顺序性用来做同步操作很具有优势
  • 并发问题来自于Canal 或 Databus
  • 拿Databus来说,由于不同行、表、库的binlog的消费并不是时间串行的

一致性 03

串行化,我们利用MQ将所有“读数据库” + “写缓存”的步骤串行化

写流程:

  • 先删除缓存之后再更新DB,监听从库(资源少的话主库也ok)的binlog,通过分析binlog解析出需要需要刷新的数据标识,然后将 数据标识写入MQ,接下来就消费MQ解析MQ消息来读库获取相应的数据刷新缓存

读流程:

  • 读缓存,如果缓存没读到,则去读DB,之后再异步将数据标识写入MQ(这里MQ与写流程的MQ是同一个),接下来就消费MQ解析MQ消息来读库获取相应的数据刷新缓存

优点

写流程容灾分析:

  • DEL缓存失败:没关系,后面会覆盖
  • 写MQ失败:Databus或Canal都会重试
  • 消费MQ的:重新消费即可

读流程容灾分析:

  • 异步写MQ失败:缓存为空,是OK的,下次还读库就好了

无并发问题:

  • 方案让 “读库 + 刷缓存” 的操作串行化,这就不存在老数据覆盖新数据的并发问题了

方案总结

实现了“最终一致性”。这个方案优点比较明显,解决了一直提到的“容灾问题”和“并发问题”,保证了缓存在最后和DB的一致。如果业务只需要达到 最终一致性”要求的话,这个方案是比较合理的

一致性 04

强一致性

  • 缓存和DB数据一致
  • 缓存中没有数据(或者说:不会去读缓存中的老版本数据)

最终一致性方案” + “时间差” = “强一致性方案”

我们加一个缓存,将近期被修改的数据进行标记锁定。读的时候,标记锁定的数据强行走DB,没锁定的数据,先走缓存

写流程:

  • 把修改的数据通过 Cache_0标记 正在被修改,如果标记成功则 一致性03,则要放弃这次修改
  • 怕DB主从延迟太久、MQ延迟太久,或Databus监听的从库挂机之类的情况,可以考虑增加一个监控定时任务
  • 比如增加一个时间间隔 *2S *的 worker 的去对比以下两个数据:
  • 时间1: 最后修改数据库的时间 VS 时间2: 最后由更新引起的’MQ刷新缓存对应数据的实际更新数据库’的时间
  • 如果 时间1 VS 时间2 相差超过5S,那我们就自动把相应的缓存分片读降级

读流程:

  • 先读Cache_0,看看要读的数据是否被标记,如果被标记,则直接读主库;如果没有被标记,则 一致性03

优点

写流程容灾分析:

  • 标记失败:没关系,放弃整个更新操作

  • DEL缓存失败:没关系,后面会覆盖

  • 写MQ失败:Databus或Canal都会重试

  • 消费MQ的:重新消费即可

读流程容灾分析:

  • 读Cache_0失败:没关系,直接读主库
  • 异步写MQ失败:缓存为空,是OK的,下次还读库就好了

无并发问题:

  • 方案让 “读库 + 刷缓存” 的操作串行化,这就不存在老数据覆盖新数据的并发问题了

缺点

增加Cache_0强依赖

  • 要强一致性,必然要牺牲一些。 可以把 Cache_0 设计成 多机器多分片,即使部分分片挂了,也只有小部分流量透过Cache直接打到DB上,这是完全是可接受的

复杂度高

  • 涉及到Databus、MQ、定时任务等等组件,实现起来复杂

Redis持久化

Redis为什么要持久化

Redis是内存数据库,为了保证效率所有的操作都是在内存中完成。数据都是缓存在内存中,当你重启系统或者关闭系统,之前缓存在内存中的数据都会丢失再也不能找回。因此为了避免这种情况,Redis需要实现持久化将内存中的数据存储起来

Redis如何实现持久化

  • RDB持久化 :能够在指定的时间间隔能对你的数据进行快照存储
  • AOF持久化:记录每次对服务器写的操作,当服务器重启的时候会重新执行这些命令来恢复原始的数据,AOF命令以redis协议追加保存每次写的操作到文件末尾。Redis还能对AOF文件进行后台重写,使得AOF文件的体积不至于过大
  • 同时开启RDB和AOF:在这种情况下当redis重启的时候会优先载入AOF文件来恢复原始的数据,在通常情况下AOF文件保存的数据集要比RDB文件保存的数据集要完整
  • 不使用持久化:如果你只希望你的数据在服务器运行的时候存在,你也可以选择不使用任何持久化方式

RDB

  • RDB文件用于 保存和还原 Redis服务器所有数据库中的 所有键值对数据
  • RDB持久化既可以手动执行,也可以根据 服务器配置选项定期执行
  • RDB持久化功能所生成的 RDB文件 是一个 经过压缩的二进制文件,通过该文件可以还原生成RDB文件时的数据库状态

RDB文件的创建与载入

有两个Redis命令可以用于生成RDB文件,一个是SAVE,另一个是BGSAVE:

  • SAVE 命令会 阻塞 Redis服务器进程,直到RDB文件创建完毕为止,在服务器进程阻塞期间,服务器不能处理任何命令请求
  • BGSAVE 命令会 派生出一个子进程,然后由子进程负责创建RDB文件,服务器进程(父进程)继续处理命令请求

RDB的载入

  • RDB文件的载入工作 是在 服务器启动时自动执行的,所以Redis并没有专门用于载入RDB文件的命令,只要Redis服务器在启动时检测到RDB文件存在,它就会自动载入RDB文件
  • 服务器在载入RDB文件期间,会一直处于阻塞状态,直到载入工作完成为止

因为AOF文件的更新频率通常比RDB文件的更新频率高

  • 如果服务器开启了AOF持久化功能,那么服务器会优先使用AOF文件来还原数据库状态
  • 在这里插入图片描述

BGSAVE命令执行时的服务器状态

BGSAVE命令执行期间,服务器处理 SAVE、BGSAVE、BGREWRITEAOF 三个命令的方式会和平时有所不同:

  • 客户端发送的 SAVE 命令会被服务器拒绝,服务器禁止SAVE命令和BGSAVE命令同时执行是为了 避免父进程(服务器进程)和子进程同时执行两个 rdbSave 调用,防止产生竞争条件
  • 客户端发送的 BGSAVE命令会被服务器拒绝,因为同时执行两个BGSAVE命令 也会产生竞争条件
  • BGREWRITEAOF和BGSAVE两个命令不能同时执行:
  • 如果 BGSAVE 命令正在执行,那么客户端发送的 BGREWRITEAOF 命令会被 延迟 到 BGSAV E命令 执行完毕之后执行
  • 如果 BGREWRITEAOF 命令正在执行,那么客户端发送的 BGSAVE 命令 会被服务器拒绝

AOF

  • AOF持久化是通过保存Redis服务器 所执行的写命令 来记录数据库状态的
  • AOF文件中的所有命令都以Redis命令 请求协议的格式保存,因为Redis的命令请求协议是 纯文本格式
  • 命令请求会先保存到 AOF缓冲区 里面,之后再定期写入并同步到AOF文件
  • AOF重写 可以产生一个新的AOF文件,这个新的AOF文件和原有的AOF文件所保存的数据库状态一样,但体积更小
  • 在执行 BGREWRITEAOF 命令时,Redis服务器会维护一个 AOF重写缓冲区,该缓冲区会在子进程创建新AOF文件期间,记录服务器执行的所有写命令。当子进程完成创建新AOF文件的工作之后,服务器会将重写缓冲区中的所有内容追加到新AOF文件的末尾,使得新旧两个AOF文件所保存的数据库状态一致。最后,服务器用新的AOF文件替换旧的AOF文件,以此来完成AOF文件重写操作

AOF持久化的实现

AOF持久化功能的实现可以分为 命令追加(append)、文件写入文件同步(sync)三个步骤

命令追加:

  • 服务器在执行完一个 写命令 之后,会以 协议格式 将被执行的写命令 追加到服务器状态的aof_buf缓冲区的末尾

AOF文件的写入与同步:

Redis的服务器进程就是一个事件循环:这个循环中的 文件事件 负责接收客户端的命令请求,以及向客户端发送命令回复,而 时间事件 则负责执行像serverCron函数这样需要定时运行的函数

  • 服务器在 处理文件事件 时可能会执行 写命令,使得一些内容被追加到aof_buf缓冲区里面,在服务器每次 结束一个 事件循环之前,它都会调用 flushAppendOnlyFile 函数,考虑是否需要将aof_buf缓冲区中的内容写入和保存到AOF文件里面

flushAppendOnlyFile 函数的行为由服务器配置的appendfsync选项的值来决定
如果用户没有主动为appendfsync选项设置值,那么appendfsync选项的 默认值为everysec
在这里插入图片描述

文件的写入和同步:

  • 系统提供了 fsyncfdatasync 两个同步函数,它们可以强制让操作系统立即将缓冲区中的数据写入到硬盘里面,从而确保写入数据的安全性

AOF文件的载入与数据还原
在这里插入图片描述
创建一个不带网络连接的伪客户端 :因为Redis的命令只能在客户端上下文中执行,而载入AOF文件时所使用的命令 直接来源于AOF文件而不是网络连接,所以服务器使用了一个没有网络连接的伪客户端来执行AOF文件保存的写命令,伪客户端执行命令的效果和带网络连接的客户端执行命令的效果完全一样

AOF重写

  • AOF后台重写,也即是 BGREWRITEAOF 命令的实现原理
  • 新旧两个AOF文件所保存的数据库状态相同,但新AOF文件不会包含任何浪费空间的冗余命令,所以新AOF文件的体积通常会比旧AOF文件的体积要小得多
  • AOF文件重写并不需要对现有的AOF文件进行任何读取、分析或者写入操作,这个功能是通过读取服务器当前的数据库状态来实现的

AOF重写功能的实现原理:

  • 从数据库中读取键现在的值,然后用一条命令去记录键值对,代替之前记录这个键值对的多条命令

Redis决定将 AOF重写程序 放到 子进程 里执行 :

  • 子进程进行AOF重写期间,服务器进程(父进程)可以继续处理命令请求
  • 子进程带有服务器进程的数据副本,使用子进程而不是线程,可以在避免使用锁的情况下,保证数据的安全性

子进程在进行AOF重写期间,服务器进程还需要继续处理命令请求,而新的命令可能会对现有的数据库状态进行修改,从而使得服务器当前的数据库状态和重写后的AOF文件所保存的数据库状态不一致

在这里插入图片描述

  • 为了解决这种 数据不一致问题,Redis服务器设置了一个 AOF重写缓冲区,这个缓冲区在服务器创建子进程之后开始使用,当Redis服务器执行完一个写命令之后,它会同时将这个写命令发送给 AOF缓冲区 和 AOF重写缓冲区

当子进程完成AOF重写工作之后,它会向父进程发送一个信号,父进程在接到该信号之后,会调用一个信号处理函数,并执行以下工作:

  • AOF重写缓冲区 中的 所有内容写入到新AOF文件中,这时新AOF文件所保存的数据库状态将和服务器当前的数据库状态一致
  • 对新的AOF文件进行改名,原子地(atomic)覆盖现有的AOF文件,完成新旧两个AOF文件的替换

在整个AOF后台重写过程中,只有信号处理函数执行时会对服务器进程(父进程)造成阻塞,在其他时候,AOF后台重写都不会阻塞父进程,这将AOF重写对服务器性能造成的影响降到了最低

主从复制

主从复制,是指将一台Redis服务器的数据,复制到其他的Redis服务器。前者称为主节点(master),后者称为从节点(slave);数据的复制是单向的,只能由主节点到从节点

主从复制的作用

  • 数据冗余: 主从复制实现了数据的热备份,是持久化之外的一种数据冗余方式
  • 故障恢复: 当主节点出现问题时,可以由从节点提供服务,实现快速的故障恢复;实际上是一种服务的冗余
  • 负载均衡: 在主从复制的基础上,配合读写分离,可以由主节点提供写服务,由从节点提供读服务,分担服务器负载;尤其是在写少读多的场景下,通过多个从节点分担读负载,可以大大提高Redis服务器的并发量
  • 高可用基石:主从复制还是哨兵和集群能够实施的基础,因此说主从复制是Redis高可用的基础

主从复制实现原理

连接建立阶段、数据同步阶段、命令传播阶段

连接建立阶段

该阶段的主要作用是在主从节点之间建立连接,为数据同步做好准备

步骤1:保存主节点信息
slaveof命令是异步的,在从节点上执行slaveof命令,从节点立即向客户端返回ok,从节点服务器内部维护了两个字段,即masterhost和masterport字段,用于存储主节点的ip和port信息

步骤2:建立socket连接

  • 从节点每秒1次调用复制定时函数replicationCron(),如果发现了有主节点可以连接,便会根据主节点的ip和port,创建socket连接
  • 从节点为该socket建立一个专门处理复制工作的文件事件处理器,负责后续的复制工作,如接收RDB文件、接收命令传播等
  • 主节点接收到从节点的socket连接后(即accept之后),为该socket创建相应的客户端状态,并将从节点看做是连接到主节点的一个客户端,后面的步骤会以从节点向主节点发送命令请求的形式来进行

步骤3:发送ping命令
从节点成为主节点的客户端之后,发送ping命令进行首次请求,目的是:检查socket连接是否可用,以及主节点当前是否能够处理请求

从节点发送ping命令后,可能出现3种情况:

  1. 返回ping:说明socket连接正常,且主节点当前可以处理请求,复制过程继续
  2. 超时:一定时间后从节点仍未收到主节点的回复,说明socket连接不可用,则从节点断开socket连接,并重连
  3. 返回ping以外的结果:如果主节点返回其他结果,如正在处理超时运行的脚本,说明主节点当前无法处理命令,则从节点断开socket连接,并重连

步骤4:身份验证

  • 如果从节点中设置了masterauth选项,则从节点需要向主节点进行身份验证;没有设置该选项,则不需要验证。从节点进行身份验证是通过向主节点发送auth命令进行的,auth命令的参数即为配置文件中的masterauth的值
  • 如果主节点设置密码的状态,与从节点masterauth的状态一致(一致是指都存在,且密码相同,或者都不存在),则身份验证通过,复制过程继续;如果不一致,则从节点断开socket连接,并重连

步骤5:发送从节点端口信息
身份验证之后,从节点会向主节点发送其监听的端口号(前述例子中为6380),主节点将该信息保存到该从节点对应的客户端的slave_listening_port字段中;该端口信息除了在主节点中执行info Replication时显示以外,没有其他作用

数据同步阶段

  • 主从节点之间的连接建立以后,便可以开始进行数据同步,该阶段可以理解为从节点数据的初始化。具体执行的方式是:从节点向主节点发送psync命令(Redis2.8以前是sync命令),开始同步
  • 数据同步阶段是主从复制最核心的阶段,根据主从节点当前状态的不同,可以分为全量复制部分复制,后面再讲解这两种复制方式以及psync命令的执行过程

命令传播阶段

  • 数据同步阶段完成后,主从节点进入命令传播阶段;在这个阶段主节点将自己执行的写命令发送给从节点,从节点接收命令并执行,从而保证主从节点数据的一致性
  • 命令传播是异步的过程,即主节点发送写命令后并不会等待从节点的回复;因此实际上主从节点之间很难保持实时的一致性,延迟在所难免。数据不一致的程度,与主从节点之间的网络状况、主节点写命令的执行频率、以及主节点中的repl-disable-tcp-nodelay配置等有关
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值