Redis 知识点归纳


Redis 概述


什么是Redis?

答:

  • Redis 是一个 基于内存、支持多种数据结构 的存储系统,可以作为 数据库、缓存 和 消息 中间件
  • 它支持的数据结构有 字符串(strings)、哈希(hashes)、列表(lists)、集合(sets)、有序集合(sorted sets)等,除此之外还支持 bitmaps、hyperloglogs 和 地理空间(geospatial)索引半径查询等功能
  • 它内置了复制(Replication)、LUA 脚本(Lua scripting)、LRU 驱动事件(LRU eviction)、事务(Transactions)和不同级别的磁盘持久化(persistence)功能,并通过 Redis 哨兵(哨兵)和集群(Cluster)保证缓存的高可用性(High availability)

使用 Redis 有哪些好处?

答:

具有以下好处:

  1. 读取速度快,因为数据存在内存中,所以数据获取快
  2. 支持多种数据类型,包括字符串、列表、集合、有序集合、哈希等
  3. 还拥有其他丰富的功能,队列、主从复制、集群、数据持久化等功能

为什么用 Redis 作为 MySQL 的缓存?

答:

MySQL是数据库系统,对于数据的操作需要访问磁盘,而将数据放在Redis中,需要访问就可以直接从内存获取,避免磁盘I/O,提高操作的速度,使用 Redis + MySQL 结合的方式可以有效提高系统QPS(Queries Per Second(每秒查询数)的缩写,是衡量系统处理查询请求的能力的一个指标)


Redis 常用的业务场景有哪些?

答:

主要常用的业务场景:

  1. 对 热点数据 的 缓存:因为 Redis 支持多种数据类型,数据存储在内存中,访问速度块,所以 Redis 很适合用来存储热点数据
  2. 限时类业务的实现:可以使用 expire 命令设置 key 的生存时间,到时间后自动删除 key。例如:使用在验证码验证、优惠活动等业务场景
  3. 计数器的实现:因为 incrby 命令可以实现原子性的递增,所以可以运用于高并发的秒杀活动、分布式序列号的生成。例如:限制一个手机号发多少条短信、一个接口一分钟限制多少请求、一个接口一天限制调用多少次等业务场景
  4. 排行榜的实现:借助 Sorted Set 进行热点数据的排序。例如:下单量最多的用户排行榜,最热门的帖子(回复最多)等业务场景
  5. 分布式锁实现:可以利用 Redis 的 setnx 命令进行
  6. 队列机制实现:Redis 提供了 list push 和 list pop 这样的命令,所以能够很方便的执行队列操作

Redis 数据对象


Redis 的数据类型都有哪些?

答:

Redis 的五种数据类型说明

Redis 提供了丰富的数据类型,常见的有五种数据类型:String(字符串)、Hash(哈希)、List(列表)、Set(集合)、Zset(有序集合)

  • String

    • String 是最基本的 key-value 结构,key 是唯一标识,value 是具体的值,value 其实不仅是字符串,也可以是数字(整数或浮点数),value 最多可以容纳的数据长度是 512M

      String结构

  • List

    • List 列表是简单的字符串列表,按照插入顺序排序,可以从 头部 或 尾部 向 List 列表添加元素

      List结构

  • Hash

    • Hash 是一个键值对(key-value)集合,其中 value 的形式如:value=[{field1, value1},...{fieldN, valueN}]。 Hash 特别适合用于存储对象

      Hash结构

  • Set

    • Set 类型是一个 无序并唯一 的键值集合,它的存储顺序不会按照插入的先后顺序进行存储

      Set结构

      • Set 类型和 List 类型的区别如下:
        • List 可以存储 重复 元素,Set 只能存储 非重复 元素
        • List 是按照元素的 先后 顺序存储元素的,而 Set 则是 无序 方式存储元素的
  • Zset

    • Zset 类型(有序集合类型)相比于 Set 类型多了一个排序属性 score(分值),对于有序集合 ZSet 来说每个存储元素相当于有两个值组成的,一个是 有序集合的元素值,一个是 排序值

      Zset结构

补充:

随着 Redis 版本的更新,后面又支持了四种数据类型: BitMap(2.2 版新增)、HyperLogLog(2.8 版新增)、GEO(3.2 版新增)、Stream(5.0 版新增)

Redis 五种数据类型的应用场景:

  • String 类型的应用场景:缓存对象、常规计数、分布式锁、共享 session 信息等
  • List 类型的应用场景:消息队列
    • 但是有两个问题:
      1. 生产者需要自行实现全局唯一 ID
      2. 不能以消费组形式消费数据等
  • Hash 类型:缓存对象、购物车等
  • Set 类型:聚合计算(并集、交集、差集)场景,比如:点赞、共同关注、抽奖活动等
  • Zset 类型:排序场景,比如:排行榜、电话和姓名排序等

Redis 后续版本又支持四种数据类型,它们的应用场景如下:

  • BitMap(2.2 版新增):二值状态统计的场景,比如签到、判断用户登陆状态、连续签到用户总数等
  • HyperLogLog(2.8 版新增):海量数据基数统计的场景,比如百万级网页 UV 计数等
  • GEO(3.2 版新增):存储地理位置信息的场景,比如滴滴叫车
  • Stream(5.0 版新增):消息队列,相比于基于 List 类型实现的消息队列,有这两个特有的特性:自动生成全局唯一消息ID,支持以消费组形式消费数据

详细链接:Redis 常见数据类型和应用场景 | 小林coding


String


Set 一个已有的数据会发生什么?

答:

会覆盖原有的值

详细链接:Redis 字符串(String) | 菜鸟教程


浮点型在 String 是用什么表示?

答:

  • 要将一个浮点数放入字符串对象里面,需要先将这个浮点数转换成字符串值,然后再保存转换所得的字符串值
  • 浮点数在字符串对象里面是用字符串值表示的,用 Raw 还是 Embstr 编码,取决于转换后字符串的长度

补充:

String的编码方式:

  • INT编码:这个很好理解,就是存一个整型,可以用 long 表示的整数就以这种编码存储
  • EMBSTR编码:如果字符串小于等于阈值字节,使用EMBSTR编码
  • RAW编码:字符串大于阈值字节,则用RAW编码

这个阈值追溯源码的话,是用常量 OBJ_ENCODING_EMBSTR_SIZE_LIMIT 来表示,3.2 版本之前是 39 字节,3.2 版之后是 44 字节


String 可以有多大?

答:

一个 Redis 字符串最大为 512MB,官网有明确注明,我看源码里也是直接写死的


Redis字符串是怎么实现的?

答:

  • Redis 字符串底层是 String 对象,String 对象有三种编码方式:INT型、EMBSTR型、RAW型
  • 如果是存一个整型,可以用 long 表示的整数就以 INT编码存储
  • 如果存字符串,当字符串长度小于等于一个阈值,使用 EMBSTR 编码,字符串大于阈值,则用 RAW 编码,在我用的 5.0.5 版本中值是 44

补充:

embstr 与 raw 二者的不同:

  • embstr 会通过 一次内存分配函数 来分配一块连续的内存空间来保存 redisobject 和 sds
  • raw 编码会通过调用 两次内存分配函数 来分别分配两块空间来保存 redisobject 和 sds

SDS有什么用?

答:

Redis 是用 C语言 写的,SDS 可以说是对 C 字符串的封装,一般对比 普通C字符串。可以从计算长度、扩容、缩容、二进制存储这几个场景来描述

主要有三点:

  1. SDS 包含已使用容量字段,O(1) 时间快速返回有字符串长度,相比之下,C原生字符串需要 O(n)
  2. 有预留空间,在扩容时如果预留空间足够,就不用再重新分配内存,节约性能,缩容时也可以将减少的空间先保留下来,后续可以再使用。
  3. 不再以 '\0' 作为判断标准,二进制安全,可以很方便地存储一些二进制数据

详细链接:Redis 常见数据类型和应用场景 | 小林coding


List


List 是完全先入先出吗?

答:

List 是 双端 操作对象,所以不是完全的先入先出,List 也可以后入先出


List 对象底层编码方式是什么?

答:

  • 在 3.2 版本之前,List对象的编码是 ZIPLIST 和 LINKEDLIST。ZIPLIST 适用于元素数量较少、且元素都较短的情况,否则用 LINKEDLIST
  • 在 3.2 版本之后,List对象的编码全部由 QUICKLIST 实现。QUICKLIST 是一个压缩列表组成的双向链表,结合了 ZIPLIST 和 LINKEDLIST 两者的优点,在后面比较新的版本(Redis 7.0)中 ZIPLIST 优化为了LISTPACK

ZIPLIST 是怎么压缩数据的?

答:

  • 压缩列表是一块连续的内存空间,元素之间是紧挨着存储的,一个压缩列表中可以包含多个节点(entry),是在 连续的内存空间 上实现的 双端链表
  • 对于一个普通的双向链表,链表中每一项都占用独立的一块内存,各项之间用地址指针(或引用)连接起来。这种方式会带来大量的内存碎片,而且地址指针也会占用额外的内存。而 ZIPLIST 却是将表中每一项存放在前后连续的地址空间内,一个 ZIPLIST 整体占用一大块内存
    • 而且 ZIPLIST的节点对于 不同类型 会有 不同长度的 数据存储,保证尽可能的压缩内存

补充:

ZIPLIST的结构:

1
<zlbytes> <zltail> <zllen> <entry> <entry> .. <entry> <zlend>

其中 <entry> 的结构为:

1
<prevlen> <encoding> <entry-data>

为了方便记忆和理解,可以将 ZIPLIST 分为三部分:

  1. 结构头,即:header,包括 <zlbytes> <zltail> <zllen> 字段
  2. 数据部分,即:entry 列表,entry 中有 <prevlen> <encoding> <entry-data> 字段
  3. 结尾标识,即:<zlend>

ZIPLIST 下 List 可以从后往前遍历吗?

答:

可以。List 是双端数据结构,无论哪种底层编码,都需要能支持从后往前遍历。ZIPLIST 每个节点(entry)中都保存了上一个节点的长度,所以可以用 当前节点地址减去上一个节点长度 来找到上个节点起始位置,进而实现 从后往前 的遍历


在 ZIPLIST 数据结构下,查询节点个数的时间复杂度是多少?

