Redis

一.Redis 基础

1.什么是 Redis?

Redis 是一个基于 C 语言开发的开源 NoSQL 数据库,存储的是键值对数据。Redis 的数据是保存在内存中的,因此读写速度非常快,被广泛应用于分布式缓存。

2.Redis 的优点

  • Redis 基于内存的,而内存的访问速度是磁盘的上千倍。

  • Redis 中数据类型的底层数据结构是优化过后的数据结构,性能非常高。

  • Redis 基于 Reactor 模式设计开发了一套高效的事件处理模型,主要是单线程事件循环和 IO 多路复用。

  • Redis 能够提高系统的并发能力,同时使用了Redis和数据库的系统,比起只使用数据库的系统,并发量能提升很多倍。

3.分布式缓存常见的技术选型方案有哪些?

一般用 Memcached 和 Redis 作为分布式缓存。Memcached 是分布式缓存最开始兴起的时候,比较常用。后来,随着 Redis 的发展,大家慢慢都转而使用更加强大的 Redis 了。

二.Redis数据结构

下面是一张 Redis 数据类型和底层数据结构的对应关图,左边是 Redis 3.0 版本的,右边是 Redis 7.0 版本的。接下来就来介绍五种常见的 Redis 数据类型的底层数据结构是怎么实现?

String类型内部实现:SDS(简单动态字符串)。

List类型内部实现:压缩列表或双向链表

  • 如果列表的元素个数小于 512 个(默认值,可以改),列表每个元素的值都小于 64 字节(默认值,可以改),Redis 会使用压缩列表作为 List 类型的底层数据结构;

  • 如果列表的元素不满足上面的条件,Redis 会使用双向链表作为 List 类型的底层数据结构;

但是,在 Redis 3.2 版本之后,List 数据类型底层数据结构就只由 quicklist 实现了,替代了双向链表和压缩列表。

Hash 类型内部实现:Hash 类型的底层数据结构是由压缩列表或哈希表实现的:

  • 如果哈希类型元素个数小于 512 个(默认值,可由 hash-max-ziplist-entries 配置),所有值小于 64 字节(默认值,可由 hash-max-ziplist-value 配置)的话,Redis 会使用压缩列表作为 Hash 类型的底层数据结构;

  • 如果哈希类型元素不满足上面条件,Redis 会使用哈希表作为 Hash 类型的底层数据结构。

在 Redis 7.0 中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了。

Set 类型内部实现:Set 类型的底层数据结构是由整数集合或者哈希表实现的:

  • 如果集合中的元素都是整数且元素个数小于 512 (默认值,set-maxintset-entries配置)个,Redis 会使用整数集合作为 Set 类型的底层数据结构;

  • 如果集合中的元素不满足上面条件,则 Redis 使用哈希表作为 Set 类型的底层数据结构。

Zset 类型内部实现:Zset 类型的底层数据结构是由压缩列表或跳表实现的。

  • 如果有序集合的元素个数小于 128 个,并且每个元素的值小于 64 字节时,Redis 会使用压缩列表作为 Zset 类型的底层数据结构;

  • 如果有序集合的元素不满足上面的条件,Redis 会使用跳表作为 Zset 类型的底层数据结构。

在 Redis 7.0 中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了。

Redis是如何实现键值对数据库的?

在开始讲数据结构之前,先介绍下 Redis 是怎样实现键值对(key-value)数据库的。

Redis 的键值对中的 key 就是字符串对象,而 value 可以是字符串对象,也可以是集合数据类型的对象,比如 List 对象、Hash 对象、Set 对象和 Zset 对象。

举个例子,我这里列出几种 Redis 新增键值对的命令:

> SET name "xiaolincoding"
OK

> HSET person name "xiaolincoding" age 18
0

> RPUSH stu "xiaolin" "xiaomei"
(integer) 4

这些命令代表着:

  • 第一条命令:name 是一个字符串键,因为键的值是一个字符串对象

  • 第二条命令:person 是一个哈希表键,因为键的值是一个包含两个键值对的哈希表对象

  • 第三条命令:stu 是一个列表键,因为键的值是一个包含两个元素的列表对象

这些键值对是如何保存在 Redis 中的呢?

Redis 是使用了一个「哈希表」保存所有键值对,哈希表的最大好处就是让我们可以用 O(1) 的时间复杂度来快速查找到键值对。哈希表其实就是一个数组,数组中的元素叫做哈希桶。

Redis 的哈希桶是怎么保存键值对数据的呢?

哈希桶存放的是指向键值对数据的指针(dictEntry*),这样通过指针就能找到键值对数据,然后因为键值对的值可以保存字符串对象和集合数据类型的对象,所以键值对的数据结构中并不是直接保存值本身,而是保存了 void * key 和 void * value 指针,分别指向了实际的键对象和值对象,这样一来,即使值是集合数据,也可以通过 void * value 指针找到。

我这里画了一张 Redis 保存键值对所涉及到的数据结构。

这些数据结构的内部细节,我先不展开讲,后面在讲哈希表数据结构的时候,在详细的说说,因为用到的数据结构是一样的。这里先大概说下图中涉及到的数据结构的名字和用途:

  • redisDb 结构,表示 Redis 数据库的结构,结构体里存放了指向了 dict 结构的指针;

  • dict 结构,结构体里存放了 2 个哈希表,正常情况下都是用「哈希表1」,「哈希表2」只有在 rehash 的时候才用,具体什么是 rehash,我在本文的哈希表数据结构会讲;

  • ditctht 结构,表示哈希表的结构,结构里存放了哈希表数组,数组中的每个元素都是指向一个哈希表节点结构(dictEntry)的指针;

  • dictEntry 结构,表示哈希表节点的结构,结构里存放了 **void * key 和 void * value 指针, key 指向的是 String 对象,而 value 则可以指向 String 对象,也可以指向集合类型的对象,比如 List 对象、Hash 对象、Set 对象和 Zset 对象

