《Redis设计与实现》 读书笔记

前言

Redis对我来说是比较神秘的存在,我大概只知道它是一个内存数据库,可以缓存一些易变的、需要频繁读写的数据(比如传感器数据,传输非常频繁,因此可以存储在Redis内,达到一定容量后统一写入数据库)。

在实践中,Redis也大抵上是如此,一般用来做缓存。除此之外,由于Redis采用网络进行通信,因此可以非常方便地将其应用于分布式场景中。再加上Redis本身就支持集群等特性,所以,Redis很适合用来做分布式缓存。

这本书对Redis的实现原理讲解得比较多,但是对应用层面则基本没有提及。所以如果想要知道Redis能怎么用,最好还是去看看《Redis in Action》,也即《Redis实战》。

此外,本书的源码分析不多,原理分析很详尽。如果对数据结构掌握得也比较好,那阅读起来可以说毫不费力,的确是一本好书。

最后还是要说明一下,读书笔记是个人的理解,各人都会有不同的见解、不同的侧重点,笔记的标题基本按照章节标题来编排,会做一些整合,也会做一些跳过,也会做一些联想。希望能给同看这本书的你一些启发~

1.引言

本书尽量采用高层次的角度对Redis的实现原理进行描述。也就是说,本书偏向原理,不会有过多的源码。

当涉及数据结构和比较简单的源码时,会直接粘贴C语言代码。而当涉及较为复杂的算法时,则会使用python语言来表示伪代码。

本书基于Redis2.9编写

2.简单动态字符串

虽然这里叫简单动态字符串,但是实际上不一定存储字符本身。即使存储二进制串也是毫无问题的。

Redis没有直接使用C语言的字符串表示(以\0结尾的字符数组),而是自己构建了一种简单动态字符串(simple dynamic string)的数据类型,并将其作为Redis的默认字符串表示。

除了用于保存数据库中的字符串值之外,还被用作缓冲区,如AOF模块中的AOF缓冲区,以及客户端状态中的输入缓冲区。

2.1 SDS的定义

包含三大属性:

struct sdshdr {
    int len;// 记录已经使用的字节数
    int free;// 记录未使用的字节数
    char* buf;// 指向实际数据区的指针
}

与C语言以空字符结尾的惯例相同,保存的字符串结尾的\0不算在len中。这是由于,可以大量复用C语言的字符串库。

2.2 SDS与C字符串的区别

也就是使用SDS的原因。提取重点来说,就是C字符串难以满足要求。当时产生了一个疑问,那为什么不用std::string呢?其原因是,string也只能满足部分需求。

1.常数复杂度获取字符串长度:strlen需要O(N)获取,而SDS存储了len对象,只需要O(1)就可以获取;
杜绝缓冲区溢出:在执行字符串修改操作时,SDS会首先检查内存空间是否够用(如果不够用,会进行内存空间重分配),然后再进行处理;
2.减少修改字符串带来的重分配次数:实现了特有的重分配策略。**一方面使用空间预分配**,当SDS需要进行空间扩展时,还会分配额外的未使用空间(重分配后,free=min(1MB, len)),这样将连续增长N次字符串所需的内存重分配次数从必定N次降低为最多N次。**另一方面使用惰性空间释放**,当缩短SDS时,不会释放多出来的字节数(但是也提供了释放SDS未使用空间的API)。
3.二进制安全:所有SDS API都会以处理二进制的方式处理SDS存放在buf里的数据,因此不仅可以保存文本数据,也可以保存任意格式的二进制数据。
4.兼容C字符串函数:可以兼容、重用部分C字符串函数

3. 链表

列表键的底层实现之一就是链表。当一个列表键包含了数据比较多的元素,或者每个元素都是比较长的字符串时,Redis就会使用链表作为链表键的底层实现。

除了列表键之外,发布订阅、慢查询、监视器等功能也用到了链表,Redis服务器还使用链表保存多个客户端的状态信息、构建客户端输出缓冲区等。

3.1 链表和链表节点的实现

是双向无环链表,会在内部存储头节点、尾节点、链表长度,非常类似JDK的LinkedList

除此之外,还保存了赋值、释放、节点值对比函数的函数指针,用于实现多态(或者说泛型,那为什么不用cpp啊!!!),方便用同一链表结构存储不同类型的数据。

4.字典

字典又称符号表(symbol table)、关联数组(associative array)、映射(map),是一种保存键值对的抽象数据结构。

用于Redis的数据库(Redis本身就是一个键值对映射的数据库)的底层实现,哈希键(所有的“键”都是指数据库里这种类型的表保存的数据)的底层实现之一(当又多又长时)。

4.1 字典的实现

哈希表是字典的底层实现。

上面还说字典是哈希键的底层实现之一呢!不要搞混,这里说的哈希表是一种数据结构,而哈希键是Redis的一种表的类型!

4.1.1 哈希表

哈希表的内部结构包含:哈希表数组、表大小、实际元素数量、大小的掩码,定义如下

struct dictht {
    dictEntry **table;//哈希表数组
    unsigned long size;//哈希表大小,一般为2的幂次
    unsigned long sizemask;//哈希表大小掩码,总是等于size-1,用于计算索引值
    unsigned long used;// 实际节点数量
}

这里的哈希表实现很类似JDK中的HashMap,表大小都是2的幂次,大小的掩码则是 “表大小-1”.这么做的原因是,哈希值到列表idx的映射是线性(取余)映射,还采用链表来解决冲突问题,所以执行 “哈希值 & 掩码” 就可以快速计算出存储的idx。

4.1.2 哈希表节点

哈希表节点由下面的结构体定义:

struct dictEntry {
    void *key;
    union{
        void *val;
        uint64_t u64;
        int64_t s64;
    }v;
    struct dictEntry *next;
}

可以看到,每个节点是由链表串起来的——但是这不完全对!准确地说,这是由于它的冲突解决策略是用链表将哈希值相同的节点链在一起,所以实际上,整体哈希表数组(是一个dictEntry**变量,两级指针哦!)是一个数组,而数组内存储的都是指向哈希表节点的指针。

此外,其内部的值可以是一个指针,或者64位带符号/无符号整数。

4.1.3 字典

字典的定义如下

struct dict {
    dictType *type;// 类型,但是实际上是类型特定的处理函数
    void *privdata;// 一些私有数据,和类型有关,需要一同传给类型特点的处理函数
    dictht ht[2]; // 存储了两个哈希表对象,一般只会用[0],在[1]中会存储正在rehash的表
    int trehashidx; // 用于渐进式rehash算法。在没有rehash时,为-1
}

4.2 哈希算法

根据字典类型特定的哈希函数计算哈希值,通过线性取余的方式映射到hash表的索引值,然后放入对应的位置(为了效率,会放在链表的第一个)

计算哈希值的方式,采用MurmurHash2算法来实现

4.3 解决键冲突

冲突是指有两个或以上的键被分配到了哈希表数组的同一个索引上。

采用链地址法解决冲突

4.4 rehash

当键太多或太少时,都可能进行rehash,以保持负载因子维持在合理的范围内。

回忆一下刚刚的字典结构体,里面存着两个hash表——其中第二个hash表,就是为了rehash准备的