答:

  • 在 ZIPLIST 编码下,查询节点个数的时间复杂度是 O(1)
  • 因为 ZIPLIST 中的 header 定义了 记录节点数量 的字段,但是这里也有个限制,记录节点数量的字段只有 2 字节,也就说如果节点数量超过 65535,就失效了,此时只能通过 O(n) 复杂度的遍历来查节点总数

LINKEDLIST编码下,查询节点个数的时间复杂度是多少?

答:

LINKEDLIST 编码下,查询节点个数的时间复杂度是 O(1)。因为 LINKEDLIST 的表头结构中定义了链表所包含
节点数量的字段 len

补充:

LinkedList的表头结构:

1
2
3
4
5
6
7
8
typedef struct list {
listNode *head;
listNode *tail;
void *(*dup)(void *ptr);
void (*free)(void *ptr);
int (*match)(void *ptr, void *key);
unsigned long len; // 这里定义了链表所包含节点数量的字段len
} list;

Set


Set 编码方式?

答:

Set 使用 整数集合和字典 作为底层编码,当元素都是 整数 同时元素个数不超过 512 个,会使用 整数集合 编码,否则使用 字典 编码


Set 是有序的吗?

答:

Set 的底层实现是 整数集合 或 字典,前者是 有序的,后者是 无序的。整体来看,但是不应该依赖 SET 的顺序,业务使用适合始终应该按 无序 来用


Set 为什么要用两种编码方式?

答:

Set 的底层编码是 整数集合 和 字典,当元素 数量小 并且全部是 整数 的时候,会使用 整数集合 编码,更加的节约内存。元素 数量变大 会使用 字典 编码,查找元素的速度会更快

补充:

Redis 用一个含有三个字段的结构体来表示 intset,分别是 编码方式、元素数量 和 实际存储元素 的有序柔性数组:

1
2
3
4
5
typedef struct intset {
uint32_t encoding; // 编码格式
uint32_t length; // 元素数量
int8_t contents[l; // 保存元素的数组
} intset;
  • intset 用来保存元素的数组默认情况下是 int16 编码,后续如果插入更大的整数,才会升级到 int32 或者 int64 编码。这种策略可以尽可能的节约内存以及提升整数集合的灵活性
  • 弊端:整个数组的编码会变成与最大元素的类型一致,假如这个时候,元素的数量非常多,就不那么节约内存了,而且数组查找的平均时间复杂度是 O(logn),不如使用字典编码

Hash


Hash的编码方式是什么?

答:

一个是 ZIPLIST,一个是 HashTable。ZIPLIST 适用于元素较少且单个元素长度较小的情况,其它情况使用 HashTable

补充:

  • ZIPLIST 适用于 元素较少 且 单个元素长度较小 的情况,这里的 阈值 分别是 元素个数少于 512 个,值和键长度都小于 64 字节

Hash查找某个key的平均时间复杂度是多少?

答:

Hash 有两种底层结构,ZIPLIST 时是 O(N),HashTable 则是 O(1)

补充:

ZIPLIST 需要 遍历,平均复杂度为 O(N),HashTable 是 字典,可以 O(1) 找到对应 key


Redis 中 HashTable 查找元素总数的平均时间复杂度是多少?

答:

HashTable 查找元素总数的平均时间复杂度是 O(1),因为 HashTable 的表头结构中有储存键值对数量的字段,这个字段我记得叫 used

补充:

Redis字典的表头结构:

1
2
3
4
5
6
7
typedef struct dictht2 {
dictEntry **table;
unsigned long size;
unsigned long sizemark;
// 键值对数量
unsigned long used;
} dictht;
  • 可以发现字典的表头结构中的 used,就记录了当前键值对数量的字段

一个数据在 HashTable 中的存储位置,是怎么计算的?

答:

首先会通过哈希函数计算出 key 的哈希值,然后与哈希掩码做与运算得到索引值,索引值就是这个数据在 HashTable 中的存储位置


HashTable 怎么扩容?

答:

首先程序会为 HashTable 的1号表分配空间,空间大小是 第一个 大于等于 0号表大小*2的2^n。在 rehash 进行期间,标记位 rehashidx 从 0 开始,每次对字典的键值对执行增删改查操作后,都会将 rehashidx 位置的数据迁移到 1 号表,然后将 rehashidx 加1,随着字典操作的不断执行,最终 0 号表的所有键值对都会被 rehash 到1号表上。之后,1 号表会被设置成 0 号表,接着在 1 号表的位置创建一个新的空白表

补充:

HashTable的结构:

HashTable的结构

  • 最外层是一个封装的 dictht 结构,其中字段含义如下:
  • table:指向实际 hash 存储。存储可以看做一个数组,所以是*table的表示,在C语言中*table可以表示一个数组
  • size:哈希表大小。实际就是 dictEntry 有多少元素空间
  • sizemask:哈希表大小的掩码表示,总是等于size-1。这个属性和哈希值一起决定一个键应该被放到table 数组的哪个索引上面,规则Index=hash&sizemask
  • used:表示已有节点数量经。通过这个字段可以很方便地查询到目前 HASHTABLE 元素总量

HashTable扩容

  • Hash表采用 渐进式扩容(就是一点一点慢慢扩容),其实为了实现渐进式扩容,Redis中没有直接把 dictht 暴露给上层,而是再封装了一层

    1
    2
    3
    4
    5
    6
    7
    typedef struct dict {
    dictType *type;
    void *privdata;
    dictht ht[2];
    long rehashidx; /* rehashing not in progress if rehashidx == -1 */
    unsigned long iterators; /* number of iterators currently running */
    } dict;
    • 可以看到 dict 结构里面,包含了2个 dictht 结构,也就是2个 HASHTABLE 结构。dictEntry 是链表结构,也就是用拉链法解决 Hash 冲突,用的是头插法
  • 实际上平常使用的就是一个 HASHTABLE,在触发扩容之后,就会两个 HASHTABLE 同时使用,详细过程是这样的:

    • 当向 字典 添加 元素 时,发现需要扩容就会进行 Rehash,Rehash 的流程大概分成三步:
      • 首先为新 Hash 表 ht[1] 分配空间。新表大小为 第一个大于等于 原表2倍 used 的 2次方幂。举个例子:原表如果 used=500,2 倍就是 1000,那第一个大于 1000 的 2 次方幂则为 1024
      • 此时字典同时持有 ht[0] 和 ht[1] 两个哈希表
      • 字典的偏移索引从 静默状态 -1,设置为 0,表示 Rehash 工作正式开始
    • 然后,迁移 ht[0] 数据到 ht[1]。在 Rehash 进行期间,每次对字典执行增删查改操作,程序会顺带迁移当前 rehashidx 在 ht[0] 上的对应的数据,并更新偏移索引。与此同时,部分情况周期函数也会进行迁移。如果 rehashidx 刚好在一个已删除的空位置上,是继续往下找,但是有个上限 n*10,例如:实际是1,则最多往后找10个
    • 随着字典操作的不断执行,最终在某个时间点上,ht[0] 的所有键值对都会被 Rehash 至 ht[1],此时再将 ht[1] 和 ht[0] 指针对象互换,同时把偏移索引的值设为 -1,表示 Rehash 操作已完成。这个事情也是在 Rehash 函数做的,每次迁移完一个元素,会检查下是否完成了整个迁移
  • 总结一下,渐进式扩容的核心就是 操作时顺带迁移


HashTable 怎么缩容?

答:

  • 扩容是数据太多存不下了,那如果太富裕呢,比如原来可能数据较多,发生了扩容,但后面数据不再需要被删除了,此时多余的空间就是一种浪费
  • 这里与扩容机制一样,渐进式缩容:首先程序会为 HashTable 的 1 号表分配空间,新表大小为 第一个大于等于原表 used 的2次方幂。在 rehash 进行期间,标记位 rehashidx 从 0 开始,每次对字典的键值对执行增删改查操作后,都会将 rehashidx 位置的数据迁移到 1 号表,然后将 rehashidx 加 1,随着字典操作的不断执行,最终 0 号表的所有键值对都会被 rehash 到 1 号表上。之后,1 号表会被设置成 0 号表,接着在 1 号表的位置创建一个新的空白表

什么时候扩容,什么时候缩容?

答:

  • 我先说 扩容,当以下两个条件中的任意一个被满足时,哈希表会自动开始扩容:

    • 第一个是服务器目前 没有在执行 BGSAVE 或者 BGREWRITEAOF,并且 负载因子 >= 1
      • BGSAVE:用于在后台异步保存当前数据库的数据到磁盘
      • BGREWRITEAOF:用于异步执行一个 AOF(Append Only File)文件重写操作
      • 因为当执行 BGSAVE 或 BGREWRITEAOF 命令过程中,Redis 需要创建服务器进程的子进程,操作系统采用的是 COW,即:写时复制 copy-on-write 的技术来优化子进程的使用效率。所以在子进程存在时,服务器会提高执行扩容所需的负载因子,从而尽可能避免在子进程存在期间进行扩容,可以避免不必要的内存写入操作,最大限度节约内存
    • 第二个是服务器目前 正在执行 BGSAVE 或者 BGREWRITEAOF,并且 负载因子>=5
  • 缩容的话也是负载因子影响,当 哈希表的负载因子小于0.1 时,程序会自动开始对哈希表进行收缩操作

补充:

设负载因子为 k,那么 k=ht[0].used/ht[0].size(负载因子 = 键值对数量 / 哈希表大小),也就是使用空间和总空间大小的比例


Zset


ZSet 底层有几种编码方式?

答:

  • ZSet 就是 有序集合 对象,ZSet 对象的底层有两种编码方式:ZIPLIST 或者 SKIPLIST + 字典
  • 当有 ZSet 对象可以同时满足以下两个条件时,对象使用 ZIPLIST 编码:
    1. ZSet 保存的 元素数量小于 128 个
    2. ZSet 保存的 所有元素成员的长度都小于 64 字节
  • 不能满足以上两个条件 的有序集合对象将使用 SKIPLIST + 字典 编码

跳表编码模式下,查询节点总数的平均时间复杂度是多少?

答:

跳表编码模式下,查询节点总数的平均时间复杂度是 O(1),因为跳表的表头结构中定义了一个保存节点数量的字段 length,源码中调用查询节点总数的 API 时会直接返回这个字段

补充:

跳表的表头结构:

1
2
3
4
5
6
typedef struct zskiplist {
struct zskiplistNode *header, *tail;
// 节点数量
unsigned long length;
int level;
} zskiplist;
  • 跳表的表头结构中定义了保存节点数量的字段:length,所以对 Redis 跳表查询节点总数的平均时间复杂度应该为 O(1)

跳表插入一条数据的平均时间复杂度是多少?

答:

跳跃表是一种支持 多级索引 的结构,查询效率可以媲美 二分查找,它插入一条数据也是需要 先查找,找到之后会进行索引的重建,整体平均时间复杂度是 O(logN)

补充:

跳表示意图1

跳表示意图2

跳表实际上就是 在一维链表上建立多层索引的二维链表多出来的层数,可以让跳表实现类似二分查找的算法。所以跳表的插入平均时间复杂度是 O(logN)

详细链接:数据结构与算法——跳表 - 知乎


为什么跳表和HashTable要配合使用?

答:

为了结合这两种数据结构各自的优势,当 ZSet 要 根据成员来查找分值 的时候,将使用 字典 来实现,时间复杂度为 O(1)。而当 ZSet 要执行范围操作时,比如:ZRANK、 ZRANGE 等命令时,将使用原本就有序的跳跃表来实现

注:可以结合跳表和字典相较于各自的优势来说明


跳表中一个节点的层高是怎么决定的?

答:

跳表在插入新节点之前会计算一个随机的层高。具体来说,跳表的每一个节点一开始默认都是 1 层,然后每增加一层的概率都是 25%,最高为 32 层

补充:

  • 跳表中插入一个节点之前会选择一个 随机化的层数,因为如果跳表的层数从下至上呈一定的比例关系,那么后期插入和删除的时候就又需要去维护这种比例关系,会使时间复杂度退化。所以跳表选择在插入节点的时候,选择一个随机化的层数
  • 但是生成的随机层数得遵循一个算法,使得生成小数值层数的概率很大,而生成大数值层数的概率很小,这个算法就是 幂次定律。跳表在插入新节点之前,会利用这个 幂次定律 算法生成一个 随机层数

详细链接:25% 的源码 redis/src/t_zset.c at 7.0 · redis/redis · GitHub


zset 为什么用跳表而不用平衡树?

答:

  • 跳表的实现非常灵活,可以通过 改变索引构建策略,有效平衡执行效率和内存消耗
  • 跳表的代码实现比 平衡树 来说,实现要简单多了,平衡树插入和删除都会导致树的旋转,实现起来很复杂,而跳表就简单很多,跳表插入就比普通的链表插入节点稍复杂一点
  • 按照区间来查找数据这个操作(比如:查找值在 [100,200] 之间的数据),平衡树的效率没有跳表高。对于按照区间查找数据这个操作,跳表可以做到 O(logN) 的时间复杂度定位区间的起点,然后在原始链表中顺序往后遍历就可以了,这样做非常高效
  • 总体来说,主要是 为了代码的简单、易读、不容易出错,比起平衡树复杂性,Redis 选择用了 跳表

补充:

在Redis官网上有说明:

主要是从 内存占用、对范围查找的支持、实现难易程度 这三方面总结的原因:

  1. 它们不是非常内存密集型的。基本上由你决定。改变关于节点具有给定级别数的概率的参数将使其比 btree 占用更少的内存
  2. Zset 经常需要执行 ZRANGE 或 ZREVRANGE 的命令,即:作为链表遍历跳表。通过此操作,跳表的缓存局部性至少与其他类型的平衡树一样好
    • ZRANGE 命令按照 分数(score) 的 升序 返回指定范围内的元素
    • ZREVRANGE 命令按照 分数(score) 的 降序 返回指定范围内的元素
  3. 它们更易于实现、调试等
    • 例如:由于跳表的简单性,我收到了一个补丁(已经在Redis master中),其中扩展了跳表,在 O(log(N)) 中实现了 ZRANK。它只需要对代码进行少量修改

详细链接:17-跳表:为什么Redis一定要用跳表来实现有序集合? | H.'s Blog


Redis 执行


Redis是单线程还是多线程?

答:

  • Redis 核心处理是 单线程 的,在 6.0 中使用了 多线程 进行 I/O解包、回包
  • 其他一些边缘点的,比较使用 多线程 进行 删除数据等异步任务,这个倒是 4.0 就引入了

补充:

  • 核心处理逻辑,Redis 一直都是 单线程 的,其它 辅助模块 也会有一些 多线程、多进程 的功能,比如:复制模块用的多进程
  • 某些异步流程从 4.0 开始用的 多线程,例如 UNLINK(会开一个新的线程专门处理)、FLUSHALL ASYNC、FLUSHDB ASYNC 等 非阻塞的删除操作。
  • 网络I/O解包 从 6.0 开始用的是 多线程
  • 但是这种分支模块,都只是辅助,最核心的还是处理架构,这块 Redis 始终是 单线程 的

引入多线程带来的额外成本:

  1. 上下文切换成本,多线程调度需要切换线程上下文,这个操作先存储当前线程的本地数据、程序指针等,然后载入另一个线程数据,这种内核操作的成本不可忽视
  2. 同步机制的开销,一些公共资源,在单线程模式下直接访问就行了,多线程需要通过加锁等方式去进行同步,这也是不可忽视的CPU开销
  3. 一个线程本身也占据内存大小,对Redis这种内存数据库而言,内存非常珍贵,多线程本身带来的内存使用的成本也需要谨慎决策

可以发现,多线程会引入额外的复杂度和成本,而 Redis 是 追求简洁高效 的存储组件。最终 Redis 选择单线程,其实是综合考虑成本和收益之后的决策,事实也证明,虽然是单线程处理架构,Redis 性能还是经受住了考验,并以快而闻名


Redis 为什么选择单线程做核心处理?

答:

我们从投入产出来看。首先 如果引入多线程,主要是 希望充分利用 多核 的性能,但 Redis 的定位,是内存 k-v 存储,是做 短平快 的热点数据处理,一般来说执行会很快,执行本身不应该成为瓶颈,而 瓶颈通常在网络 I/O 处理逻辑。多线程并不会有太大收益。同时,支持多线程的话,我们需要付出更大的复杂度、以及多线程上下文切换、同步机制的开销等成本。这样综合来看,成本高且收益不大,所以最终选择了不做,事实也证明,单线程的 Redis 也确实足够高效

补充:

从投入产出比分析:

  • 先看产出:
    • 首先 Redis 的定位,是内存 k-v 存储,是做短平快的热点数据处理,一般来说执行会很快,执行本身不应该成为瓶颈,而瓶颈通常在网络 I/O,处理逻辑多线程并不会有太大收益。
  • 再看投入:
    • 引入多线程带来极大的复杂度,比如:原来的顺序执行特性就不复存在,为了支持事务的原子性、隔离性, Redis 就不得不引入一些很复杂的实现,多线程模式也使得程序调试更加复杂和麻烦,会带来额外的开发成本及运营成本,也更容易犯错。同时,也带来上下文切换成本、同步机制的开销、线程本身也占据内存大小等投入成本

Redis 单线程性能如何?

答:

Redis 单线程的性能是很好的,在普通机器每秒能 10 多万的读性能、几万的写性能。我在我自己的电脑上也用 redis-benchmark 来测试过,写性能高达11万,读性能高达15万

补充:

Redis 实际表现为单机(普通8核16G的机器)读10多万,写几万,非常炸裂

相关指令:

1
2
3
4
5
6
# 指令:
redis-benchmark -h 127.0.0.1 -p 6379 -t set,get -n 10000 -9

# 结果:
SET:108695.65 requests per second
GET:149253.73 requests per second

详细链接:Redis 性能测试 | 菜鸟教程


为什么单线程还能这么快?

答:

我认为有三点,一个是 内存存储,这是 Redis 的定位也是快的前提;一个是 高效的数据结构,Redis 的数据结构可以说是追求极致,持续调优;最后一个是 I/O多路复用,Redis 的瓶颈在 I/O 而不是 CPU,那I/O复用正好能对症下药

补充:

Redis使用单线程的核心,是瓶颈在 I/O 而不是 CPU,那 I/O复用 正好能对症下药,主要方面有:

  1. 基于内存操作:Redis的绝大部分操作在内存里就可以实现,数据也存在内存中,与传统的磁盘文件操作相比减少了IO,提高了操作的速度
  2. 高效的数据结构:Redis有专门设计了 STRING、LIST、HASH 等高效的数据结构,依赖各种数据结构提升了读写的效率
  3. 采用单线程:单线程操作省去了上下文切换带来的开销和CPU的消耗,同时不存在资源竞争,避免了死锁现象的发生
  4. I/O多路复用:采用 I/O多路复用 机制同时监听多个 socket,根据 socket上的事件来选择对应的事件处理器进行处理

Redis6.0 之后引入了多线程,你知道为什么吗?

答:

Redis主要瓶颈是 I/O 而不是 CPU,但随着互联网的高速发展,在 部分高并发场景,单核CPU也不见得处理得过来了,所以 针对核心处理流程中的 解包、发包 这两个 CPU 耗时操作,进行了多线程优化,充分发挥多核优势

详细链接:Redis 6.0 多线程IO处理过程详解


Redis6.0 的多线程是默认开启的吗?

答:

  • 默认是 关闭 的,如果想要开启需要用户在 redis.conf 配置文件中修改

    • 设置 io-thread 的值为想要的io线程数,设置 io-threads-do-reads yes 打开读事件处理的多线程

      详细操作

  • 默认关闭的原因:

    1. 是为了兼容以前的,毕竟很多用户的认知中,Redis 是单线程的
    2. 可能也是认为多线程并不是必要的,在大多数场景不开启也是完全够用的

详细链接:redis开启多线程IO - 简书


Redis6.0 的多线程主要负责命令执行的哪一块?

答:

原来核心流程中的I/O处理,包括 解包 和 回包,也就是读写客户端 Socket 的 I/O(例如:高并发、大量小请求(如 GET/SET)、数据量较大或流式传输的场景),这两部分都消耗CPU时间,多线程的引入主要也是 为了解决单核CPU在大数据下还是不够用的问题

补充:

Redis 选择了引入多线程来处理 网络 I/O,仍然 使用 单线程框架 来执行 Redis 命令,这样既保持了 Redis 核心的单线程处理架构,完全兼容以前的实现,又 引入了 多线程 解决提升网络 I/O 的性能

Redis6.0 的多线程流程


Redis 持久化


RDB 和 AOF 本质区别是什么?

答:

本质区别就是 RDB 是 保存快照进行持久化,而 AOF 则是 追加日志文件进行持久化。因为这个本质区别,所以还会有 文件恢复速度、安全性、操作开销 等区别

补充:

RDB(Redis Database Backup):记录 Redis 某个时刻的全部数据,这种方式本质就是数据快照,直接保存二进制数据到磁盘,后续通过加载 RDB 文件恢复数据。RDB的本质是快照恢复

RDB

AOF(Append Only File):记录执行的每条命令,重启之后通过重放命令来恢复数据,AOF 本质是记录操作日志,后续通过日志重放恢复数据。AOF的本质是日志恢复

AOF

AOF和RDB的加载流程

  • 如果是讲区别可以从 文件类型、文件恢复速度、安全性 来进行回答,但本质区别就是 RDB 是 使用 快照 进行持久化,AOF 是 日志
  • 其他可以列举的区别点 都是能从 快照 与 日志 中的对比找出的:
    1. 文件类型:RDB 生成的是 二进制文件(快照),AOF 生成的是 文本文件(追加日志)
    2. 安全性:缓存宕机时,RDB 容易丢失较多的数据,AOF 根据策略决定(默认的 everysec 可以保证最多有一秒的丢失)
    3. 文件恢复速度:由于 RDB 是二进制文件,所以恢复速度也比 AOF 更快
    4. 操作的开销:每一次 RDB 保存都是一次全量保存,操作比较重,通常设置至少5分钟保存一次数据。而 AOF 的刷盘是一次追加操作,操作比较轻,通常设置策略为每一秒进行一次刷盘

详细链接:(官方说明)Redis persistence | Docs


如果RDB和AOF只能选一种,你选哪个?

答:

  • 如果从业务需要来看,如果我们能 接受分钟级别的数据丢失,可以考虑选择 RDB,如果需要 尽量保证数据安全,可以考虑 混合持久化,如果只用 AOF,那么优先选择 everysec 策略进行刷盘(在 可靠 和 性能 之间只能选择一个)
  • 从持久化理念来看,始终开启快照 是一个推荐的方式,这也是 Redis 官方为什么默认开启 RDB,而不开启 AOF,同时官网也明确不推荐只开 AOF

补充:

在文件系统中,everysec 策略通常指的是一种定期刷盘(写入磁盘)策略,每隔一定时间(通常为1秒)强制将数据从内存缓存写入磁盘。它的主要目的是通过减少写操作的频率来提升性能,同时保证一定程度的数据持久性


RDB持久化的触发时机?(了解)

答:

主要有这么几个地方,一个是调用 save 或者 bgsave 命令,一个是根据我们配置周期进行,一个是 Redis 关闭之前,这三个是比较常见的,其它边缘一点的还有主从全量复制发送 RDB 文件等,还有客户端执行数据库清空命令 FLUSHALL

补充:

  • 从源码来看,RDB 持久化函数整体上在这几个地方会被使用:
    1. Redis Shutdown(Redis关闭之前进行一次持久化)
    2. 客户端发送 save 命令
    3. 客户端发送 bgsave 命令
    4. 每一次事件循环 ServerCron 检查是否需要 bgsave(也就是判断我们的 RDB 配置)
    5. 主从全量复制发送 RDB 文件(也进行一次 RDB 持久化)
    6. 客户端执行数据库清空命令 FLUSHALL
  • 虽然 RDB 的触发条件有很多,但实际执行过程很简单,而区别点在于是 子进程来执行 还是 主进程执行这个流程:
    1. 打开一个临时的 RDB 文件(c语言库函数 fopen)
    2. 将执行命令这一时刻数据库数据写入到 I/O 缓冲区(c语言库函数 fwrite)
    3. 会将这一时刻的数据 按照 RDB 对应的版本格式 进行写入
    4. 执行fflush(将 I/O 缓冲区里的数据刷新到内核缓冲区)
    5. 执行fsync(可以将内核缓冲区里的数据刷到磁盘)
    6. 执行fclose(关闭这个临时文件)
    7. 修改临时文件名字,并让咱们的后台线程 BIO LAZY FREE 去删除旧的 RDB(到此,一次 RDB 过程结束)
  • 简单来说RDB的流程就是 触发RDB持久化时,让主进程或子进程(区分条件)来 将这一时刻的 数据库数据写到一个新的RDB文件中

AOF混合持久化方案是什么?

答:

  • AOF 混合持久化 会使用 RDB 持久化函数 将内存数据写入到 新的 AOF 文件中(数据格式也是 RDB 格式)
  • 而 重写期间 新的写入命令 追加到 新的 AOF文件,仍然是 AOF 格式
  • 此时新的 AOF 文件 就是 由 RDB 格式 和 AOF 格式组成的 日志文件

补充:

AOF 混合持久化,就是在 AOF 重写的基础上 做了一些改动


简单描述AOF重写流程?

答:

  1. 当 AOF 重写触发那一刻,主进程就会 fork 出一个子进程,然后这个子进程读取 Redis DB 中的数据,以 字符串命令 的格式写入到新 AOF 文件中
  2. 如果这个时候 Redis 接收到了新的写入命令,那么主进程会将这些 “增量数据” 写入到 AOF 重写缓冲区中
  3. 在子进程将数据都写入到新 AOF 文件后,主进程会通过 管道 将 AOF 重写缓冲区里面的数据发送给子进程,子进程再将这一份数据追加到新 AOF 文件中保证新 AOF 文件的完整性

补充:

AOF 重写就三个关键点:

  1. 子进程读取 Redis DB 中的数据以 字符串 命令的格式(也可以看作 AOF 文件格式)写入到新 AOF 文件中
  2. 如果有新数据,由主进程将数据写入到 AOF 重写缓冲区(AOF rewrite buffer)
  3. 当子进程完成重写操作后,主进程通过管道将 AOF 重写缓冲区中的数据传输给子进程,然后子进程追加到新 AOF 文件中

AOF流程


AOF 重写你觉得有什么不足之处吗?

答:

我认为主要有3点不足之处:(但是 Redis 在 7.0 版本也做了对应的优化)

  1. 额外的CPU开销
    • 在重写时,主进程需要将新的写入数据 写入到 AOF 重写缓冲(AOF rewrite buffer)
    • 主进程 需要通过 管道 向子进程发送 AOF 重写缓冲的数据
    • 子进程 还需要将这些数据 写入到新的 AOF 日志中
  2. 额外的内存开销:在重写时,AOF 缓冲 和 AOF 重写缓冲 中的数据都是一样的(浪费了一份)
  3. 额外的磁盘开销:在重写时,AOF 缓冲需要刷入 旧的 AOF 日志,AOF 重写缓冲也需要刷入到 新的 AOF 日志,导致在重写时磁盘多占一份数据

补充:

  1. 重写期间 新的写命令,会将数据写入到两处地方(AOF 缓冲和 AOF 重写缓冲)中,这是额外的CPU和内存开销
  2. 重写时会将 AOF 缓冲 和 AOF 重写缓冲 分别 写入到 旧日志 和 新日志 中,这是额外的磁盘开销
  3. Redis 7.0 就对此做了新的改进

详细链接:Redis持久化


针对AOF重写的不足,你有什么优化思路呢?

答:

  • 其实在 Redis 7.0 版本,就使用 MP-AOF 方案对 AOF 重写做了优化,核心 其实就是 去掉原来的重写缓冲,同时 将 AOF 日志拆分为 Base AOF日志 、Incr AOF 日志,由 manifest 来管理
  • 重写时,还是开一个子进程,对 BaseAOF 日志 进行重写,但是新命令会往 新的 Incr AOF 日志写,Incr AOF 日志+ 新的 Base AOF 日志 就构成了完整的新的 AOF 日志

补充:

改进之处:

  1. 在 Redis 7.0 版本,对 AOF 重写作出了优化,提出了 MP-AOF 方案,原来的 AOF 重写缓冲被移除,AOF 日志也分成了 Base AOF 日志、Incr AOF 日志
    • MP-AOF:Multi Part AOF = one BASE AOF + many INCR AOFS
    • Base AOF 日志:记录 重写 之前 的命令
    • Incr AOF 日志:记录 重写时 新的写入命令(正常 AOF 刷盘的时候写的是 Incr AOF)
  2. 当 重写发生 时,主进程 fork 出一个 子进程,对 Base AOF 日志 进行重写(将当前内存数据写入到新的 BaseAOF 日志),如果此时有新的写入命令,会由主进程 写入到 AOF buffer,再将缓冲数据刷入 新的 Incr AOF 日志。这样 新的 Incr AOF 日志 + 新的 Base AOF 日志 就构成了完整的新的 AOF 日志
  3. 子进程 重写 结束时,主进程会负责更新 manifest 文件,将新生成的 BASE AOF 和 INCR AOF 信息加进清单,并将之前的 BASE AOF 和 INCR AOF 标记为 HISTORY
    • manifest 用于追踪管理 AOF 文件
    • 这些 HISTORY 文件默认会被 Redis 异步删除(unlink),一旦 manifest 文件更新完成,就代表着整个 AOFRW 流程结束

Redis 过期删除策略和内存淘汰策略


Redis 是怎么删除过期 key 的?

答:

Redis 采用的删除策略是将 惰性删除策略 和 定期删除策略 组合使用:

  • 惰性删除策略:每次从数据库取 key 的时候 检查 key 是否过期,如果过期则删除,并返回 null,如果key 没有过期,则直接返回数据
    • 好处:占用 CPU 的时间比较少
    • 缺点:如果 key 很长时间没有被获取,将不会被删除,容易造成内存泄露
      • 原因:如果一个键在被标记为删除后 长期没有被访问或其他操作触发清理,它就会一直占用内存。这种未触发的清理可能会累积大量的 “死数据”,造成内存泄漏
  • 定期删除策略:该策略的作用是 每隔一段时间 执行一次删除过期 key 的操作
    • 好处:可以 避免惰性删除时出现内存泄露的问题,通过设置删除操作的时长频率,可以减少 CPU 时间的占用

补充:

  • Redis 是可以对 key 设置 过期时间 的,因此需要有相应的机制将已过期的键值对删除,而做这个工作的就是过期键值删除策略
  • 常见的三种过期删除策略:定时删除、惰性删除、定期删除
    1. 定时删除策略:该策略的作用是给 key 设置过期时间的同时,给 key 创建一个定时器,定时器在 key 的过期时间来临时,对这些 key 进行删除
      • 优点:保证内存空间得以释放
      • 缺点:给 key 创建一个定时器会有一定的性能损失。如果 key 很多,删除这些 key 占用的内存空间也会占用 CPU 很多时间
    2. 惰性删除策略:每次从数据库取 key 的时候检查 key 是否过期,如果过期则删除,并返回 null,如果 key 没有过期,则直接返回数据
      • 好处:占用 CPU 的时间比较少
      • 缺点:如果 key 很长时间没有被获取,将不会被删除,容易造成内存泄露
    3. 定期删除策略:该策略的作用是每隔一段时间执行一次删除过期 key 的操作,该删除频率可以在redis.conf 配置文件中设置
      • 好处:可以避免惰性删除时出现内存泄露的问题,通过设置删除操作的时长频率,可以减少 CPU 时间的占用
      • 缺点:相对内存性能友好来说,该策略不如定时删除策略,相对 CPU 性能友好来说,该策略不如惰性删除策略
  • 三种过期删除策略,每一种都有优缺点,仅使用某一个策略都不能满足实际需求。所以, Redis 选择 「惰性删除 + 定期删除」 这两种策略配和使用,以求在合理使用 CPU 时间和避免内存浪费之间取得平衡

详细链接:Redis 过期删除策略和内存淘汰策略有什么区别? | 小林coding


Redis 有几种内存回收策略?

答:

  • 一种是 不开启淘汰 策略,此时如果内存满了,写入操作失败,但是不会淘汰已有数据
  • 另一种是 开启淘汰 策略,这时候有两个大的分支
    • 一个是基于 有过期时间 的数据淘汰
    • 一个是基于 所有数据,他们都支持 LRU、LFU、RANDOM 算法,过期时间还支持按 TTL 大小淘汰

补充:

  • 不进行数据淘汰 的策略:
    • noeviction(Redis 3.0 之后,默认的内存淘汰策略):它表示当运行内存超过最大设置内存时,不淘汰任何数据,这时如果有新的数据写入,会报错通知禁止写入,不淘汰任何数据,但是如果没用数据写入的话,只是单纯的查询或者删除操作的话,还是可以正常工作
  • 进行数据淘汰 的策略:
    • 针对「进行数据淘汰」这一类策略,又可以细分为「在设置了 过期时间 的数据中进行淘汰」和「在所有数据范围内进行淘汰」这两类策略
    • 在设置了 过期时间 的数据中进行淘汰:
      • volatile-random:随机淘汰设置了过期时间的任意键值
      • volatile-ttl:优先淘汰更早过期的键值
      • volatile-lru(Redis 3.0 之前,默认的内存淘汰策略):淘汰所有设置了过期时间的键值中,最久未使用的键值
      • volatile-lfu(Redis 4.0 后新增的内存淘汰策略):淘汰所有设置了过期时间的键值中,最少使用的键值
    • 在 所有数据范围 内进行淘汰:
      • allkeys-random:随机淘汰任意键值
      • allkeys-lru:淘汰整个键值中最久未使用的键值;
      • allkeys-lfu(Redis 4.0 后新增的内存淘汰策略):淘汰整个键值中最少使用的键值

内存回收是什么时候发起?

答:

实际上,每次进行读写的时候,都会去检查是否需要释放内存,如果需要则会触发

  • 这里出发的条件是:即使没有达到 maxmemory 的时候,每次读写命令时也会调用函数,也会去尝试释放内存

补充:

了解触发时机即可,实际上,每次进行读写的时候,都会调用 processcommand 函数,processcommand 函数又会调用 freememoryIfNeeded,这个时候就会尝试去释放一定的内存,函数名只需要了解即可


介绍下 Redis LRU 回收算法?

答:

LRU,即最近最久未使用,记录每个 key 的最近访问时间,淘汰最久未没访问到的数据。但是Redis并不是标准的,LRU算法在 Redis 3.0 中做了一些优化,我能展开说一下吗?

补充:

  • Redis 的 LRU 算法概述:
    • 在 LRU 模式,redisObject 对象中 lru 字段存储的是 key 被访问时 Redis 的时钟 server.lruclock,当 key被访问的时候,Redis 会更新这个 key 的 redisObject 的 lru字段
    • 默认每 100 毫秒更新一次,缓存的值注意,Redis 为了保证核心 单线程 服务性能,缓存了 Unix 操作系统时钟,是 Unix 时间戳取模 2^24
    • 近似 LRU 算法在现有数据结构的基础上采用 随机采样 的方式来淘汰元素,当内存不足时,就执行一次近似 LRU 算法
    • 具体步骤是随机采样 n 个 key,这个采样个数默认为 5,然后根据时间戳淘汰掉最旧的那个 key,如果淘汰后内存还是不足,就继续随机采样来淘汰
  • LRU 算法的 采样范围
    • Redis 可以选择范围策略有两种
      1. allkeys
        • 所有 key 中随机采样
      2. volatile 从有过期时间的 key 随机采样
        • 分别对应 allkeys-lru、volatile-lru
  • Redis 3.0 对近似 LRU 算法进行了一些优化:
    • 新算法 会维护一个大小为 16 的候选池,池中的数据根据 访问时间 进行排序
    • 第一次随机选取的 key 都会放入池中,然后淘汰掉最久未访问的,比如:第一次选了 5 个,淘汰了 1 个,剩下 4 个继续留在池子里
      • 注:lru 字段表示的时间戳越小,就代表这个 key 空闲的时间越大,就越应该被淘汰
    • 如果池子未满,那不管空闲时间大还是小,都需要填充到池子里面
    • 如果池子满了,每次随机选取的 key 只有 空闲时间 大于 当前池子里面最小空闲时间的 key 时才会放入池中,然后将池中空闲时间最大的 key 进行淘汰
    • 通过池子存储,其表现也会非常接近真正的 LRU

Redis LRU 算法是标准的吗?为什么不用标准的

答:

  • 标准 LRU 需要维护双链表,内存成本巨大,所以 Redis 采用近似 LRU 采样来做淘汰
    • 具体步骤是:随机采样 n 个 key,这个采样个数默认为 5,然后根据时间戳淘汰掉最旧的那个 key,如果淘汰后内存还是不足,就继续随机采样来淘汰
  • 在 3.0 之后,Redis 还针对近似 LRU 算法做了淘汰池优化,也就是 维护一个候选池,池中的数据根据访问时间进行排序。第一次随机选取的 key 都会放入池中,然后淘汰掉最久未访问的
    • 比如:第一次选了5个,淘汰了1个,剩下 4 个继续留在池子里
  • 随后每次随机选取的 key 只有空闲时间大于 池子里当前空闲时间最小的 key 时,才会放入池中。当池子装满了,如果有新的 key 需要放入,则将池中空闲时间最大的 key 淘汰掉

什么是 LFU 算法,为什么 Redis 要引入 LFU 算法?

答:

LFU,即:将 访问频率 也加入到影响因素中,具体而言,LFU 下 同时记录了 上一次访问时间戳 和 访问计数,访问计数 同时受 上一次访问时间 和 访问频率 的影响,因为每次访问都可能会增加计数

补充:

对比 LRU 与 LFU:LRU 的一个弊端是 只看最近访问时间,而忽略了频率

LRU的弊端


Redis 场景


你有实际使用过 Redis 做什么应用么?

答:

有在 实习中/工作中/实验室中 涉及过 Redis 缓存场景、Redis 分布式锁场景

补充:

  • 根据学过的回答即可

Redis 缓存是如何应用的?

答:

我们是 用作 旁路 缓存,在我们 订单项目 中,是先查询 Redis,没有查 MySQL 并将数据加载到 Redis

补充:

  • Cache Aside,即:旁路缓存模式,是最常见的模式,应用服务把缓存当作数据库的旁路,直接和缓存进行交互

  • 读操作 的流程如下:

    • 应用服务收到查询请求后,先查询数据是否在缓存上,如果在,就用缓存数据直接打包返回,如果不存在就去访问数据库,从数据库查询,并放到缓存中,除了查库后加载这种模式,如果业务有需要,还可以预加载数据到缓存

      旁路缓存模式 读操作

  • 写操作 的流程如下:

    • 在 更新操作 的时候,Cache Aside 模式是一般是 先 更新 数据库,然后直接 删除 缓存,并且 更新 相比 删除 会更容易造成时序性问题,所以先更新数据库再删除缓存

      旁路缓存模式 写操作

    Cache Aside 适用于 读多写少 的场景,比如:用户信息、新闻报道等,一旦写入缓存,几乎不会进行修改。该模式的缺点是 可能会出现缓存和数据库不一致 的情况


Redis 做旁路缓存,如果 MySQL 更新了,此时何去何从?

答:

  • 我在项目中使用 过期时间 来兜底,并且在 更新 DB 后 删除缓存 来提升一致性的方式
  • 另外,在做方案设计时候我还考虑过订阅 binlog 的方式,但这种方案 额外引入了消息队列和消费服务,成本太高而收益不足,所以还是选择了前者
  • 我也调研过业界一些团队,包括腾讯云团队,字节跳动飞书团队,在大部分场景也是相同的选择,甚至一部分连删除都不用,本身对缓存一定程度不一致的容忍还是有的

补充:

  • 经典的缓存一致性问题,指多个缓存副本之间的数据不一致的情况
  • 有三个解决方向:
    1. 更新 MySQL 即可,不管 Redis,完全以 过期时间 兜底
    2. 更新 MySQL 之后,操作 Redis,当然要考虑到 Redis 更新操作可能会因为网络、进程重启等各种原因失败,所以过期时间兜底还是少不了
    3. 异步将 MySQL 的更新刷入到 Redis
      • 比如:先更新 MySQL,通过订阅 MySQL 的 binlog 记录来 异步 执行更新 Redis 的

Redis做秒杀场景可以吗?讲讲思路

答:

  • Redis 可以用来 记录库存,利用 Redis 的高性能进行库存的扣减,一个 Redis 处理 6W 的请求问题不大,100W/s 流量就 20 台 Redis 来支撑,当然,每个节点都要做主从容灾
  • 另一个方式就是把 Redis 作为轻量级消息队列,来接受请求,但是不如 kafka 这种可靠

补充:

  • Redis 在 秒杀场景 主要的应用方式是两个:
    1. 一个是把 Redis 当 消息队列 用,用于削峰
    2. 一个是用 Redis 记录库存,进行库存的加减

秒杀服务

  • 秒杀活动的主要思路:削峰、限流、异步、补偿

    • 解决高并发的问题(异步)

      • 这一步可以通过 消息队列 来实现,将 抢 和 购 解耦,还可以很方便地限频,不至于让 MySQL 过度承压
      • 抢 的话使用 Redis 来做处理,因为 Redis 处理简单的扣减请求是非常快的,而直接到 MySQL 是比较力不从心。Redis 可是单机支撑每秒几万的写入,并且可以做成集群,提高扩展能力
      • 我们可以先将库存名额预加载到 Redis,然后在 Redis 中进行扣减,扣减成功的再通过 消息队列 传递到 MySQL 做真正的订单生成
    • 拒绝超卖

      • 抢购场景最核心的,有两个步骤:
        1. 第一步:判断库存名额是否充足
        2. 第二步:减少库存名额,扣减成功就是抢到
      • 产生一个问题:如果第一步判断的时候还有库存,但是由于是并发操作,实际调用的时候,可能已经没有库存了,这样就会造成超卖。所以 第一步和第二步都是需要原子操作的,但是 Redis 没有直接提供这种场景原子化的操作
      • 解决办法:Redis + Lua,可以说是专门 为解决原子问题 而生,在 Lua 脚本中调用 Redis 的多个命令,这些命令整体上会作为原子操作来进行
    • 避免少买

      • 产生问题的原因:

        少买的情况

        1. 上面提到的,减少库存操作超时,但实际是成功的,因为超时并不会进入生成订单流程
        2. 在 Redis 操作成功,但是向 Kafka 发送消息失败,这种情况也会白白消耗 Redis 中的库存
      • 解决办法:只需要保证 Redis 库存 + Kafka 消费 的最终一致

        1. 也最简单的方式,在投递 Kafka 失败的情况下,增加 渐进式重试

        2. 更安全一点,就是在第一种的基础上,将这条消息记录在磁盘上,慢慢重试

        3. 写磁盘之前就可能失败,可以考虑走 WAL 路线,但是这样做下去说不定就做成 MySQL 的undolog、redo log 这种 WAL 技术了,会相当复杂,没有必要

          WAL路线


Redis 可以做消息队列吗?什么时候能用 Redis 做消息队列?

答:

Redis 可以作为 轻量级消息队列。如果是本身业务轻量级,且团队没有已经接入完备的消息队列,这个时候没有必要引入一个重量消息队列,使用 Redis 即可满足要求,没有不能用的组件,只有不合适的场景

补充:

  • 消息队列:有着 先入先出 的特性,消息队列一般用于 异步流程、消息分发、流量削锋 等问题,可以通过消息队列实现高性能、高可用、高扩展的架构

    • 分布式系统有不少消息队列中间件,业界比较出名的消息队列中间件有:ActiveMQ、RabbitMQ、ZeroMQ、Kafka、MetaMQ、RocketMQ等,这些队列通常具备 可靠性、高性能 等特点
  • 在 Redis 中,一般有 3 种方案来做一个 轻量级消息队列

    1. List 做消息队列

      • 本身是一个 双端列表,命令也可以支持 先入先出,从功能上,我们完全可以在一个服务将数据放进List,另一个服务来进行接受处理,这就是一个典型的生产消费模式

        List消息队列

      • 缺点:可以阻塞式消费(BLPUSH、BLPOP)了,但是没有 ACK 机制,即:消费者取消息之后,消息就出队列了,如果消费失败,消息还得想办法放回去。同时,也不支持多人消费

    2. Pub/Sub 生产订阅模式

      • 当订阅者订阅某个频道,如果生产者将消息发送到这个频道,订阅者就能收到该消息,这种模式支持多个消费者订阅相同的频道,互不干扰

        Pub/Sub 生产订阅模式

      • 缺点:

        1. 没有 ACK 功能
        2. 不支持持久化,Redis 重启消息会全部丢失,所以 PUB/SUB 比较 适合处理不那么重要的消息
    3. Stream 做消息队列

      • Redis 5.0 中发布了 Stream 类型,它提供了消息的 持久化,消费组 等功能,可以说基本的消息队列能力它都具备了

什么是分布式锁?

答:

  • 分布式锁:控制分布式系统之间 同步访问共享资源 的一种方式
    • 在 单机 或者 单进程 环境下,多线程 并发的情况下,使用 锁来 保证一个代码块在同一时间内只能由一个线程执行
    • 比如:Java 的 Synchronized 关键字和 ReentrantLock 类

补充:

分布式锁 是 用于 分布式环境 下 并发 控制的一种机制,用于控制某个资源在同一时刻只能被一个应用所使用

分布式锁


分布式锁实现要点是什么(其实就是怎么 加锁、怎么 解锁、怎么 用)?

答:

分 加锁 解锁 来说:

  • 加锁 是用 SET 命令,然后带上 NX 参数,key 是锁名字,value 是持有者 id,再加一个 过期时间
    • 这里 value 要用持有者 id的原因是谁申请、谁释放的原则,会在解锁时进行检查
    • 过期时间是为了兜底,防止异常情况下锁被永久占据
  • 解锁 的话,主要涉及两步操作,一个是 查看是不是自己的锁,如果是接着就是释放锁,为了保证 原子性 解锁需要用 LUA 脚本 进行

补充:

  • Redis 分布式锁的 加锁 命令(一行命令 实现 互斥效果 + 过期时间,原子性)

    1
    2
    3
    4
    5
    # lock_key 就是 key 键
    # unique_value 是客户端生成的唯一的标识,区分来自不同客户端的锁操作
    # NX 代表只在 lock_key 不存在时,才对 lock_key 进行设置操作
    # Px 10000 表示设置 lock key 的过期时间为 10s,这是为了避免客户端发生异常而无法释放锁
    SET lock_key unique_value NX PX 10000
    • 要注意 setnx 这个命令,是 没办法携带过期时间参数 的,如果 setnx + expire 两个命令,就没办法保证加锁的原子性,所以要用 set 命令,携带 nx 和 px 参数,才能 保证加锁的原子性
  • 解锁 的过程就是将 lock_key 键删除(del lock_key),但不能乱删,要保证执行操作的客户端就是加锁的客户端。所以,解锁的时候,我们要先判断锁的 unique_value 是否为加锁客户端,是的话,才将 lock_key 键删除

    • 解锁 是有两个操作,这时就需要 Lua 脚本来保证解锁的 原子性,因为 Redis 在执行 Lua 脚本时可以以原子性的方式执行,保证了锁释放操作的原子性

      1
      2
      3
      4
      5
      6
      -- 释放锁时,先比较 unique_value 是否相等,避免锁的误释放
      if redis.call("get", KEYS[1]) == ARGV[1] then
      return redis.call("del",KEYS[1])
      else
      return 0
      end
  • 要说出 加锁 和 解锁 的流程

Redis加锁和解锁的流程


为什么需要引入 owner 的概念呢?

答:

  • 分布式锁 需要 保证对称性,假设没有这种对称性,会有问题
  • 举个例子:服务 A 获取了 锁,由于业务流程比较长,或者网络延迟、GC卡顿等原因,导致锁过期,而业务还会继续进行。这时候,业务 B 已经拿到了锁,准备去执行,这个时候服务 A 恢复过来并做完了业务,就会释放锁,而 B 却还在继续执行,等 B 完成下次释放的可能又是别人的锁,这种情况是需要避免的

补充:

对称性的概念:对称性也就是说同一个锁,加锁和解锁必须是同一个竞争者。不能把其他竞争者持有的锁给释放了(超时自动释放除外)


你提到了 Lua,用 Lua 一定能保证原子性?

答:

  • Lua 本身 不 具备原子性,上面提到用 Lua 来保证原子性是因为 Redis 是 单线程 执行,一个流程放进 Lua 来执行,相当于是打包在一起,Redis 执行他的过程中不会被其他请求打断,所以说保证了原子性
  • 这里我们也提到,我们是在释放的时候将查询 key,删除 key 打包到一起,其中只有最后删除是写操作,所以这个流程本身是保证了原子性的

基于 Redis 实现分布式锁有什么优缺点?

答:

  • 基于 Redis 实现分布式锁的 优点:
    1. 性能高效(这是选择缓存实现分布式锁最核心的出发点)
    2. 实现方便
      • 很多研发工程师选择使用 Redis 来实现分布式锁,很大成分上是因为 Redis 提供了 setnx 方法,实现分布式锁很方便
    3. 避免单点故障(因为 Redis 是跨集群部署的,自然就避免了单点故障)
  • 基于 Redis 实现分布式锁的 缺点:
    1. 超时时间不好设置
      • 如果锁的超时时间设置过长,会影响性能,如果设置的超时时间过短会保护不到共享资源
      • 比如:在有些场景中,一个线程 A 获取到了锁之后,由于业务代码执行时间可能比较长,导致超过了锁的超时时间,自动失效,注意 A 线程没执行完,后续线程 B 又意外的持有了锁,意味着可以操作共享资源,那么两个线程之间的共享资源就没办法进行保护了
      • 那么如何合理设置超时时间呢?
        • 我们可以基于 续约 的方式设置超时时间:先给锁设置一个超时时间,然后启动一个守护线程,让守护线程在一段时间后,重新设置这个锁的超时时间
        • 实现方式:写一个守护线程,然后去判断锁的情况,当锁快失效的时候,再次进行续约加锁,当主线程执行完成后,销毁续约锁即可,不过这种方式实现起来相对复杂
    2. Redis 主从复制 模式中的数据是 异步复制 的,这样导致分布式锁的不可靠性
      • 如果在 Redis 主节点获取到锁后,在没有同步到其他节点时,Redis 主节点宕机了,此时新的 Redis 主节点依然可以获取锁,所以多个应用服务就可以同时获取到锁

Redis 如何解决集群情况下分布式锁的可靠性?

答:

  • 为了保证 集群 环境下 分布式锁 的 可靠性,Redis 官方已经设计了一个分布式锁算法 Redlock(红锁)
    • 它是基于多个 Redis 节点的分布式锁,即使有节点发生了故障,锁变量仍然是存在的,客户端还是可以完成锁操作。官方推荐是至少部署 5 个 Redis 节点,而且都是主节点,它们之间没有任何关系,都是一个个孤立的节点
    • RedLock 算法的基本思路:是让客户端和多个独立的 Redis 节点依次请求申请加锁,如果客户端能够和半数以上的节点成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁,否则加锁失败。这样一来,即使有某个 Redis 节点发生故障,因为锁的数据在其他节点上也有保存,所以客户端仍然可以正常地进行锁操作,锁的数据也不会丢失
    • Redlock 算法加锁三个过程:
      1. 第一步:客户端获取当前时间(t1)
      2. 第二步:客户端按顺序依次向 N 个 Redis 节点执行加锁操作:
        • 加锁操作使用 SET 命令,带上 NX,EX/PX 选项,以及带上客户端的唯一标识
        • 如果某个 Redis 节点发生故障了,为了保证在这种情况下,Redlock 算法能够继续运行,我们需要给「加锁操作」设置一个超时时间(不是对「锁」设置超时时间,而是对「加锁操作」设置超时时间),加锁操作的超时时间需要远远地小于锁的过期时间,一般也就是设置为几十毫秒
      3. 第三步:一旦客户端从超过半数(大于等于 N/2+1)的 Redis 节点上成功获取到了锁,就再次获取当前时间(t2),然后计算整个加锁过程的总耗时(t2 - t1)。如果 t2 - t1 < 锁的过期时间,此时,认为客户端加锁成功,否则认为加锁失败。
    • 可以看到,加锁成功要同时满足两个条件:(简述:如果有超过半数的 Redis 节点成功的获取到了锁,并且总耗时没有超过锁的有效时间,那么就是加锁成功):
      1. 条件一:客户端从超过半数(大于等于 N/2+1)的 Redis 节点上成功获取到了锁
      2. 条件二:客户端从大多数节点获取锁的总耗时(t2 - t1)小于锁设置的过期时间
    • 加锁成功后,客户端需要重新计算这把锁的有效时间,计算的结果是「锁最初设置的过期时间」减去「客户端从大多数节点获取锁的总耗时(t2 - t1)」。如果计算的结果已经来不及完成共享数据的操作了,我们可以释放锁,以免出现还没完成数据操作,锁就过期了的情况加锁失败后,客户端向所有 Redis 节点发起释放锁的操作,释放锁的操作和在单节点上释放锁的操作一样,只要执行释放锁的 Lua 脚本就可以了

缓存穿透是什么?怎么解决?

答:

  • 缓存穿透:当用户访问的数据,既不在缓存中,也不在数据库中,导致请求在访问缓存时,发现缓存缺失,再去访问数据库时,发现数据库中也没有要访问的数据,没办法构建缓存数据,来服务后续的请求。那么当有 大量 这样的请求到来时,数据库的压力骡增,这就是 缓存穿透 的问题

    缓存穿透

  • 最常见的场景就是:有攻击者伪造了大量的请求,请求某个不存在的数据。这会造成两个后果:

    1. 缓存 里面没有对应的数据,所以查询会落到 数据库 上
    2. 数据库 也没有数据,所以没有办法回写缓存,下一次请求同样的数据,请求还是会落到数据库上
  • 解决手段:

    1. 回写特殊值
      • 在缓存未命中,而且数据库里也没有的情况下,往缓存里写入一个 特殊的值。这个值就是 标记数据不存在,那么下一次查询请求过来的时候,看到这个特殊值,就知道没有必要再去数据库里查询了
      • 但如果如果攻击者每次都用不同的且都不存在的 key 来请求数据,那么 "回写特殊值” 这种手段会丧失它的效果,而且因为要回写特殊值,会浪费不少 Redis 的内存。这可能会进一步引起另外一个问题,就是 Redis 在内存不足,执行淘汰的时候,把其他有用的数据淘汰掉,而更好的就是考虑 使用布隆过滤器
    2. 使用布隆过滤器
      • 布隆过滤器是一种 快速判断元素是否存在 的数据结构,它可以在很小的内存占用下,快速判断一个元素是否在一个集合中。将所有可能存在的数据哈希到一个足够大的位数组中,当一个请求过来时,可以先通过查询布降过滤器快速判断数据是否存在,如果不存在,则直接返回,避免对数据库的查询
    3. 限流策略
      • 针对 频繁请求的特定数据,可以设置限流策略,例如:使用 令牌桶 算法或 漏桶 算法,限制对这些数据的请求频率,减轻数据库的压力

布隆过滤器是怎么工作的?

答:

  • 布隆过滤器 由 初始值都为 0 的 位图数组 和 N 个 哈希函数 两部分组成
  • 在写入数据库数据时,在 布隆过滤器 里做个 标记,这样下次查询数据是否在数据库时,只需要查询布隆过滤器,如果查询到数据没有被标记,说明不在数据库中。具体步骤:
    1. 第一步:使用 N 个哈希函数分别对数据做哈希计算,得到 N 个哈希值
    2. 第二步:将第一步得到的 N 个哈希值对位图数组的长度取模,得到每个哈希值在位图数组的对应位置
    3. 第三步:将每个哈希值在位图数组的对应位置的值设置为 1

补充:

举例

举个例子:

  • 假设有一个位图数组长度为 8,哈希函数 3 个的布隆过滤器,在数据库写入数据 x 后,把数据 x 标记在布隆过滤器时,数据 x 会被 3 个哈希函数分别计算出 3 个哈希值,然后在对这 3 个哈希值对 8 取模
  • 假设取模的结果为 1、4、6,然后把位图数组的第 1、4、6 位置的值设置为 1。当应用要查询数据 x 是否数据库时,通过布隆过滤器只要查到位图数组的第 1、4、6 位置的值是否全为 1,只要有一个为 0,就认为数据 x 不在数据库中
  • 布隆过滤器由于是基于哈希函数实现查找的,高效查找的同时存在哈希冲突的可能性,比如:数据 x 和数据 y 可能都落在第 1、4、6 位置,而事实上,可能数据库中并不存在数据 y,存在误判的情况
  • 所以,查询布隆过滤器说 数据存在,并不一定证明数据库中存在这个数据,但是查询到数据不存在,数据库中一定就不存在这个数据

缓存击穿是什么?怎么解决?

答:

  • 缓存击穿:如果缓存中的 某个热点数据过期了,此时大量的请求访问了该热点数据,就无法从缓存中读取,直接访问数据库,数据库很容易就被高并发的请求冲垮

    缓存击穿

  • 缓存击穿 的 关注点 是 热点数据 不存在于 缓存 上,因为如果是普通数据,那就是正常的缓存未命中,反之 热点数据 不在 缓存 中了,容易导致 大量请求 落到 数据库 上

    • 解决手段:
      1. 设置热点数据的热度时间窗口:对于热点数据,可以设置一个热度时间窗口,在这个时间窗口内,如果一个数据被频繁访问,就将其缓存时间延长,避免频繁刷新缓存导致缓存击穿
      2. 使用互斥锁或分布式锁:在缓存失效时,只允许一个线程去查询数据库,其他线程等待查询结果。可以使用 互斥锁 或 分布式锁 来实现,确保只有一个线程能够查询数据库,其他线程等待结果,避免多个线程同时查询数据库造成数据库压力过大
      3. 缓存永不过期:对于一些热点数据,可以将其缓存设置为永不过期,避免缓存击穿
      4. 异步更新缓存:在缓存失效时,可以异步地去更新缓存,而不是同步地去查询数据库并刷新缓存。这样可以减少对数据库的直接访问,并且不会阻塞其他请求的响应

缓存雪崩是什么?怎么解决?

答:

  • 缓存雪崩:缓存雪崩一般有两个常见场景,当 大量缓存数据在同一时间过期(失效)或者 Redis 故障宕机时,如果此时有大量的用户请求,都无法在 Redis 中处理,于是 全部请求都直接访问数据库,从而导致数据库的压力骤增,严重的会造成数据库容机,从而形成一系列连锁反应,造成整个系统崩溃,这就是缓存雪崩的问题

    缓存雪崩两个常见场景

    • 场景1:缓存集中失效。比如:为了双十一,将一批商品放入到缓存中,设置两小时过期,凌晨2点过期了,导致对这一批商品的访问都落到了数据库,数据库就会产生压力波峰
    • 场景2:当然缓存雪崩一个最严重特殊情况是,在流量高峰,一个缓存节点直接出现问题,甚至扩大到缓存集群出现问题,那么就会导致本应该访问缓存的流量透传到数据库上
  • 解决手段:

    • 大量数据同时过期的解决手段:
      1. 设置缓存数据的随机过期时间:在设置缓存数据的过期时间时,加上一个随机值,使得不同的缓存数据在过期时刻不一致。这样可以避免大量数据同时过期,减轻数据库负荷
      2. 分布式锁或互斥锁:在缓存失效时,使用分布式锁或互斥锁来保证只有一个请求可以重新加载缓存。其他请求等待该请求完成后,直接从缓存中获取数据。这样可以避免多个请求同时访问数据库
      3. 数据预热:在系统启动或者非高峰期,提前将热点数据加载到缓存中,预热缓存。这样即使在高并发时,也能够从缓存中获取到数据,减轻数据库的压力
      4. 后台更新缓存:业务线程不再负责更新缓存,缓存也不设置有效期,而是让缓存 “永久有效”,并将更新缓存的工作交由后台线程定时更新
      5. 数据库优化:对于缓存雪崩问题,除了缓存层面的应对策略,还可以从数据库层面进行优化,如:提升数据库性能、增加数据库的容量等,以应对大量请求导致的数据库压力
    • Redis 故障宕机的解决手段:
      1. 服务熔断或请求限流机制:启动服务熔断机制,暂停业务应用对缓存服务的访问,直接返回错误,所以不用再继续访问数据库,保证数据库系统的正常运行,等到 Redis 恢复正常后,再允许业务应用访问缓存服务
        • 服务熔断机制:保护数据库的正常允许,但是暂停了业务应用访问缓存服系统,全部业务都无法正常工作。也可以启用请求限流机制,只将少部分请求发送到数据库进行处理,再多的请求就在入口直接拒绝服务
      2. 提高缓存本身的可用性:通过主从节点的方式构建 Redis 缓存高可靠集群,如果 Redis 缓存的主节点故障宕机,从节点可以切换成为主节点,继续提供缓存服务,避免了由于 Redis 故障宕机而导致的缓存雪崩问题

Redis 集群


Redis 集群架构模式有哪几种?

答:

Redis 提供了三种集群模式:主从架构、哨兵集群、切片集群

  • 主从:选择一台作为主服务器,将数据到多台从服务器上,构建一主多从的模式,主从之间读写分离。主服务器可读可写,发生写操作会同步给从服务器,而从服务器一般是只读,并接受主服务器同步过来写操作命令。主从服务器之间的命令复制是 异步 进行的,所以无法实现强一致性保证(主从数据时时刻刻保持一致)
  • 哨兵:当 Redis 的主从服务器出现故障宕机时,需要手动进行恢复,为了解决这个问题,Redis 增加了哨兵模式,哨兵监控主从服务器,并且提供主从节点故障转移的功能
  • 切片集群:当数据量大到一台服务器无法承载,需要使用 Redis 切片集群(Redis Cluster)方案,它将数据分布在不同的服务器上,以此来降低系统对单主节点的依赖,提高 Redis 服务的读写性能

补充:

  • Redis 提供了三种集群模式:主从架构、哨兵集群、切片集群

  • 主从复制

    主从复制

    • 主从复制 是 Redis 高可用服务的最基础的保证,实现方案就是将从前的一台 Redis 服务器,同步数据到多台从 Redis 服务器上,即:一主多从的模式,且 主从服务器之间采用的是「读写分离」的方式
    • 主服务器 可以进行 读写 操作,当发生写操作时自动将写操作同步给从服务器,而 从服务器 一般是 只读,并接受主服务器同步过来写操作命令,然后执行这条命令
      • 也就是说:所有的数据修改 只在 主服务器 上进行,然后将 最新的数据 同步给 从服务器,这样就使得主从服务器的数据是一致的
    • 注意,主从服务器之间的命令复制 是 异步 进行的
    • 具体来说:在主从服务器命令传播阶段,主服务器收到新的写命令后,会发送给从服务器。但是,主服务器并不会等到从服务器实际执行完命令后,再把结果返回给客户端,而是主服务器自己在本地执行完命令后,就会向客户端返回结果了。如果从服务器还没有执行主服务器同步过来的命令,主从服务器间的数据就不一致了
    • 所以,无法实现强一致性保证(主从数据时时刻刻保持一致),数据不一致是难以避免的
  • 哨兵集群

    哨兵集群

    • 在使用 Redis 主从服务的时候,会有一个问题,就是当 Redis 的主从服务器出现故障宕机时,需要手动进行恢复
    • 为了解决这个问题,Redis 增加了哨兵模式(Redis Sentinel),因为 哨兵模式做到了可以监控主从服务器,并且提供主从节点故障转移的功能
  • 切片集群

    • 当 Redis 缓存数据量大到一台服务器无法缓存 时,就需要使用 Redis 切片集群(Redis Cluster)方案,它将数据分布在不同的服务器上,以此来降低系统对单主节点的依赖,从而提高 Redis 服务的读写性能
    • Redis Cluster 方案采用 哈希槽(Hash Sot),来处理数据和节点之间的映射关系。在 Redis Cluster 方案中,一个切片集群共有 16384 个哈希槽,这些哈希槽类似于数据分区,每个键值对都会根据它的 key,被映射到一个哈希槽中,具体执行过程分为两大步:
      • 根据键值对的 key,按照 CRC16 算法计算一个 16 bit 的值
      • 再用 16bit 值对 16384 取模,得到 0~16383 范围内的模数,每个模数代表一个相应编号的哈希槽
    • 哈希槽映射到具体的 Redis 节点上的方案:
      • 平均分配:在使用 cluster create 命令创建 Redis 集群时,Redis 会自动把所有哈希槽平均分布到集群节点上。比如:集群中有 9个节点,则每个节点上槽的个数为 16384/9 个。
      • 手动分配:可以使用 cluster meet 命令手动建立节点间的连接,组成集群,再使用 cluster addslots 命令,指定每个节点上的哈希槽个数
  • 为了方便你的理解,用一张图来解释数据、哈希槽,以及节点三者的映射分布关系:

    数据、哈希槽,以及节点三者的映射分布关系

    • 上图中的切片集群一共有 2 个节点,假设有 4 个哈希槽(Slot 0~Slot 3)时,就可以通过命令手动分配哈希槽,比如节点 1 保存哈希槽 0 和 1,节点 2 保存哈希槽 2 和 3

      1
      2
      redis-cli -h 192.168.1.10 -p 6379 cluster addslots 0,1
      redis-cli -h 192.168.1.11 -p 6379 cluster addslots 2,3
    • 然后在集群运行的过程中,key1 和 key2 计算完 CRC16 值后,对哈希槽总个数 4 进行取模,再根据各自的模数结果,就可以被映射到哈希槽 1(对应节点1)和 哈希槽 2(对应节点2)

    • 需要注意的是,在手动分配哈希槽时,需要把 16384 个槽都分配完,否则 Redis 集群无法正常工作


Redis 主从复制过程是怎样的?

答:

  • 当 从节点初次连接到主节点,或者 掉线重连后进度落后较多时 会进行一次全量数据同步。此时,主节点会生成 RDB 快照并传输给从节点,在此期间,主节点接受到的增量命令将会先写入 replication_buffer 缓冲区,等到从节点加载完 RDB 快照的数据后,再将缓冲区的命令传输给从节点,以此完成初次同步
  • 当 从节点掉线重连 后,如果进度落后的不多,将会进行增量同步。主节点内部维护了一个环形的固定大小的 repl_backlog_buffer 缓冲区,它用于记录最近传播的命令
    • 主节点 和 从节点 会分别在该缓冲区维护一个 offset ,用于表示自己的 写进度 和 读进度。当 从节点 掉线重连后,将会检查 主节点 和 从节点 offset 之差是否小于缓冲区大小,如果确实小于,说明从节点同步进度落后不多,则主节点将该缓冲区中的两 offset 之间的增量命令发送给从节点,完成增量同步
  • 当 主从节点完成初次同步 后,将会建立长连接进行命令传播
    • 简单的来说:就是每当主节点执行一条命令,它就会写入 replication_buffer 缓冲区,随后再将缓冲区的命令通过节点间的长连接发送给对应的从节点

补充:

  • Redis 集群支持的 主从复制,数据同步主要有两种方法:一种是全量同步,一种是增量同步
    1. 全量同步
      • 刚开始搭建主从模式时,从机需要从主机上获取所有数据,这时就需要 Slave 将 Master 上所有的数据进行同步复制。复制的步骤为:
        1. 从服务器发送 SYNC 命令,链接主服务器
        2. 主服务器收到 SYNC 命令后,进行存盘的操作,并继续收集后续的写命令,存储缓冲区
        3. 存盘结束后,将对应的数据文件发送到 Slave 中,完成一次全量同步
        4. 主服务数据发送完毕后,将进行增量的缓冲区数据同步
        5. Slave 加载数据文件和缓冲区数据,开始接受命令请求,提供操作
    2. 增量同步
      • 从节点 完成了 全量同步 后,就可以正式的开启增量备份。当 Master 节点有写操作时,都会自动同步到 Slave 节点上。Master 节点每执行一个命令,都会同步向 Slave 服务器发送相同的写命令,当从服务器接收到命令,会同步执行

详细链接:主从复制是怎么实现的? | 小林coding


Redis 的主从复制模式有什么优缺点?

答:

  • 主从复制的模式相对于单节点的 好处 在于:实行 读写分离 提高了系统的读写效率,提高了网站数据的读取加载速度
  • 缺点:由于写数据主要在主节点上操作,主节点内存空间有限,并且主节点存在单点风险

哨兵机制是什么?

答:

  • 因为在 主从架构 中 读写 是分离的,如果 主节点 挂了,将没有 主节点 来响应客户端的 写操作 请求,也无法进行数据同步
  • 哨兵的作用:实现主从节点故障转移。哨兵会监测主节点是否存活,如果发现主节点挂了,会选举一个从节点切换为主节点,并且把新主节点的相关信息通知给从节点和客户端

哨兵机制的工作原理?

答:

  • 判断节点是否存活
    • 哨兵 会 周期性 给所有主节点发送 PING 命令来判断其他节点是否正常运行。如果 PING 命令响应失败哨兵会将节点标记为 主观下线,然后该哨兵会向其他节点发出投票命令,当票数达到设定的值之后这个主节点就被标记为 客观下线。然后哨兵会从从节点中选择一个作为主节点
  • 投票
    • 哨兵集群中会选择一个 leader 来负责主从切换
    • 选举是一个投票过程:判断主节点为客观下线的是候选者,候选者向其他哨兵发送命令表示要成为 leader,其他哨兵会进行投票,每个哨兵只有一票,可以投给自己或投给别人,但是只有候选者才能把票投给自己。候选者之后拿到半数以上的赞成票并且票数大于设置的阈值,就会成为 leader
  • 选出新主节点
    • 把网络状态不好的从节点给排除:先把已经下线的从节点过滤掉,然后把以往网络连接状态不好的从节点排除掉。接下来要对所有从节点进行三轮考察:优先级、复制进度、ID号。在进行每一轮考察的时候,哪个从节点优先胜出,就选择其作为新主节点:
      1. 第一轮考察:哨兵首先会根据从节点的优先级来进行排序,优先级的值越小排名越靠前
      2. 第二轮考察:如果优先级相同,则查看复制的下标,哪个接收的复制数据多哪个就靠前
      3. 第三轮考察:如果优先级和下标都相同,选择 ID 较小的那个
  • 更换主节点
    • 选出新主节点之后,哨兵 leader 让已下线主节点属下的所有从节点指向新主节点
  • 通知客户的主节点已更换
    • 客户端和哨兵建立连接后,客户端会订阅哨兵提供的频道。主从切换完成后,哨兵就会向 +switch-master 频道发布新主节点的 IP 地址和端口的消息,这个时候客户端就可以收到这条信息,然后用这里面的新主节点的 IP 地址和端口进行通信了
  • 将旧主节点变为从节点
    • 继续监视旧主节点,当旧主节点重新上线时,哨兵集群就会向它发送 SLAVEOF 命令,让它成为新主节点的从节点

Redis sentinel(哨兵)模式优缺点有哪些?

答:

  • Redis 哨兵的好处:可以保证系统的高可用,各个节点可以对故障自动转移
  • 缺点:使用的主从模式,主节点单点风险高,主从切换过程可能会出现丢失数据的问题

说说 Redis 哈希槽的概念?

答:

Redis 集群并没有使用一致性 hash,而是引入了 哈希槽 的概念。Redis 集群有 16384(2^14) 个哈希槽,每个 key 通过 CRC16 校验后对 16384 取模来决定放置哪个槽,集群的每个节点负责一部分 hash 槽