特别说明下,void * key 和 void * value 指针指向的是 Redis 对象,Redis 中的每个对象都由 redisObject 结构表示,如下图:

对象结构里包含的成员变量:

  • type,标识该对象是什么类型的对象(String 对象、 List 对象、Hash 对象、Set 对象和 Zset 对象);
  • encoding,标识该对象使用了哪种底层的数据结构;
  • ptr,指向底层数据结构的指针

我画了一张 Redis 键值对数据库的全景图,你就能清晰知道 Redis 对象和数据结构的关系了:

接下里,就好好聊一下底层数据结构!

1.SDS

Redis自己封装了一个简单动态字符串(SDS)作为字符串的底层数据结构。其结构如下:

结构中的每个成员变量分别介绍下:

  • len,记录了字符串长度。这样获取字符串长度的时候,只需要返回这个成员变量值就行,时间复杂度只需要 O(1)。

  • alloc,分配给字符数组的空间长度。这样在修改字符串的时候,可以通过 alloc - len 计算出剩余的空间大小,可以用来判断空间是否满足修改需求,如果不满足的话,就会自动将 SDS 的空间扩展至执行修改所需的大小,然后才执行实际的修改操作,所以使用 SDS 既不需要手动修改 SDS 的空间大小,也不会出现前面所说的缓冲区溢出的问题。

  • flags,用来表示不同类型的 SDS。一共设计了 5 种类型,分别是 sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64,后面在说明区别之处。

  • buf[],字符数组,用来保存实际数据。不仅可以保存字符串,也可以保存二进制数据。

2.链表

Redis中的链表是一个双向链表,相比于普通链表多了一个结构体(list),里面提供了链表头节点指针(head)、链表尾节点指针(tail)、链表节点数量(len)等属性。这样,获取链表的表头节点、表尾节点、节点数量的时间复杂度只需O(1)。

链表节点结构设计

可以看出,这个是一个双向链表。每个节点包含了一个前置节点和后置节点,以及要存储的数据本身。

链表结构设计

Redis 在 listNode 结构体基础上又封装了 list 这个数据结构,这样操作起来会更方便,list结构如下:

list 结构为链表提供了链表头指针 head、链表尾节点 tail、链表节点数量 len、以及可以自定义实现的 dup、free、match 函数。

  • head:链表头指针
  • tail:链表尾指针
  • dup:节点值复制函数
  • free:节点值释放函数
  • match:节点值比较函数
  • len:链表节点数量

3.压缩列表

压缩列表在内存中由一块连续的内存组成,可以有效地的节省内存空间。但是,它有如下缺点:

  • 不能保存过多的元素,否则查询效率会降低。
  • 压缩列表可能会导致连锁更新的问题。连锁更新是指,如果压缩列表新插入的元素和修改后的元素较大的话,就有可能导致后续节点中 prevlen 的占用空间发生变化,从而引起多米诺骨牌的效应,导致每个节点的空间都要重新分配,造成访问压缩列表性能的下降。

所以说,Redis一般在元素值不多的情况下才会使用压缩列表作为底层数据结构。

压缩列表的结构

压缩列表有三个表头字段和一个结束标记,如下:

  • zlbytes,记录整个压缩列表占用的字节数;
  • zltail,记录压缩列表「尾部」节点距离起始地址有多少字节,也就是列表尾的偏移量;
  • zllen,记录压缩列表包含的节点数量;
  • zlend,标记压缩列表的结束点,固定值 0xFF(十进制255)。

在压缩列表中,如果我们要查找定位第一个元素和最后一个元素,可以通过表头三个字段(zllen)的长度直接定位,复杂度是 O(1)。而查找其他元素时,就没有这么高效了,只能逐个查找,此时的复杂度就是 O(N) 了,因此压缩列表不适合保存过多的元素。

另外,压缩列表节点(entry)的构成如下:

压缩列表节点包含三部分内容:

  • prevlen,记录了「前一个节点」的长度,目的是为了实现从后向前遍历;
  • encoding,记录了当前节点实际数据的「类型和长度」,类型主要有两种:字符串和整数。
  • data,记录了当前节点的实际数据,类型和长度都由 encoding 决定;

当我们往压缩列表中插入数据时,压缩列表会根据数据的类型和大小,使用不同空间大小的 prevlen 和 encoding 来保存信息。分别说下,prevlen 和 encoding 是如何根据数据的大小和类型来进行不同的空间大小分配。

压缩列表里的每个节点中的 prevlen 属性都记录了「前一个节点的长度」,而且 prevlen 属性的空间大小跟前一个节点长度值有关,比如:

  • 如果前一个节点的长度小于 254 字节,那么 prevlen 属性需要用 1 字节的空间来保存这个长度值;

  • 如果前一个节点的长度大于等于 254 字节,那么 prevlen 属性需要用 5 字节的空间来保存这个长度值;

encoding 属性的空间大小跟数据是字符串还是整数,以及字符串的长度有关。压缩列表在存储每个节点时,会先判断该节点存储的是整数还是字符串数据。

  • 如果是整数类型,选择1字节的编码。编码中的位信息会决定整数数据的大小。
  • 如果是字符串类型,根据字符串的长度选择1字节、2字节或5字节的编码。编码中的位信息会决定字符串数据的实际长度。

上图中的 content 表示的是实际数据,即本文的 data

4.哈希表

哈希表是一种保存键值对(key-value)的数据结构。它能以 O(1) 的时间复杂度快速查询数据。但是,哈希表在大小固定的情况下,随着数据不断增多,哈希冲突的可能性会越来越高。解决哈希冲突的方式,有很多种,Redis 采用了「链式哈希」来解决哈希冲突。

5.整数集合

整数集合用来存储整数,在内存中是一块连续的空间。

6.跳表

