Redis设计与实现

91 阅读18分钟

第一部分 数据结构与对象

第2章 简单动态字符串

SDS的定义

简单动态字符串:simple dynamic string, SDS
​
SDS结构:
struct sdsstr{
    //记录buf数组中已使用字节的数量
    //等于SDS所保存字符串的长度
    int len;
    //记录buf数组中未使用字节的数量
    int free;
    //字节数组,用于保存字符串
    charbuf[];
}
​
SDS遵循C字符串以空字符串结尾的惯例,保存空字符串的1字节不计算在len属性里面,额外分配1字节空间,遵循空字符串结尾的这一惯例的好吃是,SDS可以直接重用一部分C字符串函数库里面的函数

SDS与C字符串的区别

获取字符串长度复杂度不一致:
C字符串不记录自身的长度信息,获取长度需要便利整个字符串,复杂度为0(N)
SDS记录了len属性,获取SDS长度的复杂度为O(1)
​
缓冲区溢出问题:
C字符串分配了内存,如果用strcat拼接的字符串过长,会引发缓冲区溢出问题
SDS会检查空间是否满足,然后进行扩容,并且扩容的大小是len的两倍
PS:C开发一般操作字符数组,不会直接做strcat操作
​
修改字符串的内存重分配次数:
C字符串不记录自身的长度,每次增长或缩短C字符串,都需要进行内存重分配
SDS做了空间预分配和惰性释放空间,极大减少了内存分配次数。新增时会自动扩容,如果下次进行拼接的字符串长度小于free的大小,则无需进行扩容操作。像strtrim操作释放的空间会暂时保留,防止strcat等操作重新分配内存,但如果需要释放SDS的未使用空间,SDS也提供了相应的API
​
二进制安全:
C字符串只能用于保存文本,而不能保存像图片、音频、视频、压缩文件等二进制数据
SDS的API都是二进制安全的(binary-safe),所有API都会以处理二进制的方式来处理buf数组里的数据

SDS API

SDS重点回顾

Redis只会使用C字符串作为字面量,大多数情况下,Redis使用SDS作为字符串表示
比起C字符串,SDS具有以下优点:
1)常熟复杂度获取字符串长度
2)杜绝缓冲区溢出
3)减少修改字符串长度时所需的内存重分配次数
4)二进制安全
5)兼容部分C字符串函数

第3章 链表

链表和链表节点的实现

链表节点listNode结构:
typedef struct listNode{
    //前置节点
    struct listNode * prev;
    //后置节点
    struct listNode * next;
    //节点的值
    void * value;
}listNode;
多个listNode可以通过prev和next指针组成双端链表
​
一般使用list持有链表,操作起来更方便,以下是list结构:
typedef struct list{
    //表头节点
    listNode * head;
    //表尾节点
    listNode * tail;
    //链表包含的节点数量
    unsigned long len;
    //节点值复制函数
    void (*dup)(void *ptr);
    //节点值释放函数
    void (*free)(void *ptr);
    //节点值对比函数
    int (*match)(void *ptr, void *key);
}list;
​
特定函数:
    dup函数用于复制链表节点所保存的值;
    free函数用于释放链表节点所保存的值;
    match函数用于对比链表节点所保存的值和另一个输入值是否相等
​
Redis的链表实现特性总结如下:
双端:链表阶段都带有prev和next指针
无环:表头节点的prev指针和表尾节点的next指针都指向NULL,对链表的访问以NULL为终点
表头指针和表尾指针:list结构中的head指针和tail指针
链表长度计数器:len属性记录链表节点
多态:链表节点使用void*指针(listNode中的void*)来保存节点值,通过list结构的dup、free、match三个属性为节点值设置类型特定函数,所以链表可以保存各种不同类型的值

链表和链表节点的API

平时用不到,所以不总结了

链表重点回顾