1.为第二个哈希表分配空间,分配的大小一定是2的整数次幂;
2.将存在ht[0]内的键值对rehash到ht[1]上
3.当ht[0]空了之后,释放它,令ht[0]=ht[1],并为ht[1]创建新的空白hash表。

对hash表的扩展操作会发生在:负载因子大于1或大于5时(对应条件是没有/有后台进行数据写入时,为了尽量节约内存)

对hash表的收缩操作会发生在:负载因子小于0.1时

4.5 渐进式rehash

这是一个很特色的功能。为了避免rehash对服务器性能(这里应该特指响应时间)造成影响,服务器分多次将ht[0]迁移到ht[1]。具体来说,是以下的步骤:

rehash开始后,rehashidx=0(没有rehash时,它为1);
每次对字典进行操作时,顺带会将处于ht[0]中索引为rehashidx的元素rehash到ht[1]上,并将rehashidx增一(注意对于所有的插入操作,都会直接插入ht[1],ht[0]的元素只减不增;此外对于查找操作,在ht[0]&ht[1]中都会进行);
总有一天,rehash会结束的,这回将h[1]换到h[0]上,rehashidx=-1

5. 跳跃表(skiplist)

跳跃表(有时也简称跳表)是一种有序数据结构,通过在每个节点内维持多个指向其它节点的指针,从而达到快速访问节点的目的。它支持平均O(logN),最坏O(N)复杂度的节点查找,还可以通过顺序性来批量处理节点(?)

在大部分情况下,跳表的效率可以和平衡树媲美,由于其实现更加简单,因此不少程序使用跳表来替代平衡树。

跳表是有序集合键的底层实现(当又多又长时)之一,同时还被用于集群节点中的内部数据结构。在其他地方没有应用。

5.1.1 跳表节点

跳表&跳表节点的数据结构定义如下:

struct zskiplist {
    zskiplistNode *header; // 指向表头节点
    zskiplistNode *tail; // 指向表尾节点
    int level; // 节点中层数最大的节点的层数
    int length; // 跳表节点数量(不算表头)
}

struct zskiplistNode {
    struct zskiplistLevel {
        struct zskiplistNode *forward;
        unsigned int span;
    } level[];// 层的数组

    zskiplistNode *backward; // 后退指针,指向当前节点的前一个节点,用于反向遍历
    double score; // 分数,用于排序,注意跳表在Redis中是有序的!
    robj *obj; // 成员对象
}

每次创建一个节点时,都会根据幂次定律(越大的数出现概率越小)生成1~32之间的值作为level数组的大小,也即是层的高度。

跨度

在查找某个节点的过程中,将跨度累加,就可以知道当前节点的索引

分值和成员

节点的成员是一个指针,指向SDS对象(不是简单的SDS,而是SDS对象)

分值存储一个double,跳表中所有节点都根据分值排序

当节点的分值相同时,会根据成员的字典序排序

6. 整数集合

整数集合是集合键的底层实现之一(当一个集合只包含整数,且数量不多时)

6.1 整数集合的实现

intset是Redis用于保存整数值的集合。可以保存16 32 64位有符号整数值,并且保证集合中不会出现重复元素。

struct intset {
    uint32_t encoding; //该集合内部存储的是多少位的整数
    uint32_t length;//集合元素数量
    int8_t contents[];//实际上是字节数组,存储集合数据,各个元素从小到大排序,而且不会有重复元素
}

6.2 数组编码的向上转型

原书中这一节名为“升级”,但是个人觉得用向上转型更能体现这个术语的真正含义。

当一个新元素添加到整数集合中,如果新元素的类型比集合的数据类型要长时(如将64位int插入了32位的整数集合中,而且这个数真的无法用当前的编码表示才行。如果同样是-1这个数,用32位和用64位表示并插入集合中,行为上不会有任何的差别),那么整数集合要向上转型。

向上转型的步骤如下:

计算 新元素的字长x数组长度 ,重新分配底层数组大小;
将旧元素和新元素都放置在合适的位子上(实际上新元素不然处于第一位,不然处于最后一位,因为这个数的编码长度比集合现存所有数字都要长)

因此,向集合添加元素的最差时间复杂度为O(N).

6.3 向上转型的好处

节约内存

6.4 不支持降级操作

7. 压缩列表

当列表键、哈希键又少又短时,会采用压缩列表作为它们的底层实现。

压缩列表是为了节约内存、节约存储空间而定义的

7.1 压缩列表的构成

压缩列表是一个串行结构,存储在连续内存块内,可以用下方的伪数据结构表示:

struct ziplist {
    uint32_t zlbytes; // 整个压缩表占用内存字节数。注意,可能会有空闲字节!
    uint32_t zltail; // 表尾节点的开头
    uint16_t zllen; // 节点数量
    struct entry {
        (1,5 Bytes) previous_entry_length;// 上一个节点的长度,用于向前遍历,小于254为1字节表示,否则用5字节表示
        (1,2,5 Bytes) encoding;// 表示content内的数据类型和数据长度
        (int or char[]) content;// 实际数据
    } entry[];// 这是个伪数据结构,括号里表示类型可能为哪几种
    uint8_t free_space[];// 不清楚有没有空闲空间?
    uint8_t zlend = 0XFF;// 结尾字符,固定
}

其中,可以看到存储数据的时候,每个节点的长度不一。如previous_entry_length就会根据上一个节点的长度设置。

7.3 连锁更新

由于压缩列表内具有不定长字段,而且不定长字段的长度和数据的长度有关。考虑下面的情况:

每个元素长度都在250~253字节之间,这一条件还隐藏着所有的previous_entry_length都是1字节;
将一个长元素插入到表头(这一步不梦幻吗?连续内存空间,能不声不响地插到表头……应该是表示重分配空间后,插入到列表头,其它元素依次后移动);
更新其后元素的previous_entry_length,从1字节变为5字节;
哟,现在这个元素的长度变为254~257之间了,那么后续节点也需要更新previous_entry_length……

这样会导致连锁更新,因此插入的复杂度最坏情况下会执行N次内存重分配,附带还有遍历一遍列表元素,把每个的previous_entry_length都重新计算一遍。

此外,删除节点也可能导致连锁更新:假如有三个节点,分别为:长-短-短-……-短。那么删除第一个短节点时,也会导致这种情况发生。

但是实际上,这两个前提条件都很难满足。因此总体来说,对性能的影响可以忽略。

8. 对象

之前的章节中,介绍了redis的所有数据结构。但是Redis并没有采用这些数据结构实现数据库键值对系统,而是在这些数据结构之上,构建了一个对象系统。

对象系统包括字符串对象、列表对象、哈希对象、集合对象和有序集合对象这五种。

使用对象而不是底层数据结构的原因是:对象表明了用途,而数据结构是底层存储。同一个对象,可以根据数据量的多少等条件,选择不同的底层存储方式,此外,对象还实现了基于引用计数的内存回收机制&对象共享机制

8.1 对象的类型和编码

每次在数据库内创建一个键值对时,都会至少创建两个对象,一个键对象,一个值对象

每个对象都由一个redisObject表示。

struct redisObject {
    unsigned type;//对象类型
    unsigned encoding;//编码,也就是底层使用的数据结构类型
    void *ptr;//指向底层数据结构的指针
    int refcount;//引用计数
    unsigned lru;//最后一次被访问的时间,查询这个值的命令不算在内
}