链表在查找元素的时候,因为需要逐一查找,所以查询效率非常低,时间复杂度是O(N),于是就出现了跳表。跳表是在链表的基础上增加了多级索引,通过多级索引来进行位置的跳转,实现了元素的快速查找,查找节点的平均时间复杂度为 O(logN) 。Redis 只有 Zset 对象的底层实现用到了跳表。

zset 的底层数据结构有二个,分别是是跳表和哈希表。也能进行高效单点查询。跳表使得 Zset 支持范围查询;哈希表使得 Zset 能以常数复杂度获取元素权重。

Zset 在进行数据插入和数据更新时,会依次在跳表和哈希表中插入或更新相应的数据,从而保证了跳表和哈希表中记录的信息一致。

我们一般说Zset的底层数据结构的时候,都会说跳表是 Zset 对象的底层数据结构,而不会提及哈希表,是因为 struct zset 中的哈希表只是用于以常数复杂度获取元素权重,大部分操作都是跳表实现的。

跳表结构设计

下图展示了一个层级为 3 的跳表。

图中头节点有 L0~L2 三个头指针,分别指向了不同层级的节点,然后每个层级的节点都通过指针连接起来:

  • L0 层级共有 5 个节点,分别是节点1、2、3、4、5;

  • L1 层级共有 3 个节点,分别是节点 2、3、5;

  • L2 层级只有 1 个节点,也就是节点 3 。

如果我们要在链表中查找节点 4 这个元素,只能从头开始遍历链表,需要查找 4 次,而使用了跳表后,只需要查找 2 次就能定位到节点 4,因为可以在头节点直接从 L2 层级跳到节点 3,然后再往前遍历找到节点 4。

可以看到,这个查找过程就是在多个层级上跳来跳去,最后定位到元素。当数据量很大时,跳表的查找复杂度就是 O(logN)。那跳表节点是怎么实现多层级的呢?这就需要看「跳表节点」的结构体了,如下:

typedef struct zskiplistNode {
    //Zset 对象的元素值
    sds ele;
    //元素权重值
    double score;
    //后向指针
    struct zskiplistNode *backward;
  
    //节点的level数组,保存每层上的前向指针和跨度
    struct zskiplistLevel {
        struct zskiplistNode *forward;
        unsigned long span;
    } level[];
} zskiplistNode;

Zset 对象要同时保存「元素」和「元素的权重」,对应到跳表节点结构里就是 sds 类型的 ele 变量和 double 类型的 score 变量。每个跳表节点都有一个后向指针,指向前一个节点,目的是为了方便从跳表的尾节点开始访问节点,这样倒序查找时很方便。

跳表是一个带有层级关系的链表,每一层级可以包含多个节点,每一个节点通过指针连接起来,实现这一特性就是靠跳表节点结构体中的zskiplistLevel 结构体类型的 level 数组

level 数组中的每一个元素代表跳表的一层,也就是由 zskiplistLevel 结构体表示,比如 leve[0] 就表示第一层,leve[1] 就表示第二层。zskiplistLevel 结构体里定义了「指向下一个跳表节点的指针」和「跨度」,跨度时用来记录两个节点之间的距离。

比如,下面这张图,展示了各个节点的跨度。

第一眼看到跨度的时候,以为是遍历操作有关,实际上并没有任何关系,遍历操作只需要用前向指针(struct zskiplistNode *forward)就可以完成了。

跨度实际上是为了计算这个节点在跳表中的排位。具体怎么做的呢?因为跳表中的节点都是按序排列的,那么计算某个节点排位的时候,从头节点点到该结点的查询路径上,将沿途访问过的所有层的跨度累加起来,得到的结果就是目标节点在跳表中的排位。

举个例子,查找图中节点 3 在跳表中的排位,从头节点开始查找节点 3,查找的过程只经过了一个层(L2),并且层的跨度是 3,所以节点 3 在跳表中的排位是 3。

另外,图中的头节点其实也是 zskiplistNode 跳表节点,只不过头节点的后向指针、权重、元素值都没有用到,所以图中省略了这部分。

问题来了,由谁定义哪个跳表节点是头节点呢?这就介绍「跳表」结构体了,如下所示:

typedef struct zskiplist {
    struct zskiplistNode *header, *tail;
    unsigned long length;
    int level;
} zskiplist;

跳表结构里包含了:

  • 跳表的头尾节点,便于在O(1)时间复杂度内访问跳表的头节点和尾节点;
  • 跳表的长度,便于在O(1)时间复杂度获取跳表节点的数量;
  • 跳表的最大层数,便于在O(1)时间复杂度获取跳表中层高最大的那个节点的层数量;

在跳表中查找节点的过程

查找一个跳表节点时,跳表会从头节点的最高层开始,逐一遍历每一层。在遍历某一层的跳表节点时,会用跳表节点中的 SDS 类型的元素和元素的权重来进行判断,共有两个判断条件:

  • 如果当前节点的权重「小于」要查找的权重,跳表就会访问该层上的下一个节点。

  • 如果当前节点的权重「等于」要查找的权重,并且当前节点的 SDS 类型的数据「小于」要查找的数据时,跳表就会访问该层上的下一个节点。

如果上面两个条件都不满足,或者下一个节点为空时,跳表就会使用目前遍历到的节点的 level 数组里的下一层指针,然后沿着下一层指针继续查找,这就相当于跳到了下一层接着查找。

跳表节点层数设置

跳表的相邻两层的节点数量的比例会影响跳表的查询性能。跳表的相邻两层的节点数量最理想的比例是 2:1。

跳表在创建节点时候,会生成范围为[0-1]的一个随机数,如果这个随机数小于 0.25(相当于概率 25%),那么层数就增加 1 层,然后继续生成下一个随机数,直到随机数的结果大于 0.25 结束,最终确定该节点的层数。这样的做法,相当于每增加一层的概率不超过 25%,层数越高,概率越低,层高最大限制是 64。

下图的跳表就是,相邻两层的节点数量的比例是 2 : 1。

