一、基本概念
1.1 常见考点
1、Redis 为何这么快?
1)基于内存;
2)单线程减少上下文切换,同时保证原子性;
3)IO多路复用;
4)高级数据结构(如 SDS、Hash以及跳表等)。
2、为何使用单线程?
官方答案
因为 Redis 是基于内存的操作,CPU 不会成为 Redis 的瓶颈,而最有可能是机器内存的大小或者网络带宽。既然单线程容易实现,而且 CPU 不会成为瓶颈,那就顺理成章地采用单线程的方案了。
详细原因
(1)不需要各种锁的性能消耗
Redis 的数据结构并不全是简单的 Key-Value,还有 List,Hash 等复杂的结构,这些结构有可能会进行很细粒度的操作,比如在很长的列表后面添加一个元素,在hash当中添加或者删除一个对象。这些操作可能就需要加非常多的锁,导致的结果是同步开销大大增加。
(2)单线程多进程集群方案
单线程的威力实际上非常强大,每核心效率也非常高,多线程自然是可以比单线程有更高的性能上限,但是在今天的计算环境中,即使是单机多线程的上限也往往不能满足需要了,需要进一步摸索的是多服务器集群化的方案,这些方案中多线程的技术照样是用不上的。
所以单线程、多进程的集群不失为一个时髦的解决方案。
3、缓存三大问题以及解决方案?
缓存穿透:查询数据不存在
- 对空值缓存。 如果一个查询返回的数据为空(不管是数据是否不存在),我们仍然把这个空结果(null)进行缓存,设置空结果的过期时间会很短,最长不超过五分钟
- key 值校验。如布隆筛选器
- 设置可访问的名单(白名单)。 使用bitmaps类型定义一个可以访问的名单,名单id作为bitmaps的偏移量,每次访问和bitmap里面的id进行比较,如果访问id不在bitmaps里面,进行拦截,不允许访问
缓存击穿:缓存过期,伴随大量对该 key 的请求
-
互斥锁
-
热点数据永不过期
缓存雪崩:同一时间大批量的 key 过期
- 热点数据不过期
- 设置过期时间,随机分散过期时间
- 构建多级缓存架构: nginx缓存 + redis缓存 +其他缓存(ehcache等)
- 使用锁或队列
4、先删后写还是先写后删?
先删缓存后写 DB
产生脏数据的概率较大(若出现脏数据,则意味着再不更新的情况下,查询得到的数据均为旧的数据)。
比如两个并发操作,一个是更新操作,另一个是查询操作,更新操作删除缓存后,查询操作没有命中缓存,先把老数据读出来后放到缓存中,然后更新操作更新了数据库。于是,在缓存中的数据还是老的数据,导致缓存中的数据是脏的,而且还一直这样脏下去了。
先写 DB 再删缓存
产生脏数据的概率较小,但是会出现一致性的问题;若更新操作的时候,同时进行查询操作并命中,则查询得到的数据是旧的数据。但是不会影响后面的查询。
比如一个是读操作,但是没有命中缓存,然后就到数据库中取数据,此时来了一个写操作,写完数据库后,让缓存失效,然后之前的那个读操作再把老的数据放进去,所以会造成脏数据。
解决方案
1)缓存设置过期时间,实现最终一致性;
2)使用 Cannel 等中间件监听 binlog 进行异步更新;
3)通过 2PC 或 Paxos 协议保证一致性。
5、如何保证 Redis 的高并发?
Redis 通过主从加集群架构,实现读写分离,主节点负责写,并将数据同步给其他从节点,从节点负责读,从而实现高并发。
6、Redis 如何保证原子性?
答案很简单,因为 Redis 是单线程的,所以 Redis 提供的 API 也是原子操作。
但我们业务中常常有先 get 后 set 的业务常见,在并发下会导致数据不一致的情况。
如何解决
1)使用 incr、decr、setnx 等原子操作;
2)客户端加锁;
3)使用 Lua 脚本实现 CAS 操作。
7、有哪些应用场景?
Redis 在互联网产品中使用的场景实在是太多太多,这里分别对 Redis 几种数据类型做了整理:
1)String:缓存、限流、分布式锁、计数器、分布式 Session 等。
2)Hash:用户信息、用户主页访问量、组合查询等。
3)List:简单队列、关注列表时间轴。
4)Set:赞、踩、标签等。
5)ZSet:排行榜、好友关系链表。
8、Redis 支持哪几种数据类型?
String、List、Set、Sorted Set、hashes
9、Redis 主要消耗什么物理资源?
内存。
10、Redis 有哪几种数据淘汰策略?
allkeys-lru: 尝试回收最少使用的键(LRU),使得新添加的数据有空间存放。
volatile-lru: 尝试回收最少使用的键(LRU),但仅限于在过期集合的键,使得新添加的数据有空间存放。
allkeys-random: 回收随机的键使得新添加的数据有空间存放。
volatile-random: 回收随机的键使得新添加的数据有空间存放,但仅限于在过期集合的键。
11、为什么 Redis 需要把所有数据放到内存中?
Redis 为了达到最快的读写速度将数据都读到内存中,并通过异步的方式将数据写入磁盘。
所以 redis 具有快速和数据持久化的特征,如果不将数据放在内存中,磁盘 I/O 速度为严重影响 redis 的性能。
1.2 常用命令
终端连接
`redis-cli -h 127.0.0.1 -p 6379`
key
keys * # 获取所有的key
select 0 # 选择第一个库
move myString 1 # 将当前的数据库key移动到某个数据库,目标库有,则不能移动
flush db # 清除指定库
randomkey # 随机key
type key # 类型
set key1 value1 # 设置key
get key1 # 获取key
mset key1 value1 key2 value2 key3 value3
mget key1 key2 key3
del key1 # 删除key
exists key # 判断是否存在key
expire key 10 # 10s 过期
pexpire key # 1000 毫秒
persist key # 删除过期时间
订阅发布
subscribe chat1 # 订阅频道
publish chat1 "hell0 ni hao" # 发布消息
pubsub channels # 查看频道
pubsub numsub chat1 # 查看某个频道的订阅者数量
unsubscrible chat1 # 退订指定频道 或 punsubscribe java.*
psubscribe java.* # 订阅一组频道
1.3 数据结构
1、Redis 有哪些数据结构?
- 字符串 String:底层实现SDS
- 字典 Hash:底层实现哈希表
- 列表 List:底层实现双向链表
- 集合 Set: 底层实现intset(整数集合) 或者 hashtable(字典)
- 有序集合 Zset:底层实现跳表
2、String 类型的底层实现?
为了将性能优化到极致,Redis 作者为每种数据结构提供了不同的实现方式,以适应特定应用场景。以最常用的 String 为例,其底层实现就可以分为 int、embstr 以及 raw 这三种类型。这些特定的底层实现在 Redis 中被称为编码(Encoding),可以通过 OBJECT ENCODING [key] 命令查看。
Redis 中所有的 key 都是字符串,这些字符串是通过一个名为简单动态字符串(SDS) 的抽象数据类型实现的。
struct sdshdr{
//记录buf数组中已使用字节的数量
//等于 SDS 保存字符串的长度
int len;
//记录 buf 数组中未使用字节的数量
int free;
//字节数组,用于保存字符串
char buf[];
}
3、说说 SDS 带来的好处?
我们知道 Redis 是使用 C 语言写的,那么相比使用 C 语言中的字符串(即以空字符 \0 结尾的字符数组),自己实现一个 SDS 的好处是什么?
1)常数复杂度获取字符串长度:由于 len 属性的存在,我们获取 SDS 字符串的长度只需要读取 len 属性,时间复杂度为 O(1)。
2)杜绝缓冲区溢出
3)减少修改字符串的内存重新分配次数
4)二进制安全:客户端给到redis的是字节数组,一般面向流分为字节流和字符流,当客户端通过socket访问redis时, redis从socket中获取到字节流,,并没有拿字符流,因为redis只存字节,只要未来双方客户端有统一的编解码,数据就不会被破坏
5)兼容部分 C 字符串函数
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-KEFcU6GG-1684128686913)(…/05-springcloud/面试/05.png)]
4、Redis 实现的链表有哪些特性?
链表是一种常用的数据结构,C 语言内部是没有内置这种数据结构的实现,所以 Redis 自己构建了链表的实现。
typedef struct list{
//表头节点
listNode *head;
//表尾节点
listNode *tail;
//链表所包含的节点数量
unsigned long len;
//节点值复制函数
void (*free) (void *ptr);
//节点值释放函数
void (*free) (void *ptr);
//节点值对比函数
int (*match) (void *ptr,void *key);
}list;
typedef struct listNode{
//前置节点
struct listNode *prev;
//后置节点
struct listNode *next;
//节点的值
void *value;
}listNode
1)双端:链表具有前置节点和后置节点的引用,获取这两个节点时间复杂度都为 O(1)。
2)无环:表头节点的 prev 指针和表尾节点的 next 指针都指向 NULL,对链表的访问都是以 NULL 结束。
3)带长度计数器:通过 len 属性获取链表长度的时间复杂度为 O(1)。
4)多态:链表节点使用指针来保存节点值,可以保存各种不同类型的值。
5、Redis 是如何实现字典的?
我们知道Redis是一个键值型(Key-Value Pair)的数据库,我们可以根据键实现快速的增删改查。而键与值的映射关系正是通过Dict来实现的。Dict由三部分组成,分别是:哈希表(DictHashTable)、哈希节点(DictEntry)、字典(Dict)
typedef struct dictht{
//哈希表数组
dictEntry **table;
//哈希表大小
unsigned long size;
//哈希表大小掩码,用于计算索引值
//总是等于 size-1
unsigned long sizemask;
//该哈希表已有节点的数量
unsigned long used;
}dicthts
哈希算法
Redis计算哈希值和索引值方法如下:
// 1、使用字典设置的哈希函数,计算键 key 的哈希值
hash = dict->type->hashFunction(key);
// 2、使用哈希表的sizemask属性和第一步得到的哈希值,计算索引值
// 根据情况不同, ht[x] 可以是 ht[0] 或者 ht[1]
index = hash & dict->ht[x].sizemask;
当我们向Dict添加键值对时,Redis首先根据key计算出hash值(h),然后利用 h & sizemask来计算元素应该存储到数组中的哪个索引位置。我们存储k1=v1,假设k1的哈希值h =1,则1&3 =1,因此k1=v1要存储到数组角标1位置。
扩容和收缩
当哈希表保存的键值对太多或者太少时,就要通过 rerehash(重新散列)来对哈希表进行相应的扩展或者收缩。具体步骤如下:
1)如果执行扩展操作,会基于原哈希表创建一个大小等于 ht[0].used * 2n 的哈希表(也就是每次扩展都是根据原哈希表已使用的空间扩大一倍创建另一个哈希表)。相反如果执行的是收缩操作,每次收缩是根据已使用空间缩小一倍创建一个新的哈希表。
2)重新利用上面的哈希算法,计算索引值,然后将键值对放到新的哈希表位置上。
3)所有键值对都迁徙完毕后,释放原哈希表的内存空间。
触发扩容条件
1)服务器目前没有执行 BGSAVE 命令或者 BGREWRITEAOF 命令,并且负载因子等于 1。
2)服务器目前正在执行 BGSAVE 命令或者 BGREWRITEAOF 命令,并且负载因子等于 5。
(其中 负载因子 = 哈希表已保存节点数量 / 哈希表大小。)
6、什么是渐近式 rehash?
也就是说扩容和收缩操作不是一次性、集中式完成的,而是分多次、渐进式完成的。 如果保存在 Redis 中的键值对只有几个几十个,那么 rehash 操作可以瞬间完成,但是如果键值对有几百万,几千万甚至几亿,那么要一次性的进行 rehash,势必会造成 Redis 一段时间内不能进行别的操作。所以 Redis 采用渐进式 rehash。
这样在进行渐进式 rehash 期间,字典的删除查找更新等操作可能会在两个哈希表上进行,第一个哈希表没有找到,就会去第二个哈希表上进行查找。但是进行增加操作,一定是在新的哈希表上进行的。
7、有序集合 Zset 的底层实现?
zset 是 Redis 中一个非常重要的数据结构,其底层是基于跳表(skip list) 实现的。
跳表是一种随机化的数据结构,基于并联的链表,实现简单,插入、删除、查找的复杂度均为 O(logN)。简单说来跳表也是链表的一种,只不过它在链表的基础上增加了跳跃功能,正是这个跳跃的功能,使得在查找元素时,跳表能够提供 O(logN) 的时间复杂度。
跳表为了避免每次插入或删除带来的额外操作,不要求上下相邻两层链表之间的节点个数有严格的对应关系,而是为每个节点随机出一个层数(level)。而且新插入一个节点不会影响其它节点的层数。因此,插入操作只需要修改插入节点前后的指针,而不需要对很多节点都进行调整。
8、Zset 为何不使用红黑树等平衡树?
1)跳跃表范围查询比平衡树操作简单。 因为平衡树在查询到最小值的时还需要采用中序遍历去查询最大值。 而跳表只需要在找到最小值后,对第一层的链表遍历即可。
2)平衡树的删除和插入需要对子树进行相应的调整,而跳表只需要修改相邻的节点即可。
3)跳表和平衡树的查询操作都是O(logN)的时间复杂度。
4)从整体上来看,跳表算法实现的难度要低于平衡树。
9、什么是 Redis 的 intset?
整数集合(intset) 是 Redis 用于保存整数值的集合抽象数据类型,它可以保存类型为 int16_t、int32_t 或者 int64_t 的整数值,并且保证集合中不会出现重复元素。
typedef struct intset{
//编码方式
uint32_t encoding;
//集合包含的元素数量
uint32_t length;
//保存元素的数组
int8_t contents[];
}intset;
整数集合的每个元素都是 contents 数组的一个数据项,它们按照从小到大的顺序排列,并且不包含任何重复项。需要注意的是虽然 contents 数组声明为 int8_t 类型,但是实际上 contents 数组并不保存任何 int8_t 类型的值,其真正类型有 encoding 来决定。
集合升级过程
当我们新增的元素类型比原集合元素类型的长度要大时,需要对整数集合进行升级,才能将新元素放入整数集合中。具体步骤:
1)根据新元素类型,扩展整数集合底层数组的大小,并为新元素分配空间。
2)将底层数组现有的所有元素都转成与新元素相同类型的元素,并将转换后的元素放到正确的位置,放置过程中,维持整个元素顺序都是有序的。
3)将新元素添加到整数集合中(保证有序)。
集合是否降级
整数集合不支持降级操作,一旦对数组进行了升级,编码就会一直保持升级后的状态。
10、说说什么是压缩列表?
压缩列表(ziplist) 是 Redis 为了节省内存而开发的,是由一系列特殊编码的连续内存块组成的顺序型数据结构,一个压缩列表可以包含任意多个节点(entry),每个节点可以保存一个字节数组或者一个整数值。
压缩列表并不是对数据利用某种算法进行压缩,而是将数据按照一定规则编码在一块连续的内存区域,目的是节省内存。
压缩列表的每个节点构成如下:
previous_entry_ength
记录压缩列表前一个字节的长度。previous_entry_ength 的长度可能是 1 个字节或者是 5 个字节,如果上一个节点的长度小于 254,则该节点只需要一个字节就可以表示前一个节点的长度了,如果前一个节点的长度大于等于 254,则 previous_length 的第一个字节为254,后面用四个字节表示当前节点前一个节点的长度。利用此原理即当前节点位置减去上一个节点的长度即得到上一个节点的起始位置,压缩列表可以从尾部向头部遍历。这么做很有效地减少了内存的浪费。
encoding
节点的 encoding 保存的是节点的 content 的内容类型以及长度,encoding 类型一共有两种,一种字节数组一种是整数,encoding 区域长度为 1 字节、2 字节或 5 字节。
content
content 区域用于保存节点的内容,节点内容类型和长度由 encoding 决定。
11、什么是 RedisObject?
我们知道,Redis 底层实现了很多高级数据结构,如简单动态字符串、双端链表、字典、压缩列表、跳跃表、整数集合等。然而 Redis 并没有直接使用这些数据结构来实现键值对的数据库,而是在这些数据结构之上又包装了一层 RedisObject(对象),也就是我们常说的五种数据结构:字符串对象、列表对象、哈希对象、集合对象和有序集合对象。
typedef struct redisObject {
// 类型
unsigned type:4;
// 编码,即对象的底层实现数据结构
unsigned encoding:4;
// 对象最后一次被访问的时间
unsigned lru:REDIS_LRU_BITS;
// 引用计数
int refcount;
// 指向实际值的指针
void *ptr;
} robj;
这样做有两个好处:
1)通过不同类型的对象,Redis 可以在执行命令之前,根据对象的类型来判断一个对象是否可以执行该的命令。
2)可以针对不同的使用场景,为对象设置不同的实现,从而优化内存或查询速度。
12、五种类型分别对于哪种实现?
字符串(String)
字符串对象的 encoding 有三种,分别是:int、raw、embstr。
1)如果一个字符串对象保存的是整数值,并且这个整数值可以用 long 类型标识,那么字符串对象会讲整数值保存在 ptr 属性中,并将 encoding 设置为 int。比如 set number 10086 命令。
2)如果字符串对象保存的是一个字符串值,并且这个字符串的长度大于 44 字节,那么字符串对象将使用一个简单动态字符串(SDS)来保存这个字符串值,并将对象的编码设置为 raw。
3)如果字符串对象保存的是一个字符串值,并且这个字符串的长度小于等于 44 字节,那么字符串对象将使用 embstr 编码的方式来保存这个字符串。
embstr 的编码方式的一些优点:
embstr 存储形式是这样一种存储形式,它将 RedisObject 对象头和 SDS 对象连续存在一起,使用 malloc 方法一次分配。embstr 最小占用空间为 19(16+3),而 64-19-1(结尾的 \0)=44,所以 embstr 只能容纳 44 字节。
1)embstr 编码将创建字符串对象所需的内存分配次数从 raw 编码的两次降低为一次。
2)释放 embstr 编码的字符串对象只需要调用一次内存释放函数,而释放 raw 编码的字符串对象需要调用两次内存释放函数。
3)因为 embstr 编码的字符串对象的所有数据都保存在一块连续的内存里面,所以这种编码的字符串对象比起 raw ,编码的字符串对象能够更好地利用缓存带来的优势。
哈希对象(hash)
哈希对象的编码有 ziplist 和 hashtable 两种。当哈希对象保存的键值对数量小于 512,并且所有键值对的长度都小于 64 字节时,使用压缩列表存储;否则使用 hashtable 存储。
列表对象(list)
列表对象的编码有 ziplist 和 linkedlist 两种。当列表的长度小于 512,并且所有元素的长度都小于 64 字节时,使用压缩列表存储,否则使用 linkedlist 存储。
集合对象(set)
列表对象的编码有 intset 和 hashtable 两种。当集合的长度小于 512,并且所有元素都是整数时,使用整数集合存储;否则使用 hashtable 存储。
有序集合对象(sort set)
有序集合对象的编码有 ziplist 和 skiplist 两种。当有序集合的长度小于 128,并且所有元素的长度都小于 64 字节时,使用压缩列表存储;否则使用 skiplist 存储。
intset(整数集合)和 ziplist(压缩列表)主要是为节省内存而设计的内存结构,它的优点就是节省内存,但缺点就是比其他结构要消耗更多的时间,所以 Redis 在数据量小的时候使用整数集合存储。
1.4 数据存储
1、Redis 的数据过期策略是什么?
在回答词问题之前,首先需要回答另一个问题,就是如何设置 Redis 中数据的过期时间?
expire key time
(以秒为单位)–这是最常用的方式setex(String key, int seconds, String value)
–字符串独有的方式
除了字符串自己独有设置过期时间的方法外,其他方法都需要依靠 expire
方法来设置时间,如果没有设置时间,那缓存就是永不过期。 如果设置了过期时间,使用 persist key
让缓存永不过期。
常见的过期策略
1)定时删除
在设置 key 的过期时间的同时,为该 key 创建一个定时器,让定时器在 key 的过期时间来临时,对 key 进行删除。
2)惰性删除
3)定期删除
每隔一段时间执行一次删除(在 redis.conf 配置文件设置,1s 刷新的频率)过期 key 操作。
Redis采用的过期策略
Redis 采用了 惰性删除+定期删除 的方式处理过期数据。
惰性删除的流程
1)在进行get或setnx等操作时,先检查key是否过期;
2)若过期,删除key,然后执行相应操作;
3)若没过期,直接执行相应操作。
定期删除的流程
其核心是对指定个数个库的每一个库随机删除小于等于指定个数个过期 key:
(1)遍历每个数据库(就是 redis.conf 中配置的 “database” 数量,默认为16);
(2)检查当前库中的指定个数个 key (默认是每个库检查 20 个,相当于该循环执行 20 次):
-
如果当前库中没有一个 key 设置了过期时间,直接执行下一个库的遍历;
-
随机获取一个设置了过期时间的 key,检查是否过期,如果过期则删除;
-
判断定期删除操作是否已经达到指定时长,若已经达到,直接退出定期删除。
2、持久化文件对过期策略的处理?
过期 key 是不会写入 RDB 和 AOF 文件,同时数据恢复时也会做过期验证。
3、Redis 有哪些内存淘汰机制?
Redis 作为一个内存数据库,在内存空间不足的时候,为了保证命中率,就会和我们操作系统中的页面置换算法类似,选择一定的数据淘汰策略。
volatile(设置过期时间的数据集)
-
volatile-lru:从已设置过期时间的数据集中挑选最近最少使用的数据淘汰。
-
volatile-ttl:从已设置过期时间的数据集中挑选将要过期的数据淘汰。
-
volatile-random:从已设置过期时间的数据集中任意选择数据淘汰。
-
volatile-lfu:从已设置过期时间的数据集挑选使用频率最低的数据淘汰。
allkeys(所以数据集)
- allkeys-lru:从数据集中挑选最近最少使用的数据淘汰
- allkeys-lfu:从数据集中挑选使用频率最低的数据淘汰。
- allkeys-random:从数据集(server.db[i].dict)中任意选择数据淘汰
no-enviction
- no-enviction(驱逐):禁止驱逐数据,这也是默认策略。
意思是当内存不足以容纳新入数据时,新写入操作就会报错,请求可以继续进行,线上任务也不能持续进行,采用 no-enviction 策略可以保证数据不被丢失。
PS:在 redis.config 文件中,我们可以设置 maxmemory 的值来配置 Redis 的最大占用内存字节数。
4、Redis 有哪些持久化机制?
RDB 和 AOF。
5、说说 Redis 的 RDB?
RDB 持久化是指在指定的时间间隔内将内存中的数据集快照写入磁盘。也是默认的持久化方式。也就是将内存中数据以快照的方式写入到二进制文件中,默认的文件名为 dump.rdb。
RDB 支持 同步(save 命令)、后台异步(bgsave)以及自动配置三种方式触发。
优点
- RDB 文件紧凑,全量备份,非常适合用于进行备份和灾难恢复
- 生成 RDB 文件时支持异步处理,主进程不需要进行任何磁盘IO操作
- RDB 在恢复大数据集时的速度比 AOF 的恢复速度要快
缺点
- RDB 快照是一次全量备份,存储的是内存数据的二进制序列化形式,存储上非常紧凑。且在快照持久化期间修改的数据不会被保存,可能丢失数据。
6、说说 Redis 的 AOF?
全量备份总是耗时的,有时候我们提供一种更加高效的方式 AOF,其工作机制更加简单:会将每一个收到的写命令追加到文件中。
随着时间推移,AOF 持久化文件也会变的越来越大。为了解决此问题,Redis 提供了 bgrewriteaof 命令,作用是 fork 出一条新进程将内存中的数据以命令的方式保存到临时文件中,完成对AOF 文件的重写。
AOF 也有三种触发方式:1)每修改同步 always 2)每秒同步 everysec 3)不同no:从不同步。
优点
-
AOF 可以更好的保护数据不丢失,一般 AOF 隔 1 秒通过一个后台线程执行一次 fsync 操作
-
AOF 日志文件没有任何磁盘寻址的开销,写入性能非常高,文件不容易破损
-
AOF 日志文件即使过大的时候,出现后台重写操作,也不会影响客户端的读写
-
AOF 日志文件的命令通过非常可读的方式进行记录,这个特性非常适合做灾难性的误删除的紧急恢复
缺点
-
对于同一份数据来说,AOF 日志文件通常比 RDB 数据快照文件更大
-
AOF开启后,支持的写 QPS 会比RDB支持的写 QPS 低,因为 AOF 一般会配置成每秒 fsync 一次日志文件,当然,每秒一次 fsync,性能也还是很高的
7、RDB 和 AOF 该如何选择?
通过前面章节我们已经明白了两种机制的原理,接下来就该结合具体需求进行机制的选择,通常情况我们是二者结合使用的。
命令 | RDB | AOF |
---|---|---|
启动优先级 | 低 | 高 |
体积 | 小 | 大 |
恢复速度 | 快 | 慢 |
数据安全性 | 丢数据 | 取决于刷盘策略 |
轻重 | 重 | 轻 |
1.5 Pipeline
1、什么是 Redis 的 Pipeline?
在出现 Pipeline 之前,我们梳理一下 Redis 客户端执行一条命令需要经过哪些步骤: 发送命令-〉命令排队-〉命令执行-〉返回结果。
这个过程称为 Round trip time(简称RTT, 往返时间),mget 和 mset 有效节约了 RTT,但大部分命令(如 hgetall 并没有 mhgetall)不支持批量操作,需要消耗 N 次 RTT ,这个时候需要 pipeline 来解决这个问题。
2、原生批命令 (mset, mget) 与 Pipeline 区别?
1)原生批命令是原子性的,而 pipeline 是非原子操作。
2)原生批命令一命令多个 key, 但 pipeline 支持多命令(存在事务),非原子性。
3)原生批命令是服务端实现,而 pipeline 需要服务端与客户端共同完成。
1.6 多线程
1、Redis 6 之前真的是单线程吗?
Redis 在处理客户端的请求时,包括获取 (socket 读)、解析、执行、内容返回 (socket 写) 等都由一个顺序串行的主线程处理,这就是所谓的单线程。但如果严格来讲从 Redis 4 之后并不是单线程,除了主线程外,它也有后台线程在处理一些较为缓慢的操作,例如清理脏数据、无用连接的释放、大 key 的删除等等。
2、Redis 6 之前为什么使用单线程?
使用了单线程后,可维护性高。多线程模型虽然在某些方面表现优异,但是它却引入了程序执行顺序的不确定性,带来了并发读写的一系列问题,增加了系统复杂度、同时可能存在线程切换、甚至加锁解锁、死锁造成的性能损耗。
同时 Redis 通过 AE 事件模型以及 IO 多路复用等技术,即使单线程处理性能也非常高,因此没有必要使用多线程。单线程机制使得 Redis 内部实现的复杂度大大降低,Hash 的惰性 Rehash、Lpush 等等 “线程不安全” 的命令都可以无锁进行。
3、Redis 6 为何引入多线程?
随着目前行业内越来越复杂的业务场景,有些公司动不动就上亿的交易量,因此需要更大的 QPS。常见的解决方案是在分布式架构中对数据进行分区并采用多个服务器,但该方案有非常大的缺点,比如:
-
要管理的 Redis 服务器太多,维护代价大;
-
某些适用于单个 Redis 服务器的命令不适用于数据分区;
-
数据分区无法解决热点读/写问题;
-
数据偏斜,重新分配和放大/缩小变得更加复杂等等。
从 Redis 自身角度来说,因为读写网络的 read/write 系统调用占用了 Redis 执行期间大部分 CPU 时间,瓶颈主要在于网络的 IO 消耗, 优化主要有两个方向:
- 提高网络 IO 性能,典型的实现比如使用 DPDK 来替代内核网络栈的方式;
- 使用多线程充分利用多核,典型的实现比如 Memcached。
协议栈优化的这种方式跟 Redis 关系不大,支持多线程是一种最有效最便捷的操作方式。所以总结起来,Redis 支持多线程主要就是两个原因:
可以充分利用服务器 CPU 资源,目前主线程只能利用一个核;
多线程任务可以分摊 Redis 同步 IO 读写负荷。
4、多线程如何开启以及配置?
Redis 6 的多线程默认是禁用的,只使用主线程。如需开启需要修改 redis.conf 配置文件中的 io-threads-do-reads yes。
开启多线程后,还需要设置线程数,否则是不生效的。同样修改 redis.conf 文件中的 io-threads [n] 配置。
关于线程数的设置,官方有一个建议:4 核的机器建议设置为 2 或 3 个线程,8 核的建议设置为 6 个线程,线程数一定要小于机器核数。还需要注意的是,线程数并不是越大越好,官方认为超过了 8 个基本就没什么意义了。
5、Redis 多线程的实现机制?
大致流程如下:
1)主线程负责接收建立连接请求,获取 socket 放入全局等待读处理队列;
2)主线程处理完读事件之后,通过 RR(Round Robin) 将这些连接分配给这些 IO 线程;
3)主线程阻塞等待 IO 线程读取 socket 完毕;
4)主线程通过单线程的方式执行请求命令,请求数据读取并解析完成,但并不执行;
5)主线程阻塞等待 IO 线程将数据回写 socket 完毕;
6)解除绑定,清空等待队列。
该设计的特点:
1)IO 线程要么同时在读 socket,要么同时在写,不会同时读或写。
2)IO 线程只负责读写 socket 解析命令,不负责命令处理。
6、多线程是否会导致线程安全问题?
从上面的实现机制可以看出,Redis 的多线程部分只是用来处理网络数据的读写和协议解析,执行命令仍然是单线程顺序执行。所以我们不需要去考虑控制 key、lua、事务,LPUSH/LPOP 等等的并发及线程安全问题。
7、Redis 和 Memcached 多线程区别?
相同点:都采用了 master 线程 - worker 线程的模型。
不同点:Memcached 执行主逻辑也是在 worker 线程里,模型更加简单,实现了真正的线程隔离,符合我们对线程隔离的常规理解。而 Redis 把处理逻辑交还给 master 线程,虽然一定程度上增加了模型复杂度,但也解决了线程并发安全等问题。
2 集群
2.1 集群模式
1、Redis 集群搭建有几种模式?
主从模式
和 MySQL 需要主从复制的原因一样,Redis 虽然读写速度非常快,但是也会产生性能瓶颈,特别是在读压力上,为了分担压力,Redis 支持主从复制。Redis 的主从结构一主一从,一主多从或级联结构,复制类型可以根据是否是全量而分为全量同步和增量同步。
哨兵模式
在主从复制实现之后,如果想对 master 进行监控,Redis 提供了一种哨兵机制,哨兵的含义就是监控 Redis 系统的运行状态,通过投票机制,从 slave 中选举出新的 master 以保证集群正常运行。
还可以启用多个哨兵进行监控以保证集群足够稳健,这种情况下,哨兵不仅监控主从服务,哨兵之间也会相互监控。
Cluster 集群模式
2、Redis 主从复制的实现?
主从复制可以根据需要分为全量同步的增量同步两种方式。
全量同步
Redis 全量复制一般发生在 slave 的初始阶段,这时 slave 需要将 master 上的数据都复制一份,具体步骤如下:
1)slave 连接 master,发送 SYNC 命令;
2)master 接到 SYNC 命令后执行 BGSAVE 命令生产 RDB 文件,并使用缓冲区记录此后执行的所有写命令;
3)master 执行完 BGSAVE 后,向所有的 slave 发送快照文件,并在发送过程中继续记录执行的写命令;
4)slave 收到快照后,丢弃所有的旧数据,载入收到的数据;
5)master 快照发送完成后就会开始向 slave 发送缓冲区的写命令;
6)slave 完成对快照的载入,并开始接受命令请求,执行来自 master 缓冲区的写命令;
7)slave 完成上面的数据初始化后就可以开始接受用户的读请求了。
增量同步
增量复制实际上就是在 slave 初始化完成后开始正常工作时 master 发生写操作同步到 slave 的过程。增量复制的过程主要是 master 每执行一个写命令就会向 slave 发送相同的写命令,slave 接受并执行写命令,从而保持主从一致。
3、Redis 的主从同步策略?
主从同步刚连接的时候进行全量同步,全量同步结束后开始增量同步。
如果有需要,slave 在任何时候都可以发起全量同步,其主要策略就是无论如何首先会尝试进行增量同步,如果失败则会要求 slave 进行全量同步,之后再进行增量同步。
注意:如果多个 slave 同时断线需要重启的时候,因为只要 slave 启动,就会和 master 建立连接发送SYNC请求和主机全量同步,如果多个同时发送 SYNC 请求,可能导致 master IO 突增而发送宕机。所以我们要避免多个 slave 同时恢复重启的情况。
4、哨兵模式的原理?
哨兵主要用于管理多个 Redis 服务器,主要有以下三个任务:监控、提醒以及故障转移。
每个哨兵会向其它哨兵、master、slave 定时发送消息,以确认对方是否还存活。如果发现对方在配置的指定时间内未回应,则暂时认为对方已挂。若“哨兵群”中的多数 sentinel 都报告某一 master 没响应,系统才认为该 master “彻底死亡”,通过一定的 vote 算法从剩下的 slave 节点中选一台提升为 master,然后自动修改相关配置。
5、哨兵模式故障迁移流程?
1)首先是从主服务器的从服务器中选出一个从服务器作为新的主服务器。
选点的依据依次是:
网络连接正常 -> 5 秒内回复过 INFO 命令 -> 10*down-after-milliseconds 内与主连接过的 -> 从服务器优先级 -> 复制偏移量 -> 运行id较小的。
2)选出之后通过 slaveif no ont 将该从服务器升为新主服务器;
3)然后再通过 slaveof ip port 命令让其他从服务器复制该信主服务器。
缺点
- 主从服务器的数据要经常进行主从复制,这样会造成性能下降
- 当主服务器宕机后,从服务器切换成主服务器的那段时间,服务是不可用的
2.2 Cluster 集群
1、什么是一致性 Hash 以及解决什么问题?
原来的hash算法,在增加或删除一个结点后,取模发生变化,会有大量数据失效;而一致性hash只会失效部分数据。
一致性 hash 其实是普通 hash 算法的改良版,其 hash 计算方法没有变化,但是 hash 空间发生了变化,由原来的线性的变成了环。
缓存 key 通过 hash 计算之后得到在 hash 环中的位置,然后顺时针方向找到第一个节点,这个节点就是存放 key 的节点。
由此可见,一致性 hash 主要是为了解决普通 hash 中扩容和宕机的问题。
同时还可以通过虚拟节点来解决数据倾斜的问题:就是在节点稀疏的 hash 环上对物理节点虚拟出一部分虚拟节点,key 会打到虚拟节点上面,而虚拟节点上的 key 实际也是映射到物理节点上的,这样就避免了数据倾斜导致单节点压力过大导致节点雪崩的问题。
2、Cluster 模式的原理?
其实现原理就是一致性 Hash。Redis Cluster 中有一个 16384 长度的槽的概念,他们的编号为 0、1、2、3 …… 16382、16383。这个槽是一个虚拟的槽,并不是真正存在的。正常工作的时候,Redis Cluster 中的每个 Master 节点都会负责一部分的槽,当有某个 key 被映射到某个 Master 负责的槽,那么这个 Master 负责为这个 key 提供服务。
至于哪个 Master 节点负责哪个槽,这是可以由用户指定的,也可以在初始化的时候自动生成(redis-trib.rb脚本)。这里值得一提的是,在 Redis Cluster 中,只有 Master 才拥有槽的所有权,如果是某个 Master 的 slave,这个slave只负责槽的使用,但是没有所有权。
3、Cluster 的分片机制?
为了使得集群能够水平扩展,首要解决的问题就是如何将整个数据集按照一定的规则分配到多个节点上。对于客户端请求的 key,根据公式 HASH_SLOT=CRC16(key) mod 16384
,计算出映射到哪个分片上。而对于 CRC16 算法产生的 hash 值会有 16bit,可以产生
2
16
=
65536
2^{16}=65536
216=65536 个值。
Redis 集群提供了灵活的节点扩容和收缩方案。在不影响集群对外服务的情况下,可以为集群添加节点进行扩容也可以下线部分节点进行缩容。可以说,槽是 Redis 集群管理数据的基本单位,集群伸缩就是槽和数据在节点之间的移动。
4、Cluster 集群的扩容流程?
当一个 Redis 新节点运行并加入现有集群后,我们需要为其迁移槽和数据。首先要为新节点指定槽的迁移计划,确保迁移后每个节点负责相似数量的槽,从而保证这些节点的数据均匀。
1)首先启动一个 Redis 节点,记为 M4。
2)使用 cluster meet
命令,让新 Redis 节点加入到集群中。新节点刚开始都是主节点状态,由于没有负责的槽,所以不能接受任何读写操作,后续给他迁移槽和填充数据。
3)对 M4 节点发送 cluster setslot { slot } importing { sourceNodeId }
命令,让目标节点准备导入槽的数据。
4)对源节点,也就是 M1,M2,M3 节点发送 cluster setslot { slot } migrating { targetNodeId }
命令,让源节点准备迁出槽的数据。
5)源节点执行 cluster getkeysinslot { slot } { count }
命令,获取 count 个属于槽 { slot } 的键,然后执行步骤 6)的操作进行迁移键值数据。
6)在源节点上执行 migrate { targetNodeIp} " " 0 { timeout } keys { key... }
命令,把获取的键通过 pipeline 机制批量迁移到目标节点,批量迁移版本的 migrate 命令在 Redis 3.0.6 以上版本提供。
7)重复执行步骤 5)和步骤 6)直到槽下所有的键值数据迁移到目标节点。
8)向集群内所有主节点发送 cluster setslot { slot } node { targetNodeId }
命令,通知槽分配给目标节点。为了保证槽节点映射变更及时传播,需要遍历发送给所有主节点更新被迁移的槽执行新节点。
5、Cluster 集群收缩流程?
收缩节点就是将 Redis 节点下线,整个流程需要如下操作流程。
1)首先需要确认下线节点是否有负责的槽,如果是,需要把槽迁移到其他节点,保证节点下线后整个集群槽节点映射的完整性。
2)当下线节点不再负责槽或者本身是从节点时,就可以通知集群内其他节点忘记下线节点,当所有的节点忘记改节点后可以正常关闭。
6、客户端如何路由?
既然 Redis 集群中的数据是分片存储的,那我们该如何知道某个 key 存在哪个节点上呢?即我们需要一个查询路由,该路由根据给定的 key,返回存储该键值的机器地址。
常规的实现方式便是采用如下图所示的代理方案,即采用一个中央节点(比如HDFS中的NameNode)来管理所有的元数据,但是这样的方案带来的最大问题就是代理节点很容易成为访问的瓶颈,当读写并发量高的时候,代理节点会严重的拖慢整个系统的性能。
Redis 并没有选择使用代理,而是客户端直接连接每个节点。Redis 的每个节点中都存储着整个集群的状态,集群状态中一个重要的信息就是每个桶的负责节点。在具体的实现中,Redis 用一个大小固定为 CLUSTER_SLOTS 的 clusterNode 数组 slots 来保存每个桶的负责节点。
typedef struct clusterNode {
...
unsigned char slots[CLUSTER_SLOTS/8];
...
} clusterNode;
typedef struct clusterState {
// slots记录每个桶被哪个节点存储
clusterNode *slots[CLUSTER_SLOTS];
...
} clusterState;
在集群模式下,Redis 接收任何键相关命令时首先计算键对应的桶编号,再根据桶找出所对应的节点,如果节点是自身,则处理键命令;否则回复 MOVED 重定向错误,通知客户端请求正确的节点,这个过程称为 MOVED 重定向。重定向信息包含了键所对应的桶以及负责该桶的节点地址,根据这些信息客户端就可以向正确的节点发起请求。
7、为什么是 16384 个槽位?
从前面的 Cluster 集群原理我们已经了解到集群中的所有节点在握手成功后悔定期发送 ping/pong 消息,交换数据信息。
先来了解一下消息体传递了哪些数据:
typedef struct {
//消息的长度(包括这个消息头的长度和消息正文的长度)
uint32_t totlen;
//消息的类型
uint16_t type;
//消息正文包含的节点信息数量
//只在发送MEET 、PING 、PONG 这三种Gossip 协议消息时使用
uint16_t count;
//发送者所处的配置纪元
uint64_t currentEpoch;
//如果发送者是一个主节点,那么这里记录的是发送者的配置纪元
//如果发送者是一个从节点,那么这里记录的是发送者正在复制的主节点的配置纪元
uint64_t configEpoch;
//发送者的名字(ID )
char sender[REDIS_CLUSTER_NAMELEN];
//发送者目前的槽指派信息
unsigned char myslots[REDIS_CLUSTER_SLOTS/8];
//如果发送者是一个从节点,那么这里记录的是发送者正在复制的主节点的名字
//如果发送者是一个主节点,那么这里记录的是REDIS_NODE_NULL_NAME
//(一个40 字节长,值全为0 的字节数组)
char slaveof[REDIS_CLUSTER_NAMELEN];
//发送者的端口号
uint16_t port;
//发送者的标识值
uint16_t flags;
//发送者所处集群的状态
unsigned char state;
//消息的正文(或者说,内容)
union clusterMsgData data;
} clusterMsg;
上图展示的消息体结构无外乎是一些节点标识,IP,端口号,发送时间等,但需要注意一下标红的 myslots 的 char 数组,长度为 16383/8,这其实是一个 bitmap,每一个位代表一个槽,如果该位为1,表示这个槽是属于这个节点的。
消息体大小上的考量
至于这个消息体有多大?显然最占空间的就是 myslots 数组:16384÷8÷1024=2kb。如果槽位达到 65536,则所占空间提升到 65536÷8÷1024=8kb,极大浪费带宽。
Redis 集群主节点数量基本不可能超过 1000 个
集群节点越多,心跳包的消息体内携带的数据越多。如果节点过1000个,也会导致网络拥堵。
槽位越小,节点少的情况下,压缩比高
8、集群的故障发现与迁移?
故障发现
当集群内某个节点出现问题时,需要通过一种健壮的方式保证识别出节点是否发生了故障。Redis 集群内节点通过 ping/pong 消息实现节点通信,消息不但可以传播节点槽信息,还可以传播其他状态如:主从状态、节点故障等。因此故障发现也是通过消息传播机制实现的。 主要环节包括:
主观下线(PFAIL-Possibly Fail)
集群中每个节点都会定期向其他节点发送ping消息,接收节点回复pong消息作为响应。如果在cluster-node-timeout时间内通信一直失败,则发送节点会认为接收节点存在故障,把接收节点标记为主观下线(PFail)状态。
客观下线(Fail)
Redis 集群对于节点最终是否故障判断非常严谨,只有一个节点认为主观下线并不能准确判断是否故障。当某个节点判断另一个节点主观下线后,相应的节点状态会跟随消息在集群内传播,通过Gossip消息传播,集群内节点不断收集到故障节点的下线报告。当半数以上持有槽的主节点都标记某个节点是主观下线时。触发客观下线流程。
客观下线流程
故障恢复
故障节点变为客观下线后,如果下线节点是持有槽的主节点则需要在它的从节点中选出一个替换它,从而保证集群的高可用。下线主节点的所有从节点承担故障恢复的义务,当从节点通过内部定时任务发现自身复制的主节点进入客观下线时,将会触发故障恢复流程。
9、Codis、Twemproxy 以及 Cluster 方案对比?
https://cloud.tencent.com/developer/article/1701574
3 业务场景
1、如何设置 Key 当天失效?
- expireAt
- key 值带上时间戳
2、如何实现分布式锁?
- 「互斥性」: 任意时刻,只有一个客户端能持有锁
- 「锁超时释放」:持有锁超时,可以释放,防止不必要的资源浪费,也可以防止死锁
- 「可重入性」:一个线程如果获取了锁之后,可以再次对其请求加锁
- 「高性能和高可用」:加锁和解锁需要开销尽可能低,同时也要保证高可用,避免分布式锁失效
- 「安全性」:锁只能被持有的客户端删除,不能被其他客户端删除
Redison 底层原理:
RedLock 加锁步骤:
1)按顺序向集群中所有 master 节点请求加锁;
2)根据设置的超时时间来判断,是不是要跳过该 master 节点;
3)如果大于等于半数节点( N/2+1 )加锁成功,并且使用的时间小于锁的有效期,即可认定加锁成功啦;
4)如果获取锁失败,解锁!
但是用这个方式 set key uuid ex 2000 nx
可以保证一定的分布式锁,但是,还是会有原子性问题,此时还需要lua脚本解决
3、如何实现排行榜功能?
活动运营中经常会有这样的需求:1)Value 值排序 2)Value 相同按时间排序。
使用 MySQL:
EXPLAIN SELECT
*
FROM
(
SELECT
@rank := @rank + 1 AS rank,
s.uid AS uid,
s.coin AS coin
FROM
`user` s,
(SELECT @rank := 0) r
ORDER BY
coin DESC,
create_time
) q
WHERE
q.uid = 'xxx';
使用 Redis:
# 插入或者更新数据
Long zadd(final String key, final double score, final String member)
key : 排行榜的名字
memeber : 用户
score : 用户的分数
# 获取用户分数
Double zscore(String key, final String member)
# 获取用户的排名
Long zrevrank(final String key, final String member):(score从大到小,从0开始,所以需要加1)
Long zrank(final String key, final String member):(score从小到大,从0开始,所以需要加1)
# 获取某个范围内的用户排名
Set<Tuple> zrevrangeWithScoresBytes(String key, final long start, final long end) (从大到小)
Set<Tuple> zrangeWithScoresBytes(String key, final long start, final long end) (从小到大)
start : 开始排名
end : 结束排名
Tuple :
同时为了实现先到先得,zset 中的 score 并不能仅仅是排序的 Value 值,还需加入时间戳因子:score = (value * 10000000000(十次方)) + (100000000000(十一次方) - ts)。
由于时间戳相同一个月的时间内,头三位是相等的,即我们还可以进一步压缩时间位数以提高排序值的精度。
4 优化
1、为什么要优化大 key
2、如何优化热点 key?
热 key 带来的问题:请求到的分片过于集中,超过单台 Server 的性能极限。
解决方案:
1)服务端缓存:即将热点数据缓存至服务端的内存中;
2)备份热点Key:即将热点Key+随机数,随机分配至 Redis 其它节点中。