0%

redis的三高架构设计实现分析

一般,互联网系统软件设计上,会有三高的概念,即 高并发,高性能,高可用。对于高并发,主要是指,系统可以在大流量请求场景下,支撑业务正常响应,应用系统不出现宕机。

业务应对高并发场景的常用手段,总结为三板斧:缓存,降级,和限流。在缓存方案集中,一般分为本地缓存,和 分布式缓存。而,针对分布式缓存,业务应用最多的技术组件,就是redis。因此,本文就来介绍redis如何以优秀的架构设计从众多开源缓存产品中脱颖而出,成为目前互联网公司缓存方案的技术组件的首选。

首先,作为支持高并发解决方案的三板斧之一的缓存,其底层支持的组件必须本身能支持高并发请求,否则依赖的缓存组件都无法响应请求了,那应用系统肯定会无法支持高并发了。

其次,缓存的价值,就是可以减少请求的处理时间,提供高性能的数据处理服务。对缓存组件的要求,就是需要用合适的资源来支持高并发请求下的快速响应。如果应用程序依赖的缓存,处理每一次请求的耗时都比较长,那么显然也没办法帮助应用程序来支持更高的并发请求了。

最后,任何一个在线互联网软件系统本身,其必须要保证高可用性。如果不断出现各种问题,并且故障出现后恢复时间长,最终导致整体SLA很低,也会影响业务系统的SLA,显然是不可以的。

因此,本文将从这三个方面来介绍 redis 是如何设计的。

一、redis高性能

首先,本文简单介绍下这里高性能和高并发的区分。高性能和高并发,在很多问题和目标上是一致的,尤其是高并发下,一般要求系统的性能也需要非常高,当然这不是一定的。因此,本文为了方便,对redis高性能和高并发的分类,主要是按照单机性能提升的优化,归为高性能;分布式的优化方案,归为高并发。

然后,我们再来说高性能。在网络通信场景下的组件,在高性能方便的支持,主要从以下几个方面来优化:

  1. 客户端:主要是建立TCP长连接,批量请求,数据压缩,本地缓存等
  2. 网络通信:epoll多路复用
  3. 线程模型:线程池,reactor模型,
  4. 数据处理:数据持久化,数据读取,数据结构
  5. 内存管理:内存池分配管理,堆外内存,内存回收

当然,还会有其他的一些特定优化。不过,一般软件的高性能优化,从这几个方面去分析,基本上就差不多了。

1.1 数据结构

首先,来了解redis的数据结构。redis支持的多个数据类型,其对应的底层数据结构的设计,一直被津津乐道,所以,这里先来看,redis在支持多种常用数据类型对象的场景下,做了哪些高性能的设计。

reids 对象的类型主要分5种:

  1. 字符串对象:string
  2. 列表对象:list
  3. 哈希对象:hash
  4. 集合对象:set
  5. 有序集合对象:zset

为了利用更少的内存空间,或者更快的查找效率,redis对每种对象对象,在对象大小和类型不同的情况下,底层选择的数据结构,可能会存在不同。

redis底层的数据结构,主要分为8种:

  1. long类型的整数:INT
  2. embstr编码的简单动态字符串:EMBSTR
  3. 简单动态字符:RAW
  4. 字典:HT
  5. 整数集合:INTSET
  6. 跳跃表和字段:SKIPLIST
  7. 快速列表:QUICKLIST
  8. 压缩列表:ZIPLIST
  9. 紧凑列表:LISTPACK

1.1.1 全局数据结构

首先,我们了解下redis server整体的数据结构是什么样子的。

server数据结构

从上图可以知道,对于redis内部的数据,最终都是由redisObject数据结构来安排。其中里面的type就是我们说的对象,encoding就是我们说的底层数据结构。

redis为了更低的内存存储空间,或者更高的数据查询效率,设计了很多数据结构,来支持上层的不同使用场景。

1.1.1 字符串对象类型支持

在redis中,字符串的优化算是最多的,毕竟redis作为缓存组件,最常用的方式还是将对象序列化成字符串,然后存储。

stringObject字符串对象,在底层主要有三种编码方式,分别是:INTEMBSTRRAW。其中EMBSTR和RAW数据结构都是用的redis自己实现的SDSHDR数据结构。

首先说明INT编码。INT编码下的redis对象,就是上图的redisObject对象中ptr直接转为long类型,存储一个数字。在对字符串进行编码时,如果value < 10000,则可以直接从共享内存中拿到对应构造好的stringObject,提高构造字符串对象速度,并且如果整个内存空间存在大量10000以内的字符串时,也会同时降低内存空间使用。

然后说下RAW编码。RAW编码将对象数据结构中 ptr指向sdshdr数据结构对象的内存地址位置。按照正常的内存分配逻辑,其首先创建一个redisObject对象,申请一个内存分配,然后在把字符串构造成sdshdr对象,申请一次内存分配;将redisObject的ptr指向sdshdr对象地址。

上面RAW编码是个通用的解决方案,但是对于一些小的字符串,为了减少内存分配次数,并且充分利用缓存局部性原理,就有了针对性的优化方案:EMBSTR编码。EMBSTR 和 RAW,都是基于 SDSHDR 数据结构,但是当字符串长度小于OBJ_ENCODING_EMBSTR_SIZE_LIMIT=44时,则直接一次分配内存构造 stringObject。也就是,在createObject的时候,直接按照字符串长度,构造了包含buf在内的内存空间,然后将字符串直接memcpy过去。在redisObject对象中的ptr指针,指向sdshdr的buf地址,