虽然上图中的跳表的「头节点」都是 3 层高,但是其实如果层高最大限制是 64,那么在创建跳表「头节点」的时候,就会直接创建 64 层高的头节点。

7.quicklist

quicklist 是一个双向链表,链表中的每个元素又是一个压缩列表。所以,quicklist 就是「双向链表 + 压缩列表」的组合。

quicklist 解决办法是通过控制每个链表节点中的压缩列表的大小或者元素个数,来规避连锁更新的问题。因为压缩列表元素越少或越小,连锁更新带来的影响就越小,从而提供了更好的访问性能。

压缩列表虽然是通过紧凑型的内存布局节省了内存开销,但是因为它的结构设计,如果保存的元素数量增加,或者元素变大了,压缩列表会有「连锁更新」的风险,一旦发生,会造成性能下降。

Redis 3.0 之前,List 对象的底层数据结构是双向链表或者压缩列表。在 Redis 3.2 的时候,List 的底层改为 quicklist。

quicklist 结构设计

quicklist 的结构体跟链表的结构体类似,都包含了链表头和链表尾,区别在于 quicklist 的节点是 quicklistNode。

typedef struct quicklist {
    //quicklist的链表头
    quicklistNode *head;      //quicklist的链表头
    //quicklist的链表尾
    quicklistNode *tail; 
    //所有压缩列表中的总元素个数
    unsigned long count;
    //quicklistNodes的个数
    unsigned long len;       
    ...
} quicklist;

接下来看看,quicklistNode 的结构定义:

typedef struct quicklistNode {
    //前一个quicklistNode
    struct quicklistNode *prev;     //前一个quicklistNode
    //下一个quicklistNode
    struct quicklistNode *next;     //后一个quicklistNode
    //quicklistNode指向的压缩列表
    unsigned char *zl;              
    //压缩列表的的字节大小
    unsigned int sz;                
    //压缩列表的元素个数
    unsigned int count : 16;        //ziplist中的元素个数 
    ....
} quicklistNode;

可以看到,quicklistNode 结构体里包含了前一个节点和下一个节点指针,quicklistNode 通过这二个指针进行连接,形成了一个双向链表。但是链表节点的元素不再是单纯保存元素值,而是保存了一个压缩列表,所以 quicklistNode 结构体里有个指向压缩列表的指针。下面是quicklist 数据结构。

在向 quicklist 添加一个元素的时候,不会像普通的链表那样,直接新建一个链表节点。而是会检查插入位置的压缩列表是否能容纳该元素,如果能容纳就直接保存到 quicklistNode 结构里的压缩列表,如果不能容纳,才会新建一个新的 quicklistNode 结构。

quicklist 会控制 quicklistNode 结构里压缩列表的元素个数,来规避连锁更新的风险,但是这并没有完全解决连锁更新的问题。

8.listpack

quicklist 虽然通过控制 quicklistNode 结构里的压缩列表的大小或者元素个数,来减少连锁更新带来的性能影响,但是并没有完全解决连锁更新的问题。

因为 quicklistNode 还是用了压缩列表来保存元素,压缩列表连锁更新的问题,来源于它的结构设计,所以要想彻底解决这个问题,需要设计一个新的数据结构。

于是,Redis 在 5.0 新设计一个数据结构叫 listpack,目的是替代压缩列表,它最大特点是 listpack 中每个节点不再包含前一个节点的长度了,压缩列表每个节点正因为需要保存前一个节点的长度字段,就会有连锁更新的隐患。

我看了 Redis 的 Github,在最新 6.2 发行版本中,Redis Hash 对象、ZSet 对象的底层数据结构的压缩列表还未被替换成 listpack,而 Redis 的最新代码(还未发布版本)已经将所有用到压缩列表底层数据结构的 Redis 对象替换成 listpack 数据结构来实现,估计不久将来,Redis 就会发布一个将压缩列表为 listpack 的发行版本。

listpack 结构设计

listpack 采用了压缩列表的很多优秀的设计,比如还是用一块连续的内存空间来紧凑地保存数据,并且为了节省内存的开销,listpack 节点会采用不同的编码方式保存不同大小的数据。

我们先看看 listpack 结构:

listpack 头包含两个属性,分别记录了 listpack 总字节数和元素数量,然后 listpack 末尾也有个结尾标识。图中的 listpack entry 就是 listpack 的节点了。

每个 listpack 节点结构如下:

主要包含三个方面内容:

  • encoding,定义该元素的编码类型,会对不同长度的整数和字符串进行编码;

  • data,实际存放的数据;

  • len,encoding+data的总长度;

可以看到,listpack 没有压缩列表中记录前一个节点长度的字段了,listpack 只记录当前节点的长度,当我们向 listpack 加入一个新元素的时候,不会影响其他节点的长度字段的变化,从而避免了压缩列表的连锁更新问题。

二.Redis 应用

1.Redis有哪些应用

  • 缓存:Redis 最普遍地一个用法是做缓存

  • 分布式锁:通过 Redis 来做分布式锁是一种比较常见的方式。通常情况下,我们都是基于 Redisson 来实现分布式锁。

  • 消息队列:Redis 可以用来做消息队列,但很少使用。

2.如何基于 Redis 实现分布式锁?

什么是分布式锁

分布式锁是指在分布式系统或集群模式下多进程可见并且互斥的锁。

如何通过Redis实现

版本一:可以通过 String 数据类型的 setNX 命令实现最简单的分布式锁。

版本二:

  • 分布式锁误删问题:但是这种锁会存在分布式锁误删的问题,比如线程一获取到了分布式锁,但是在锁的内部出现了阻塞,导致锁超时释放,如果线程二此时来尝试获取锁,就会成功拿到锁,但是线程一阻塞完成后继续执行,执行了删除锁逻辑,就会把线程二的分布式锁删除掉,线程二后续的执行就会存在并发安全问题。

  • 解决方法:在版本一的基础上,获取锁的时候在 Redis 中存入线程标识,删除锁的时候取出线程标识,判断是不是自己的锁,如果是才允许删除。