链表被广泛用于实现Redis的各种功能,比如列表键、发布与订阅、慢查询、监视器等。
每个链表节点由一个listNode结构来表示,每个点都有一个指向前置节点和后置节点的指针,所以Redis的链表实现是双端链表。
每个链表使用一个list结构来表示,这个结构带有表头节点指针、表尾节点指针,以及链表长度等信息。
因为链表表头节点的前置节点和表尾节点的后置节点都指向NULL,所以Redis的链表实现是无环链表。
通过为链表设置不同的类型特定函数,Redis的链表可以用于保存各种不同类型的值。

第4章 字典

字典的实现

Redis的字典使用哈希表作为底层实现,一个哈希表里面可以有多个哈希表节点,而每个哈希表节点就保存了字典中的一个键值对
​
哈希表结构定义:
typeof struct dictht{
    //哈希表数组
    dictEntry **table;
    //哈希表大小
    unsigned long size;
    //哈希表大小掩码,用于计算索引值
    //总是等于size-1
    unsigned long sizemask;
    //该哈希表已有节点的数量
    unsigned long used;
}dictht;
​
table属性是一个数组,数组中的每个元素都是一个指向dict.h/dictEntry结构的指针,每个dictEntry结构保存着一个键值对。size属性记录了哈希表的大小,也即是table数组的大小,而used属性则记录了哈希表目前已有节点(键值对)的数量。sizemask属性的值总是等于size-1,这个属性和哈希值一起决定一个键应该被放到table数组的哪个索引上面。
​
dictEntry结构:
typeof struct dictEntry{
    //键
    void *key;
    //值
    union{
        void *val;
        unit64_tu64;
        int64_ts64;
    }v;
    //指向下个哈希表节点,形成链表
    struct dictEntry *next;
}dictEntry;
next属性是指向另一个哈希表节点的指针,这个指针可以将多个相同的键值对连接在一起,以此来解决键冲突(collision)的问题
​
Redis中的字典结构:
typeof struct dict{
    //类型特定函数
    dictType *type;
    //私有数据
    void *privdata;
    //哈希表
    dictht ht[2];
    //rehash索引
    //当rehash不在进行时,值为-1
    int trehashidx; /* rehashing not in progress if rehashidx == -1*/
}dict;
​
type属性和privdata属性是针对不同类型的键值对,为创建多态字典而设置的;
type属性是一个指向dictType结构的指针,每个dictType结构保存了一簇用于操作特定类型键值对的函数,Redis会为用途不同的字典设置不同的类型特定函数
而privdata属性则保存了需要传给那些类型特定函数的可选参数
typeof struct dictType{
    //计算哈希值的函数
    unsigned int (*hashFunction)(const void *key);
    //复制键的函数
    void *(*keyDup)(void *privdata, const void *key);
    //复制值的函数
    void *(*valDup)(void *privdata, const void *obj);
    //对比键的函数
    int (*keyCompare)(void *privdata, const void *key1, const void *key2);
    //销毁键的函数
    void (*keyDestructor)(void *privdata, void *key);
    //销毁值的函数
    void (*valDestructor)(void *privdata, void *obj);
}dictType;
​
dict结构中,ht属性是一个包含两个项的数组,数组中的每个项都是一个dictht哈希表,一般情况下,字典只使用ht[0]哈希表,ht[1]哈希表只会在对ht[0]哈希表进行rehash时使用。
除了ht[1]之外,另一个和rehash有关的属性就是rehashidx,它记录了rehash目前的进度,如果目前没有在进行rehash,那么它的值为-1

哈希算法

在将新的键值对添加到字典里面时,程序需要根据键来计算出哈希值和索引值,再根据索引值放入哈希表数组的指定索引上面
Redis计算哈希值和索引值的方法如下:
//使用字典设置的哈希函数,计算键key的哈希值
hash = dict->type->hashFunction(key);
​
//使用哈希表的sizemask属性和哈希值,计算出索引值
//根据情况不同,ht[x]可以是ht[0]或者ht[1],rehashing的时候使用ht[1]
index = hash & dict->ht[x].sizemask;
​
Redis使用MurmurHash2算法来计算键的哈希值,MurmurHash算法目前最新版本为MurmurHash3