最后来说下SDSHDR数据结构。这是redis专门为字符串设计的数据结构。

1
2
3
4
5
6
7
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len; /* used */
uint8_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};

和老版本的有些不同,增加了flags标识,对len和alloc进行了更省内存的整数类型。
和C语言的字符串相比,其主要是可以快速获取字符串长度,剩余空间,同时其增加了预分配空间和惰性释放空间的策略,避免频繁内存分配和回收。此外,在C语言中使用\0表示字符串结束,但是在sds中,使用长度来判断,避免非预期的字符影响字符串结束判断。

1.1.2 列表对象类型支持

list列表,在3.2之前底层基于ziplist和linkedlist两种数据结构来完成列表对象功能,但是到3.2之后,则使用QUICKLIST数据结构来完成列表所有场景的功能。

linkedlist双向链表,除了数据本身之外,还会有前后两个指针,内存消耗比较大,另外由于链表各个节点不要求内存连续,所以会存在比较多的内存碎片问题。
ziplist为了解决上面两个问题,使用了连续的内存空间来存储列表数据,但是因为其是连续空间,因此对于列表元素的增删插入性能很不好,类似数组,因此在3.2之前,当数据量小的时候,使用ziplist,大于512时,使用linkedlist数据结构。

我们发现ziplist和linkedlist优缺点挺互补,因此在3.2之后,数据结构统一为quicklist;而quicklist,其本质上就是混合ziplist和linkedlist特点的数据结构。

quicklist数据结构

从上图的结构,可以清晰看出quicklist就是整体结构是一个linkedlist,然后每个linkedlist节点,是一个ziplist。
插入的时候,会找到对应的ziplist插入,如果没有位置,则在上下的quicklistNode的zl中找插入位置,如果还没有,则新建一个quicklistNode节点,然后插入到quicklist链表中。
查找的时候,由于主要是基于index查询,所以基于quicklistNode的count数,可以快速找到对应的quicklistNode的ziplist内部,然后遍历entry数组找到对应的index元素。

1.1.3 哈希对象类型支持

hash对象,在最新的非稳定版本中,hash对象已经从之前的ziplist+dict组合,变更为listpack+dict组合,也就是用listpack替换了ziplist数据结构。

老版本的ziplist的数据结构,上面已经介绍。ziplist有一个级联更新的问题:ziplist会存储上一个节点的长度,但是其长度字段,在上个节点长度小于254时,prevrawlensize=1,否则prevrawlensize=5,因此影响prevrawlen所用字节数,最后,当某个前面节点长度发生变化,导致prevrawlensize的值1->5,然后影响当前长度,如果当前节点长度刚好处于临界点,又会影响后面长度,最终导致级联更新的情况。

因此,listpack数据结构的目标,就是解决这个问题。简单结构如下:

listpack数据结构

相对于ziplist,其数据更少,并且对于element只存储当前节点相关信息,因此不会受到级联更新问题的困扰。此外,为了更节省内存空间,编码字段enctype和数据字段ele-data,会混在一起编码,比较复杂,具体可以参考[DevOps] Redis listpack

当元素梳理少于512时,使用listpack数据结构存储,对于列表元素,结对表示哈希对象的key-value对,因此listpack本身元素个数高于1024时,则使用另一种数据结构:hashtable

hashtable,也就是dict,是一种经典的数据结构,在redis中,针对hash值冲突,使用链表法,新元素拆入链表头部,解决。

对于哈希表,最经典的问题,就是扩容和缩容的场景。

redis中,针对dict数据结构,当redis服务正在执行bgsave或者bgwriteaof时,负载因子为5,否则为1时,执行扩容操作。当负载因此为0.1时,执行缩容操作。

在dict中,存在两个ht_table对象:ht[0]和ht[1]。所谓的缩容或者扩容操作,就是将元素从ht[0]逐步拷贝到ht[1]中。redis在扩容后新申请的内存空间为大于需要空间的最接近2^n。

此外,为了在rehash阶段,可以持续提供高可用性服务,redis使用渐进式rehash。

  1. 首先,将dict对象的rehashidx设置为1,表示开始rehash。
  2. rehash期间,对字典的写操作,除了完成本身操作之外,还需要将dict对应的rehashidx索引对应的数据迁移到ht[1]上面;对一个元素迁移完成之后,将rehashidx + 1。
  3. 对字典的写操作,会在两个ht上面进行,例如发现未迁移,则ht[0]上,否则ht[1],新添加的,都在ht[1]上进行;对字典的读操作,则需要先在ht[0]上面查询,如果查不到,则去ht[1]上面查。
  4. 当某个时间点rehash操作完成,则将rehashidx = -1,此时恢复正常。

1.1.4 集合对象类型支持

set集合对象,主要基于intset和dict两种数据结构来实现。

inset顾名思义,就是基于类型为整形的集合对象优化。dict是字典,针对集合而言,将value对象设置为null即可。

此外,需要说明,只有在集合元素类型是整数,并且集合长度小于512的时候,才会使用intset。

intset数据结构如下所示:

1
2
3
4
5
typedef struct intset {
uint32_t encoding;
uint32_t length;
int8_t contents[];
} intset;

这种数据结构,从代码上可以看到,ineset其内存占用明显比dict要低很多。

1.1.5 有序集合对象类型支持

zset有序集合,在之前的版本中同样使用的是ziplist+skiplist,在最新的非稳定版本中,已经变更为listpack+skiplist