版本三:

  • 删锁时的原子性问题:在分布式锁误删的基础之上还是会存在删锁时的原子性问题,具体来说,如果线程 1 在持有锁之后,在执行业务逻辑过程中,在删除锁之前,判断这把锁确实是属于自己的,正准备删除锁的时候,线程1出现了阻塞,并且在阻塞时锁到期了,那么此时线程 2 进来获取到了锁,但是线程 1 在阻塞结束后就会去删除锁,相当于条件判断并没有起到作用。

  • 解决方法:这就是删锁时的原子性问题,之所以有这个问题,是因为线程 1 的拿锁,比锁,删锁,并不是原子性的,Redis 当中提供了 lua 脚本,在一个脚本中编写多条 Redis 命令,能确保多条命令执行时的原子性,我们可以在lua脚本中编写拿锁,比锁,删锁的逻辑一次性发送给 Redis 执行,来解决删锁时的原子性问题。

版本四:基于 String 数据类型的 setNX 命令还是会存在一些问题,可通过基于 Redis 的 Redisson 解决。

三.Redis 数据类型

1.Redis 常用的数据类型有哪些?

  • 5 种基本数据类型:String(字符串)、Hash(散列)、List(列表)、Set(集合)、Zset(有序集合)。

  • 3 种特殊数据类型:HyperLogLog(基数统计)、Bitmap (位图)、Geospatial (地理位置)。

2.什么是 String 数据类型,其应用场景有哪些?

String 是 Redis 中最简单的数据类型,内存占用最大可以达到512MB。String 数据类型可以用来做:

  • 常规数据的缓存。比如序列化后的对象、Token、图片路径、Session等。

  • 实现分布式锁。可以利用 String 数据类型的 SETNX key value 命令实现分布式锁。

3.什么是 Hash 数据类型,其应用场景有哪些?

Hash 数据类型当中,一个键对应的值可以是多个键值对。Hash 可以用来:

  • 存储对象。

  • 存储购物车信息,因为购物车中的商品会频繁修改和变动,而 Hash 可以对部分字段进行增删改查,能够节省网络流量,提高一定的性能

在存储购物车信息时,通常用户 id 为 key,商品 id 为 field,商品数量为 value。用户购物车信息的维护,可以按照如下的方式操作:

  • 用户添加商品就是往 Hash 里面增加新的 field 与 value;

  • 查询购物车信息就是遍历对应的 Hash;

  • 更改商品数量直接修改对应的 value 值,直接 set 或者做运算皆可。

  • 删除商品就是删除 Hash 中对应的 field;

  • 清空购物车直接删除对应的 key 即可。

Note:这里只是以业务比较简单的购物车场景举例,实际电商场景下,field 只保存一个商品 id 是没办法满足需求的。

4.String 和 Hash 哪个存储对象数据更好呢?

  • Hash 比较适合存储对象中某些字段需要经常变动或者经常需要查询的情况。Hash 是对对象的每个字段单独存储,在对部分字段进行增删改查时,能够节省网络流量,提高一定的性能。而 String 存储的是序列化后的整个对象。

  • String 比较适合存储内存占用大的对象。缓存相同数量的对象,String 消耗的内存大约是 Hash 的一半。

但是绝大部分情况,建议使用 String 来存储对象数据就可以了

5.什么是 List 数据类型,应用场景是什么?

List 数据类型的特点是存储的元素是有序、可重复,可以用来:

  • 实现评论列表、朋友圈点赞列表等。

6.什么是 Set 数据类型,应用场景是什么?

Set 数据类型的特点是存储的元素无序、不可重复,可以用来:

  • 实现网站 UV 统计文章点赞等功能。

  • 需要获取多个数据源的交集、并集和差集的场景:共同好友(交集)、共同粉丝(交集)、共同关注(交集)、好友推荐(差集)、音乐推荐(差集)、订阅号推荐(差集+交集) 等等。

  • 需要随机获取数据源中的元素的场景:抽奖系统、随机点名等等。

5.什么是 Sorted Set,应用场景是什么?

Sorted Set 的特点是存储的元素可排序、不可重复。可以用在:

实现各种排行榜,比如直播间送礼物的排行榜、朋友圈的微信步数排行榜、王者荣耀中的段位排行榜、话题热度排行榜等等。

6.什么是 GEO,应用场景是什么

GEO 是 Geolocation 的简写形式,存储的是地理位置和这个位置对应的经纬度。可以用来实现:

查找附近的商家、查找附近的人。

6.什么是 BitMap,应用场景是什么

Bitmap 存储的是连续的二进制位,能极大的节省内存空间。BitMap 可以用来实现签到功能、统计活跃用户数。

用 BitMap 统计活跃用户的具体步骤如下:

如果想要使用 Bitmap 统计活跃用户的话,可以使用精确到天的日期作为 key,然后用户 ID 为 offset,如果当日活跃过就设置为 1。例如:

初始化数据:

SETBIT 20210308 1 1
(integer) 0
SETBIT 20210308 2 1
(integer) 0
SETBIT 20210309 1 1
(integer) 0

统计 20210308~20210309 总活跃用户数:

BITOP and desk1 20210308 20210309
(integer) 1
BITCOUNT desk1
(integer) 1

统计 20210308~20210309 在线活跃用户数:

BITOP or desk2 20210308 20210309
(integer) 1
BITCOUNT desk2
(integer) 2

Note:可以将 Bitmap 看作是一个存储二进制数字的数组,数组中每个元素的下标叫做偏移量(offset)。

7.什么是 HyperLogLog,应用场景是什么

Hyperloglog 数据类型无论存储多少元素,其内存占用永远小于16kb,主要用来统计自己存储的元素数量。但是,统计结果会有0.81%以内的误差。可以用来实现:

网站的 UV 统计。