8.1.1 类型

数据库内的键总是字符串对象,而值对象为字符串对象、列表对象、哈希对象、集合对象和有序集合对象这五种之一。

当执行TYPE指令时,返回的结果是值对象的类型(键对象永远是字符串对象,无意义)

8.1.2 编码和底层实现

encoding表明了ptr指向的底层数据结构的类型。分为以下几种:

long //整数,一定是一个long
embstr //字符串的紧凑型编码方式,直接分配连续内存存储SDS,只读
raw //字符串的正常编码方式,也就是SDS,和embstr的结构一样,只不过buf是动态分配而不是栈上分配的
ht //字典
linkedlist
ziplist
intset
skiplist
类型编码备注
STRINGINT EMBSTR RAWINT编码是用于存储整型的,Redis中并没有整型这个对象!
LISTZIPLIST LINKEDLIST又小又短,压缩表
HASHZIPLIST HT又小又短,压缩表
SETINTSET HT又小又短,整数数组。如果使用HT,那么val指针都为NULL
ZESTZIPLIST SKIPLIST又小又短,压缩表

8.2 字符串对象

embstr是只读的,对它的任何修改都会变成raw编码;

字符串对象可能会嵌套在其他对象之中。

8.3 列表对象

8.3.1 编码转换

同时满足以下两个条件时,列表对象使用ziplist编码,否则,使用linkedlist:

所有字符串元素的长度都小于64字节;
列表保存的元素小于512个;

需要额外指出的是:这两个参数是redis的默认参数,可以在配置文件中调整。

8.4 哈希对象

对于ziplist编码的哈希对象,会将同一键值对(键+值)紧挨着存入ziplist。会按插入的顺序放入ziplist。

对于以字典为底层存储,则字典内部每个键值都是字符串对象。

8.5 集合对象

编码对象可以是intset和hashtable。

如果是hashtable,则所有的val全都为NULL

8.6 有序集合

这个有序的“序”,并不是像JAVA中的LinkedHashMap一样,维护插入时的顺序,而是一方面要对每个元素按节点值大小进行排序,另一方面要实现常数时间查找。所以这里的“序”是指节点按值从小到大排序。

有序集合内部也会存储score。可以执行按键找值的操作,也可以执行范围型操作。

编码可以是ziplist和(skiplist+ht)。

后者可以实现按键找值O(1),范围型操作O(N)。使用这两者一起的原因是,范围型操作时避免排序。

8.7 类型检查与命令多态

Redis中用于操作键的命令分为两种,一种可以对任意类型的键执行,另一则是对特定类型才能执行。

命令执行时,会检查类型是否正确。由于对象内存储了类型相关的操作函数,因此可以根据不同的底层数据结构进行不同的操作。

8.8&8.9 对象共享&引用计数

针对这两个知识点谁在前谁在后的问题,我个人觉得,对象共享是原因,引用计数是为了解决对象共享的内存释放问题而提出的解决方案。

在redis的一些对象,如有序集合中,会使用skiplist&ht来实现对象,而每个节点都保存着对值对象的引用,从而节约空间。这样的好处显而易见,但是坏处就是,如何知道何时释放对象呢?

常用的垃圾回收方法有引用计数和可达性分析法。前者的特点是实现简单,但是无法处理循环引用的问题;后者实现则比较复杂,计算代价也很高,但是可以处理循环引用问题。大部分带有垃圾回收机制的语言都是采用后一种方式进行垃圾回收,但是Redis采用了前者,为什么呢?

这件事的关键在于,Redis中,可能出现循环引用问题吗?答案是,不可能。在Redis的世界里,字符串对象是最底层的对象,不会引用其它对象;字符串之外的对象只有可能引用字符串对象,而不会嵌套引用。所以这决定了Redis的对象引用关系不会成环,这样使用简单的引用计数就可以又快又好地解决内存回收问题了。

每个对象都有一个redcount变量,标记这一对象被引用的次数。

Redis在初始化服务器时,会创建1w个(可以通过设置文件调整)字符串对象,包含了09999所有的整数值(也就是字符串对象,整数编码)。当服务器需要使用这范围内的整数值时,会直接引用这些对象。JDK内部也有类似的实现,比如字符串的intern()方法,以及缓存-128128之间的Integer等。

但是除此之外,Redis就不共享(这里的共享特指验证一个指定值的对象是否存在,若存在则不创建,而是指向对其的引用)其它更复杂对象了——因为虽然共享复杂对象更能节约内存,但是验证相等的操作非常费时间,得不偿失。因此Redis只会共享包含整数值的字符串对象(O(1)级别)

8.10 对象空闲时长

如果服务器打开了maxmemory选项,并且服务器用于回收内存的算法为lru,那么当服务器占用的内存超过了限制,空闲时长较高的那部分键会优先被服务器释放回收内存。

9.数据库

9.1 服务器中的数据库

服务器的数据库存储在数组中,默认创建16个数据库。

客户端可以根据SELECT指令来选择数据库,实际上在服务器中,客户端的状态由redisClient表示。

struct redisServer {
    redisDb *db;//数组,保存服务器中所有的数据库
    int dbnum;//数据库总数
}
struct redisClient {
    redisDb *db;//记录客户端当前正在使用的数据库
}

9.3 数据库键空间

Redis是一个键值对数据库服务器,每个数据库都由redisDb结构表示,其中的dict字典保存了数据库中的所有键值对,这个字典被称为键空间。其键为数据库的键,字符串对象;值为数据库的值,可以是五大对象之一。

所有对Redis的某个键的操作,都是对键空间进行操作。

struct redisDb {
    dict *dict;//数据库键空间,保存着数据库中所有的键值对
    dict *expires;//保存每个(时效)键的过期时间。其键为指向键对象的指针,值为过期时间,long整数
}

9.4 设置键的生存时间或过期时间

使用EXPIRE/PEXPIRE命令可以以秒/毫秒精度设置某个键的生存时间(TTL)。

同样可以使用EXPIREAT/PEXPIREAT实现在某个时间点过期。

实际上这些命令都是通过PEXPIREAT命令来实现的。因为系统内部的时间戳一般以ms为单位,而判断是否过期只要对比当前时间戳和过期时间戳的相对大小即可,免去多次加减法。

redisDb中的expires字典保存着所有键的过期时间。

9.5 过期键的删除策略

惰性&定期删除相结合。

惰性:所有读写数据库的命令执行之前都会调用expireIfNeed()函数,检查自身是否过期,若过期,则删除键,返回键不存在的错误

定期:activeExpireCycle函数实现。它会分多次遍历所有数据库,从每个数据库中随机检查部分键的过期时间,删除其中的过期键。请注意这里的分多次,Redis可以设定该函数的最大执行时间,若超过时间,则会自动跳出循环,下次遍历时会选择上一次遍历的下一个数据库开始进行遍历。

9.7 RDB AOF 复制对过期键的处理

RDB:已过期的键会在保存和加载(因为保存的时候可能有的时效键还未过期,但是等到加载时就过期了)的时候都进行检查,如果已过期,不会保存、加载(准确地说,主服务器不会加载,但是从服务器会不检查时效全部加载)。

AOF:如果键过期,会追加一条DEL指令。如果是重写AOF,则不会追加过期键的任何信息。

