Redis知识点
一、认识Redis
1.什么是Redis?
Redis 是一种基于内存的数据库,对数据的读写操作都是在内存中完成,因此读写速度非常快(内存IO快于磁盘),常用于缓存、消息队列、分布式锁等场景。
Redis 对数据类型的操作都是原子性的,因为执行命令由单线程负责,不存在并发竞争的问题。
(1)数据类型
Redis存储的是key-value结构的数据,其中key是字符串类型,value有5种常用的数据类型:
-
String(字符串)
-
Hash(哈希)
适合存储对象。
-
List (列表)
按照插入顺序排序,允许重复元素。
-
Set(集合)
无序集合,没有重复元素。
-
Zset(有序集合)
集合中每个元素关联一个分数(score),根据分数升序排序,没有重复元素。
(2)Redis 和 Memcached 有什么区别?
-
共同点
- 都是基于内存的数据库,一般都用来当做缓存使用。
- 都有过期策略。
- 两者的性能都非常高。
-
不同点
- Redis 支持的数据类型更丰富(String、Hash、List、Set、ZSet),而 Memcached 只支持最简单的 key-value 数据类型;
- Redis 支持数据的持久化,可以将内存中的数据保持在磁盘中,重启的时候可以再次加载进行使用,而 Memcached 没有持久化功能,数据全部存在内存之中,Memcached 重启或者挂掉后,数据就没了;
- Redis 原生支持集群模式,Memcached 没有原生的集群模式,需要依靠客户端来实现往集群中分片写入数据;
- Redis 支持发布订阅模型、Lua 脚本、事务等功能,而 Memcached 不支持。
2.Redis的优缺点?
(1)优点-
Redis是基于内存的,读写性能很好;
- Redis采用多种高效的数据类型,比如包括list、set、zset、hash等,操作非常方便;
- Redis支持持久化存储,它作为一个内存数据库,最担心的就是万一机器挂了,数据会消失;
- Redis在工作时是单线程的,避免了多线程间的上下文切换和资源竞争带来的性能损失,同时Redis采用IO多路复用,监听多个socket连接客户端,增加了吞吐量;
(2)缺点
- Redis的容量受物理内存限制,不能实现海量数据的高性能读写;
- 在高并发下,Redis作为缓存可能出现缓存雪崩、缓存穿透、缓存击穿的问题,导致缓存失效。
二、Redis数据类型
Redis存储的是key-value结构的数据,其中key是字符串类型,value有5种常用的数据类型:String(字符串),Hash(哈希),List(列表),Set(集合)、Zset(有序集合)。
1.String 字符串
String 是最基本的value数据类型,value不仅可以是字符串, 也可以是数字(整数或浮点数)。
value 最多可以容纳的数据长度是 512M
(1)常用指令
-
普通字符串的基本操作:
-
批量操作——在命令前加"M":
-
计数器(字符串的内容为整数的时候可以使用):
-
过期(默认为永不过期):
-
不存在就插入:
(2)★应用场景
-
缓存对象
如缓存学生对象的json数据:{"id":1,"name":"xiaolin", "age":18}。 -
★分布式锁——SET命令+Lua脚本
SET 命令有个 NX 参数可以实现「key不存在时才插入」,可以用它来实现分布式锁:- 如果 key 不存在,则显示插入成功,可以用来表示加锁成功;
- 如果 key 存在,则会显示插入失败,可以用来表示加锁失败。
SET lock_key unique_value NX PX 10000
- lock_key:key 键;
- unique_value:是客户端生成的唯一的标识;
- NX:代表只在 lock_key 不存在时,才对 lock_key 进行设置操作;
- PX 10000:表示设置 lock_key 的过期时间为 10s。
// 释放锁时,先比较 unique_value 是否相等,避免锁的误释放 if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end
-
共享 Session 信息
通常我们会使用 Session 来保存用户的会话(登录)状态,这些 Session 信息会被保存在服务器中,但如果是分布式系统,就会出现Session数据不同步问题。
造成数据不同步的原因是session都是保存在各自服务器中,可以用 Redis 对session进行统一存储,即将每个服务器的session都存到一个Redis中,以此来实现共享session信息(解决session数据不同步问题)。
2.List 列表
List 列表是简单的字符串列表,按照插入顺序排序,可以从头部或尾部向 List 列表添加元素。列表的最大长度为 2^32 - 1。
(1)内部实现
List 类型底层数据结构是由双向链表或压缩列表实现的:
- 如果列表元素个数小于 512 个(默认值,可由 list-max-ziplist-entries 配置),列表每个元素的值都小于 64 字节(默认值,可由 list-max-ziplist-value 配置),Redis 会使用压缩列表作为 List 类型的底层数据结构;
- 如果列表元素不满足上面的条件,Redis 会使用双向链表作为 List 类型的底层数据结构;
(2)常用命令
(3)应用场景
List 列表最主要的应用场景就是消息队列。
-
消息队列的三个需求
消息队列在存取消息时,必须要满足按先进先出的顺序存取(消息保序)、能处理重复的消息、保证消息的可靠性。 -
消息保序——LPUSH + RPOP
List 本身就是按先进先出的顺序对数据进行存取的,可以使用 LPUSH + RPOP (或者反过来 RPUSH+LPOP)命令实现消息队列。
★如何解决消费者读取数据时的性能风险?——阻塞读取(BRPOP)
——生产者往 List 中写入数据时,List 并不会主动地通知消费者有新消息写入,所以消费者想要及时处理消息,就要不停地调用 RPOP 命令(比如使用一个while(1)循环)。所以即使没有新消息写入List,消费者也要不停地调用 RPOP 命令,这就会导致消费者程序的 CPU 一直消耗在执行 RPOP 命令上,带来不必要的性能损失。
——为了解决这个问题,Redis提供了 BRPOP 命令(阻塞式读取),它的作用是在没有读到队列数据时,会自动阻塞,直到有新数据写入队列,再开始读取新数据。这种方式更能节省CPU开销。
-
处理重复消息——全局唯一 ID
消费者要实现重复消息的判断,需要满足每个消息都有一个全局ID,并且消费者要能记录已经处理过的消息的ID。
但是 List 不会自动为每个消息生成一个 ID,所以我们需要自己为每个消息生成一个全局唯一ID,在用把消息插入 List 时,要在消息中包含这个全局唯一 ID。(自行生成,插入包含)
-
保证消息可靠性——BRPOPLPUSH 留存消息
当消费者从 List 中读取一条消息后,List 就不会再留存这条消息了(阅完既焚)。所以如果消费者没处理完消息就宕机了,当消费者再次启动后,就没法再次从 List 中读取该消息了。
为了留存消息,可以用 BRPOPLPUSH 命令,它的作用是从 List 中读取消息的同时,再把这个消息插入到另一个 List(备份 List)中留存。
List 作为消息队列有什么缺陷?
——不支持多个消费者消费同一条消息,因为一旦消费者拉取一条消息后,这条消息就从 List 中删除了,无法被其它消费者再次消费。
3.Hash
Hash 特别适合用于存储对象。
上面我们提到String也能用来存储对象,Hash与String存储对象的区别是:
(1)内部实现
Hash 类型的底层数据结构是由压缩列表或哈希表实现的:
- 如果哈希类型元素个数小于 512 个(默认值,可由 hash-max-ziplist-entries 配置),所有值小于 64 字节(默认值,可由 hash-max-ziplist-value 配置)的话,Redis 会使用压缩列表作为 Hash 类型的底层数据结构;
- 如果哈希类型元素不满足上面条件,Redis 会使用哈希表作为 Hash 类型的底层数据结构。
(2)常用命令
(3)应用场景
-
缓存对象
Hash 类型的 (key,field, value) 的结构与对象的(对象id, 属性, 值)的结构相似,也可以用来存储对象。我们以用户信息为例,它在关系型数据库中的结构是这样的:
可以使用如下命令,将用户对象的信息存储到 Hash 类型:
对应的Redis Hash 存储其结构如下图:
【tips】前面提到String+JSON也能存储对象,那么存储对象时,到底用 String + json 还是用 Hash 呢?
——一般对象用 String + Json 存储,对于对象中某些频繁变化的属性可以用 Hash 类型存储。比如购物车数据,购物车里的商品数量可能会频繁变化,所以可以用Hash类型存储购物车数据,以用户 id 为 key,商品 id 为 field(hash-key),商品数量为 value。
4.Set 无序集合
Set 类型是一个无序且元素唯一的键值集合。一个集合最多可以存储 2^32-1 个元素。
Set 类型和 List 类型的区别如下:
Set 类型和 List 类型的区别如下:
- List 可以存储重复元素,Set 不能存储重复元素;
- List 是按照元素的先后顺序存储元素的,而 Set 则是无序的。
(1)内部实现
Set 类型的底层数据结构是由哈希表或整数集合实现的:
- 如果集合中的元素都是整数且元素个数小于 512 (默认值,set-maxintset-entries配置)个,Redis 会使用整数集合作为 Set 类型的底层数据结构;
- 如果集合中的元素不满足上面条件,则 Redis 使用哈希表作为 Set 类型的底层数据结构。
(2)常用命令
-
常用操作命令
-
运算命令
(3)应用场景
由于set集合无序、不可重复等特点,所以当我们存储的数据是无序并且需要去重的情况下,比较适合使用set集合进行存储,因此set比较适合用来做数据去重以及保障数据一致性,还可以用来统计多个集合的并、交、差集。
但是 Set 的并、交、差集的计算复杂度较高,在数据量较大的情况下,如果直接执行这些计算,会导致 Redis 实例阻塞。为了解决这个问题,我们可以搭建Redis主从集群。为了避免主库因为 Set 做聚合计算(交集、差集、并集)时导致主库被阻塞,我们可以选择一个从库完成聚合统计,或者把数据返回给客户端,由客户端来完成聚合统计。
常用的场景:
- 点赞文章——同一个用户只能点赞一次
- 抽奖——保证每个用户只能中一次奖
5.Zset
Zset 是有序的唯一集合,比 Set 多了一个排序属性 score(分值),对于有序集合 ZSet 来说,存储的每个元素相当于由两个值组成的,一个是有序集合的元素值,一个是排序值。
Zset只能进行并交计算,不支持差集运算。
(1)内部实现
Zset 的底层是由压缩列表或跳表(增加了向前指针的链表叫作跳表)实现的:
- 如果有序集合的元素个数小于 128 个,并且每个元素的值小于 64 字节时,Redis 会使用压缩列表作为 Zset 类型的底层数据结构;
- 如果有序集合的元素不满足上面的条件,Redis 会使用跳表作为 Zset 类型的底层数据结构;
在 Redis 7.0 中,压缩列表数据结构已经废弃了,交由 listpack(紧凑列表) 来实现了。
(2)常用命令
-
常用操作:
-
运算操作:
(3)应用场景
Zset 可以根据元素的权重来排序,这个权重我们可以自己来决定。所以 Zset 常用的场景就是需要对数据进行排序的情况,如排行榜、按姓氏排序。
三、Redis 数据类型的底层数据结构
Redis 为什么那么快?
——除了Redis是基于内存这个原因以外,还有一个重要因素就是它实现的数据结构,使得能更高效地对数据进行增删查改。
——除了Redis是基于内存这个原因以外,还有一个重要因素就是它实现的数据结构,使得能更高效地对数据进行增删查改。
1.不同版本Redis的数据结构
Redis 数据类型的底层数据结构随着版本的更新也有所不同:
- 在 Redis 3.0 中 List 的底层数据结构由「双向链表」或「压缩表列表」实现,但是在 3.2 版本之后,List 数据类型底层数据结构是由 quicklist 实现的;
- 在最新的 Redis 代码(还未发布正式版本)中,压缩列表已经废弃了,由 listpack 来实现了。
2.键值对数据库是怎么实现的?
我们知道数据以键值对的形式保存在Redis中,所以Redis可以看做是键值对数据库,那么这些键值对是如何保存在Redis中的呢?
Redis 使用了哈希表来保存所有键值对,哈希表的最大好处就是让我们可以用 O(1) 的时间复杂度来快速查找到键值对。这个哈希表其实就是一个数组,数组中的元素叫做哈希桶。哈希桶存放的是指向键值对的指针,这些指针指向key和value对应的Redis数据类型(也叫redis对象)。
Redis的数据类型(String、List、Hash等)都是由redisObject表示的,
3.SDS
Redis 的 String数据类型的底层数据结构是 SDS(simple dynamic string,简单动态字符串)。
Redis 是用C语言实现的,C语言中字符串底层实现是字符数组,而这种字符数组存在以下缺陷:
- 通过遍历的方式来统计字符串长度,因此时间复杂度为 O(n);
- 字符串的结尾是以 “\0” 字符标识,字符串里面不能包含有 “\0” 字符,因此不能保存二进制数据;
- 字符串操作函数不高效且不安全,比如有缓冲区溢出的风险,有可能会造成程序运行终止。
- len,记录了字符串长度。这样获取字符串长度的时候,只需要返回这个成员变量值就行,时间复杂度只需要 O(1)。
- alloc,分配给字符数组的空间长度。这样在修改字符串的时候,可以通过 alloc - len 计算出剩余的空间大小,可以用来判断空间是否满足修改需求,如果不满足的话,就会自动将 SDS 的空间扩展至执行修改所需的大小,然后才执行实际的修改操作,所以使用 SDS 既不需要手动修改 SDS 的空间大小,也不会出现前面所说的缓冲区溢出的问题。
- flags,用来表示不同类型的 SDS。一共有 5 种类型的sds,分别是 sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64。
- buf[],字符数组,用来保存实际数据。不仅可以保存字符串,也可以保存二进制数据。
4.链表
Redis 的 List 对象的底层实现之一就是链表。C 语言本身没有链表这个数据结构的,所以 Redis 自己设计了一个双向链表来实现list数据类型。
(1)Redis使用链表的优势
- 获取某个节点的前置节点或后置节点的时间复杂度只需O(1);
- list 结构因为提供了表头指针 head 和表尾节点 tail,所以获取链表的表头节点和表尾节点的时间复杂度只需O(1);
- list 结构因为提供了链表节点数量 len,所以获取链表中的节点数量的时间复杂度只需O(1);
- listNode 链表节使用 void* 指针保存节点值,并且可以通过 list 结构的 dup、free、match 函数指针为节点设置该节点类型特定的函数,因此链表节点可以保存各种不同类型的值;
(2)缺点
-
链表每个节点之间的内存都是不连续的,意味着无法很好利用 CPU 缓存。能很好利用 CPU 缓存的数据结构就是数组,因为数组的内存是连续的,这样就可以充分利用 CPU 缓存来加速访问。
-
保存一个链表节点的值都需要一个链表节点结构头的分配,内存开销较大。因此,Redis 3.0 的 List 对象在数据量比较少的情况下,会采用「压缩列表」作为底层数据结构的实现,它的优势是节省内存空间,并且是内存紧凑型的数据结构。
5.压缩列表
压缩列表的最大特点就是被设计成一种内存紧凑型的数据结构,占用一块连续的内存空间,不仅可以利用 CPU 缓存,而且会针对不同长度的数据,进行相应编码,这种方法可以有效地节省内存开销。
压缩列表的缺陷:
-
查找复杂度高
查找元素时只能逐个查找,此时的复杂度就是 O(N) 了,因此压缩列表不适合保存过多的元素。 -
★连锁更新(类似多米诺骨牌)
压缩列表新增或修改某个元素时,如果空间不够,压缩列表占用的内存空间就需要重新分配。当这个元素较大时,可能会导致后续元素的 prevlen(记录了前一个节点的长度) 占用空间都发生变化,从而引起「连锁更新」问题,导致每个元素的空间都要重新分配,造成访问压缩列表性能的下降。这种在特殊情况下产生的连续多次空间扩展操作就叫做「连锁更新」。
因此压缩列表不适合保存太多数据以及大数据。
6.哈希表
哈希表是Redis Hash数据类型的底层数据结构之一。它的优点是可以通过key快速查找数据,但是随着数据增多,发生哈希冲突的可能性也会更高。dictEntry 结构里不仅包含指向键和值的指针,还包含了指向下一个哈希表节点的指针,这个指针可以将多个哈希值相同的键值对链接起来,以此来解决哈希冲突的问题,这就是链式哈希。
(1)哈希表结构设计
哈希表是一个数组(dictEntry **table),数组的每个元素是一个指向「哈希表节点(dictEntry)」的指针。
(2)哈希冲突
Redis 采用「链式哈希」的方法来解决哈希冲突。实现方式就是每个哈希表节点都有一个 next 指针,用于指向下一个哈希表节点,因此被分配到同一个节点上的多个哈希表节点可以用 next 指针构成一个单向链表,这样就解决了哈希冲突。
链式哈希局限性也很明显,随着链表长度的增加,在查询这一位置上的数据的耗时就会增加,毕竟链表的查询的时间复杂度是 O(n)。要想解决这一问题,就需要进行 rehash,也就是对哈希表的大小进行扩展。
(3)rehash
Redis在定义哈希表时,定义了两个哈希表,用来实现 rehash 。
在正常服务请求阶段,插入的数据都会写入到「哈希表 1」,此时的「哈希表 2 」 并没有被分配空间。随着数据增多,触发了 rehash 操作,这个过程分为三步:
- 给「哈希表 2」 分配空间,一般是「哈希表 1」的 2 倍;
- 将「哈希表 1 」的数据迁移到「哈希表 2」 中;
- 迁移完成后,「哈希表 1 」的空间会被释放,并把「哈希表 2」 设置为「哈希表 1」,然后在「哈希表 2」 新创建一个空白的哈希表,为下次 rehash 做准备。(角色互换)
(4)渐进式rehash
为了避免 rehash 在数据迁移过程中,因拷贝数据的耗时,影响 Redis 性能的情况,所以 Redis 采用了渐进式 rehash,也就是将数据的迁移的工作不再是一次性迁移完成,而是分多次迁移。
在进行渐进式 rehash 的过程中,会有两个哈希表,所以在渐进式 rehash 进行期间,哈希表元素的删除、查找、更新等操作都会在这两个哈希表进行。
7.整数集合
整数集合是 Set 对象的底层实现之一。当一个 Set 对象只包含整数值元素,并且元素数量不大时,就会使用整数集这个数据结构作为底层实现。
(1)结构设计
整数集合本质上是一块连续内存空间:
保存元素的容器是一个 contents 数组,虽然 contents 被声明为 int8_t 类型的数组,但是实际上 contents 数组并不保存任何 int8_t 类型的元素,contents 数组的真正类型取决于 intset 结构体里的 encoding 属性的值。不同类型的 contents 数组,意味着数组的大小也会不同。
(2)整数集合的升级操作——空间扩展
整数集合会有一个升级规则,当我们将一个新元素加入到整数集合里面,如果新元素的类型(int32_t)比整数集合现有所有元素的类型(int16_t)都要长时,整数集合需要先进行升级,也就是按新元素的类型(int32_t)扩展 contents 数组的空间大小,然后才能将新元素加入到整数集合里。
整数集合升级的过程不会重新分配一个新类型的数组,而是在原本的数组上扩展空间,然后将每个元素按间隔类型大小分割,如果 encoding 属性值为 INTSET_ENC_INT16,则每个元素的间隔就是 16 位。举个例子,假设有一个整数集合里有 3 个类型为 int16_t 的元素,现在往这个整数集合中加入一个新元素 65535,这个新元素需要用 int32_t 类型来保存,所以整数集合要进行升级操作,首先需要为 contents 数组扩容,在原本空间的大小之上再扩容 80 位(4x32-3x16=80),这样就能保存下 4 个类型为 int32_t 的元素。扩容完 contents 数组空间大小后,需要将之前的三个元素转换为 int32_t 类型,并将转换后的元素放置到正确的位上面,并且需要维持底层数组的有序性不变。
(3)整数集合相关问题
-
整数集合升级有什么好处呢?
如果要让一个数组同时保存 int16_t、int32_t、int64_t 类型的元素,可以直接使用 int64_t 类型的数组,但当元素都是 int16_t 类型的,就会造成内存浪费的情况。因此,整数集合升级的好处是节省内存资源。 -
整数集合支持降级操作吗?
不支持,一旦升级,即使删除大间隔元素,也不会降级。
8.跳表
Zset 的底层实现用到了跳表,跳表的优势是能支持平均时间复杂度为 O(logN)的节点查找。
当使用跳表实现zset时,zset的结构体里其实有两个数据结构——跳表和哈希表。这样的好处是既能进行高效的范围查询(跳表),也能进行高效单点查询(哈希表)。那为什么我们不说zset的底层实现是压缩列表或跳表+哈希表,而只说是压缩列表或跳表自己呢?这是因为这里的哈希表只是方便用来以O(1)获取元素的权重,zset的大部分操作还是由跳表实现的。
(1)结构设计
链表需要逐一查找元素,所以查询效率非常低,是O(N)。跳表是在链表基础上改进过来的,实现了一种多层有序链表,这样的好处是能快读定位数据。
下图展示了一个层级为 3 的跳表,头节点有 L0~L2 三个头指针(3层),分别指向了不同层级的节点,然后每个层级的节点都通过指针连接起来。
如果要在链表中查找节点 4 这个元素,只能从头开始遍历链表,需要查找 4 次。使用了跳表后,只需要查找 2 次就能定位到节点 4,因为可以在头节点直接从 L2 层级跳到节点 3,然后再遍历找到节点 4。这个查找过程就是在多个层级上跳来跳去,最后定位到元素。当数据量很大时,跳表的查找复杂度就是 O(logN)。
-
跳表节点的数据结构如下:
Zset 需要同时保存元素和元素的权重,每个跳表节点都有一个指向前一个节点的指针,方便从跳表的尾节点开始访问节点(倒序查找)。
跳表是一个带有层级关系的链表,而且每一层级可以包含多个节点,每一个节点通过指针连接起来,实现这一特性就是靠跳表节点结构体中的zskiplistLevel 结构体类型的 level 数组。level 数组中的每一个元素代表一层,定义了指向下一个跳表节点的指针和两个节点间的跨度。
-
跳表的结构体:
跳表的结构体定义了跳表的头节点和尾节点,以及跳表的长度和最大层数。
(2)跳表的查询过程
跳表按照元素权重对元素排序,权重越小越靠近头结点。
查找一个节点时,跳表会从头节点的最高层开始,逐一遍历每一层。在遍历某一层时,会用跳表节点中的元素和权重进行判断:
- 如果当前节点的权重<要查找的权重时,跳表就会继续遍历该层的下一个节点。
- 如果当前节点的权重 = 要查找的权重且当前元素<要查找的数据时,跳表就会继续访问该层的下一个节点。
如果上面两个条件都不满足,或者下一个节点为空时,跳表会跳到下一层继续查找。
(3)跳表每层节点数量设置
相邻两层的节点数量的比例会影响跳表的查询性能,因此需要对每层节点数量进行设置,而跳表的相邻两层的节点数量最理想的比例是2:1,这样查找元素的时间复杂度可以降低到O(logN)。
-
怎样才能维持相邻两层的节点数量的比例为 2 : 1 呢?
Redis 跳表在创建节点的时候,会随机生成每个节点的层数,并没有严格维持相邻两层的节点数量比例为 2 : 1 。具体的做法是,跳表在创建节点时候,会生成范围为[0-1]的一个随机数,如果这个随机数小于 0.25(相当于概率 25%),那么层数就增加 1 层,然后继续生成下一个随机数,直到随机数的结果大于 0.25 结束,最终确定该节点的层数。这样做相当于每增加一层的概率不超过 25%,层数越高,概率越低,层高最大限制是 64。
(4)★为什么 Zset 的实现用跳表不用平衡树(如 AVL树、红黑树等)?
主要从以下三个方面分析:
- 从内存占用上来说,跳表比平衡树更灵活一些。平衡树每个节点包含 2 个指针(分别指向左右子树),而跳表每个节点包含的指针数目平均为 1/(1-p),具体取决于参数 p 的大小。如果像 Redis里的实现一样,取 p=1/4,那么平均每个节点包含 1.33 个指针,比平衡树更有优势。
- 在做范围查找的时候,跳表比平衡树操作要简单。在平衡树上,我们找到指定范围的小值之后,还需要以中序遍历的顺序继续寻找其它不超过大值的节点。而在跳表上进行范围查找就非常简单,只需要在找到小值之后,对第 1 层链表进行若干步的遍历就可以找到其他不超过大值的节点。
- 从算法实现难度上来说,跳表比平衡树要简单得多。平衡树的插入和删除操作可能导致子树的调整,逻辑复杂;而跳表的插入和删除只需要修改相邻节点的指针,操作简单又快速。
9.quicklist
在 Redis 3.0 之前,List 对象的底层数据结构是双向链表或者压缩列表;从Redis 3.2 开始,List 对象的底层改由 quicklist 实现。
其实 quicklist 就是「双向链表 + 压缩列表」组合,一个 quicklist 就是一个链表,而链表中的每个元素又是一个压缩列表。
(1)为什么用quicklist代替双向链表和压缩列表实现list?
因为如果元素数量比较多,用压缩列表保存数据可能会引起连锁更新,造成性能下降;而quicklist通过控制每个链表节点中的压缩列表的元素个数来规避连锁更新问题,但是这并没有完全解决连锁更新的问题。因为压缩列表元素越少或越小,连锁更新带来的影响就越小,从而提供了更好的访问性能。
(2)结构设计
上面我们说到,quicklist是一个双向链表,链表节点是一个压缩列表。
10.listpack
(1)优势
- listpack 采用了压缩列表的很多优秀的设计,比如还是用一块连续的内存空间来紧凑地保存数据,并且为了节省内存的开销,listpack 节点会采用不同的编码方式保存不同大小的数据。
-
quicklist 虽然通过控制 quicklistNode 结构里的压缩列表的大小或者元素个数,来减少连锁更新带来的性能影响,但是由于 quicklistNode 还是用了压缩列表来保存元素,所以 quicklist 并没有完全解决连锁更新的问题。
压缩列表因为保存了前一个节点的长度字段,所以会有连锁更新的隐患。而 listpack 最大的特点是每个节点不再包含前一个节点的长度了
(2)结构设计
listpack 头包含两个属性,分别记录了 listpack 总字节数和元素数量,然后 listpack 末尾也有个结尾标识。listpack 的每个节点(listpack entry)包含三个信息:
- encoding:定义了该元素的编码类型,会对不同长度的整数和字符串进行编码;
- data:实际存放的数据;
- len:encoding+data的总长度。
四、Redis持久化问题
Redis有两种持久化技术——AOF日志 和 RDB快照(默认)。
1.AOF日志持久化
(1)原理
AOF持久化的原理就是在执行写操作命令后,将写操作的命令写入日志,重启Redis时去读取并执行日志文件里的命令,就实现了持久化。
-
Redis 是先执行写操作命令后,才将该命令记录到 AOF 日志里,为什么这样做?
- ①避免额外的检查开销。如果先将写操作命令记录到 AOF 日志里,再执行该命令的话,如果当前的命令语法有问题,那么如果不进行命令语法检查,该错误的命令记录到 AOF 日志里后,Redis 在使用日志恢复数据时,就可能会出错。而如果先执行写操作命令再记录日志的话,只有在该命令执行成功后,才将命令记录到 AOF 日志里,这样就不用额外的检查开销,保证记录在 AOF 日志里的命令都是可执行并且正确的。
- ②先写后记录,不会阻塞当前写操作的执行。
-
AOF的缺陷?
- ①执行写操作和记录日志是两个过程,如果执行了写操作但没来得及写入硬盘Redis就挂了,就会导致数据的丢失。
- ②执行写操作和记录日志都是由主线程完成的,因此虽然不会阻塞当前写操作的执行,但可能阻塞下一条命令的执行。比如写入磁盘的速度太慢,下一条命令就迟迟执行不了。
(2)三种写回策略
AOF的缺陷归根结底都与写入磁盘的时机有关。
Redis 执行完写操作后通过write()系统调用,将数据写入到AOF文件中,但此时数据并没有写入磁盘,而是在内核的page cache,等待内核将数据写入磁盘。因此Redis提供了三种写入磁盘的策略:
- Always:表示每次写操作命令执行完后,同步将 AOF 日志数据写回硬盘;
- Everysec:每秒,表示每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,然后每隔一秒将缓冲区里的内容写回到硬盘;
- No:表示由操作系统决定何时将缓冲区内容写回硬盘。
- 如果想要高可靠,最大程度的保证数据不丢失,就选always,但是这可能阻塞主线程;
- 如果想要高性能,就选no,但是这可能导致数据丢失;
- Everysec就属于折中策略了。
-
这三种策略是通过控制 fsync() 函数的调用时机来实现的:
- Always 策略就是每次写入 AOF 文件数据后,就执行 fsync() 函数;
- Everysec 策略就会创建一个异步任务来执行 fsync() 函数;
- No 策略就是永不执行 fsync() 函数
(3)AOF重写机制
随着执行的写操作越来越多,AOF文件的大小会越来越大。如果 AOF 日志文件过大,重启 Redis 后,通过读 AOF 文件来恢复数据的过程就会很慢。所以,Redis 为了避免 AOF 文件越写越大,提供了 AOF 重写机制,当 AOF 文件的大小超过所设定的阈值后,Redis 就会启用 AOF 重写机制,来压缩 AOF 文件。
-
AOF重写机制的原理?
当 AOF 文件的大小超过所设定的阈值后,Redis 就会启用 AOF 重写机制,在重写时,将每一对键值对只用一条命令记录到新的AOF文件中,然后新文件替换现有的AOF文件。
重写机制的妙处在于,尽管某个键值对被多条写命令反复修改,最终也只需要根据这个「键值对」当前的最新状态,然后用一条命令去记录键值对,代替之前记录这个键值对的多条命令,这样就减少了 AOF 文件中的命令数量。
-
为什么重写 AOF 的时候,不直接复用现有的 AOF 文件,而是先写到新的 AOF 文件再覆盖旧文件?
如果 AOF 在重写过程中失败了,现有的 AOF 文件就不能用了,可能无法用于恢复数据。所以 AOF 在重写时,先重写到一个新的 AOF 文件,重写失败的话,就直接删除这个文件,不会对现有的 AOF 文件造成影响。
(4)AOF后台重写
写入AOF日志的操作由于写入的内容不多,所以是在主进程完成的,但是AOF的重写需要对每一对键值对记录一条命令,还要替换以前的AOF文件,所以重写过程比较耗时,一般由后台子进程 bgrewriteaof 来完成。
-
AOF重写交给后台子进程的好处?
- 由后台子进程执行重写操作,主进程可以继续处理其他命令,从而避免阻塞主进程;
- ★交给后台子进程而不是线程,这是因为使用线程的话多线程之间会共享内存,在修改共享内存数据时需要加锁来保证数据的安全,这样就会降低性能;而使用子进程,父子进程会以只读的方式共享内存数据,当父子任意一方修改了共享数据,就会发生写时复制,不用加锁来保证数据安全。
-
AOF重写一定不会阻塞主进程吗?
不一定。
-- 当发生写时复制时,主线程需要拷贝物理内存,会阻塞主线程;
-- 当主进程收到信号后,调用信号处理函数时,会阻塞主进程。
(5)★写时复制
主进程通过 fork() 系统调用创建子进程时,操作系统会把主进程的「页表」复制给子进程(这个页表记录着虚拟地址和物理地址映射关系,而不会复制物理内存),所以父子进程可以共享内存数据。同时,物理内存的权限是 只读 的。当父进程或子进程对共享数据进行写操作时,由于是只读权限,所以会触发写保护中断,CPU就会进行数据的复制,并重新设置映射关系,将读写权限设置为可读写,最后才进行对共享数据的写操作。这个过程就称为 写时复制。
写时复制的目的是为了防止fork子进程时,由于复制物理内存数据的时间过长而长时间阻塞父进程。
(6)★重写过程中修改对键值对进行了修改会发生什么?是如何解决的?
如果重写 AOF 过程中,主进程修改了已经存在 key-value,会导致子进程的内存数据跟主进程的内存数据不一致。
为了解决这种数据不一致的问题,Redis 设置了一个 AOF 重写缓冲区,在重写 AOF 期间,当主线程执行完一个写命令之后,它会同时将这个写命令写入到 AOF 缓冲区和 AOF 重写缓冲区。当子进程将命令记录到新的AOF日志(未替换旧日志)后,会向主进程发送一条异步信号,主进程收到信号后,会调用一个信号处理函数,将AOF重写缓冲区的内容追加到新日志中,然后再覆盖旧日志。这样就使得即使在重写时做了修改操作,也能从AOF重写缓冲区中将该操作追加到新日志中,保证了数据的一致性。
2.RDB快照持久化
(1)如何生成RDB快照文件?
Redis 提供了两个命令来生成 RDB 文件——save 和 bgsave,他们的区别就在于是否在「主线程」执行:
- 执行了 save 命令,就会在主线程生成 RDB 文件,由于和执行操作命令在同一个线程,所以如果写入 RDB 文件的时间太长,会阻塞主线程;
- 执行了 bgsave 命令,会创建一个子进程来生成 RDB 文件,这样可以避免主线程的阻塞;
RDB 文件的加载是在Redis服务器启动时自动加载的,Redis 没有提供专门用于加载 RDB 文件的命令。
(2)RDB快照的缺点
RDB 快照是全量快照,也就是说每次执行快照,都是把内存中的「所有数据」都记录到磁盘中。
- 如果频繁执行快照,可能会对 Redis 性能产生影响。
- 如果执行快照的频率太低,服务器故障时,丢失的数据会更多。因此在服务器发生故障时,RDB 快照丢失的数据会比 AOF 持久化的方式更多,因为 RDB 快照是全量快照的方式,执行的频率不能太频繁,而 AOF 日志可以以秒级的方式记录操作命令,所以丢失的数据就相对较少。
(3)执行快照时,数据能被修改吗?
执行 bgsave 过程中,由于是交给子进程来构建 RDB 文件,因此主线程还是可以继续处理操作命令,也就是说数据是能被修改的。
这一实现的完成依赖于写时复制(Copy-On-Write,COW)技术。
但是无法将bgsave过程中主线程刚修改的数据写入RDB快照中,因此此时父子进程的内存数据已经分离了,子进程写入到RDB中的内存数据只能是修改前的数据。因此,如果系统恰好在 RDB 快照文件创建完毕后崩溃了,那么 Redis 将会丢失主线程在快照期间修改的数据。
3.混合持久化——AOF+RDB
AOF写入频率快,因此可以丢失更少的数据;RDB是全量快照,因此恢复数据的速度快,因此在Redis 4.0中,提出了混合使用AOF和RDB的持久化方式——混合持久化(混合使用AOF日志和内存快照)。
混合持久化发生在AOF日志重写,当AOF进行重写时,fork出的重写子进程会先将与主进程的共享内存数据以RDB的方式写入AOF文件,然后主进程处理的操作命令会被记录在重写缓冲池,缓冲池中的命令会以AOF的方式写入AOF文件,然后主进程会将新的RDB+AOF格式的AOF日志替换旧日志。
也就是说使用了混合持久化,AOF 文件的前半部分是 RDB 格式的全量数据,后半部分是 AOF 格式的增量数据。这样的好处在于,重启 Redis 加载数据的时候,由于前半部分是 RDB 内容,这样加载的时候速度会很快。加载完 RDB 的内容后,才会加载后半部分的 AOF 内容,这里的内容是 Redis 后台子进程重写 AOF 期间,主线程处理的操作命令,可以使得数据更少的丢失。
4.★Redis 中大key的影响?
-
大key对持久化的影响:
-
大key会影响AOF写回硬盘。AOF有三种写回策略:
- 当采用always写回时,写回操作由主进程执行,因此如果写入的是一个大key,主进程在执行fsync()写回硬盘时可能会长时间阻塞其他操作的执行;
- 当采用everysec写回时,由于是异步调用fsync()写回硬盘,因此持久化一个大key不会影响主进程;
- 当采用no写回时,不会调用fsync(),所以持久化一个大key也不会影响主进程。
-
大key可能会触发AOF重写机制。
当 AOF 日志写入了很多的大 Key,AOF 日志文件的大小会很大,那么很快就会触发 AOF 重写机制。AOF重写时主进程会fork一个子进程进行重写AOF,将主进程的页表复制给子进程,而如果Redis在内存中保存了很多大key,对应的页表也会很大,那么复制页表耗时就很长,也就是说主进程fork子进程会花较长的时间,因此会阻塞主进程。而且如果在重写过程中主进程修改了共享内存中的大key,那么就会发生写时复制,将大key的物理内存复制一份,这个过程也会大量耗时,主进程会再次阻塞。
- 由于RDB在用bgsave执行快照时,也是通过主进程fork子进程来实现的,所以大key也会像上面影响AOF重写那样影响RDB快照。
-
大key会影响AOF写回硬盘。AOF有三种写回策略:
-
大key对客户端和服务器的影响:
由于 Redis 执行命令是单线程处理,由于操作大 key 会比较耗时,因此会阻塞 Redis,从而导致客户端或服务器的长时间阻塞。
五、Redis 的过期删除和内存淘汰策略
1.过期删除策略
Redis 可以对key设置过期时间,因此就需要相应的机制将已过期的键值对删除。
(1)如何设置过期时间(TTL)?
-
创建好key之后为其设置过期时间
- expire <key> <n>:设置 key 在 n 秒后过期,比如 expire key 100 表示设置 key 在 100 秒后过期;
- pexpire <key> <n>:设置 key 在 n 毫秒后过期,比如 pexpire key2 100000 表示设置 key2 在 100000 毫秒(100 秒)后过期。
- expireat <key> <n>:设置 key 在某个时间戳(精确到秒)之后过期,比如 expireat key3 1655654400 表示 key3 在时间戳 1655654400 后过期(精确到秒);
- pexpireat <key> <n>:设置 key 在某个时间戳(精确到毫秒)之后过期,比如 pexpireat key4 1655654400000 表示 key4 在时间戳 1655654400000 后过期(精确到毫秒)
-
创建key-value的同时设置TTL
- set <key> <value> ex <n> :等价于 setex <key> <n> <valule>,设置键值对的时候,同时指定过期时间(精确到秒);
- set <key> <value> px <n> :设置键值对的时候,同时指定过期时间(精确到毫秒);
-
查看某个key的TTL
TTL <key> -
取消某个key的过期时间
PERSIST <key>
(2)如何判断 key 已过期了?
每当我们对一个 key 设置了过期时间,Redis 就会把该 key 及其过期时间存储到一个过期字典(expires dict)中,也就是说「过期字典」保存了数据库中所有 key 的过期时间。
字典实际上是哈希表,当我们查询一个 key 时,先检查该 key 是否存在于过期字典中:
- 如果不在,说明该key未设置过期时间,就正常读取键值;
- 如果存在,先获取该 key 的过期时间,然后与当前系统时间进行比对,如果比系统时间大,那就没有过期,否则判定该 key 已过期。
(3)Redis 使用的过期删除策略
常见的三种过期删除策略:
-
定时删除:设置 key 的过期时间时,会同时创建一个定时事件,当key过期时,由事件处理器自动执行 key 的删除操作。
- 优点:可以保证过期的 key 被尽快删除,也就是可以尽快地释放内存。
- 缺点:删除过期 key 会占用 CPU,所以如果过期的 key 比较多,在 CPU 紧张的情况下,会对服务器的响应时间和吞吐量造成影响。
-
惰性删除:不主动删除过期key,每次访问 key 时,检查该 key 是否过期,如果过期就删除。
-
优点:因为只在访问时,才会检查 key 是否过期,所以该策略对CPU的占用比较少。
- 缺点:如果一个过期key一直不被访问,那么它就会一直占用内存空间,会造成内存空间浪费。
-
优点:因为只在访问时,才会检查 key 是否过期,所以该策略对CPU的占用比较少。
-
定期删除:每隔一段时间「随机」从 Redis 中取出一定数量的 key 进行检查,并删除其中的过期key。
- 属于一种这种方案,通过限制删除过期key的时长和频率,来减少对CPU和内存的影响。但是在内存清理方面没有定时删除好,在CPU占用上也不如惰性删除。
——Redis采用了 惰性删除+定期删除 这两种策略配合使用,以求在合理使用 CPU 时间和避免内存浪费之间取得平衡。
-
Redis 如何实现惰性删除?
-
Redis 的惰性删除策略由 db.c 文件中的 expireIfNeeded() 函数实现:
-
在访问或者修改 key 之前,都会调用 expireIfNeeded 函数对key进行检查,判断其是否过期:
-- 如果过期,则删除该 key。在 Redis4.0 提供了一个参数 lazyfree_lazy_expire,来决定是异步删除,还是同步删除,该参数为1则异步删除,反之同步删除,然后返回 null 给客户端;
-- 如果没有过期,不做任何处理,然后返回正常的键值对给客户端;
-
Redis 的惰性删除策略由 db.c 文件中的 expireIfNeeded() 函数实现:
-
Redis 如何实现定期删除的?
-
定期删除是每隔一段时间随机检查一定数量的key,那么这个间隔时间和随机抽查的数量如何确定?
- 间隔时间:在 Redis 中,默认是每秒进行 10 次过期检查,可在 Redis 的配置文件 redis.conf 中进行配置 hz 参数。
- 随机抽查数:定期删除的实现是在 expire.c 文件下的 activeExpireCycle() 函数中,随机抽查数是由 ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP 定义的,它是写死在代码中的,数值是 20。也就是说每次过期检查时,会从过期字典中随机选择20个key进行判断。同时定期删除是个循环的过程,在一次检查中如果过期key占随机抽取数的比例大于25%,就会再抽取20个重复进行,直到比例小于25%或超过循环时间上限25ms。
-
定期删除是每隔一段时间随机检查一定数量的key,那么这个间隔时间和随机抽查的数量如何确定?
2.内存淘汰策略
前面说的过期删除策略,是删除过期 key,而当 Redis 的运行内存超过设置的最大内存后,则会使用内存淘汰策略删除符合条件的 key,来保障 Redis 高效的运行。
(1)如何设置 Redis 最大运行内存?
在配置文件 redis.conf 中,可以通过参数 maxmemory <bytes> 来设定最大运行内存。在不同位数的OS中,该参数的默认值也不同:
- 在 64 位操作系统中,maxmemory 的默认值是 0,表示没有内存大小限制,也就是说不管用户存多少数据,Redis 也不会对可用内存进行检查,直到 Redis 实例因内存不足而崩溃也无作为。
- 在 32 位操作系统中,maxmemory 的默认值是 3G,因为 32 位的机器最大只支持 4GB 的内存。
(2)★Redis 内存淘汰策略有哪些?
Redis 内存淘汰策略共有八种,这八种策略大体分为「不进行数据淘汰」和「进行数据淘汰」两类策略。
-
不进行数据淘汰的策略
-
noeviction:是Redis3.0之后默认的内存淘汰策略,它表示当Redis运行内存超过最大设置内存时,不淘汰任何数据。这样如果有新数据写入,就会触发 OOM,但是只要没有新数据写入,只是单纯的查询或者删除操作的话,Redis 还是可以正常工作。
-
-
进行数据淘汰的策略
又可以细分为在设置了过期时间的数据中进行淘汰和在所有数据范围内进行淘汰这两类策略。-
在设置了过期时间的数据中进行淘汰:
- volatile-random:随机淘汰设置了过期时间的任意键值;
- volatile-ttl:优先淘汰更早过期的键值;
- volatile-lru(Redis3.0 之前,默认的内存淘汰策略):淘汰设置了过期时间且最久未使用的键值;
- volatile-lfu(Redis 4.0 后新增的内存淘汰策略):淘汰设置了过期时间且最少使用的键值;
-
在所有数据范围内进行淘汰:
- allkeys-random:随机淘汰任意键值;
- allkeys-lru:淘汰所有键值对中最久未使用的键值;
- allkeys-lfu(Redis 4.0 后新增的内存淘汰策略):淘汰所有键值中最少使用的键值。
-
如何查看当前 Redis 使用的内存淘汰策略?
——可以使用 config get maxmemory-policy 命令,来查看当前 Redis 的内存淘汰策略。
如何修改 Redis 内存淘汰策略?
- 通过命令设置:通过 config set maxmemory-policy <策略> 命令设置。它的优点是设置之后立即生效,不需要重启 Redis 服务,缺点是重启 Redis 之后,设置就会失效。
- 修改配置文件:在 Redis 配置文件中设置 maxmemory-policy <策略> ,它的优点是重启 Redis 服务后配置不会丢失,缺点是必须重启 Redis 服务,设置才能生效。
(3)★LRU 算法和 LFU 算法有什么区别?Redis 如何实现这两个算法?
1)LRU 算法
LRU(Least Recently Used)算法即最近最少使用算法,会淘汰最近最少使用的数据。
传统 LRU 算法是基于链表实现的,元素按操作顺序排列,刚操作过的键会被移动到表头,尾部就代表最近最少使用的数据,因此当需要内存淘汰时,只需要删除尾部的元素即可。
Redis 并没有使用传统的 LRU 算法,因为传统的 LRU 算法存在两个问题:
- 用链表管理数据,会带来额外的空间开销;
- 刚被访问的数据需要移动到表头,如果有大量数据被访问,就会带来很多链表移动操作,会很耗时,从而会降低 Redis 缓存性能。
Redis 实现 LRU 的方式是在 Redis 的对象结构体中添加一个额外的字段,用于记录此数据的最后访问时间。当进行内存淘汰时,随机取几个值并淘汰最久没有使用的那个。
Redis 实现的 LRU 算法的优点:
- 不再用链表管理数据,节省了内存空间的占用;
- 不用在每次数据访问时都移动链表项,提高了Redis的性能。
但是 Redis 实现的 LRU 算法还有一个问题——无法解决缓存污染问题,比如应用一次读取了大量的数据,但这些数据只会被读取这一次,那么这些数据就会在 Redis 中保留很长一段时间,造成缓存污染。因此,在 Redis 4.0 之后引入了 LFU 算法来解决缓存污染问题。
2)LFU 算法
LFU(Least Frequently Used)算法即最近最不常用算法,根据数据访问次数来淘汰数据的,它认为“如果数据过去被访问多次,那么将来被访问的频率也更高”,所以会淘汰访问频率低的数据,这样就解决了偶尔被访问一次后,数据留存在缓存中很长一段时间的问题,相比于 LRU 算法也更合理一些。
Redis 实现 LFU 是在 LRU 的基础上多记录了数据的访问频率。Redis 通过 Redis 对象头的24位 lru 字段记录该对象的访问信息。但是该字段在 LRU 和 LFU 中记录的内容不同:
-
在 LRU 算法中,lru 字段用来记录 key 的访问时间戳,因此在 LRU 模式下,Redis可以根据对象头中的 lru 字段来淘汰最久未被使用的 key。
-
在 LFU 算法中,lru 字段被分成两段:
-
高 16bit 记录key的访问时间戳
-
低 8bit 记录key的访问频率,值越小表示访问频率越低,就越容易被淘汰。每个新key的logc初始值都为5。
-
六、Redis 高可用
如果所有的Redis缓存都保存在一台服务器上,容易出现单点故障,所以为了避免一台服务器导致的单点故障,最好的办法是将数据备份到其他服务器上,让这些服务器也可以对外提供服务,这样即使有一台服务器出现了故障,其他服务器依然可以继续提供服务。
1.Redis 主从复制
多台服务器要保存同一份数据,这些服务器之间的如何保持数据一致性呢?数据的读写操作是否每台服务器都可以处理?
Redis 提供了主从复制模式,来保证多台服务器的数据一致性,且主从服务器之间采用的是「读写分离」的方式。 主服务器可以进行读写操作,当发生写操作时自动将写操作同步给从服务器,而从服务器一般是只读,并接受主服务器同步过来写操作命令,然后执行这条命令。也就是说,所有的数据修改只在主服务器上进行,然后将最新的数据同步给从服务器,这样就使得主从服务器的数据是一致的。
(1)主从第一次同步
多台服务器如何确定谁是主谁是从呢?Redis 5.0 之前使用 slaveof,之后可以使用 replicaof 来设置主从。比如在服务器 B 上执行下面这条命令,服务器B就成了A的从服务器,然后与主服务器进行第一次同步。
# 服务器 B 执行这条命令 replicaof <服务器A的IP地址> <服务器A的Redis端口号>
【tips】slave-奴隶,replica-复制品,确实replica好一点。。。
主从服务器间的第一次同步过程可分为三个阶段:
-
第一阶段是建立链接、协商同步
-
从服务器执行replicaof命令后,就会给主服务器发送psync命令要求同步数据,主服务器收到psync命令后,会用 FULLRESYNC 响应命令返回给从服务器,表示采用全量复制的进行数据同步。
psync 命令包含两个参数,分别是主服务器的 runID 和复制进度 offset。- runID:每个 Redis 服务器在启动时都会自动生产一个随机的 ID 来唯一标识自己。当从服务器和主服务器第一次同步时,因为不知道主服务器的 runID,所以将其设置为 "?"。
- offset:复制的进度,第一次同步时值为 -1。
-
从服务器执行replicaof命令后,就会给主服务器发送psync命令要求同步数据,主服务器收到psync命令后,会用 FULLRESYNC 响应命令返回给从服务器,表示采用全量复制的进行数据同步。
-
第二阶段是主服务器将数据同步给从服务器
- 主服务器会执行 bgsave 命令生成 RDB 文件,然后把文件发送给从服务器。从服务器收到 RDB 文件后,会先清空当前的数据,然后载入 RDB 文件。
- ★由于主服务器是异步生成RDB文件,并未阻塞主进程,所以主进程仍可以处理写操作命令,但是这些写操作没有被写入RDB文件,这时主从数据就不一致了。Redis为了保证这种情况下的主从数据一致性,会将生成RDB文件、向从服务器发送RDB文件和从服务器加载RDB文件期间进行的写操作写入replication buffer缓冲区里。
-
第三阶段是主服务器将新的写操作命令发送给从服务器
- 从服务器收到 RDB 文件,完成 RDB 的载入后,会回复一个确认消息给主服务器。主服务器会将 replication buffer 缓冲区里记录的写操作命令发送给从服务器来执行,这样主从服务器的数据就一致了。
-
至此,主从服务器的第一次同步就完成了。
(2)命令传播
主从服务器在完成第一次同步后,会一直维持一个 TCP 连接。主服务器可以通过这个连接继续将写操作命令发送给从服务器,使主从数据一致。而且这个连接是TCP长连接,目的是避免频繁的 TCP 连接和断开带来的性能开销。
这个过程被称为基于长连接的命令传播,Redis 通过这种方式来保证第一次同步后的主从数据一致性。
(3)分摊主服务器的压力
第一次数据同步的过程中,主服务器会做两件耗时的操作:生成 RDB 文件和传输 RDB 文件。如果从服务器非常多,而且都与主服务器进行全量同步的话,就会带来两个问题:
- 由于是通过 bgsave 命令来生成 RDB 文件的,那么主服务器就会忙于 fork 子进程,如果主服务器的内存数据很大,在 fork 时会阻塞主线程;
- 传输 RDB 文件会占用主服务器的网络带宽,会对主服务器响应命令请求产生影响。
(4)增量复制
主从服务器在完成第一次同步后,就会基于长连接进行命令传播。如果主从服务器间的网络连接断开了,就无法进行命令传播了,此时就无法保证主从数据一致了。
如果断开的网络又恢复正常了,要怎么继续保证主从服务器的数据一致性呢?
-
在 Redis 2.8 之前,如果主从服务器在命令同步时出现了网络断开又恢复的情况,主从服务器就会重新进行一次全量复制,很明显这样的开销太大了;
-
从 Redis 2.8 开始,网络断开又恢复后,主从服务器会采用增量复制的方式继续同步,只会把断开期间主服务器接收到的写操作命令同步给从服务器。
- 在恢复网络后,从服务器会发送 psync 命令给主服务器,此时 offset 参数就不是 -1了;
- 主服务器收到该命令后,然后用 CONTINUE 响应命令告诉从服务器接下来采用增量复制的方式同步数据;
-
然后主服务将主从断线期间所执行的写命令发送给从服务器执行。
主服务器怎么知道要将哪些增量数据发送给从服务器?(Redis 如何实现主从复制时的增量复制?)
-
主服务器将写操作命令发送给从服务器时,还会将其写入repl_backlog_buffer 环形缓冲池。当网络恢复从服务器重新连上主服务器时,从服务器会通过 psync 命令将自己的复制偏移量 slave_repl_offset 发送给主服务器,主服务器将自己的复制偏移量 master_repl_offset 和从服务器复制偏移量进行比较判断从服务器要读的数据在不在 repl_backlog_buffer 中,来决定对从服务器执行全量同步还是增量同步:
- 如果判断出从服务器要读取的数据还在 repl_backlog_buffer 缓冲区里,就会采用增量同步;
- 如果判断出从服务器要读取的数据已经不在 repl_backlog_buffer 缓冲区里,就会采用全量同步。
-
当主服务器决定采用增量同步后,就会将增量数据写入到 replication buffer 复制缓冲区,然后将缓冲区里记录的写操作命令发送给从服务器来执行。
repl_backlog_buffer 环形缓冲区的默认大小是 1M,所以当缓冲区写满后,主服务器继续写入的话就会覆盖之前的数据。所以如果主服务器的写入速度远超从服务器的读取速度,那么缓冲区的数据一下就会被覆盖。在网络恢复时,主服务器就会采用全量同步,增大开销。因此,为了避免在网络恢复时,主服务器频繁地使用全量同步,我们应该调大 repl_backlog_buffer 缓冲区,减少出现从服务器要读取的数据被覆盖的概率,从而使得主服务器采用增量同步。
★主从复制常见面试题
-
(1)★怎么判断 Redis 某个节点是否正常工作?
Redis 判断节点是否正常工作,基本都是通过互相的 ping-pong 心跳检测机制,如果有一半以上的节点去 ping 节点A的时候没有 pong 回应,就会认为节点A挂掉了,会断开与节点A的连接。Redis 主从节点发送心跳的间隔是不一样的,而且作用也有区别:- 主节点默认每隔 10 秒 ping 一次从节点,判断从节点是否存活性和连接状态;
- 从节点每隔 1 秒给主节点上报自身当前的复制偏移量,来实时监测主从节点的网络状态以及检查复制的数据是否丢失,如果数据丢失了,还要从主节点的replication buffer复制缓冲区中拉取丢失数据。
-
(2)主从复制架构中,过期key如何处理?
主节点处理了一个过期key或者通过淘汰算法淘汰了一个ke后,会发送 del 命令给从节点删除相应的key。
-
(3)Redis 是同步复制还是异步复制?
异步复制。主节点每次收到写命令后,先写到缓冲区,然后异步发送给从节点。 -
(4)★主从复制中两个 Buffer(replication buffer 、repl backlog buffer)有什么区别?
-
出现的阶段不同:
- replication buffer(复制缓冲区)在全量复制和增量复制阶段都会出现,主节点会给每个新连接的从节点,分配一个 replication buffer;
- repl backlog buffer(复制积压缓冲区)只在增量复制阶段出现,一个主节点只分配一个 repl backlog buffer;
-
满了后的操作不同:
- replication buffer 满了会导致连接断开,删除缓存后,再重新建立连接;
- repl backlog buffer 是环形的,满了会直接覆盖之前的数据。
-
出现的阶段不同:
-
(5)为什么会出现主从数据不一致?如何解决?
这里的主从数据不一致指的是客户端从从节点读到的值和主节点的最新值不一致。之所以会出现主从数据不一致,是因为主从复制是一种异步复制,所以无法使主从数据时时刻刻保持一致。具体来说就是,主节点收到客户端的写操作命令后,会发送给从节点。但是,主节点并不会等从节点执行完写命令后,再把结果返回给客户端,而是主节点自己执行完命令后,就会向客户端返回结果了。所以如果从节点还没有执行完这个写命令,主从节点间的数据就不一致了。
那么如何解决主从数据不一致呢?- Redis 的 INFO replication 命令可以查看主节点接收写命令的进度(master_repl_offset)和从节点复制写命令的进度(slave_repl_offset),所以我们可以开发一个监控程序,先用命令查到主、从节点的进度,然后用 master_repl_offset 减去 slave_repl_offset,得到主从节点间的进度差,如果这个进度差大于我们预设的阈值,可以让客户端不从这个从节点读取数据。
-
★(6)主从切换如何减少数据丢失?
主从切换产生数据丢失的情况有两种:异步复制导致的数据丢失和集群产生脑裂导致的数据丢失:-
异步复制导致的数据丢失
Redis主从节点间的复制是异步的,当主节点将命令异步发送给各个从节点时,如果从节点还没收到主节点就断电了,那么主节点内存数据就会丢失。
【解决方案】
在 Redis 配置里有一个参数 min-slaves-max-lag,表示如果所有从节点同步数据的延迟都超过了这个参数定义的值,那么主节点就会拒绝接收客户端的任何请求。比如将 min-slaves-max-lag 配置为 10s ,如果数据同步所需时间超过10s,master 就会拒绝接收新的请求。这样就能将 master 和 slave 数据差控制在10s内,即使 master 宕机也只是这未复制的 10s 数据。对于客户端,当客户端发现 master 不可写后,可以采取降级措施(先写到别的地方,Redis让写了再写到Redis),将数据暂时写入本地缓存和磁盘中,等 master 恢复后重新写入 master 来保证数据不丢失,也可以将数据写入 kafka 消息队列,等 master 恢复正常,再隔一段时间去消费 kafka 中的数据,让将数据重新写入 master 。 -
集群产生脑裂导致的数据丢失
当主节点由于网络故障与从节点失联了,但是客户端与主节点还是连接的,此时客户端写给主节点的数据会写到缓冲区,不能同步到从节点。然后哨兵以为主节点挂了,就会从从节点中选一个新的主节点,这时集群就有两个主节点了(产生脑裂)。然后网络恢复后,哨兵会把原来的主节点降级为从节点A,然后再与选举出来的主节点进行第一次全量同步,从节点A会先清掉缓存再同步数据,这样就把之前客户端发的数据给清掉了,导致数据丢失。
【解决方案】
主节点如果发现下线的从节点太多或者与从节点的延迟太大,会禁止客户端写入。可以通过配置 min-slaves-to-write 和 min-slaves-max-lag 来分别配置从节点的阈值 N 和延迟阈值 T,也就是说主节点要求连接的从节点中至少有 N 个并且进行数据复制耗时不能超过 T 秒,否则,主节点就会停止接收客户端的写请求了。
-
异步复制导致的数据丢失
2.哨兵(sentinel)机制
(1)为什么要有哨兵机制?(哨兵机制是用来干嘛的?)
在Redis主从架构中,如果主节点挂了(监控),需要选择一个从节点作为新的主节点(选主),然后将其他从节点指向这个新的主节点,并通知连接到原来主节点上的客户端,将主节点ip地址更新为新的主节点ip(通知)。哨兵机制就是用来自动完成上述操作,实现主从节点故障转移的。
(2)哨兵如何监控主节点?
哨兵会每隔 1 秒给所有主从节点发送 PING 命令,主从节点收到 PING 命令后,会回发一个响应命令,这样哨兵就可以判断它们是否在正常运行。如果节点没有在规定的时间内响应,哨兵就会将它们标记为主观下线(真的故障了)。
对于主节点,还有客观下线一说。客观下线是指主节点没有挂掉,可能只是因为主节点系统压力较大或者网络拥塞,导致主节点没有在规定时间内响应哨兵的 PING 命令。所以为了减少将主节点误判成挂掉了的主观下线,一般会将哨兵部署成哨兵集群。当一个哨兵判断主节点为主观下线后,就会询问其他哨兵,其他哨兵会根据自身和主节点的网络状况,做出认同或不认同是主观下线的响应,如果认同数达到配置的值后,主节点就会被该哨兵标记为客观下线。
(3)主从故障转移的过程
如果主节点被判定为客观下线,哨兵就要进行主从故障转移了。但在故障转移之前,还需要从哨兵集群中选出一个leader,由leader进行故障转移。leader通过选举产生,将主节点判定为客观下线的那个哨兵就是候选人,候选人会给其他哨兵发送命令,对其进行投票,如果候选人拿到半数以上(所以哨兵集群至少由3个哨兵组成)赞成票,就可以成为leader。然后由leader进行故障转移,从该主节点的所有从节点中选一个变成新的主节点,让其他从节点成为新主节点的从节点,将新主节点的ip地址等信息通过订阅发布者模式通知给客户端,同时继续监控旧主节点,一旦重新上线,将其设置为新主节点的从节点。
主从故障转移详细过程:
-
1)选出新主节点
从已下线主节点的所有从节点中,选择一个从节点,向其发送 SLAVEOF no one 命令(没有主节点了,你就是主节点!),将这个从节点转换为主节点。
从从节点中选的时候也不是随机选一个从节点,而是先过滤掉网络连接状态不好(与主节点断连超过10次)的从节点,再选择优先级高的从节点,如果优先级相同再选择复制进度高的从节点,再相同就选择id小的从节点。
【tips】主节点知道所有从节点的信息,所以哨兵会每 10 秒向主节点发送 INFO 命令来获取从节点的信息。
-
2)将从节点指向新主节点
leader 向所有从节点发送 SLAVEOF 命令,让它们成为新主节点的从节点。
-
3)通知客户端
客户端可以通过发布订阅机制从哨兵订阅消息,主从切换完成后,哨兵就会发布新主节点的 IP 地址和端口的消息,然后客户端就可以收到这条信息,用新主节点的 IP 地址和端口进行通信了。
-
4)旧主节点恢复成为从节点
哨兵还会继续监视旧主节点,当旧主节点重新上线时,哨兵集群就会向它发送 SLAVEOF 命令,让它成为新主节点的从节点。
(4)哨兵集群的组成
在搭建哨兵集群时,只需要填下面几个参数,设置好主节点名字、主节点的 IP 地址和端口号以及 quorum 值,不需要填其他哨兵节点的信息,那么它们是如何感知对方的,又是如何组成哨兵集群的呢?
哨兵节点之间是通过发布订阅机制来相互发现的。主节点上有一个名为__sentinel__:hello的频道,哨兵会将自己的ip地址和端口发布到该频道上,同时哨兵都会订阅该频道,这样就能实现互相通信,形成哨兵集群。
七、缓存雪崩、击穿、穿透问题
用户数据一般都存在数据库,但是数据库的数据都在磁盘,磁盘读写速度太慢了,一旦数据库访问量上来了,数据库很容易崩溃,所以引入了基于内存的Redis缓存。但是使用Redis作为缓存,会产生其他问题。
1.缓存雪崩
(1)什么是缓存雪崩?(缓存雪崩是如何产生的?)
当Redis突然有大量缓存数据同时过期或Redis挂了,此时会有大量请求直接访问数据库,导致数据库压力骤增,严重会造成数据库崩溃,造成整个系统的崩溃,这就是缓存雪崩。
(2)解决方案
可以看到,导致雪崩的原因有两个——大量缓存同时过期或Redis宕机,不同原因应对措施也不同。
-
大量缓存同时过期
-
均匀设置过期时间
在给缓存数据设置过期时间时,要避免将大量缓存设置成同一过期时间。比如可以给过期时间加上一个随机数。 -
设置互斥锁
如果发现访问的数据不在 Redis 里,就加个互斥锁,保证同一时间内只能有一个请求访问数据库,再将数据缓存到 Redis,然后释放锁。这样其他请求就可以从Redis中读取数据,而不用访问数据库了。
【tips】实现互斥锁的时候,最好设置超时时间。
-
双 key 策略
对同一个缓存数据两个 key,一个主 key 设置过期时间,另一个备用 key 不设置过期时间,二者的 value 值是一样的,相当于给缓存数据做了个副本。当访问不到主 key 的缓存数据时,就直接返回备用 key 的缓存数据,然后在更新缓存的时候,同时更新两个key的数据。
-
后台更新缓存
不给缓存设置过期时间,将更新缓存的工作交给后台线程,当由于内存紧张淘汰一些缓存时,业务线程访问不到这些缓存数据,就会通过消息队列通知后台线程访问数据库更新缓存。
-
均匀设置过期时间
-
Redis宕机
-
服务熔断或请求限流机制
由于 Redis 宕机而导致缓存雪崩时,可以启动服务熔断机制,拦截一切对数据库的访问,直接返回错误,从而降低对数据库的访问压力,保证数据库系统的正常运行,但是这样做会导致全部业务都无法正常工作。为了减少对业务的影响,我们可以启用请求限流机制,只让一部分请求访问数据库,等到 Redis 恢复正常后,再解除限流。 -
构建 Redis 缓存高可靠集群
服务熔断或请求限流都是雪崩发生后的应对措施,我们可以搭建Redis集群,当一个Redis宕机后,可以让其他Redis节点继续提供缓存服务。
-
服务熔断或请求限流机制
2.缓存击穿
(1)产生原因
如果缓存中的某个热点数据过期了,此时大量请求访问了该热点数据,就无法从缓存中读取,直接访问数据库,数据库很容易就被高并发的请求冲垮,这就是缓存击穿。
可以发现缓存击穿跟缓存雪崩很相似,可以认为缓存击穿是缓存雪崩的一个子集。
(2)解决方案
应对缓存击穿可以采用缓存雪崩中说到两种方案:
- 互斥锁方案,保证同一时间只有一个业务线程更新缓存,未能获取互斥锁的请求,要么等待锁释放后重新读取缓存,要么就返回空值或者默认值。
- 不给热点数据设置过期时间,由后台异步更新缓存,或者在热点数据准备要过期前,提前通知后台线程更新缓存以及重新设置过期时间。
3.缓存穿透
(1)产生原因
缓存雪崩和击穿涉及的数据都在数据库中有,但是缓存穿透是由于访问了既不在缓存又不在数据库中的数据,导致的高并发请求搞崩数据库。
(2)解决方案
-
限制非法请求
当大量恶意请求访问不存在的数据时,也会发生缓存穿透。因此在 API 入口处我们要判断求请求参数是否合理,请求参数是否含有非法值、请求字段是否存在,如果判断出是恶意请求就直接返回错误,避免进一步访问缓存和数据库。 -
缓存空值或者默认值
可以针对查询的不存在的数据,在缓存中设置一个空值或者默认值,这样后续请求就可以从缓存中读取到空值或者默认值,就不用查询数据库了。 -
使用布隆过滤器快速判断数据是否存在,避免通过查询数据库来判断数据是否存在
向数据库保存数据时,用布隆过滤器做个标记。这样大量请求只需要查询 Redis 和布隆过滤器即可,不会查询数据库,保证了数据库的正常运行。
(3)布隆过滤器相关知识
布隆过滤器可以用于快速检索一个元素是否在一个集合中。布隆过滤器由一个位图数组和 N 个哈希函数组成。
对于一个数据x,先用N个哈希函数分别对x做哈希计算,得到N个哈希值,然后将N个哈希值分别对位图数组的长度取模(%),得到每个哈希值在位图数组中对应的位置,最后将这些位置的值置为1。这样要查数据x存不存在时,只需要通过布隆过滤器看看对应位置的值是否全为1,全是1才认为数据x存在。
【存在的问题】
由于布隆过滤器是基于哈希函数实现查找的,高效查找的同时也存在哈希冲突的可能,比如数据 x 和数据 y 可能都落在第 1、4、6 位置,而事实上并不存在数据 y,造成误判的情况。所以通过布隆过滤器判断出来的数据存在不一定存在(可能因为哈希冲突而误判),而不存在就一定不存在。
八、如何保证Redis和数据库的数据一致性?
由于引入了Redis缓存,那么在数据更新时,不仅要更新数据库,还要更新缓存,那么到底是先更新缓存再更新数据库,还是先更新数据库再更新缓存呢?
1.并发造成的数据不一致问题
在没有并发时,无论是先更新数据库还是先更新缓存,都能保证数据的一致性。但是一旦出现并发,比如请求A和B都要更新同一条数据,那么无论哪种更新顺序,都可能造成数据不一致。
(1)先更新缓存再更新数据库
假设「请求 A 」和「请求 B 」同时更新「同一条」数据,则可能出现这样的顺序:A 先将缓存数据更新为 1,然后在更新数据库前,B 来了,将缓存数据更新为 2,紧接着 B 把数据库更新为 2,然后 A 将数据库的数据更新为 1。此时,数据库中的数据是 1,而缓存中的数据却是 2,出现了缓存和数据库中的数据不一致的现象。
(2)先更新数据库再更新缓存
请求 A 和 B 同时更新同一条数据,则可能出现这样的顺序:A 先将数据库的数据更新为 1,然后在更新缓存前, B 将数据库的数据更新为 2,紧接着 B 把缓存更新为 2,然后 A 更新缓存为 1。此时数据库中的数据是 2,而缓存中的数据却是 1,出现了缓存和数据库中的数据不一致的现象。
2.Cache Aside策略——旁路缓存策略
既然无论先更新缓存还是先更新数据库在并发下都可能导致数据不一致,那么有一种策略就是干脆不更新缓存了(Cache Aside),而是删除缓存,等读取数据缓存未命中时,再从数据库中读取数据,更新到缓存中。这种策略就叫Cache Aside策略。
【tips】Cache Aside这个名字其实很见名知意了,翻译过来就是“撇开缓存不谈”。
新的问题又来了,是先更新数据库还是先删除缓冲呢?
(1)★先更新数据库再删除缓存
假如某个用户的年龄缓存不存在,请求 A 就去读取数据库,查询到年龄为 20,在未写入缓存时另一个请求 B 将更新数据库中的年龄为 21,并且删除了缓存。这时 A 把从数据库中读到的年龄为 20 的数据写入到缓存中。最终,该用户年龄在缓存中是 20(旧值),在数据库中是 21(新值),缓存和数据库数据不一致。
但是由于Redis是基于内存的,所以Redis的写入要远远快于数据库的写入,一般不会出现 A 在回写缓存前 B 已经更新了数据库数据的情况。所以认为「先更新数据库 + 再删除缓存」是可以保证数据一致性的。
(2)先删除缓存再更新数据库
假设用户年龄是 20,请求 A 要更新年龄为 21,所以它会删除缓存中的内容。这时另一个请求 B 要读取这个用户的年龄,它查询缓存发现未命中后,会从数据库中读取到年龄为 20,并且写入到缓存中,然后请求 A 将数据库中用户的年龄更新为 21。最终,该用户年龄在缓存中是 20(旧值),在数据库中是 21(新值)。因此先删除缓存,再更新数据库,在「读 + 写」并发的时候,会出现缓存和数据库的数据不一致的问题。
3.删除缓存失败
先更新数据库再删除缓存虽然可以保证Redis和数据库的数据一致性,但是如果删除缓存失败,还是会造成数据不一致。
(1)删除缓存失败导致的数据不一致
把数据 X 的值从 1 更新为 2,先成功更新了数据库,然后再删除 X 的缓存,但是却删除失败了,此时数据库中 X 为 2,Redis 中的 X 为 1,出现了数据库和缓存数据不一致的问题。
(2)两种解决方案
-
重试机制
引入mq,将要删除的缓存数据加到mq中,如果删除缓存失败,可以从mq中重新读到数据,再次尝试删除,这就是重试机制。我们还可以设定重试次数,当重试超过一定次数后,就返回一个错误信息;如果删除缓存成功,就把数据从mq移除。 -
订阅 MySQL binlog,再操作缓存
我们可以通过 Canal 订阅数据库的 binlog 日志,拿到具体要操作的数据,然后再执行缓存删除。
4.★总结
保证数据库和Redis的数据一致性的方案有很多,可以采用先更新数据库再删除缓存、延迟双删方案、基于Canal实现二者数据同步。
(1)如何基于Canal实现数据同步?
更新数据库时会产生binlog日志,可以通过Canal去自动同步binlog日志,然后再通过MQ把数据同步给Redis。Canal有client-server模式,主要是通过将canal-server伪装成一个从库,从binlog中同步数据;我们可以整合canal-client,编写一个同步器类实现EntryHandler,EntryHandler中提供了增删改方法,当监听到数据变化时,会调用对应的方法拿到数据,然后就可以把数据同步到Redis了。
(2)什么是延迟双删?
延迟双删就是进行两次删除缓存的操作,也就是说先删除Redis缓存,再更新数据库,然后延迟一段时间后再删除Redis缓存,延迟的时间要保证小于写入Redis的时间,不然的话
怎么实现延迟删除?
可以采用延迟队列或者开启一个异步线程延迟一段时间再删除Redis。
如何开启异步延迟线程?
可以使用延迟任务线程池,也就是ScheduledThreadPoolExecutor。
九、补充
1.Redis是单线程还是多线程?为什么?
我们平时说Redis是单线程的主要是指Redis的工作线程是单线程的,但是Redis在持久化和主从复制时,又都是多线程的。
Redis的工作线程是单线程的原因主要有以下几点:
- Redis是基于内存的,采用单线程去操作性能更高;
- 采用单线程可以避免不必要的上下文切换和多线程间的资源竞争,减少了时间和性能上的消耗;
- Redis使用IO多路复用技术来监听多个socket连接客户端,这样单线程也能处理多个请求。
2.