解决键冲突

上文提到我们使用hash值和sizemask属性计算出索引值,然后根据索引值放入到哈希表中。
当两个及以上的键被分配到哈希表数组的同一个索引上面时,我们称为键冲突(collision)
Redis的哈希表使用链地址法(separate chaining)来解决键冲突(Java的HashMap解决键冲突也是如此),每个哈希表节点都有一个next指针,多个哈希表节点可以用next指针构成一个单向链表,被分配到同一个索引上的多个节点可以用这个单向链表连接起来,这就解决了键冲突的问题
因为dictEntry节点组成的链表没有指向链表表尾的指针,所以为了速度考虑,程序总是将新节点添加到链表的表头位置(复杂度为O(1)),排在其他已有节点的前面。新加入的节点很方便指向next,如果添加到末尾,则需要更新旧节点的next属性指向新节点

rehash

当哈希表保存的键值对数量太多或者太少时,哈希表将进行相应的扩展或收缩,这个过程就是称为rehash
具体步骤如下:
1)为字典的ht[1]哈希表分配空间,空间大小取决于要执行的操作(扩展或收缩),以及ht[0]当前包含的键值对
    如果是扩展,ht[1]的大小为第一个大于等于ht[0].used*22^n(2的n次幂)
    如果是收缩,那么ht[1]的大小为第一个大于大于等于ht[0].used2^n
2)将保存在ht[0]中的所有键值对rehash到ht[1]上:rehash是指重新计算键的哈希值和索引值,然后放置到ht[1]哈希表的指定位置
3)ht[0]的键值对全部迁移到ht[1]之后(ht[0]变为空表),释放ht[0],将ht[1]设置为ht[0],并在ht[1]新创建一个空白哈希表,为下一次rehash做准备

渐进式rehash

上一节说过,扩展或收缩哈希表需要将ht[0]里面的所有键值对rehash到ht[1]里面,但是,这个rehash动作并不是一次性、集中式地完成的,而是分多次、渐进式地完成的。
这样做的原因在于,如果ht[0]里只保存着四个键值对,那么服务器可以在瞬间就将这些键值对全部rehash到ht[1];但是,如果哈希表里保存的键值对数量不是四个,而是四百万、四千万甚至四亿个键值对,那么要一次性将这些键值对全部rehash到ht[1]的话,庞大的计算量可能会导致服务器在一段时间内停止服务。
​
因此,为了避免rehash对服务器性能造成影响,服务器不是一次性将ht[0]里面的所有键值对全部rehash到ht[1],而是分多次、渐进式地将ht[0]里面的键值对慢慢地rehash到ht[1]。
以下是哈希表渐进式rehash的详细步骤:
1)为ht[1]分配空间,让字典同时持有ht[0]和ht[1]两个哈希表。
2)在字典中维持一个索引计数器变量rehashidx,并将它的值设置为0,表示rehash工作正式开始。
3)在rehash进行期间,每次对字典执行添加、删除、查找或者更新操作时,程序除了执行指定的操作以外,还会顺带将ht[0]哈希表在rehashidx索引上的所有键值对rehash到ht[1],当rehash工作完成之后,程序将rehashidx属性的值增一。
4)随着字典操作的不断执行,最终在某个时间点上,ht[0]的所有键值对都会被rehash至ht[1],这时程序将rehashidx属性的值设为-1,表示rehash操作已完成。
渐进式rehash的好处在于它采取分而治之的方式,将rehash键值对所需的计算工作均摊到对字典的每个添加、删除、查找和更新操作上从而避免了集中式rehash而带来的庞大计算量。

字典API

重点回顾