复制:主服务器删除过期键时,会向所有的从服务器发送一条DEL指令。从服务器不会检查键是否过期。

9.8 数据库通知

可以让客户端订阅消息来获知数据库内键的变化。如:订阅对xx键做的任何操作,或者订阅任何键做的xx操作(其中xx表示由用户指定)。

观察者模式,在每个操作执行完后,会检查是否有注册的观察者,有则进行通知。

10&11. 持久化

Redis是内存数据库,一旦程序退出,数据库状态也就消失了。因此提供了RDB持久化功能。

Redis内的持久化分为RDB和AOF持久化两种。前者是将整个键空间压缩并存放,有点类似MySQL(准确地说是InnoDB)中的redo日志,是物理日志(也就是记录了内存中实际的存放数据)。后者是将Redis执行的修改语句一句一句追加(甚至干脆追加的都是ASCII码,可读)在AOF文件的末尾,有点类似MySQL中的二进制日志,是逻辑日志(也就是按顺序记录进行过操作,即使插入的某个键后面已经被删除,逻辑日志还是会记录这次插入和这次删除)

10.RDB持久化

RDB文件可以将Redis在内存中的数据库状态保存到磁盘里,一般以rdb为后缀名。

所谓RDB文件,也就是压缩过的二进制文件。

有两个用于生成RDB文件的命令,一个是SAVE,另一个是BGSAVE。区别在于前者阻塞整个服务器进行保存,后者开启子进程进行保存。当BGSAVE命令执行时,redis会阻塞其它相关的SAVE&BGSAVE操作,避免竞争。

可以设置多个自动保存条件,其中任意一个满足就会执行BGSAVE。如:

# 以下配置表示,900s内至少进行一次修改、300s内至少进行10次修改、60s内至少进行1w次修改,这三者满足其一,自动保存。
save	900	1
save	300	10
save	60	10000

11.AOF持久化

AOF持久化是通过记录Redis服务器的写命令来记录数据库状态的。

11.1 AOF持久化的实现

服务器每执行一个修改指令,都会在aof_buf的末尾追加这条命令。

Redis的定时任务的末尾都会写入AOF文件,并根据设置决定是否要同步AOF文件(也就是执行fsync函数)

可选AOF持久化的同步方式,分为总是(实际上定时任务100ms执行一次,所以约等于0.1s),每秒,从不这三种。

11.2 AOF文件的载入与还原

服务器启动时通过执行AOF中所有的指令,来还原数据库状态。需要创建一个伪客户端,socketId=-1来执行。

11.3 AOF重写

指令:REWRITEOF/BGREWRITEOF

由于AOF只是单纯地追加命令,因此随着时间增长,文件会越来越大,启动时恢复的代价也会越来越高。因此可以使用AOF重写功能对AOF进行压缩——实际上,并不会重写现有的AOF文件,而是创建一个新的AOF文件,将键空间的所有数据都以命令的方式插入一遍。这样可以合并多条指令,并且去掉无效的指令(如之前插入后,过期了或被删除了),从而缩小AOF文件。

在进行AOF后台重写时,会设置AOF重写缓冲区,每执行一条写指令,不仅会向AOF缓冲区内写入,还会向AOF重写缓冲区内写入这一指令。当重写结束后,将重写缓冲区中的内容追加到AOF文件中(但是,仅仅靠这个数据不一致问题似乎还没解决哦,假如执行的语句,对还未重写的键进行了修改,那AOF实际上会记录这次修改,而且会将其重写到文件中,这不就相当于执行了两次这个指令了嘛!所以应该是作者忽略了一些实现细节)

12.事件

Redis服务器是一个(单线程的)事件驱动程序,需要处理以下两类事件:

文件事件:也就是处理与客户端的socket连接事件;
时间事件:处理定时事件

12.1 文件事件

Redis基于Reactor模式开发了自己的网络事件处理器。其采用IO多路复用程序来同时监听多个套接字,并根据执行得任务为套接字关联不同的事件处理器。事件有:应答、读取、写入、关闭等。

文件事件处理器以单线程方式运行。

12.1.1文件事件处理器的构成

由套接字、IO多路复用程序、文件事件分派器、事件处理器构成。

IO多路复用程序会将所有产生事件的套接字都放到一个队列中,然后有序、同步、每次一个地向文件事件分派器传送套接字。

redis在编译时,会自动选择性能最高的多路复用API实现多路复用程序。

12.1.3 事件的类型

主要是可读和可写两种。其中可读是指:有新的可应答的套接字或新数据可读时。可写是指:客户端对套接字执行read操作。

12.1.5 文件事件处理器

最常用的为连接应答处理器(执行accept成功的回调,一直保持关联)、命令请求处理器(执行read成功的回调,一直保留关联)、命令回复处理器(执行命令成功后可写数据信号出现的回调,一次性关联)。

12.2 时间事件

主要有定时事件和周期时间两类。

每个时间事件都有以下几个部分组成:id,触发时间,时间事件处理器。

服务器将所有的时间事件(实际上一般只有serverCron一个时间事件,100ms触发一次),按事件注册的先后顺序(而不是触发时间的先后顺序)放置于链表中。

12.2.3 serverCron函数

他的主要工作包括:

更新服务器各类统计信息;
清理数据库中过期键值对(9.5中,是遍历所有数据库并采样数据库键值实现的)
关闭、清理链接失效客户端
持久化操作
若为主服务器,则对从服务器进行定期同步
若处于集群模式,对集群进行定期同步和连接测试

触发时间默认为100ms,可调。

12.3 事件的调度与执行

redis对文件事件和时间事件的调度策略如下:

获取距离当前事件最近的时间事件,并计算距离该事件到达还有多少ms,记为remain_ms;
阻塞并等待文件事件的产生,这里调用的是带超时参数的阻塞函数,最长阻塞remain_ms毫秒;
处理所有已产生的文件事件;
处理所有已到达的时间事件

需要注意的是:由于redis在单线程内执行这些步骤,也没有任何的抢占式调度机制,因此事件需要主动的让出执行权。比如当文件事件执行时间超过限度时,则会自动退出该文件事件,从而降低事件饥饿的可能性。此外,对于非常耗时的持久化操作,会放到子线程或子进程中执行。

由于非抢占式调度策略,实际定时任务的执行时间总是比设定的稍微晚一些。

13.客户端

对于每个与服务器连接的客户端,服务器都会为其建立相应的redisClient结构,其中包括客户端的套接字、名字、正在使用的数据库指针、正在执行的命令、输入输出缓冲区、事务状态、发布订阅状态、身份验证标志、创建时间通信时间等时间信息……

所有的客户端redisClient都会记录在redisServer的clients链表结构内。

13.1 客户端属性

13.1.4 输入缓冲区

用于保存客户端发送的命令请求。例如

SET key value
*3\r\n$3\r\nSET\r\n$3\r\nkey\r\n$5\r\nvalue\r\n

大致可以猜出,第一个3表示子命令个数,每个子命令前面的数字表示该命令字符串长度,然后每个属性都采用\r\n隔开。

输入缓冲区会根据输入内容动态地扩大或缩小(如果设置为可变大小的话。也可设置为固定大小)。如果太大,服务器将关闭这个客户端。

13.1.5&6 命令参数及实现函数