和集合对象不太一样,使用listpack列表存储有序集合对象时,其虽然一样使用两个元素作为一对键值对,但是其对应的值,是score值。当元素个数小于128的时候,并且其中元素长度都小于64,就使用listpack;否则使用跳跃表skiplist。

在redis的有序集合中,基于skiplist实现的zset,其数据结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
typedef struct zskiplistNode {
sds ele;
double score;
struct zskiplistNode *backward;
struct zskiplistLevel {
struct zskiplistNode *forward;
unsigned long span;
} level[];
} zskiplistNode;

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

typedef struct zset {
dict *dict;
zskiplist *zsl;
} zset;

也就是 zset是结合skiplistdict两种数据结构来完成有序集合对象的实现的。

skiplist按照score分值对集合元素进行排序,dict保证集合属性,此外,当需要直接获取元素分值时,可以dict通过key获取。两种结合,保证了O(lgN)按照score有序查找,此外,还可以O(1)查找对应key的信息。

为了节省空间,虽然skiplistdict是两个数据结构,并且都会存储元素和score,但是这些数据其实是被共享的,也就是实际上内存中只会有一份数据,避免不必要的内存浪费。

1.1.6 总结

redis是一个内存数据库,因此 redis 需要设计良好的数据结构来支撑更多数据的存储。同时,作为缓存组件,对于数据的读写,尤其是查询性能,也需要放在非常高的优先级去考虑。

因此,我们在redis 数据结构设计实现中,可以发现非常多的优化点。比如使用sds数据结构来减少字符串的扩容成本,为了更少的内存分配,还是要emb内嵌数据编码模式等;比如使用quicklist来优化linkedlist的内存问题,同时又解决ziplist插入删除效率问题;比如使用listpack来进一步压缩ziplist的内存使用,同时解决极端更新情况下的级联更新效率问题等等。

此外,针对集合列表成员的数量,使用不同底层数据结构,来压缩内存使用,同时保证效率不受影响。

1.2 网络模型

redis是一个独立的远程字典服务,client-server模式,基于网络通信提供服务,就需要考虑网络通信以及对应的网络处理模型。

一下基于linux服务器环境下的redis。

一般我们说,redis高性能的原因,其中必然会提到:单线程模式 + reactor模型。

在redis 6.0 以前,redis的网络模型,使用的是单线程模式,主要原因有以下几点:

  1. redis作为内存数据库,主要的操作是数据的读写,因此认为其主要是IO密集型,由于CPU一般不会成为瓶颈,所以使用单线程;
  2. 众所周知,多线程执行任务,最大的一个问题点,就是多线程导致的上下文切换。上下文切换导致CPU的上下文环境变量和缓存需要切换,影响效率,因此redis的单线程模式,可以避免这种情况的频繁发生。
  3. 此外,redis支持很多数据结构,这些数据结构在多线程环境下需要考虑并发操作的正确性问题,为了解决问题,必然需要使用锁或者其他同步工具来实现,这样就会导致可观的同步开销,从而影响性能。
  4. 最后,我觉得也是最重要的一点,redis作者本身期望redis的代码实现保持简洁性,从其网络编程库和数据结构的使用,就能看出。此外,多线程情况下操作当前redis的数据结构对象,未必会有性能上的提升,但是复杂度和bug数,肯定会大幅提高。性价比不高。

针对以上原因,redis在6.0以前,对于网络模型一直都是使用单线程模式来搞定数据读取。

1.2.1 redis 4.0 多线程优化

既然有以上原因,来解释redis的网络模型使用多线程没有单线程好,那么非网络模型链路的任务处理呢?可不可以使用多线程来优化效率。因此,redis 4.0 引入了 多线程来处理一些可以异步化的操作任务,减少同步指令执行时候的耗时。

在redis 4.0之后,对于redis中大key删除DEL指令,则可以改用UNLINK指令。redis在接收到unlink删除指令之后,会判断删除的key对于包含的元素比较多时,大于LAZYFREE_THRESHOLD=64,则交给多线程后台去异步删除。如果是少量的,则直接同步删除,比较异步里面,还需要获取lock+cond_signal来提交任务,等待执行。

同样,针对redis中flushDbflushAll清空指令,也是通过多线程异步任务的方式来执行。

4.0之后的redis版本,将阻塞单线程的耗时很大,并且其对实时要求也不高的任务,提交给后台多线程,这种方式很好的提升了redis处理线上指令的性能。

那么,对于最核心的网络模型,能不能也进行多线程化的改造,从而提升性能呢?

1.2.2 redis 6.0 多线程优化

还是基于以上基于单线程执行原因。我们知道对于网络IO的操作,多线程reactor模型是非常通用的优化方案。在java域中基于netty网络框架实现的中间件,基本上都是基于该模型;之前分析的nsq实现,也是使用协程+reactor模型。

因此,我们可不可以将网络IO操作进行多线程异步化。同时,基于上面的原因,考虑当前redis数据结构在多线程下的同步成本和复杂度,在redis 命令执行时,使用同步方式进行。这样可以很好的综合两者的优势,提升redis整体性能。

基于以上,在 redis 6.0以后版本,redis 对核心的网络模型进行了多线程化的改造。基于网络IO的异步化改造,是充分利用服务器多核性能;另一种经常提及的零拷贝Zero-Copy技术,由于redis服务在指令执行时,还会对数据进行过滤、查询计算、编解码处理等,因此零拷贝技术不太适合。

1.2.3 redis 6.0 网络模型

上节介绍了,redis 6.0 开始采用多线程来处理网络IO,但是它不是标准的多线程reactor网络模型。