字典被广泛用于实现Redis的各种功能,其中包括数据库和哈希键。
Redis中的字典使用哈希表作为底层实现,每个字典带有两个哈希表,一个平时使用,另一个仅在进行rehash时使用。
当字典被用作数据库的底层实现,或者哈希键的底层实现时,Redis使用MurmurHash2算法来计算键的哈希值。
哈希表使用链地址法来解决键冲突,被分配到同一个索引上的多个键值对会连接成一个单向链表。
在对哈希表进行扩展或者收缩操作时,程序需要将现有哈希表包含的所有键值对rehash到新哈希表里面,并且这个rehash过程并不是一次性地完成的,而是渐进式地完成的。

第5章 跳跃表

跳跃表(skiplist)是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。Redis只有两个地方用到了跳跃表,一个是实现有序集合键,另一个是在集群节点中用作内部数据结构

第6章 整数集合

整数集合的构成

集合质保函整数元素,并且元素数量不多时,Redis使用整数集合作为集合键的底层实现
整数集合结构:
typedef struct intset{
    //编码方式
    unit32_t encoding;
    //集合包含的元素数量
    unit32_t length;
    //保存元素的数组
    int8_t contents[];
}intset;
​
contents保存集合的元素,按值的大小从小到大有序地排列,不包含重复项
length属性记录了集合的元素数量,也就是contents数组的长度
encoding属性保存了整数类型:int16、int32、int64
​
升级:contents数组可以存储163264位的整数,根据最大位整数存储,不支持降级
升级的好处是提升整数集合的灵活性,以及尽可能的节约内存
比如:现有516位的整数,存入一个32位的整数,其余所有的整数均需要升级

整数集合API

第7章 压缩列表

压缩列表(ziplist)是列表键和哈希键的底层实现之一。
不太好懂,也没什么值得学习的

第8章 对象

Redis有5种对象类型:字符串、列表、哈希、集合、有序集合,Redis是key-value形式的,我们称为键对象和值对象
Redis的每个对象都有一个redisObject结构表示:
typedef struct redisObject{
    //类型
    unsigned type:4;
    //编码
    unsigned encoding:4;
    //指向底层实现数据结构的指针
    void *ptr;
    //...
}robj;
​
type属性记录值对象的类型,有5种: 使用type命令找到的是值对象的类型
    REDIS_STRING:字符串对象,string
    REDIS_LIST:列表对象,list
    REDIS_HASH: 哈希对象,hash
    REDIS_SET:集合对象,set
    REDIS_ZSET:有序集合对象,zset
    
encoding属性记录了对象所使用的编码,以下是编码列表:
编码常量                             编码所对应的底层数据结构
REDIS ENCODING INT                  long类型的整数
REDIS ENCODING EMBSTR               emstr编码的简单动态字符串
REDIS ENCODING RAW                  简单动态字符串
REDIS ENCODING HT                   字典
REDIS ENCODING LINKEDLIST           双端链表
REDIS ENCODING ZIPLIST              压缩列表
REDIS ENCODING INTSET               整数集合
REDIS ENCODING SKIPLIST             跳跃表和字典
​
使用不同类型的编码可以在合适的场景选择不同的编码,节约内存

对象的编码

不同类型和编码的对象

字符串对象

字符串对象的编码可以是int、raw或者embstr
embstr用来存储短字符串,分配内存的操作次数更少
​
字符串对象相关命令:SET GET APPEND INCRBYFLOAT INCRBY DECRBY STRLEN SETRANGE GETRANGE

字符串对象保存各类型值的编码方式

编码
可以用long类型保存的整数int
可以用long double类型保存的浮点数embstr或者raw
字符串值,或者因为长度太大而没办法用long类型表示的整数,又或者 因为长度太大而没办法用long double类型表示的浮点数ebmstr或者raw

列表对象

列表对象保存的所有字符串对象的长度都小于64字节,元素数量小于512个
这两可以通过配置文件中list-max-ziplist-value选项和list-max-ziplist-entries修改
​
列表相关命令:LPUSH RPUSH LPOP RPOP LINDEX LLEN LINSERT LREM LTRIM LSET