四.Redis 持久化机制

RDB、AOF都是用来实现Redis的持久化机制

1.什么是 RDB 持久化?

RDB 持久化是通过获得内存存储的数据在某个时间点上的副本,来实现 Redis 的持久化机制。

作用:Redis 得到副本之后,可以将副本留在当前服务器,在重启服务器的时候使用,也可以在主从同步时,将副本发送给从库。

RDB 持久化在三种情况下会执行:

  • 在 Redis 客户端执行 save 命令或者 bgsave 命令。

  • Redis 停机时,会执行 save 命令

  • 触发 RDB 条件时,会执行 bgsave 命令

RDB 是 Redis 默认采用的持久化方式,在 redis.conf 配置文件中默认有此下配置会触发 RDB 条件:

save 900 1           #在900秒(15分钟)之后,如果至少有1个key发生变化,Redis就会自动触发bgsave命令创建快照。

save 300 10          #在300秒(5分钟)之后,如果至少有10个key发生变化,Redis就会自动触发bgsave命令创建快照。

save 60 10000        #在60秒(1分钟)之后,如果至少有10000个key发生变化,Redis就会自动触发bgsave命令创建快照。

2.RDB 的原理

RDB 持久化是通过 save 命令和 bgsave 命令来执行的。

如果执行的是 SAVE 命令,Redis 的主进程会去执行 RDB 持久化。在持久化过程中,整个 Redis 服务器会被阻塞,不能处理任何其他命令。因此,一般在 Redis 停机时才会执行SAVE 命令,以确保所有数据都被保存到磁盘。

如果执行的是 bgsave 命令,主进程会 fork 一个子进程,并复制自身的页表给子进程,子进程可以通过页表来共享主进程的内存数据,从而将共享内存中的数据写到磁盘上的 RDB 文件。fork 时采用了 copy-on-write 技术:

  • 当有读请求访问主进程时,会访问共享内存;

  • 当有写请求访问主进程时,则会拷贝一份共享内存中的数据,执行写操作。

3.RDB 创建快照时会阻塞主进程吗?

如果执行的时 save 命令,会阻塞 Redis 主进程;如果执行的是 bgsave 命令,会 fork 出一个子进程,通过子进程生成副本,不会阻塞 Redis 主进程,默认选项。

4.什么是 AOF 持久化

AOF 持久化是指把所有对 Redis 的修改操作记录到 AOF 文件中,来实现 Redis 的持久化机制。默认是关闭的。

5.AOF持久化的原理

开启 AOF 持久化后每执行一条更改数据的命令,Redis 就会将该命令写入到 AOF 缓冲区,然后再根据 AOF 的持久化配置来决定何时将缓存区的数据同步到磁盘上。持久化方式的配置如下:

# 表示每执行一次写命令,立即记录到AOF文件
appendfsync always 
# 写命令执行完先放入AOF缓冲区,然后表示每隔1秒将缓冲区数据写到AOF文件,是默认方案
appendfsync everysec 
# 写命令执行完先放入AOF缓冲区,由操作系统决定何时将缓冲区内容写回磁盘
appendfsync no

Note:

  • Redis 的主进程会在适当的时机,例如处理完一批命令后,将 AOF 缓冲区的数据写入到系统内核缓冲区,这个过程是非阻塞的。也就是说,Redis 在将数据写入系统内核缓冲区的同时,还可以继续处理其他的命令。这是因为系统内核缓冲区通常比硬盘快得多,所以写入操作的延迟非常小。

  • 在关系型数据库当中,如 MySQL 通常都是执行命令之前记录日志,方便故障恢复,而 Redis AOF 持久化机制是在执行完命令之后再记录日志。

6.AOF 的文件重写了解吗?

AOF进行文件重写的原因:AOF 文件记录的是命令,所以导致 AOF 文件比较大。

AOF的文件重写就是用来减小磁盘上 AOF 文件的大小。因为 AOF 文件会记录对同一个 key 的多次写操作,但是只有最后一次写操作才有意义,通过执行文件重写的 bgrewriteaof 命令来保留最后一次写操作,删除其余写操作,来减小文件的体积。

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

# AOF文件比上次文件 增长超过多少百分比则触发重写
auto-aof-rewrite-percentage 100
# AOF文件体积最小多大以上才触发重写 
auto-aof-rewrite-min-size 64mb 

7.RDB 与 AOF 的对比

  • 持久化方式:RDB 会对 Redis 的整个内存数据做快照、AOF 会记录每一次执行的写命令。

  • 数据完整性:RDB 不完整,在二次备份之间可能会丢失数据;AOF相对完整,取决于刷盘策略。

  • 文件大小:RDB 的文件会有压缩,体积小;AOF 文件记录的是命令,体积大。

  • 宕机恢复速度:RDB 快于 AOF。

五.Redis 线程模型

六.Redis 内存管理

1.Redis 给缓存数据设置过期时间有什么用?

  • 因为内存是有限的,缓存如果不设置过期时间的话,内存很快就会溢出。

  • 有时候,我们的业务场景需要某个数据只在某一时间段内存在,比如我们的短信验证码可能只在 1 分钟内有效,用户登录的 Token 可能只在 1 天内有效。

2.Redis 中过期数据的删除策略了解吗?

Redis 是通过过期字典来保存数据过期的时间。过期字典的键指向 Redis 数据库中的某个 key,过期字典的值保存的是对应 key 的过期时间。

过期字典是存储在 redisDb 这个结构里的:

typedef struct redisDb {
    ...
    dict *dict;     // 数据库键空间,保存着数据库中所有键值对
    dict *expires   // 过期字典,保存着键的过期时间
    ...
} redisDb;

Redis 可以通过过期数据的删除策略来判断数据是否过期并进行删除。过期数据的删除策略常用的有惰性删除和定期删除二种,Redis 采用的是定期删除和惰性删除相结合来对过期数据进行删除

  • 惰性删除:惰性删除是指在取出 key 的时候对数据进行过期检查,如果过期了就删除掉。

  • 定期删除:定期删除是指每隔一段时间抽取一批 key ,将其中的过期 key 删除。