通常,我们对于网络通信处理模型的流程,是读请求 -> 解析请求 -> 执行业务逻辑 -> 编码返回信息 -> 写响应数据,简化一下,就是请求读取解析 --> 业务处理请求 --> 写回处理结果。对于标准的多线程reactor模型,其会有一个主reactor主线程,主要负责接收连接请求accept,然后多个subReactor线程负责数据读取,解析等,然后交给业务处理器(业务处理器本身也可能会有业务线程池)处理,业务处理完成之后,同一个subReactor线程会将结果进行编码序列化,最后写到网络连接通道中。

但是,redis本身会有些不同,上面说的,为了保证数据结构的执行在单线程中,redis的数据模型,只是在网络IO数据的读取和写入,使用了多线程,在指令执行的时候,还是只有主线程来处理。借用Redis 6.0 新特性:带你 100% 掌握多线程模型的图:

多线程模型

在处理请求时,其维护了clients_pending_readclients_pending_write,多线程IO操作,实际上技术处理这两个链表,处理完成,则会交给主线程处理。具体实现,可以参考Redis 6.0 多线程IO处理过程详解

此外,和其他所有远程服务组件一样,redis在linux上也是基于epoll IO多路复用,来提交网络请求处理性能。

1.3 内存管理

一般,我们介绍内存管理,主要涉及两方面:内存分配和内存回收。

1.3.1 redis内存分配

redis内存分配主要伴随着各种数据结构的构造而发生的。因此,redis内存分配优化,主要发生在数据结构优化中。

1.3.1.1 INT编码的字符串对象

在redis启动的时候,默认会构造0-9999数字对应的字符串对象,这些整数字符串,被存放在共享对象池中。这些字符串对象,可以被string对象,hash对象,list对象,set对象和zset对象共享使用,因此构造对象的时候,直接去共享内存池拿对象实例,无需申请内存分配再赋值;并且也节省了内存占用。

1.3.1.2 EMBSTR字符串对象

正常的基于sds构造的字符串对象,需要两次内存分配,第一次申请RedisObject对象,第二次申请sds对象,而embstr直接将2次内存分配合成1次,提升内存分配速率。

此外,sds本身数据结构的内存预分配和惰性回收机制,也减少了内存操作(内存分配+数据拷贝),从而提升效率。

1.3.1.3 intset集合对象

intset只是针对int8_t的整数数组,组成的集合。这种只有小整数的集合,redis 都会专门为其定义一个数据结构,可见redis的内存优化工作不一般。

此外,在redis自定义的数据结构中,在内存利用率和分配速率方面,都有着或多或少的优化。

1.3.2 redis内存回收

有了内存分配,就会有内存回收。内存回收的时机和策略,同样对redis性能有着很大的影响。
对于redis而言,其主要是针对过期数据的内存回收。

过期数据回收,最直接精确的一种方式,就是当redis键对象有效期到期,立马删除,从而完成内存的回收。这就是所谓的定时删除。定时删除成本比较高,如果某一时刻存在大量key过期时间到期,则会大量占用CPU时间来完成过期对象删除和内存回收。

为了避免这种情况,redis提供了另一种策略,惰性删除。惰性删除,则是key对象过期时,并不会去删除数据,而是当有执行需要访问过期数据时,执行删除对象和回收内存的操作。惰性删除成本很低,但是如果存在大量过期对象一直没有被访问,则这些内存会一直在内存空间中占用,而不会被回收,对于内存数据库,这是相当致命的。

上面两种策略都比较极端,因此,redis还提供了一种折中策略,就是定期删除。定期删除,每隔一段时间执行一次删除过期对象回收内存的操作,并且通过限制一次任务执行的时长,最终来限制CPU的使用。

一般而言,我们的内存回收,会综合使用惰性删除+定期删除策略。定期删除策略,用来弥补惰性删除对一些长时间不操作的过期对象不能进行内存回收的问题。

1.4 客户端缓存

客户端缓存,是redis 6.0之后提供的一种客户端优化方式。

一般而言,我们使用redis作为分布式缓存,数据持久化在mysql/hbase中。客户端首先会去redis查询数据,如果不存在,则去mysql/hbase查询。虽然redis的数据查询qps 能到10w,但是对于某些热key而言,redis的性能,不一定能够支撑的住。

针对热key,一般为了使用redis,我们都会拆分成多个key,放在不同的redis实例中,从而来支撑这种场景。或者,在客户端使用本地缓存localcache,将压力分解、转移到客户端。但是,不过使用上面的哪种方案,都会大大提高缓存使用成本。例如,使用本地缓存localcache,需要考虑缓存数据更新后,客户端如何能快速变更(缓存时间短?开发其他工具来通知客户端更新),是需要解决的难点。

因此,redis 6.0 推出的客户端缓存,将redis内部的部分数据,上浮到客户端侧,从而大大降低redis服务端对某些key的响应压力,并且客户端缓存数据的查询响应时间,也大大降低。

redis在服务端记录访问的连接和相关的key, 当key有变化时,通知相应的连接(应用)。应用收到请求后自行处理有变化的key, 进而实现客户端缓存与redis内对应数据的一致。

redis对客户端缓存的支持方式被称为Tracking,分为两种模式:默认模式,广播模式。

默认模式
redis 会记录每个客户端连接访问的Key数据,当发生变更时,向客户端推送数据过期消息。

  • 优点:只对客户端发送其访问过的被修改的数据
  • 缺点:redis需要额外存储较大的数据量,耗内存。