哈希对象

编码可以使ziplist或者hashtable
哈希相关命令:HSET HGET HEXISTS HDEL HLEN HGETALL

集合对象

编码可能是intset或者hashtable
intset编码的集合对象使用整数集合作为底层实现,集合对象包含的所有元素都被保存在整数集合里面
集合相关命令:SADD SCARD SISMEMBER SRANDMEMBER SPOP SREM 

有序集合对象

编码可以是ziplist或者skiplist
skiplist编码的zset结构:
typedef struct zset{
    zskiplist *zsl;
    dict *dict;
}zset;
zset结构中的zsl跳跃表按分值从小到大保存了所有集合元素,每个跳跃表节点都保存了一个集合元素:节点的object属性保存了元素的成员,节点的score属性则保存了元素的分值。通过跳跃表,程序可以对有序集合进行范围性操作,比如ZRANK、ZRANGE等命令就是基于跳跃表API来实现的
除此之外,zset结构中的dict字典为有序集合创建了一个从成员到分值的映射,字典中的每个键值对都保存了一个集合元素:字典的键保存了元素的成员,而字典的值则保存了元素的分值。通过这个字典,程序可以用O(1)复杂度查找给定成员的分值,ZSCORE命令就是根据这一特性实现的,而很多其他有序集合命令都在实现的内部用到了这一特性。
​
为什么有序集合需要同时使用跳跃表和字典来实现?
    在理论上,有序集合可以单独使用字典或者跳跃表的其中一种数据结构来实现,但无论单独使用字典还是跳跃表,在性能上对比起同时使用字典和跳跃表都会有所降低。举个例子,如果我们只使用字典来实现有序集合,那么虽然以O(1)复杂度查找成员的分值这一特性会被保留,但是,因为字典以无序的方式来保存集合元素,所以每次在执行范围型操作——比如ZRANK、ZRANGE等命令时,程序都需要对字典保存的所有元素进行排序,完成这种排序需要至少O(NlogN)时间复杂度,以及额外的O(N)内存空间(因为要创建一个数组来保存排序后的元素)。
    另一方面,如果我们只使用跳跃表来实现有序集合,那么跳跃表执行范围型操作的所有优点都会被保留,但因为没有了字典,所以根据成员查找分值这一操作的复杂度将从O(1)上升为O(logN)。因为以上原因,为了让有序集合的查找和范围型操作都尽可能快地执行,Redis选择了同时使用字典和跳跃表两种数据结构来实现有序集合。
    
有序集合的命令: ZADD ZCARD ZCOUNT ZRANGE ZREVRANGE ZRANK ZREVRANK ZREM ZSCORE

类型检查与命令多态

Redis操作命令可以分为两类,一种是通用命令,一种是独有命令
通用命令:DEL EXPIRE RENAME TYPE OBJECT
独有命令:
SET GET APPEND STRLEN 等命令只对字符串键执行
HDEL HSET HGET HLEN 等命令只对哈希键执行
RPUSH LPOP LINSERT LLEN 等命名只对列表键执行
SADD SPOP SINTER SCARD 等命令只对集合键执行
ZADD ZCARD ZRANK ZSCORE ZRANGE 等命令只对有序集合键执行
​
命令多态是指同一个命令,无论哪种数据类型执行都能够正确执行,执行流程是:先用type命令拿到数据类型,然后根据数据类型里的命令执行方法

内存回收

Redis的底层是C语言,但C语言不具备自动回收内存的功能,所以Redis在自己的对象系统中构建了一个引用计数(reference counting)计数实现内存回收机制,每个对象的引用计数信息由redisObject结构的refcount属性记录
typedef struct redisObject{
    //...
    //引用计数
    int refcount;
}robj;
​
refcount操作: increRefCount decrRefCount restRefCount

第二部分 单机数据库的实现