惰性删除对 CPU 更加友好,定期删除对内存更加友好,两者都有自己的优点。 

3.Redis 的内存淘汰策略了解么?

如果只是通过惰性删除和定期删除可能还是会漏掉很多过期 key,这些过期 key 在内存里不断堆积,内存最终还是会溢出,此时就需要用到内存淘汰机制,Redis 提供了多种内存淘汰策略:

  • volatile-lru(least recently used):从已设置过期时间的数据中挑选最近最少使用的数据淘汰。

  • volatile-ttl:从已设置过期时间的 key 中挑选将要过期的数据淘汰。

  • volatile-random:从已设置过期时间的 key 中任意选择数据淘汰。

  • allkeys-lru(least recently used):会从所有的 key 中删除最近最少使用的 key(这个是最常用的)。

  • allkeys-random:从所有 key 中选择任意 key 淘汰。

  • no-eviction:禁止删除数据和写入新数据,一般不会使用。

4.0 版本后增加以下两种:

  • volatile-lfu(least frequently used):从已设置过期时间的数据集中挑选最不经常使用的数据淘汰。

  • allkeys-lfu(least frequently used):当内存不足以容纳新写入数据时,在键空间中,移除最不经常使用的 key。

最近最少使用不考虑过期时间。假设缓存中有数据 A、B、C 和 D,它们的访问顺序为 A->B->C->D,其中 A 是最近被访问的。当缓存空间满了需要淘汰数据时,LRU算法会选择D进行淘汰,因为 D 是最久未被访问的。

七.Redis事务

1.什么是 Redis 事务?

Redis 事务就是将多个命令进行打包。然后,再按包里的命令顺序执行命令。但是,Redis 事务存在一些缺点,最好不要在日常开发中使用。

Redis事务的缺点

  • 不满足原子性和持久性。

  • 事务中的每条命令都需要通过网络和 Redis 服务器进行交互,这是比较浪费资源的行为。

2.如何使用 Redis 事务?

因为在日常开发中基本不使用,所以了解即可,如果面试被问到,直接回答 Redis 的缺点,所以没用过。

3.Redis 事务为什么不支持原子性?

因为 Redis 事务在执行过程中如果出现了错误的命令,是不会进行回滚操作的。并且,这个错误命令之后的命令还是能正常执行。

Redis 官网也解释了自己为啥不支持回滚。简单来说就是 Redis 开发者们觉得没必要支持回滚,这样更简单便捷并且性能更好。Redis 开发者觉得即使命令执行错误也应该在开发过程中就被发现而不是生产过程中。

4.Redis 事务为什么不支持持久性?

因为 Redis 支持的持久化方式都存在数据丢失的情况。Redis 支持的三种持久化方式:

  • RDB:在 RDB 的持久化方式下,在二次备份之间可能会出现数据丢失。

  • AOF:在AOF 的持久化方式下,如果持久化的策略为 no 或者 everysec,都会存在数据丢失。如果是 always,虽然基本可以满足持久性要求,但性能太差,实际开发过程中不会使用。

  • RDB + AOF:RDB 和 AOF 结合还是会存在数据丢失情况。

与 RDB 持久化相比,AOF 持久化的实时性更好。在 Redis 的配置文件中存在三种不同的 AOF 持久化方式,它们分别是:

appendfsync always    #每次有数据修改发生时都会调用fsync函数同步AOF文件,fsync完成后线程返回,这样会严重降低Redis的速度
appendfsync everysec  #每秒钟调用fsync函数同步一次AOF文件
appendfsync no        #让操作系统决定何时进行同步,一般为30秒一次

5.如何解决 Redis 事务的缺陷?

可以通过 lua 脚本来一定程度上弥补 Redis 事务的缺陷。Redis 从 2.6 版本开始支持 Lua 脚本,Lua 脚本可以批量执行多条 Redis 命令,这些 Redis 命令会被一次性提交到 Redis 服务器执行,能大幅减小网络开销。并且,如果 Lua 脚本运行时出现了错误的命令,出错之后的命令不会被执行。但是,出错之前执行的命令是不会进行回滚操作的,严格来说的话,通过 Lua 脚本来批量执行 Redis 命令实际也是不完全满足原子性的。

八.Redis性能优化

1.大量 key 集中过期问题

问题:Redis 采用的是 定期删除+惰性删除 策略。定期删除执行过程中,如果突然遇到大量过期 key 的话,定期删除抽中的那批 key,可能大部分都是过期 key,客户端请求必须等待定期清理过期 key 任务线程执行完成,因为这个定期任务线程是在 Redis 主线程中执行的。这就导致客户端请求没办法被及时处理,响应速度会比较慢。

解决方法:

给 key 设置随机过期时间

开启 lazy-free(惰性删除/延迟释放)。lazy-free 特性是 Redis 4.0 开始引入的,指的是让 Redis 采用异步方式延迟释放 key 使用的内存,将该操作交给单独的子线程处理,避免阻塞主线程。

2.大 Key 问题

什么是 bigkey:简单来说,如果一个 key 对应的 value 所占用的内存比较大,那这个 key 就可以看作是 bigkey。具体多大才算大呢,有一个不是特别精确的参考标准:

  • string 类型的 value 超过 10 kb

  • 复合类型的 value 包含的元素超过 5000 个。当然,对于复合类型的 value 来说,不一定包含的元素越多,占用的内存就越多。

redis-cli -a 密码 --bigkeys

bigkey 有什么危害:bigkey 除了会消耗更多的内存空间和带宽,还会对性能造成比较大的影响。因此,我们应该尽量避免 Redis 中存在 bigkey。

如何发现 bigkey:

使用 Redis 自带的 --bigkeys 参数来查找。

