凡事预则立,不预则废。能读到这里的人,我相信都是这个世界上的“有心人”,还是那句老话:上天不负有心人!我相信你的每一步努力,都会收获意想不到的回报。
1)基于内存;
2)单线程减少上下文切换,同时保证原子性;
3)IO多路复用;
4)高级数据结构(如 SDS、Hash以及跳表等)。
官方答案
因为 Redis 是基于内存的操作,CPU 不会成为 Redis 的瓶颈,而最有可能是机器内存的大小或者网络带宽。既然单线程容易实现,而且 CPU 不会成为瓶颈,那就顺理成章地采用单线程的方案了。
详细原因
1)不需要各种锁的性能消耗
Redis 的数据结构并不全是简单的 Key-Value,还有 List,Hash 等复杂的结构,这些结构有可能会进行很细粒度的操作,比如在很长的列表后面添加一个元素,在hash当中添加或者删除一个对象。这些操作可能就需要加非常多的锁,导致的结果是同步开销大大增加。
2)单线程多进程集群方案
单线程的威力实际上非常强大,每核心效率也非常高,多线程自然是可以比单线程有更高的性能上限,但是在今天的计算环境中,即使是单机多线程的上限也往往不能满足需要了,需要进一步摸索的是多服务器集群化的方案,这些方案中多线程的技术照样是用不上的。
所以单线程、多进程的集群不失为一个时髦的解决方案。
缓存穿透:查询数据不存在
1)缓存空值
2)key 值校验,如布隆筛选器 ref 分布式布隆过滤器(Bloom Filter)详解(初版)
缓存击穿:缓存过期,伴随大量对该 key 的请求
1)互斥锁
2)热点数据永不过期
3)熔断降级
缓存雪崩:同一时间大批量的 key 过期
1)热点数据不过期
2)随机分散过期时间
先删缓存后写 DB
产生脏数据的概率较大(若出现脏数据,则意味着再不更新的情况下,查询得到的数据均为旧的数据)。
比如两个并发操作,一个是更新操作,另一个是查询操作,更新操作删除缓存后,查询操作没有命中缓存,先把老数据读出来后放到缓存中,然后更新操作更新了数据库。于是,在缓存中的数据还是老的数据,导致缓存中的数据是脏的,而且还一直这样脏下去了。
先写 DB 再删缓存
产生脏数据的概率较小,但是会出现一致性的问题;若更新操作的时候,同时进行查询操作并命中,则查询得到的数据是旧的数据。但是不会影响后面的查询。
比如一个是读操作,但是没有命中缓存,然后就到数据库中取数据,此时来了一个写操作,写完数据库后,让缓存失效,然后之前的那个读操作再把老的数据放进去,所以会造成脏数据。
解决方案
1)缓存设置过期时间,实现最终一致性;
2)使用 Cannel 等中间件监听 binlog 进行异步更新;
3)通过 2PC 或 Paxos 协议保证一致性。
Redis 通过主从加集群架构,实现读写分离,主节点负责写,并将数据同步给其他从节点,从节点负责读,从而实现高并发。
ref 如何保证Redis的高并发
答案很简单,因为 Redis 是单线程的,所以 Redis 提供的 API 也是原子操作。
但我们业务中常常有先 get 后 set 的业务常见,在并发下会导致数据不一致的情况。
如何解决
1)使用 incr、decr、setnx 等原子操作;
2)客户端加锁;
3)使用 Lua 脚本实现 CAS 操作。
Redis 在互联网产品中使用的场景实在是太多太多,这里分别对 Redis 几种数据类型做了整理:
1)String:缓存、限流、分布式锁、计数器、分布式 Session 等。
2)Hash:用户信息、用户主页访问量、组合查询等。
3)List:简单队列、关注列表时间轴。
4)Set:赞、踩、标签等。
5)ZSet:排行榜、好友关系链表。
为了将性能优化到极致,Redis 作者为每种数据结构提供了不同的实现方式,以适应特定应用场景。以最常用的 String 为例,其底层实现就可以分为 int、embstr 以及 raw 这三种类型。这些特定的底层实现在 Redis 中被称为编码(Encoding),可以通过 OBJECT ENCODING [key] 命令查看。
Redis 中所有的 key 都是字符串,这些字符串是通过一个名为简单动态字符串(SDS) 的抽象数据类型实现的。
- struct sdshdr{
- //记录buf数组中已使用字节的数量
- //等于 SDS 保存字符串的长度
- int len;
- //记录 buf 数组中未使用字节的数量
- int free;
- //字节数组,用于保存字符串
- char buf[];
- }
我们知道 Redis 是使用 C 语言写的,那么相比使用 C 语言中的字符串(即以空字符 \0 结尾的字符数组),自己实现一个 SDS 的好处是什么?
1)常数复杂度获取字符串长度
由于 len 属性的存在,我们获取 SDS 字符串的长度只需要读取 len 属性,时间复杂度为 O(1)。
2)杜绝缓冲区溢出
3)减少修改字符串的内存重新分配次数
4)二进制安全
5)兼容部分 C 字符串函数
一般来说,SDS 除了保存数据库中的字符串值以外,还可以作为缓冲区(Buffer):包括 AOF 模块中的 AOF 缓冲区以及客户端状态中的输入缓冲区。
ref Redis详解(四)------ 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 是如何实现字典的?
字典又称为符号表或者关联数组、或映射(Map),是一种用于保存键值对的抽象数据结构。
- typedef struct dictht{
- //哈希表数组
- dictEntry **table;
- //哈希表大小
- unsigned long size;
- //哈希表大小掩码,用于计算索引值
- //总是等于 size-1
- unsigned long sizemask;
- //该哈希表已有节点的数量
- unsigned long used;
-
- }dictht
哈希算法
Redis计算哈希值和索引值方法如下:
- # 1、使用字典设置的哈希函数,计算键 key 的哈希值
- hash = dict->type->hashFunction(key);
- # 2、使用哈希表的sizemask属性和第一步得到的哈希值,计算索引值
- # 根据情况不同, ht[x] 可以是 ht[0] 或者 ht[1]
- index = hash & dict->ht[x].sizemask;
当字典被用作数据库的底层实现,或者哈希键的底层实现时,Redis 使用 MurmurHash2 算法来计算键的哈希值。这种算法的优点在于,即使输入的键是有规律的,算法仍能给出一个很好的随机分布性,并且算法的计算速度也非常快。
ref哈希算法
扩容和收缩
当哈希表保存的键值对太多或者太少时,就要通过 rerehash(重新散列)来对哈希表进行相应的扩展或者收缩。具体步骤如下:
1)如果执行扩展操作,会基于原哈希表创建一个大小等于 ht[0].used * 2n 的哈希表(也就是每次扩展都是根据原哈希表已使用的空间扩大一倍创建另一个哈希表)。相反如果执行的是收缩操作,每次收缩是根据已使用空间缩小一倍创建一个新的哈希表。
2)重新利用上面的哈希算法,计算索引值,然后将键值对放到新的哈希表位置上。
3)所有键值对都迁徙完毕后,释放原哈希表的内存空间。
触发扩容条件
1)服务器目前没有执行 BGSAVE 命令或者 BGREWRITEAOF 命令,并且负载因子等于 1。
2)服务器目前正在执行 BGSAVE 命令或者 BGREWRITEAOF 命令,并且负载因子等于 5。
(其中 负载因子 = 哈希表已保存节点数量 / 哈希表大小。)
也就是说扩容和收缩操作不是一次性、集中式完成的,而是分多次、渐进式完成的。 如果保存在 Redis 中的键值对只有几个几十个,那么 rehash 操作可以瞬间完成,但是如果键值对有几百万,几千万甚至几亿,那么要一次性的进行 rehash,势必会造成 Redis 一段时间内不能进行别的操作。所以 Redis 采用渐进式 rehash。
这样在进行渐进式 rehash 期间,字典的删除查找更新等操作可能会在两个哈希表上进行,第一个哈希表没有找到,就会去第二个哈希表上进行查找。但是进行增加操作,一定是在新的哈希表上进行的。
zset 是 Redis 中一个非常重要的数据结构,其底层是基于跳表(skip list) 实现的。
跳表是一种随机化的数据结构,基于并联的链表,实现简单,插入、删除、查找的复杂度均为 O(logN)。简单说来跳表也是链表的一种,只不过它在链表的基础上增加了跳跃功能,正是这个跳跃的功能,使得在查找元素时,跳表能够提供 O(logN) 的时间复杂度。
跳表为了避免每次插入或删除带来的额外操作,不要求上下相邻两层链表之间的节点个数有严格的对应关系,而是为每个节点随机出一个层数(level)。而且新插入一个节点不会影响其它节点的层数。因此,插入操作只需要修改插入节点前后的指针,而不需要对很多节点都进行调整。
ref Redis内部数据结构详解(6)——skiplist
1)跳跃表范围查询比平衡树操作简单。 因为平衡树在查询到最小值的时还需要采用中序遍历去查询最大值。 而跳表只需要在找到最小值后,对第一层的链表遍历即可。
2)平衡树的删除和插入需要对子树进行相应的调整,而跳表只需要修改相邻的节点即可。
3)跳表和平衡树的查询操作都是O(logN)的时间复杂度。
4)从整体上来看,跳表算法实现的难度要低于平衡树。
ref redis中的Zset原理
整数集合(intset) 是 Redis 用于保存整数值的集合抽象数据类型,它可以保存类型为 int16_t、int32_t 或者 int64_t 的整数值,并且保证集合中不会出现重复元素。
- typedef struct intset{
- //编码方式
- uint32_t encoding;
- //集合包含的元素数量
- uint32_t length;
- //保存元素的数组
- int8_t contents[];
-
- }ints
et;
整数集合的每个元素都是 contents 数组的一个数据项,它们按照从小到大的顺序排列,并且不包含任何重复项。需要注意的是虽然 contents 数组声明为 int8_t 类型,但是实际上 contents 数组并不保存任何 int8_t 类型的值,其真正类型有 encoding 来决定。
集合升级过程
当我们新增的元素类型比原集合元素类型的长度要大时,需要对整数集合进行升级,才能将新元素放入整数集合中。具体步骤:
1)根据新元素类型,扩展整数集合底层数组的大小,并为新元素分配空间。
2)将底层数组现有的所有元素都转成与新元素相同类型的元素,并将转换后的元素放到正确的位置,放置过程中,维持整个元素顺序都是有序的。
3)将新元素添加到整数集合中(保证有序)。
集合是否降级
整数集合不支持降级操作,一旦对数组进行了升级,编码就会一直保持升级后的状态。
压缩列表(ziplist) 是 Redis 为了节省内存而开发的,是由一系列特殊编码的连续内存块组成的顺序型数据结构,一个压缩列表可以包含任意多个节点(entry),每个节点可以保存一个字节数组或者一个整数值。
压缩列表并不是对数据利用某种算法进行压缩,而是将数据按照一定规则编码在一块连续的内存区域,目的是节省内存。
压缩列表的每个节点构成如下:
revious_entry_ength
记录压缩列表前一个字节的长度。previous_entry_ength 的长度可能是 1 个字节或者是 5 个字节,如果上一个节点的长度小于 254,则该节点只需要一个字节就可以表示前一个节点的长度了,如果前一个节点的长度大于等于 254,则 previous_length 的第一个字节为254,后面用四个字节表示当前节点前一个节点的长度。利用此原理即当前节点位置减去上一个节点的长度即得到上一个节点的起始位置,压缩列表可以从尾部向头部遍历。这么做很有效地减少了内存的浪费。
encoding
节点的 encoding 保存的是节点的 content 的内容类型以及长度,encoding 类型一共有两种,一种字节数组一种是整数,encoding 区域长度为 1 字节、2 字节或 5 字节。
content
content 区域用于保存节点的内容,节点内容类型和长度由 encoding 决定。
我们知道,Redis 底层实现了很多高级数据结构,如简单动态字符串、双端链表、字典、压缩列表、跳跃表、整数集合等。然而 Redis 并没有直接使用这些数据结构来实现键值对的数据库,而是在这些数据结构之上又包装了一层 RedisObject(对象),也就是我们常说的五种数据结构:字符串对象、列表对象、哈希对象、集合对象和有序集合对象。
- typedef struct redisObject {
- // 类型
- unsigned type:4;
- // 编码,即对象的底层实现数据结构
- unsigned encoding:4;
- // 对象最后一次被访问的时间
- unsigned lru:REDIS_LRU_BITS;
- // 引用计数
- int refcount;
- // 指向实际值的指针
- void *ptr;
- } rob
这样做有两个好处:
1)通过不同类型的对象,Redis 可以在执行命令之前,根据对象的类型来判断一个对象是否可以执行该的命令。
2)可以针对不同的使用场景,为对象设置不同的实现,从而优化内存或查询速度。
字符串(String)
字符串对象的 encoding 有三种,分别是:int、raw、embstr。
1)如果一个字符串对象保存的是整数值,并且这个整数值可以用 long 类型标识,那么字符串对象会讲整数值保存在 ptr 属性中,并将 encoding 设置为 int。比如 set number 10086 命令。
2)如果字符串对象保存的是一个字符串值,并且这个字符串的长度大于 44 字节,那么字符串对象将使用一个简单动态字符串(SDS)来保存这个字符串值,并将对象的编码设置为 raw。
3)如果字符串对象保存的是一个字符串值,并且这个字符串的长度小于等于 44 字节,那么字符串对象将使用 embstr 编码的方式来保存这个字符串。
ref为何是 44 个字节
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 在数据量小的时候使用整数集合存储。
在回答词问题之前,首先需要回答另一个问题,就是如何设置 Redis 中数据的过期时间?
1)expire key time (以秒为单位)–这是最常用的方式
2)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 次):
2.1)如果当前库中没有一个 key 设置了过期时间,直接执行下一个库的遍历;
2.2)随机获取一个设置了过期时间的 key,检查是否过期,如果过期则删除;
2.3)判断定期删除操作是否已经达到指定时长,若已经达到,直接退出定期删除。
过期 key 是不会写入 RDB 和 AOF 文件,同时数据恢复时也会做过期验证。
Redis 作为一个内存数据库,在内存空间不足的时候,为了保证命中率,就会和我们操作系统中的页面置换算法类似,选择一定的数据淘汰策略。
volatile(设置过期时间的数据集)
1)volatile-lru:从已设置过期时间的数据集中挑选最近最少使用的数据淘汰。
2)volatile-ttl:从已设置过期时间的数据集中挑选将要过期的数据淘汰。
3)volatile-random:从已设置过期时间的数据集中任意选择数据淘汰。
4)volatile-lfu:从已设置过期时间的数据集挑选使用频率最低的数据淘汰。
allkeys(所以数据集)
5)allkeys-lru:从数据集中挑选最近最少使用的数据淘汰
6)allkeys-lfu:从数据集中挑选使用频率最低的数据淘汰。
7)allkeys-random:从数据集(server.db[i].dict)中任意选择数据淘汰
no-enviction
8)no-enviction(驱逐):禁止驱逐数据,这也是默认策略。
意思是当内存不足以容纳新入数据时,新写入操作就会报错,请求可以继续进行,线上任务也不能持续进行,采用 no-enviction 策略可以保证数据不被丢失。
PS:在 redis.config 文件中,我们可以设置 maxmemory 的值来配置 Redis 的最大占用内存字节数。
RDB 和 AOF。
RDB 持久化是指在指定的时间间隔内将内存中的数据集快照写入磁盘。也是默认的持久化方式。也就是将内存中数据以快照的方式写入到二进制文件中,默认的文件名为 dump.rdb。
RDB 支持 同步(save 命令)、后台异步(bgsave)以及自动配置三种方式触发。
优点
RDB 文件紧凑,全量备份,非常适合用于进行备份和灾难恢复
生成 RDB 文件时支持异步处理,主进程不需要进行任何磁盘IO操作
RDB 在恢复大数据集时的速度比 AOF 的恢复速度要快
缺点
RDB 快照是一次全量备份,存储的是内存数据的二进制序列化形式,存储上非常紧凑。且在快照持久化期间修改的数据不会被保存,可能丢失数据。
全量备份总是耗时的,有时候我们提供一种更加高效的方式 AOF,其工作机制更加简单:会将每一个收到的写命令追加到文件中。
随着时间推移,AOF 持久化文件也会变的越来越大。为了解决此问题,Redis 提供了 bgrewriteaof 命令,作用是 fork 出一条新进程将内存中的数据以命令的方式保存到临时文件中,完成对AOF 文件的重写。
AOF 也有三种触发方式:1)每修改同步 always 2)每秒同步 everysec 3)不同no:从不同步。
优点
缺点
在出现 Pipeline 之前,我们梳理一下 Redis 客户端执行一条命令需要经过哪些步骤:发送命令-〉命令排队-〉命令执行-〉返回结果。
这个过程称为 Round trip time(简称RTT, 往返时间),mget 和 mset 有效节约了 RTT,但大部分命令(如 hgetall 并没有 mhgetall)不支持批量操作,需要消耗 N 次 RTT ,这个时候需要 pipeline 来解决这个问题。
1)原生批命令是原子性的,而 pipeline 是非原子操作。
2)原生批命令一命令多个 key, 但 pipeline 支持多命令(存在事务),非原子性。
3)原生批命令是服务端实现,而 pipeline 需要服务端与客户端共同完成。
Redis 在处理客户端的请求时,包括获取 (socket 读)、解析、执行、内容返回 (socket 写) 等都由一个顺序串行的主线程处理,这就是所谓的单线程。但如果严格来讲从 Redis 4 之后并不是单线程,除了主线程外,它也有后台线程在处理一些较为缓慢的操作,例如清理脏数据、无用连接的释放、大 key 的删除等等。
使用了单线程后,可维护性高。多线程模型虽然在某些方面表现优异,但是它却引入了程序执行顺序的不确定性,带来了并发读写的一系列问题,增加了系统复杂度、同时可能存在线程切换、甚至加锁解锁、死锁造成的性能损耗。
同时 Redis 通过 AE 事件模型以及 IO 多路复用等技术,即使单线程处理性能也非常高,因此没有必要使用多线程。单线程机制使得 Redis 内部实现的复杂度大大降低,Hash 的惰性 Rehash、Lpush 等等 “线程不安全” 的命令都可以无锁进行。
随着目前行业内越来越复杂的业务场景,有些公司动不动就上亿的交易量,因此需要更大的 QPS。常见的解决方案是在分布式架构中对数据进行分区并采用多个服务器,但该方案有非常大的缺点,比如:
1)要管理的 Redis 服务器太多,维护代价大;
2)某些适用于单个 Redis 服务器的命令不适用于数据分区;
3)数据分区无法解决热点读/写问题;
4)数据偏斜,重新分配和放大/缩小变得更加复杂等等。
从 Redis 自身角度来说,因为读写网络的 read/write 系统调用占用了 Redis 执行期间大部分 CPU 时间,瓶颈主要在于网络的 IO 消耗, 优化主要有两个方向:
1)提高网络 IO 性能,典型的实现比如使用 DPDK 来替代内核网络栈的方式;
2)使用多线程充分利用多核,典型的实现比如 Memcached。
协议栈优化的这种方式跟 Redis 关系不大,支持多线程是一种最有效最便捷的操作方式。所以总结起来,Redis 支持多线程主要就是两个原因:
Redis 6 的多线程默认是禁用的,只使用主线程。如需开启需要修改 redis.conf 配置文件中的 io-threads-do-reads yes。
开启多线程后,还需要设置线程数,否则是不生效的。同样修改 redis.conf 文件中的 io-threads [n] 配置。
关于线程数的设置,官方有一个建议:4 核的机器建议设置为 2 或 3 个线程,8 核的建议设置为 6 个线程,线程数一定要小于机器核数。还需要注意的是,线程数并不是越大越好,官方认为超过了 8 个基本就没什么意义了。
大致流程如下:
1)主线程负责接收建立连接请求,获取 socket 放入全局等待读处理队列;
2)主线程处理完读事件之后,通过 RR(Round Robin) 将这些连接分配给这些 IO 线程;
3)主线程阻塞等待 IO 线程读取 socket 完毕;
4)主线程通过单线程的方式执行请求命令,请求数据读取并解析完成,但并不执行;
5)主线程阻塞等待 IO 线程将数据回写 socket 完毕;
6)解除绑定,清空等待队列。
该设计的特点:
1)IO 线程要么同时在读 socket,要么同时在写,不会同时读或写。
2)IO 线程只负责读写 socket 解析命令,不负责命令处理。
从上面的实现机制可以看出,Redis 的多线程部分只是用来处理网络数据的读写和协议解析,执行命令仍然是单线程顺序执行。所以我们不需要去考虑控制 key、lua、事务,LPUSH/LPOP 等等的并发及线程安全问题。
相同点:都采用了 master 线程 - worker 线程的模型。
不同点:Memcached 执行主逻辑也是在 worker 线程里,模型更加简单,实现了真正的线程隔离,符合我们对线程隔离的常规理解。而 Redis 把处理逻辑交还给 master 线程,虽然一定程度上增加了模型复杂度,但也解决了线程并发安全等问题。
主从模式
和 MySQL 需要主从复制的原因一样,Redis 虽然读写速度非常快,但是也会产生性能瓶颈,特别是在读压力上,为了分担压力,Redis 支持主从复制。Redis 的主从结构一主一从,一主多从或级联结构,复制类型可以根据是否是全量而分为全量同步和增量同步。
哨兵模式
在主从复制实现之后,如果想对 master 进行监控,Redis 提供了一种哨兵机制,哨兵的含义就是监控 Redis 系统的运行状态,通过投票机制,从 slave 中选举出新的 master 以保证集群正常运行。
还可以启用多个哨兵进行监控以保证集群足够稳健,这种情况下,哨兵不仅监控主从服务,哨兵之间也会相互监控。
主从复制可以根据需要分为全量同步的增量同步两种方式。
全量同步
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 接受并执行写命令,从而保持主从一致。
主从同步刚连接的时候进行全量同步,全量同步结束后开始增量同步。
如果有需要,slave 在任何时候都可以发起全量同步,其主要策略就是无论如何首先会尝试进行增量同步,如果失败则会要求 slave 进行全量同步,之后再进行增量同步。
注意:如果多个 slave 同时断线需要重启的时候,因为只要 slave 启动,就会和 master 建立连接发送SYNC请求和主机全量同步,如果多个同时发送 SYNC 请求,可能导致 master IO 突增而发送宕机。所以我们要避免多个 slave 同时恢复重启的情况。
哨兵主要用于管理多个 Redis 服务器,主要有以下三个任务:监控、提醒以及故障转移。
每个哨兵会向其它哨兵、master、slave 定时发送消息,以确认对方是否还存活。如果发现对方在配置的指定时间内未回应,则暂时认为对方已挂。若“哨兵群”中的多数 sentinel 都报告某一 master 没响应,系统才认为该 master “彻底死亡”,通过一定的 vote 算法从剩下的 slave 节点中选一台提升为 master,然后自动修改相关配置。
1)首先是从主服务器的从服务器中选出一个从服务器作为新的主服务器。
选点的依据依次是:
网络连接正常 -> 5 秒内回复过 INFO 命令 -> 10*down-after-milliseconds 内与主连接过的 -> 从服务器优先级 -> 复制偏移量 -> 运行id较小的。
2)选出之后通过 slaveif no ont 将该从服务器升为新主服务器;
3)然后再通过 slaveof ip port 命令让其他从服务器复制该信主服务器。
缺点
主从服务器的数据要经常进行主从复制,这样会造成性能下降
当主服务器宕机后,从服务器切换成主服务器的那段时间,服务是不可用的
一致性 hash 其实是普通 hash 算法的改良版,其 hash 计算方法没有变化,但是 hash 空间发生了变化,由原来的线性的变成了环。
缓存 key 通过 hash 计算之后得到在 hash 环中的位置,然后顺时针方向找到第一个节点,这个节点就是存放 key 的节点。
由此可见,一致性 hash 主要是为了解决普通 hash 中扩容和宕机的问题。
同时还可以通过虚拟节点来解决数据倾斜的问题:就是在节点稀疏的 hash 环上对物理节点虚拟出一部分虚拟节点,key 会打到虚拟节点上面,而虚拟节点上的 key 实际也是映射到物理节点上的,这样就避免了数据倾斜导致单节点压力过大导致节点雪崩的问题。
其实现原理就是一致性 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只负责槽的使用,但是没有所有权。
为了使得集群能够水平扩展,首要解决的问题就是如何将整个数据集按照一定的规则分配到多个节点上。对于客户端请求的 key,根据公式 HASH_SLOT=CRC16(key) mod 16384,计算出映射到哪个分片上。而对于 CRC16 算法产生的 hash 值会有 16bit,可以产生 2^16-=65536 个值。
Redis 集群提供了灵活的节点扩容和收缩方案。在不影响集群对外服务的情况下,可以为集群添加节点进行扩容也可以下线部分节点进行缩容。可以说,槽是 Redis 集群管理数据的基本单位,集群伸缩就是槽和数据在节点之间的移动。
当一个 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 } 命令,通知槽分配给目标节点。为了保证槽节点映射变更及时传播,需要遍历发送给所有主节点更新被迁移的槽执行新节点。
收缩节点就是将 Redis 节点下线,整个流程需要如下操作流程。
1)首先需要确认下线节点是否有负责的槽,如果是,需要把槽迁移到其他节点,保证节点下线后整个集群槽节点映射的完整性。
2)当下线节点不再负责槽或者本身是从节点时,就可以通知集群内其他节点忘记下线节点,当所有的节点忘记改节点后可以正常关闭。
ref Redis Cluster数据分片机制
既然 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 重定向。重定向信息包含了键所对应的桶以及负责该桶的节点地址,根据这些信息客户端就可以向正确的节点发起请求。
从前面的 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个,也会导致网络拥堵。
槽位越小,节点少的情况下,压缩比高
ref 为什么Redis集群有16384个槽
ref 集群之间的消息
故障发现
当集群内某个节点出现问题时,需要通过一种健壮的方式保证识别出节点是否发生了故障。Redis 集群内节点通过 ping/pong 消息实现节点通信,消息不但可以传播节点槽信息,还可以传播其他状态如:主从状态、节点故障等。因此故障发现也是通过消息传播机制实现的。 主要环节包括:
主观下线(PFAIL-Possibly Fail)
集群中每个节点都会定期向其他节点发送ping消息,接收节点回复pong消息作为响应。如果在cluster-node-timeout时间内通信一直失败,则发送节点会认为接收节点存在故障,把接收节点标记为主观下线(PFail)状态。
客观下线(Fail)
Redis 集群对于节点最终是否故障判断非常严谨,只有一个节点认为主观下线并不能准确判断是否故障。当某个节点判断另一个节点主观下线后,相应的节点状态会跟随消息在集群内传播,通过Gossip消息传播,集群内节点不断收集到故障节点的下线报告。当半数以上持有槽的主节点都标记某个节点是主观下线时。触发客观下线流程。
客观下线流程
故障恢复
故障节点变为客观下线后,如果下线节点是持有槽的主节点则需要在它的从节点中选出一个替换它,从而保证集群的高可用。下线主节点的所有从节点承担故障恢复的义务,当从节点通过内部定时任务发现自身复制的主节点进入客观下线时,将会触发故障恢复流程。
expireAt
key 值带上时间戳
「互斥性」: 任意时刻,只有一个客户端能持有锁
「锁超时释放」:持有锁超时,可以释放,防止不必要的资源浪费,也可以防止死锁
「可重入性」:一个线程如果获取了锁之后,可以再次对其请求加锁
「高性能和高可用」:加锁和解锁需要开销尽可能低,同时也要保证高可用,避免分布式锁失效
「安全性」:锁只能被持有的客户端删除,不能被其他客户端删除
Redison 底层原理:
ref Redis实现分布式锁的7种方案,及正确使用姿势!
RedLock 加锁步骤:
1)按顺序向集群中所有 master 节点请求加锁;
2)根据设置的超时时间来判断,是不是要跳过该 master 节点;
3)如果大于等于半数节点( N/2+1 )加锁成功,并且使用的时间小于锁的有效期,即可认定加锁成功啦;
4)如果获取锁失败,解锁!
活动运营中经常会有这样的需求: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
zrevrangeWithScoresBytes(String key, final long start, final long end) (从大到小) - Set
zrangeWithScoresBytes(String key, final long start, final long end) (从小到大) - start : 开始排名
- end : 结束排名
- Tuple :
同时为了实现先到先得,zset 中的 score 并不能仅仅是排序的 Value 值,还需加入时间戳因子:score = (value * 10000000000(十次方)) + (100000000000(十一次方) - ts)。
由于时间戳相同一个月的时间内,头三位是相等的,即我们还可以进一步压缩时间位数以提高排序值的精度。
限于篇幅,更多完整详细面试题及答案注解更新中,或关注我私信领取完整版PDF,我是Java程序V,下次见