广播模式
客户端订阅key前缀的广播(空串表示订阅所有失效广播),redis记录key前缀与客户端的对应关系。当相匹配的key发生变化时,通知客户端。

  • 优点:服务端记录信息比较少
  • 缺点:客户端会收到自己未访问过的key的失效通知。

1.5 总结

redis高性能,关注于单redis实例,如何支持更多请求,降低请求响应时间,提升处理效率。同时,作为内存数据库,内存占用,也是高性能的一个重要指标。

通过自定义的数据结构,以及在对象构建中,基于不同数据使用不同数据结构的设计方案,使得在内存利用,数据查询上面,有着显著的提升。

通过不断将单线程模型,逐步逐场景升级为多线程模型,并且采用经典的epoll多路复用模型,能够支撑更多的客户端接入请求,同时多线程处理IO读写数据,也能平均响应时间缩短,吞吐量提升。

此外,为了提升服务能力,在一些内存回收,大key删除等长耗时任务,使用异步方式或者惰性方式,避免阻塞redis指令执行,影响服务质量和服务响应。

最后,从客户端角度,通过客户端缓存,来降低redis服务压力,从而支持更大的吞吐能力。并且,通过减少网络通信,从而提升响应时间。

二、redis高可用

redis单机处理能力已经很优秀了,但是作为互联网产品,需要持续提供在线服务。因此,关注高性能的同时,我们还需要格外关注高可用,也就是SLA水平。

数据服务的高可用架构,最核心的技术点,就是 复制。通过集群方式,机器冗余,多数据副本,最终完成高可用系统的支撑。

redis的高可用,同样和其他存储组件一样,采用多副本机制,通过主从复制,来完成服务的高可用要求,最终提升redis 字典服务的SLA水平。

redis服务的高可用,主要使用主从复制机制,主redis对外提供服务;当主redis不能继续对外服务的时候,通过哨兵机制来选择从redis晋升为主redis。

2.1 redis主从复制

2.1.1 持久化

因为redis是内存数据库,所以如果服务器进行重启或者其他异常导致redis进程退出,则redis内的数据都会消失。因此,redis提供了持久化功能。

redis提供两种持久化机制:RDB快照和AOF日志。

所谓RDB快照,就是对当前redis的内存数据进行压缩,然后落盘持久化为一个二进制文件。通过这个RDB文件,redis可以还原到RDB快照时内存的状态。

在使用bgsave命令保存RDB快照时,redis会单独启动一个子进程,由于子进程在由父进程fork出来之后,其内存数据是共享的,因此,子进程可以在自己的独立进程中,读取父进程的数据,写入RDB文件中,从而落盘数据,完成持久化。

但是,使用RDB持久化全量数据,实际上是很耗资源的。如果避免资源消耗过大,降低RDB快照频率,则又会让redis宕机下大量最新数据丢失,因此我们需要save rdb快照之后的数据,以增量日志的方式落盘。这样就能基本消除redis恢复时最近数据丢失的问题。

增量日志持久化机制,就是AOF。

AOF日志,在redis执行完写/更新指令之后,向AOF添加一条redis命令的日志。AOF首先以aof_buf缓存,然后按照具体flush策略,将要求持久化到aof文件内。redis aof flush策略,主要有三种:

  • always:同步flush到文件中,这种方式会降低数据写入效率,最终影响redis性能;
  • everysec:定期每隔1s,将buf数据flush到磁盘文件内。如果redis进程出现问题,则最多会导致1s的变更数据丢失。
  • no:redis不主动flush buf 数据到持久化日志文件中,而是靠操作系统控制。此时,因此redis进程不会等待flush操作结束再返回响应,所以其效率最高。同时,数据丢失也是最严重。

2.1.2 复制

对于redis持久化使用RDB快照文件和AOF日志文件,来保证redis进程退出后重启能恢复内存状态。我们也可以将这两个持久化机制,拿来做主从复制,从而保证从redis服务器保持了主redis服务器保持内存数据一致。

主从复制,一般存在三个阶段:

  1. 首次主redis数据全量复制;
  2. 正常运行期间的增量数据同步;
  3. 异常情况下恢复同步。

首次全量复制
首次全量复制,此时从redis内存不存在主redis的数据。

  1. 从redis首先需要向主redis建立连接,然后从redis执行replicaof指令,并且向主redis发送同步指令psync,主redis回复确认后,主从redis开始同步。
  2. 然后,主redis会通过bgsave生成RDB文件,并且将生成完的RDB文件同步给从redis,同时,主redis也会给对应的从redis构建一个client buf缓冲区,同aof一样,存放RDB生成之后的所有写命令。
  3. 从redis拿到RDB文件之后持久到本地硬盘上,然后情况内存数据,像redis进程退出恢复一样,将RDB文件加载进内存中。

以上,首次全量数据复制,就完成了。这个时候,从redis服务器内存数据,和主redis生成RDB文件时的内存状态,保持一致。

增量数据同步
正常运营期间的增量数据同步,和,异常情况下的恢复同步,都使用同样的方式做数据同步。这里统称为,增量数据同步。

在首次全量复制的时候,提到主redis在发送RDB文件的同时,会把后面的所有写指令,写入client buf缓存中,并且针对每个从redis都会维护一个buf。

增量数据同步,就是主redis将buf数据不断的写给从redis,但是,这里涉及一个问题,就是主redis如何知道从redis当前的内存数据状态,也就是主redis如何知道接下来要push什么数据给到从redis,让其继续主从同步,尤其是从redis如果进程退出恢复重启之后,如果同步复制。