服务器根据用户输入,进行切割得到子字符串数组,在命令表中查找命令对应的实现函数。

13.2 客户端的创建与关闭

13.2.1 普通客户端

当发送如下情况之一,客户端会被关闭:

客户端主动退出;
客户端发送不符合协议格式的命令请求;
客户端成为了CLIENT KILL命令的目标;
客户端空转时间超过设定的timeout属性(也可以不设定,那就不会超时);
客户端发送命令请求超过了输入缓冲区限制;
发送给客户端的命令回复大小超过了输出缓冲区限制。

13.2.3 Lua脚本的伪客户端

负责执行Lua脚本的伪客户端,在服务器运行的生命周期中会一直存在

13.2.4 AOF文件的伪客户端

载入AOF时,用于执行AOF中包含的redis命令的伪客户端,载入完成后被关闭。

14.服务器

14.1 请求命令的执行过程

发送命令请求:Redis客户端将用户键入的命令转换成协议格式,发送给服务器

读取命令请求:触发可读事件,服务器分析输入缓冲区的命令,提取出命令参数,调用命令执行器

命令执行器:

通过命令类型(也就是第一个子字符串)查找命令处理器,该过程是大小写不敏感的;
执行预备操作,检查是否找到对应命令处理器,参数个数是否正确,是否通过身份验证,检查服务器内存占用情况(如果设定了maxmemory功能),发送命令和参数给监视器等;
调用命令的实现函数,该函数内部还会将客户端套接字关联命令回复处理器,由后者将命令回复给客户端;
执行后续工作,如记录慢查询日志,将命令写入AOF缓冲区,将命令传播给从服务器等

将命令回复发送给客户端;

客户端接受并打印命令回复

14.2 serverCron函数

该函数默认100ms执行一次(可修改),负责管理服务器资源、保持服务器自身良好运作。

他的主要工作如下:

更新服务器时间缓存:获取一次系统时间并缓存起来,避免一些需要查询时间函数的进行频繁的系统调用降低效率。可以轻松得知,这个时间缓存的精确度为100ms,因此只会用于日志打印、lru、持久化计时等场景下。对于键过期、慢查询日志等需要高精度时间的功能,它们还是会执行系统调用重新获取系统时间;
更新LRU时钟:lru时钟也是时间缓存的一种,用于计算键的空闲时间;
管理客户端资源:检查客户端是否超时、读写缓冲区是否超过长度
管理数据库资源:删除过期键、收缩字典等
执行被延迟的BGREWRITEOF:在BGSAVE时,后台重写AOF会被延迟到BGSAVE执行完之后进行
更新服务器每秒执行命令次数、内存峰值记录、处理SIGTERM信号、检查持久化操作、写AOF缓冲区到文件、增加cronloops计数值……

14.3 初始化服务器

初始化服务器状态结构:创建redisServer实例存储服务器状态,如设置服务器id,默认配置文件路径,cpu架构、默认端口号等。注意,这一步只是创建,还需要根据启动参数和配置文件进行修改后才会真正启动。

载入配置:根据启动时的命令行参数和指定的配置文件来修改服务器默认配置,如端口号等;

初始化服务器数据结构:根据最终的服务器配置信息来创建数据结构,如db数组的数量,clients链表,lua客户端,共享字符串对象……最终,在控制台中打印Redis的图标及一些启动信息。

还原数据库状态:通过RDB或AOF还原数据库状态

执行事件循环:开始执行服务器的事件循环(12章内)

多机数据库的实现

第15 16 17章都是多机数据库的实现。这三章的主要关系如下:

15.复制:介绍了主从服务器的概念,以及主从服务器数据同步的方法

16.Sentinel:介绍了Sentinel机制是如何保障主从服务器高可靠性

17.集群:介绍了Redis的集群功能,尤其是它的分片技术:将键分配给固定数量的槽,而每个集群中的主节点保存不相交的槽、所有主节点的槽数量之和等于槽总数,相当于将不同的键存入不同的服务器中。

这三章中,复制、Sentinel、分片都只是技术点,而集群则将它们汇聚在一起,成为Redis的分布式数据库方案:集群中每个主节点需要有从服务器,主从之间都需要进行复制,而主从服务器之间的高可用性保证使用类似Sentinel的机制实现。

???????

15.复制

在Redis中,用户可以执行SLAVEOF或者设置salveof选项,让一个服务器去复制另一个服务器。我们称前者为从服务器,后者为主服务器。命令使用方式:

SLAVEOF <ip> <port>
如:SLAVEOF 127.0.0.1 6379
其中,SLAVEOF no one表示取消从服务器状态,转换为主服务器。

进行复制中的主从服务器双方的数据库将保存相同的数据。

以2.8版本为界,之前的版本都采用旧版复制,如果不小心丢失连接,则需要复制主服务器的整个数据库;而之后的版本采用新版复制,通过部分重同步来解决旧版复制低效的问题。

15.1 旧版复制功能的实现

复制功能主要分为同步和命令传播两个操作。

复制将从服务器的数据库状态更新至主服务器当前所处的数据库状态;

命令传播用于主服务器状态被修改时,导致主从服务器状态不一致时,让主从服务器的数据库重新回到一致。

15.1.1 同步

当客户端向从服务器发送SALVEOF指令,从服务器首先要执行同步操作。

从服务器对主服务器的同步操作需要通过向主服务器发送SYNC指令实现,其命令执行步骤为:

1.从服务器发送SYNC命令
2.收到命令的主服务器执行BGSAVE命令,后台生成RDB文件,并使用一个缓冲区记录从现在开始执行的所有写命令;
3.主服务器将生成的RDB文件发送给从服务器,从服务器接受并载入这个RDB文件,但是这只能将自己的状态更新到主服务器开始执行BGSAVE命令时的数据库状态;
4.主服务器将记录在缓冲区内的所有写命令都发送给从服务器,从服务器执行这些指令,从而将自己的数据库状态更新到主服务器当前的状态。

15.1.2 命令传播

主服务器会将自己执行的写命令,发送给从服务器执行

15.2 旧版复制功能的缺陷

在Redis2.8以前,出现以下两种情况之一时,都需要进行同步操作:

初次同步:当从服务器之前没有复制过任何主服务器,或者需要复制的主服务器和上一次复制的不同;
断线后同步:处理命令传播阶段的主从服务器由于网络原因中断了复制(而且在中断的过程中,主服务器执行过一些写指令),但从服务器又重新自动连接上了主服务器,并继续复制主服务器。

对于断线后复制的情况,可以想象,其执行效率是很低的。也许主从服务器之间只有很小的一部分键值的差异,但是需要执行额外的保存RDB、发送RDB、载入RDB等工作。

15.3 新版复制功能的实现

采用PSYNC替代SYNC命令。其中P的含义是partial,部分重同步。

PSYNC既可以执行完整重同步,也可以执行部分重同步。前者发生在初次同步时,而后者发生在断线后同步时,只需要将断线过程中主服务器执行过的少量命令发送给从服务器,即可保证主从服务器同步。

15.4 部分重同步的实现

部分重同步实现的关键有三:

主从服务器的复制偏移量:统计主服务器传播字节数和从服务器接收的传播字节数,用于定位从服务器接收到的最后一条传播指令;
主服务器的复制缓冲区:具有固定大小(所以需要根据自己的需求,修改这一参数)的FIFO队列,缓存了主服务器最近执行的命令,用于向断线时间不长的从服务器传播丢失的命令;
服务器的运行ID:用于判断本次和上次复制的是否是相同的主服务器。若不是,则执行完整重同步。

15.5 PSYNC命令的实现

当从服务器第一次复制主服务器时,略。

当从服务器断线重连时,

从服务器发送:
	PSYNC <runid> <offset> 			// 指明上一次复制的服务器id和复制偏移量

主服务器检查runid是否与自己的id相同,且offset开始的命令是否还在复制缓冲区内,会有以下三种回复:
	+FULLRESYNC <runid> <offset>	//执行完整同步
	+CONTINUE					  //执行部分重同步
	-ERR						  // 主服务器版本低于2.8,不支持PSYNC命令

15.6 复制的实现

当客户端向服务器发送SLAVEOF命令时,服务器首先将服务器的IP和port存入redisServer的masterhost&masterport变量中,返回客户端OK;

从服务器连接设定的ip和端口,为这个套接字关联一个处理复制工作的文件事件处理器。主服务器接受从服务器的连接,并把它看作自己的客户端;

从服务器发送PING命令,主服务器回复PONG(挺有趣的,原来“乒”对应“乓”啊),以确定双方的网络状态和读写状况;

身份验证(如果主从服务器都设置了需要身份验证的话);

从服务器发送PSYNC指令,开始同步。开始同步之后,主服务器也成为从服务器的客户端,以便向其发送命令(如命令缓冲区内的指令);

同步成功,进入命令传播阶段

15.7 心跳检测

在命令传播阶段,从服务器会默认以每秒一次的频率向服务器发送命令:

REPLCONF ACK <replication_offset>

这一命令有两大功能:作为心跳包检测双方连接状况,根据offset和主服务器的offset对比,检查命令是否丢失(也就是网络不好,丢包的情况,需要执行部分同步)

16.Sentinel

Sentinel是哨兵、哨岗的意思,在Redis多机应用中起着监视主服务器及主服务器下属从服务器的作用。当监视的主服务器下线时,可以将主服务器下属的某个从服务器升级成主服务器,由新的主服务器替代已下线的主服务器继续处理命令请求。

16.1 启动并初始化Sentinel

需要在启动时指定redis运行在Sentinel模式下。Sentinel不使用数据库,因此启动时无需加载RDB/AOF文件。

初始化时需要创建连向被监视的主服务器的两条网络连接。

命令连接:即Sentinel成为了主服务器的客户端,可以向它发送命令获取信息。
订阅连接:订阅主服务器的 _ _sentinel_ _:hello 频道,各个sentinel会向主服务器的这个频道发送信息,sentinel可以根据这个订阅连接来发现监视这个主服务器的其它Sentinel的信息。

从下文可以看到,命令连接可以使S自动发现主服务器下属的所有从服务器(当然,额外的控制作用也是少不了的),而订阅连接可以让监视同一主服务器的S发现彼此。

16.2&3&4&5 Sentinel获取主从服务器及其它Sentinel的信息

获取主服务器信息

Sentinel默认以十秒一次的频率,通过命令连接向主服务器发送INFO指令,获取主服务器当前的信息。这些信息包括主服务器自身的信息以及主服务器下属其它所有从服务器的信息。这样Sentinel可以自动发现从服务器。这些信息都会被存储在Sentinel内。

获取从服务器信息

当Sentinel发现主服务器有新的从服务器出现时,除了记录从服务器的信息之外,还会创建连向该从服务器的两条连接。

向主从服务器发送消息

每2s,向 _ sentinel _:hello频道发送Sentinel本身的信息以及主服务器的信息

接收来自主从服务器的频道信息

每个与服务器相连的S,既会订阅频道,也会向这个频道发送消息。这样,每当一个新的S向频道发送消息时,所有订阅了频道的S也会收到这条消息,这样S就可以发现同样监视这个服务器的其它S的信息。

连接其它S

每个S都会保存其它S的信息,并发起连接(相互连接,互相作为对方的客户端),这样每个监视同一服务器的S都能相互发现彼此。

16.6&7 检测下线状态

主观下线

默认情况下,Sentinel会向每个创建了命令连接的实例发送PING命令,检查实例是否在线。当超过指定时间长度(配置文件中指定,每个S都可能不一样)没有回复时,就会将该实例置为主观下线状态

客观下线

当将一个主服务器判定为主观下线后,会向其它监视这一主服务器的S询问它的下线状态。

当有一定数量(也是通过配置文件配置,是一个绝对数量)的S判定这一服务器主观下线后,这个S将其置为客观下线

16.8 选举领头S

当一个主服务器被判定为客观下线时,所有监视它的S会进行协商,选出一个领头S,并由它执行故障转移(即选出一个从服务器替代下线服务器)操作。

选举要求在指定时间内必须有超过(也就是大于)半数的S同意某一S确立为领头S,选举才能有效——因此,选举会进行好几轮(epoch)。当一轮投票无效时,所有的S会过段时间继续选举。

选举的投票机制也比较简单,每个想要成为领头S(也就是发现了客观下线的S们)主动向其它S发送拉票请求,而每个接到拉票请求的S会将票投给收到的第一个拉票的S(实质上,不就是说把票投给发现得最早、跟自己连接最快的那个S嘛)

16.9 故障转移

包含以下三个操作:

选出新的主服务器

S中具有所有的从服务器信息。从所有上线的从服务器中,选出一个最合适的从服务器。选择条件有如下这些(由主到次):最近成功通信过的(连接质量好)、最近和主服务器未断开连接(保证数据比较新)、从服务器的优先级、复制偏移量、运行ID(实在没得挑了,那就按名字笔画排序吧)

发送 SLAVEOF no one 命令,将其设定为主服务器

修改从服务器的复制目标

将剩下的其它从服务器的主服务器都改为新的主服务器。也即发送:

SLAVEOF <newip> <new port>

将旧的主服务器变为从服务器

这一步是非常显然的,就像是一个新的从服务器上线了嘛——

但是仔细思考一下,这个旧的服务器重新上线后,他并不知道自己已经成为了从服务器了,也不知道新的主服务器是谁。假如有其它客户端继续连接这个服务器,那就会造成数据不一致(因为他已经不再是主服务器了)。而S知道它的历史,也知道这个世界的变化,因此需要S主动告诉他世界变了,身份变了,自己要找的大哥是谁。

因此,这一步也是故障转移中非常重要的一步

17.集群

Redis集群是Redis提供的分布式数据库方案。集群通过分片来进行数据共享,并提供复制和故障转移功能。

集群通过分片来进行数据共享,并提供复制和故障转移功能。

17.1 节点

一个Redis集群通常由多个节点构成,在刚开始时,每个节点都是相互独立的,都处于一个只包含自己的集群中。连接各个节点可以通过向一个节点发送CLUSTER MEET命令实现

CLUSTER MEET <ip> <port>

其中,ip和端口是新节点的。这样可以将一个指定的节点加入本集群中。

17.1.2 集群的数据结构

工作在集群模式下的节点都有特有的数据结构来保存集群状态:

struct clusterState {
    clusterNode *myself;//指向当前节点的指针
    uint64_t currentEpoch;//当前选举的轮次,类似Sentinel
    int state;//集群状态,上线/下线
    int size;//集群内被分配槽的节点数量
    dict *nodes;//集群节点名单,包括myself
    clusterNode *slots[16384];
    // ...其它字段
}
struct clusterNode {
    mstime_t ctime;//节点创建时间
    char name[40+1];//节点名,40字节的字符串,在启动时生成
    int flags;//位标识符,会标识上下线、主/从节点等
    uint64_t configEpoch;
    char ip[];
    int port;
    clusterLink *link;//保存了连接节点所需的信息,如fd,输入输出缓冲区等
    
    unsigned char slots[16384/8];//存储二进制位数组,共16384个bit,若某个位为1,则表示这个槽由这个节点处理
    int numslots;//slots中为1的位数
    // ...其它字段
}

17.1.3 CLUSTER MEET命令的实现

客户端向集群内的节点A发送CLUSTER MEET指令,令节点B加入节点A所在的集群内。双方会进行握手,创建clusterNode结构加入nodes字典中。

之后,节点A会将节点B的信息通过Gossop协议传播给集群中的其他节点,最终,经过一段时间之后,节点B会被集群中的所有节点认识。

17.2 槽指派

Redis通过分片的方式来保存数据库中的键值对。整个集群拥有16384(=2048*8)个槽(slot),数据库中的每个键都会被分配到一个槽中,集群中的每个节点都可以处理0~16384个槽。

当所有的槽都有节点在处理时,节点处于上线状态;反之,任何一个槽没有被处理,则集群处于下线状态。

节点的槽指派信息

节点的槽指派信息存在clusterNode.slots位数组中,位为1表示该槽由该节点处理。这个数组可以用于高效的传输节点被指派的槽信息(也就是告诉别人,这些坑是我占的)

每个节点都会将自己指派的槽信息告知其它节点,因此每个节点都会知晓集群中其它节点的槽指派信息。

集群的槽指派信息

clusterState.slots数组内保存了当前集群的槽指派信息,提供了槽索引到节点结构体的映射。这样可以高效地知道客户端请求的槽是否被自己处理,并且告知客户端,跳转到哪个节点才能找到请求的槽。

CLUSTER ADDSLOTS命令的实现

该命令接收一个或多个槽作为参数,将所有输入的槽都指派给接收该命令的节点负责。

CLUSTER ADDSLOTS <slot(s)>
可以用...表示略去中间的,如
CLUSTER ADDSLOTS 1 2 ... 1000

接受到这个指令后,节点首先遍历输入的槽,检查这些槽是否都没有被指派过。如果没有,那就再遍历一遍,将这些节点指派给自己

指派后,还会将自己的指派信息告知集群中的其他节点。(会不会有并发冲突?)

17.3 在集群中执行命令

当所有的槽都被指派之后,集群会进入上线状态。

键到槽的映射

采用CRC16算法(是的,就是那个用于生成校验码的CRC16)将键转换成16位的整数(范围从065535,约等于16383的4倍),然后对其取余。这样可以将任意键(也即字符串)转化成016383之间的一个数。

客户端向节点发送数据库键有关的指令

节点首先检查当前键所属的槽是否由自己处理。若是,则处理;反之,查找这个槽所属的节点信息,返回客户端一个MOVED错误,并指引客户端跳转到另一个节点处理该键。

工作在集群状态的客户端会识别MOVED错误,并自动跳转到那个指定的节点执行同样的命令。

MOVED错误

MOVED <slot> <ip>:<port>

处于集群模式的redis-cli客户端会自动处理MOVED错误并连接新的节点。但是处于单机模式的客户端则会显式地打印出这个错误。

节点的数据库

集群节点只能使用0号数据库

同时,节点内除了保存键值对,还会用跳表保存槽和键之间的关系——在一个跳表中,按槽的顺序排列所有键。这样的好处是,可以根据槽号找到对应的键,或者对槽进行批量处理。

17.4 重新分片

重新分片是指将任意数量已经指派给某个节点的槽改为指派给另一个节点。重新分片是在线完成的,不会停止集群的命令请求。

重新分片由Redis集群管理软件redis-trib执行,其大致过程为:

1.通知源节点和目标节点,准备好进行交接;
2.trib获取源节点指定槽内一定数量的键名;
3.对于每个键名,trib都会向源节点发送一条迁移指令,将键一个一个地迁移到目标节点;
4.重复步骤2&3,直到槽内所有的键值对都迁移完成
5.trib向集群内任意节点发送槽的指派命令,这条消息会传播给整个集群,最终所有节点都会知道槽已经被重新分派了。

ASK错误

在重新分片进行中,槽的指派信息并未发生改变。假如此时客户端向源节点请求的键已经被迁移到了目标节点,那么源节点会返回一个ASK错误,引导客户端找到导入槽的目标节点。

但是实际上,向目标节点发送命令之前,还需要向目标节点发送一个ASKING指令。这一指令表示,虽然这个槽不归你负责,但是它在你这里,你跳过槽指派的检查机制,直接把它给我吧。否则,会返回MOVED错误。

17.6 复制与故障转移

集群中的节点分为主节点和从节点。主节点负责处理槽,而从节点负责复制主节点,并在主节点下线时替代下线节点处理命令请求(难道不能顺便处理读的请求吗???)

其中,复制的过程和故障转移的过程和15&16章的原理非常类似。

17.7 消息

集群中各个节点通过发送和接收消息来进行通信。节点发送的消息有以下几种:

MEET:收到CLUSTER MEET指令后,节点会向新节点发送MEET信息,请求接收者假如发送者当前所处的集群中
PING:默认每隔一秒就会从已知节点中随机选出5个节点,然后对这五个节点中最长没发送过PING消息的节点发送PING消息,以检测其在线情况。除此之外,还会选择较长没收到过PONG回复的节点,想起发送PING消息
PONG:PING的回应
FAIL:通知其它节点,某个节点已经下线
PUBLISH:当节点收到该命令时,节点执行该指令,并将集群广播这条PUBLISH消息。

17.7.4 PUBLISH命令的实现

向集群的某个channel广播message,可以向节点发送这一指令

PUBLISH <channel> <message>

第四部分 独立功能的实现

接下来都是一些比较独立的功能的实现,不论在单机还是多机工作模式下都会用到。

18.发布和订阅

由PUBLISH SUBSCRIBE PSUBSCRIBE等命令组成。

PUBLISH:向指定频道发送一条消息
SUBSCRIBE/UNSUBSCRIBE:订阅/退订指定频道,后续有消息发送到这个频道时,订阅者都会收到这条消息
PSUBSCRIBE/PUNSUBSCRIBE:P表示Pattern,模式,也就是可以采用通配符(不如直接说,正则表达式)订阅具有相似模式的话题

18.1 频道的订阅与退订

redisServer中,保存着该服务器频道的订阅关系。

struct redisServer {
    dict *pubsub_channels;//其键名为被订阅的频道名,值为一个链表,记录所有订阅该频道的客户端
}

当有新订阅命令时,服务器会按需添加新的订阅频道键,并将客户端添加到链表尾;

当出现了退订命令时,服务器会将客户端从链表中移除,按需删去订阅的频道键

18.2 模式的订阅与退订