命令如下所示,从这个命令的运行结果,我们可以看出:这个命令会扫描 Redis 中的所有 key,会对 Redis 的性能有一点影响。并且,这种方式只能找出每种数据结构 top 1 bigkey(占用内存最大的 string 数据类型,包含元素最多的复合数据类型)。然而,一个 key 的元素多并不代表占用内存也多,需要我们根据具体的业务情况来进一步判断。

redis-cli -a 密码 --bigkeys
# redis-cli -p 6379 --bigkeys

# Scanning the entire keyspace to find biggest keys as well as
# average sizes per key type.  You can use -i 0.1 to sleep 0.1 sec
# per 100 SCAN commands (not usually needed).

[00.00%] Biggest string found so far '"ballcat:oauth:refresh_auth:f6cdb384-9a9d-4f2f-af01-dc3f28057c20"' with 4437 bytes
[00.00%] Biggest list   found so far '"my-list"' with 17 items

-------- summary -------

Sampled 5 keys in the keyspace!
Total key length in bytes is 264 (avg len 52.80)

Biggest   list found '"my-list"' has 17 items
Biggest string found '"ballcat:oauth:refresh_auth:f6cdb384-9a9d-4f2f-af01-dc3f28057c20"' has 4437 bytes

1 lists with 17 items (20.00% of keys, avg size 17.00)
0 hashs with 0 fields (00.00% of keys, avg size 0.00)
4 strings with 4831 bytes (80.00% of keys, avg size 1207.75)
0 streams with 0 entries (00.00% of keys, avg size 0.00)
0 sets with 0 members (00.00% of keys, avg size 0.00)
0 zsets with 0 members (00.00% of keys, avg size 0.00

借助开源工具分析 RDB 文件。

通过分析 RDB 文件来找出 big key。这种方案的前提是你的 Redis 采用的是 RDB 持久化。网上有现成的工具可以直接拿来使用:

借助公有云的 Redis 分析服务。

如果你用的是公有云的 Redis 服务的话,可以看看其是否提供了 key 分析功能

如何处理 bigkey?

bigkey 的常见处理以及优化办法如下,这些方法可以配合起来使用:

  • 手动清理:Redis 4.0+ 可以使用 UNLINK 命令来异步删除一个或多个指定的 key。Redis 4.0 以下可以考虑使用 SCAN 命令结合 DEL 命令来分批次删除。

  • 采用合适的数据结构:比如使用 HyperLogLog 统计页面 UV。

  • 开启 lazy-free(惰性删除/延迟释放):lazy-free 特性是 Redis 4.0 开始引入的,指的是让 Redis 采用异步方式延迟释放 key 使用的内存,将该操作交给单独的子线程处理,避免阻塞主线程。

九.Redis 生产问题

1.缓存穿透

问题:缓存穿透是指客户端请求的数据在缓存和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库,如果这些请求过多,会给数据库带来巨大的压力。

常用的解决方法:

  • 缓存无效 key:如果客户端请求的数据在缓存和数据库中都不存在,就写一个无效 key 并设置过期时间缓存到 Redis 中去。但是,如果黑客恶意攻击,每次构建不同的请求 key,会导致 Redis 中缓存大量无效的 key 。很明显,这种方案并不能从根本上解决此问题。如果非要用这种方式来解决穿透问题的话,尽量将无效的 key 的过期时间设置短一点比如 1 分钟。

  • 使用布隆过滤器:把客户端可能请求的所有值都存放在布隆过滤器中,当客户端请求过来,先判断请求的值是否存在于布隆过滤器中。存在的话才会放行;不存在的话,直接返回请求参数错误信息。

但是布隆过滤器会出现误判的情况,这个要从布隆过滤器的原理来说!

我们先来看一下,当一个元素加入布隆过滤器中的时候,会进行哪些操作:

  1. 使用布隆过滤器中的哈希函数对元素值进行计算,得到哈希值(有几个哈希函数得到几个哈希值)。

  2. 根据得到的哈希值,在位数组中把对应下标的值置为 1。

我们再来看一下,当我们需要判断一个元素是否存在于布隆过滤器的时候,会进行哪些操作:

  1. 对给定元素再次进行相同的哈希计算;

  2. 得到值之后判断位数组中的每个元素是否都为 1,如果值都为 1,那么说明这个值在布隆过滤器中,如果存在一个值不为 1,说明该元素不在布隆过滤器中。

由于哈希函数的特性,不同的元素值可能哈希出来的位置相同,这就会导致误判

Note:位数组是一个由 bit 组成的数组,每个 bit 只能存储 0 或 1。在布隆过滤器中,位数组用于表示元素的存在与否。

2.缓存击穿

缓存击穿问题也叫热点 Key 问题,就是一个被高并发访问的 key 突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击

解决办法

  • 把热点key的过期时间设置为永久的

  • 使用互斥锁:具体来说,就是如果客户端的请求从缓存中没有查询到数据,则进行互斥锁的获取,如果获取到了锁,再去数据库查询,查询后将数据写入 redis,再释放锁,返回数据;如果没有获得互斥锁,则让线程休眠,过一会再尝试查询缓存和获取互斥锁,然后一直尝试,直到查询到缓存或获取到互斥锁为止。利用互斥锁能保证只有一个线程去操作数据库,防止缓存击穿。

  • 使用逻辑过期方案:给热点 key 设置一个逻辑过期字段,当用户查询 redis 时,将缓存的数据取出,判断数据中的过期时间是否满足,如果没有过期,则直接返回;如果过期,则获取互斥锁并开启独立线程,开启独立线程后,直接返回之前的数据,独立线程去重构数据,重构完成后释放互斥锁。

3.缓存雪崩

缓存雪崩是指缓存在一个时间段内大面积的失效,导致大量的请求都直接落到了数据库上,对数据库造成了巨大的压力。

解决方法:给不同的 key 设置不同的过期时间。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

真滴book理喻

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值