这里就涉及到两个offset,一个是主redis维护的master_repl_offset,表示自己写buf的数据偏移量;一个是从redis维护的slave_repl_offset,记录自己读取到数据的偏移量。因此,当主redis从redis断开增量数据同步之后,从redis会发送psync同步指令给主redis,并且在指令中,还会带上slave_repl_offset偏移量值,这样主redis就从指定的偏移量继续推送增量写指令。

2.1.3 主从复制的问题

主从复制,解决了高可用最核心的问题之一,多副本。当主redis出现故障无法恢复时,从redis可以升级为主redis,继续对外提供服务。

但是,主从复制只是简单的解决了数据多副本问题,对于主redis的故障检测,集群故障自动恢复这两个高可用中同样核心的问题,并没有解决。

因此,就有了redis哨兵模式。

2.2 redis 哨兵模式

redis哨兵模式的出现,主要是为了解决上面主从复制遗留的两个高可用问题:故障检测和故障自动恢复。

redis sentinel 哨兵,顾名思义,就是由一组机器实例组成的redis sentinel系统,用来监控redis服务器的状态,包括主redis服务器和从redis服务器。当sentinel系统判断主redis服务器处于下线状态,则选出一个从redis服务器作为新的主redis服务器,对外继续提供读写服务。

因此,sentinel需要解决三个核心问题:

  1. 如何发现和判断主redis下线;
  2. 如何选出新的主redis服务器;
  3. 如何通知所有相关方,新主redis服务器上线。

2.2.1 sentinel监控

在讨论sentinel如何解决上面三个问题之前,首先来简单看下sentinel如何完成最基础的监控工作。

sentinel系统,一般会有一组机器组成一个sentinel集群。

sentinel服务器会监控,主redis服务器,从redis服务器,sentinel集群其他服务器的状态。整个监控,是通过3个定时任务完成的:

  1. 监控redis服务的主从信息:每隔10s,sentinel服务器会向所有redis服务器发送INFO指令,获取redis主从拓扑信息。尤其是INFO里面的副本信息:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
     

    # Replication
    role:master //角色(master、slave),一个从服务器也可能是另一个服务器的主服务器
    connected_slaves:1 //连接slave实例的个数
    slave0:ip=192.168.163.132,port=6382,state=online,offset=64547142,lag=1 //连接的slave的信息
    master_replid:1726c598c37f039c4b69db7a4281392a650eb88b //服务器的复制ID
    master_replid2:0000000000000000000000000000000000000000 //第二服务器复制ID,用于故障转移后的PSYNC,用于集群等高可用之后主从节点的互换
    master_repl_offset:64547142 //复制偏移量1
    second_repl_offset:-1 //第二服务器复制偏移量2
    repl_backlog_active:1 //复制缓冲区状态
    repl_backlog_size:1048576 //复制缓冲区的大小(以字节为单位)
    repl_backlog_first_byte_offset:63498567 //复制缓冲区的偏移量,标识当前缓冲区可用范围
    repl_backlog_histlen:1048576 //复制缓冲区中数据的大小(以字节为单位)
    #如果是从节点,会有以下状态:
    master_host:192.168.163.132 //Master IP
    master_port:6379 //Master Port
    master_link_status:up //Master的连接状态(up/down)
    master_last_io_seconds_ago:8 //最近一次主从交互之后的秒数
    master_sync_in_progress:0 //表示从服务器是否一直在与主服务器进行同步
    slave_repl_offset:64547142 //复制偏移量
    slave_priority:100 //从服务器的优先级
    slave_read_only:1 //从服务是否只读
  2. sentinel集群信息交换:每隔2秒,sentinel服务器,会向redis数据节点的__sentinel__:hello频道上,发送自身的信息,以及对主节点的判断信息。这样,sentinel集群中各个节点之间就可以交换信息;
  3. redis服务器状态监控:每隔1秒,sentinel服务器,会向所有redis服务器、其余sentinel服务器发送PING命令做心跳检测,来确认这些节点当前是否可达。

2.2.2 发现和判断主redis下线

基于以上sentinel服务器的监控数据,我们就可以判断主redis是否是下线状态了。

在sentinel监控中,会每1s会去pingredis服务器,如果down-after-milliseconds时间内,redis服务器没有响应,则该sentinel服务器会将对应的redis服务器标记为主观下线状态,主观下线,对于redis而言并没有什么影响。

但是,sentinel发现主redis服务被标记为主观下线之后,它会向sentinel用户交换信息的频道__sentinel__:hello发送is-master-down-by-addr指令,询问其他sentinel对该主redis确认状态,如果其他sentinel也发现该主redis下线,则恢复Y,这样,当sentinel服务器得到超过quorum数量的同意回复之后,则将主redis标记为客观下线状态。

这个时候,主redis确定下线状态了,也就是故障检测发现主redis服务已不可用。

2.2.3 选出新的主redis服务器

上面检测到主redis发生故障之后,此时就需要做故障恢复/转移。因此,对于主从模式,我们就需要从 从redis服务器集群中,选出一台作为新的主redis,继续对外提供redis服务。

选择主redis的工作,同样交给sentinel完成。但是sentinel是一个集群,因此首先需要确认哪个sentinel服务器作为leader,来操作redis的故障恢复。

sentinel使用raft算法来完成集群leader选举。

在判断客观下线的过程中,sentinel会向指定频道发送指令is-master-down-by-addr,确认redis下线状态,同时,也尝试获取sentinel集群 leader角色,来主导本次故障转移。

sentinel集群其他节点,并不是确认redis下线就回复Y,而是在判断主redis下线之后,还会判断是否之前已经回复过Y,如果回复过,则本次响应N。