通道模式的订阅信息会存储在pubsub_patterns中。其它原理类似频道

struct redisServer {
    list *pubsub_patterns;//每个模式订阅命令都会成为链表中的一个节点
}

18.3 发送消息

服务器需要执行两个动作:将消息发送给频道所有的订阅者,同时遍历模式订阅者的链表,将消息发给匹配的模式订阅者。

18.4 查看订阅信息

PUBSUB命令可以查看频道或者模式的相关信息。

19.事务

Redis通过MULTI EXEC WATCH命令来实现事务。

Redis由于其简单性、高效性的初衷,因此对事务ACID的支持和传统关系型数据库相比,约等于无。其事务不支持回滚,只能保证一串命令执行的过程中不会插入被别的客户端的命令,同时,遇到错误时(如查询了不存在的键),会直接忽略错误。

19.1 事务的实现

Redis的事务实现通常经历以下三个阶段:事务开始(MULTI指令),命令入队(一串命令敲敲敲),事务执行(EXEC)。

MULTI:标志事务的开始,实际做法是将client的flag进行置位
命令入队:除了MULTI DISCARD WATCH EXEC指令会立刻执行之外,其它指令都会放入一个事务队列内,然后返回客户端QUEUED回复。事务队列存储在redisClient结构中,是一个FIFO的队列。
EXEC:执行事务,遍历事务队列,执行每条命令并缓存执行结果。执行完成后,将执行结果一次性返回客户端。最后清除客户端flag中的事务位

19.2 WATCH命令的实现

WATCH命令是一个乐观锁(optimistic lock,不会不允许其它客户端的修改,而是在键发生修改时,事务整体无法执行,并向执行的客户端报告一个错误。而悲观锁则是我们常说的锁,直接禁止其它客户端修改)。

WATCH命令被用于保证事务的安全性。也就是监视一个标志位,当标志位未被修改时,就执行事务;反之,服务器拒绝执行事务。

19.2.2 监视机制的触发

所有对数据库进行修改的命令,执行后都会检查修改的键中是否包含了被WATCH的键,如果时,则将客户端的REDIS_DIRTY_CAS标识打开。

在服务器收到EXEC指令时,首先检查这个标识是否打开,如果已经打开,说明WATCH的键已经被修改过,事务不安全,拒绝执行。

那执行过程中,有没有可能使事务变得不安全呢?没有。因为Redis保证在事务执行的过程中,不会插入其它客户端的命令。

19.3 事务的ACID性质

原子性

Redis保证在事务执行的过程中,不会插入其它客户端的命令。由于Redis不支持回滚机制,因此即使指令执行出错,事务也会继续执行下去。

一致性

Redis在命令入队时,会检查命令语法是否符合要求。因此影响一致性的只有执行错误&服务器停机。

可以认为,命令执行前后,数据库是一致的(如果认为执行错误的命令不是命令的话)。

隔离性

Redis串行执行事务,因此隔离性就是串行隔离,这一点可以很好地保证。

持久性

决定于Redis所使用的持久化模式,与事务本身无关。

20.Lua脚本

Lua是一种轻量小巧的脚本语言,采用C语言编写。

Redis支持Lua脚本,客户端可以使用脚本在服务器端原子地执行多个Redis指令。

21.排序

Redis的SORT命令可以对列表键、集合键或有序集合键的值进行排序。

有以下几种用法:

SORT <key>			//对数值进行排序
SORT <key> ASC		//对数值进行升序排序,不指定时也是升序
SORT <key> DESC		//对数值进行降序排序
SORT <key> ALPHA	 //对包含字符串的值进行排序,也可以配合DESC指令进行降序排序

其它的一些额外指令有:

BY:指定键的一部分作为排序依据,而不是整串
LIMIT <offset> <count>:指定返回某个范围之间的排序结果
GET:将排序执行后,获取一些模式的键,类似Linux中的grep指令
STORE:可以指定将排序结果保存到某个键中

22.二进制位数组

Redis提供SETBIT GETBIT BITCOUNT BITOP四种

SETBIT <key> <bitIdx> <0/1>:置位
GETBIT <key> <bitIdx>:读取位
BITCOUNT <key>:统计1的位数
BITOP <OP> <saveKey> <in1> <in2>:对in1 in2两个二进制数组做按位运算,并将结果存入saveKey中 

其中,只有BITCOUNT指令稍微有点技术含量,采用了查表+SWAR算法实现

22.4 BITCOUNT的实现

查表法

创建一个足够大的数组,将整型映射为值为1的数量。

这种方法在表过大的情况下,会导致内存占用过高、CPU缓冲命中率低的缺点。

variable-precision SWAR算法

在JDK的Integer.bitCount()函数中,也采用了这个算法进行计算。其核心思想是:将位分组统计并求和。

所谓分组统计并求和,我们可以换个角度理解bitCount:

如果统计某个32位整数n中,有多少个位是1;
已知前16位有x个,后16位有y个,那么这个整数中共有x+y个位是1;
假如我有一个计算16位整数位为1的函数,那可以将n和n>>16(也即把高16位挪到低16位)传入,将得到的结果相加;
那假如我有一个计算8位整数位为1的函数,……
把这个分治的过程反过来看,就是SWAR算法了——先两位分组,然后四位分组,然后八位分组,最后合并。

先看一个32位的实现:

uint32_t swar(uint32_t i) {
    // 0x55 = 01010101b,即按奇偶分组,把每一组偶数位的cnt加到奇数位的cnt上,这样只要把结果中每两位分别求和即可得到答案
    i = (i & 0x55555555) + (i >> 1) & 0x55555555;
    // 0x33 = 00110011b,即每四位是一组,而之前每两位的cnt已经求出,这样只要把结果中每四位分别求和即可得到答案
    i = (i & 0x33333333) + (i >> 2) & 0x33333333;
    i = (i & 0x0F0F0F0F) + (i >> 4) & 0x0F0F0F0F;
    // 在最后一步执行以前,i已经是按8位分组统计过的了。0x01010101=0x01+0x0100+0x010000+0x01000000,使用乘法分配律后,等于分别表示将i向前移动0、8、16、24位后相加,而32位整数的bitCount <= 32,5位足矣,相加后的低8位(准确地说,低6位就够表示了)就是答案。
    i = (i * 0x01010101) >> 24;
}

Redis的实现

Redis综合使用了查表和SWAR算法进行bitCount

若剩余位数 >= 128:循环载入128位,使用四次32位SWAR算法计数
反之,循环取8位,使用8位(也即0~255)查表法计数

23 慢查询日志

慢查询日志用于记录执行时间超过给定时长的命令请求,这一点和MySQL等关系型数据库的定义一样。

慢查询日志可以用于监视和优化指令执行速度。

慢查询日志有最大长度限制,这一限制可修改。如果已经达到最大限度,那么插入新日志时会删除最老的日志。

使用如下命令可以查询慢查询日志:

SLOWLOG GET

24. 监视器

通过执行

MONITOR

命令,客户端可以将自己变成一个监视器,实时地接收并打印处服务器当前处理的命令请求的相关信息。

实现方式就是服务器处理完客户端的命令请求之后,还会将其发给所有的监视器。

底层数据结构为:

struct redisServer {
    list *monitors //链表保存所有的monitor,发送时肯定要遍历
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值