发送指令的sentinel节点,统计回复Y的数量,如果超过quorum,则除了表明主redis客观下线,同时也表明该sentinel获取了本次故障转移的leader角色。

接下来,sentinel leader 就可以选出一个redis作为主redis了。基于以下几点来选择主redis:

  1. redis节点和前主redis节点的断开连接时长。如果断开时间比较长,则认为该redis节点不适合作为主redis。
  2. 从redis的优先级。
  3. 主从同步的进度,通过master_repl_offset来比较。
  4. run id。如果以上都一样,则最后看从redis的run id,小的被选上。

基于以上4点策略,来选择新的主redis。

Leader Sentinel节点,会从新的master节点那里得到一个config_epoch,本质是个version版本号,每次主从切换的version号都必须是唯一的。其他的哨兵都是根据vetsion来更新自己的master配置。

2.2.4 对外通知,新主redis上线

首先,对新的主redis,发送slaveof no one命令,告知其升级为主redis节点。
然后,对剩余其他从redis,发送slaveof 新主redis命令,让他们成为新主redis的从redis,后续的主从复制,将copy 该redis节点。
接下来,故障转移完成之后,sentinel leader 发送 +switch-master消息,客户端订阅+switch-master消息,当存在消息事件之后,客户端就可以知道发送了故障转移,并且拿到新的主redis地址信息,然后客户端就可以去和新的redis建立连接。

此外,sentinel会持续监控已经标记为下线的原主redis节点,一旦其上线,立马对它切换为从redis角色操作。

2.2.5 redis哨兵模式的问题

redis sentinel模式 解决了主从复制的故障发现和故障转移的问题。因此,从高可用的角度而言,redis 主从复制+redis sentinel模式,已经完成的高可用架构设计。

但是,redis主从复制+哨兵模式,有一个问题,就是其是基于单实例的,也就是其数据库数据收到单机内存大小的限制。这就导致一个问题,主从复制的主redis和从redis必须要一样的配置,否则在同步的时候,会因为内存不够报错,或者内存多余而浪费。

此外,当前互联网存有大量的数据,如果不使用集群模型分片来搞,而是使用多实例部署,成本太大,使用也非常不便。

三、redis 高并发

最后,我们来看redis 高并发,单机的高性能某种程度上,可以提升redis的并发处理能力。此外,高并发,很多时候我们可以通过分布式集群来支持。

高并发的架构方案,一般通过水平扩展垂直扩展来优化。而,水平扩展,就是通过数据分片的方式来完成。

对于redis,其本身是基于内存的数据库,通过副本+多分片的方式,分散单实例的压力,可以更好的支撑高并发场景,从而提升redis集群的qps/tps。

因此,在redis高并发设计中,重点去介绍redis集群机制。

3.1 一致性hash原理和实现

在介绍redis cluster集群方案之前,首先介绍一致性hash。

一般而言,我们简单做分片或者负载均衡之类的算法时,都是直接用hash算法生成一个数字,然后对某个分片数/机器数取模,即 m = hash(key) % n。但是,这种算法对于微服务架构的扩缩容场景下,无法很好地使用。对一组系统加入1台机器或者减少1台机器,都会导致大量的数据需要重新被其他机器加载。

因此,基于简单的hash取模方式去分片,数据复用率太低;这个时候,就有了一致性hash。

一致性hash具体执行步骤为:

  1. 首先,构造一个长度为2^32的整数环(这个环被称为一致性Hash环),根据节点名称的Hash值(其分布为[0, 2^32-1])将缓存服务器节点放置在这个Hash环上;
  2. 然后,根据需要缓存的数据的Key值计算得到其Hash值(其分布也为[0, 2^32-1]);
  3. 最后,在Hash环上顺时针查找距离这个Key值的Hash值最近的服务器节点,完成Key到具体服务器的映射查找。

关于节点构造的一致性hash环,其数据结构,可以简单采用java中的TreeMap,也可以直接用一个有序数组,查找的时候,通过二分查找算法找到大于等于m的最小节点;以上两种数据结构的算法复杂度都是O(lgN)。

但是,普通的一致性hash算法存在一个很严重的问题,就是服务节点hash后,落在一致性hash环上并不是均匀的,这样会导致有些节点上需要承担很大的数据量和访问量,最终导致负载不均衡。此外,如果一个服务节点挂了,则这个服务节点上面的数据和请求,全部落到下一个节点上,最终也可能把下一个节点压垮。

出现以上问题,最本质的原因,就是因为我们的服务节点数量是非常有限的。因此,我们需要想办法去扩大我们的服务节点数。实体节点,肯定无法说加就加,于是就自然的,考虑将实体节点虚拟化,这样我们可以无限的增加虚拟节点数量,从而导致在一致性hash环上的均衡分布。

基于虚拟节点的一致性hash算法,其最核心的,就是将实体节点,映射出多个虚拟节点。例如,我们有实体服务器192.168.0.1192.168.0.2192.168.0.3192.168.0.4192.168.0.5 一共5台,我们可以基于虚拟节点的思想,将每台机器扩展虚拟节点,例如,192.168.0.1 扩展为192.168.0.1#1,192.168.0.1#2,192.168.0.1#3,192.168.0.1#4,192.168.0.1#5,192.168.0.1#6…..,分别对新的key进行hash,然后分布在一致性hash环上。

当有一个查询请求,则根据hash(key)找到其在一致性hash环上右侧最接近的虚拟节点,然后拿到虚拟节点#前的字符串,从而确定对应的实体机器。

3.2 redis 集群(分片)

redis cluster,是redis官方提供的一套完整的集群部署方式,包括我们说的分片,多套主从redis实例之外,还自己独立实现了 高可用相关的功能,而不是使用redis sentinel 方式。

3.2.1 redis cluster高可用

简单说下redis cluster的高可用设计,其思想和redis sentinel很相似,同样对一个问题节点A存在主观下线和客观下线状态。和sentinel不同的是,cluster本身没有单独的sentinel集群来对redis节点进行故障检测和故障转移,因此在cluster集群中,redis节点需要承担sentinel角色,这就是redis官方部署时要求redis节点需要>= 3(这里redis节点,包含1台主redis机器和N台从redis机器)。

在标记节点A客观下线之后,cluster同样使用投票机制,选出一个leader,操作某个从redis升级为主redis。新任主redis,会通过PONG消息,通知集群自己是新的主redis了。

对于集群监控这块,和redis sentinel的定时ping方式存在很大的不同;redis cluster将使用 gossip协议,来获取其他节点信息,或者广播本节点的信息。

cluster gossip 会每隔1s时间,选择5个最久没有通信的节点,发送PING消息,检测对应的节点是否在线;同时还有一种策略是,如果某个节点的通信延迟大于了cluster-node-time的值的一半,就会立即给该节点发送PING消息,避免数据交换延迟过久。接收到PING消息的节点,正常会回复 PONG消息给发送方。如果发送方,发现某个节点超过指定时间没有回复PONG消息,则发送方会发送FAIL消息广播自己的对某个节点的宕机判断,假设当前节点对A节点判断为宕机,就会立即向Redis Cluster广播自己对于A节点的判断,所有收到消息的节点就会对A节点做标记。

需要注意的是,gossip协议,不会对所有节点进行ping,而是有选择的发送;这就意味着,其只是做数据的最终一致性。

3.2.2 redis机器分配扩容

本大节主要是介绍redis如果做水平扩展,从而支持业务方高并发请求。因此,接下来,我们重点介绍redis如何支持水平扩展。

对于redis集群而言,做水平扩展而新增的redis机器,将需要的数据迁移上去,然后客户端可以查询到数据即可。而,redis集群需要明确的就是两点:

  1. 将什么数据迁移到新的redis节点上;
  2. 客户端如何知道去新的redis节点查询数据。

3.2.2.1 节点重分片

上文已经提前介绍了 一致性hash算法,其本质上就是解决扩容缩容场景下,失效数据最小化的问题。这里的redis节点水平扩展导致的重分片,就是这种问题在redis机器扩容场景下的表现。

所以,redis cluster 就采用一致性hash算法来解决重分片问题。

和标准的一致性hash算法不同,redis采用的一致性hash环大小为2^14,也就是16384。redis cluster 将自己划分成 16384个slot槽位。每个slot槽位映射成一个虚拟节点,而redis cluster中的redis节点,则会和其他一些节点进行映射。redis cluster中维护每个redis节点映射到哪些虚拟的节点范围,新上的redis节点可以通过命令获取到一批新slot槽位存储过来。

以下图,来自:Redis集群与插槽分配(动态新增或删除结点)

通过命令启动集群,会按照redis节点数来计算分配虚拟节点,如下:

启动集群

每个redis节点负责的slot范围,都是均匀的分布。然后,在有新的节点需要加入到集群的时候,一开始是没有负责的slot虚拟节点范围的,需要通过指令来迁移数据,例如上面的环境下,新增一台redis节点,然后执行./redis-trib.rb reshard 192.168.56.102:6379命令,在执行的时候,输入需要迁移的slot数,例如1000,输入完成之后,redis开始迁移数据,如下:

迁移数据中

迁移完成后,如下:

迁移数据后

当然,我们也可以使用原生的 cluster setslot 命令来操作,不过非常不友好,而且操作很麻烦,细度太细。

前面高可用讲过,redis cluster通过gossip协议进行信息交流,其中会通过PONG消息将自身的状态和信息发送出去。当新的redis节点通过MEET消息加入到集群之后,通过命令操作完slot数据迁移,然后发送的PONG消息就会包含新的其内存中负责的slot区间范围的数据,然后其他所有的redis节点,就能拿到集群中所有slotredis节点的映射数据。

虽然有些地方,redis cluster slot 是 普通的hash桶方式做数据分片。但是,这里认为,redis cluster 利用 超过机器数量的虚拟节点,然后每个机器负责其中一部分的slot范围列表,其本质上还是跟 一致性hash思想,更接近一些。

3.2.2.2 客户端查询

上面的redis cluster集群已经按照 16384个slot做分片,数据根据key值,均衡的分布到各个分片上,对外提供redis服务。

客户端的操作,都会有一个key值,当客户端将请求发送到redis cluster集群中某个redis 节点后,redis会通过CRC16(key) % 16384计算出对应的slot位置,然后按照每个机器上都会维护的全量 slot->redis节点的映射上找到对应redis节点。

如果redis节点发现slot是由自己维护的,则直接返回结果给客户端;否则返回moved响应给到客户端,客户端根据响应里的正确redis节点地址,去发送请求,获取数据。

从上面的请求响应过程来看,通过moved重定向方式来完成请求,很大概率需要多次网络IO,才能拿到需要的数据,效率非常低。

如果客户端需要使用redis cluster集群提供的服务,则建议升级客户端为smart client,这样通过在本地缓存一份hashslot -> node的映射表,来减少大量的重定向消耗的网络IO时间,提升性能。

四、参考资料