Redis从了解到掌握(二):Redis基础数据类型及底层实现详解

381 阅读38分钟

前言

在这篇文章会简要学习一下Redis五种基础的数据结构及其底层实现,因为《Redis设计与实现》此书作者参考Redis版本为2.x,而如今Redis有5.0.10及6.0.9,版本迭代过快,书中涉及的源码有些早已改得面目全非。回想起写这系列博客的初心,实际是为了记录一下学习的过程以及以后有需要或者忘记了某些知识点的时候,翻出来看一下回忆起来,所以决定仍然参考旧版本的实现来学习,当然有机会也会涉及部分新版本的实现方式,并思考新版本为何要做这种优化。

这篇文章将学习Redis五种基础数据类型以及他们各自的底层实现,还有这些数据结构的常用操作命令展示。

如无注明,源码均使用reids-2.8.1。

redis数据结构底层实现 接下来会按照字符串、列表、哈希、集合和有序集合的顺序学习。每一个数据结构都会从它的底层编码和实现来学习,以及他们的部分API、何时会转换编码,以及五种数据结构的主要操作。

字符串及简单动态字符串SDS

字符串是Redis体系中的基石,所有的键都是字符串,字符串还可以用来表示整数和浮点数。Redis没有直接使用C语言传统的字符串表示,而是自己构建了一种名为简单动态字符串SDS的抽象类型,并将SDS用作Redis的默认字符串表示。

struct sdshdr {
    int len;//buf中已经使用的字节数,即该SDS保存的字符串长度
    int free;//buf中未使用的字节数
    char buf[];//存储字符串的字节数组
};

字符串对象sds-embstr

看完图就简单明了了,接下来通过SDS与C字符串对比,来比较学习这两种的区别。

SDS与C字符串的区别

传统C字符串使用长度N+1的字符串来表示长度为N的数组,结尾总是空字符'\0'。而SDS额外使用len记录字符串长度、free记录未使用字节数,以及buf数组来存储字符串,同样是以空字符'\0'结尾。

free为0: free为0

free为5: free为5

1、常数复杂度获取字符串长度:C字符串不记录自身长度信息,如需获取,需要遍历整个字符串,而SDS在自身len属性中记录了自身长度。可通过O(1)复杂度获取自身长度信息。

2、杜绝缓冲区溢出:因为C字符串不记录自身长度,所以在进行字符串拼接操作时,函数假定用户在执行操作时,已经为这个字符串分配了足够的内存,所以如果实际未预分配内存,很可能造成后面内存空间的数据被覆盖。而SDS在执行拼接操作时,会先通过获取自身free属性来得知缓冲区剩余的未使用的内存空间大小,如果不够就会先扩展SDS的空间然后才执行拼接操作。

3、减少修改字符串时带来的内存重分配次数:如上对C字符串的拼接截断操作都会带来内存重新分配操作,SDS通过未使用空间解除了字符串长度和底层数组长度之间的关联:在SDS中,buf数组的长度不一定就是字符串数量加一,数组里面可以包含未使用空间,而这些字节的数量就由SDS的free属性记录。通过未使用空间,SDS实现了空间预分配和惰性空间释放两种优化策略:

  • 1.空间预分配:用于优化SDS字符串增长操作。当SDS的API对SDS进行修改,并且需要对SDS进行空间扩展时,程序不仅会为SDS分配修改所必须要的空间,还会为SDS分配额外的未使用空间。如果修改后SDS的长度小于1MB,那么程序分配和len属性同样大小的未使用空间;如果大于1MB则只分配1MB的未使用空间。
  • 2.惰性空间释放:用于优化SDS字符串缩短时操作。当SDS的API需要缩短SDS保存的字符串时,程序并不会立即使用内存重新分配来回收缩短后多出来的字节,而是使用free属性将这些字节的数量记录起来,并等待将来的使用。如有需要SDS也提供了相应的API真正地释放未使用空间。

4、二进制安全:所有SDS的API都会以二进制的方式来处理SDS存放在buf数组里的数据,Redis不是用这个数组来保存字符,而是用它来保存一系列二进制数据,例如:SDS使用len的属性值而不是空字符来判定字符串是否结束,所以可以在SDS的buf数组内存入空字符。

5、兼容部分C字符串函数:虽然SDS的API都是二进制安全的,但它们一样遵循C字符串以空字符串结尾的惯例,这时为了让那些保存文本数据的SDS可以重用一部分<string.h>库定义的函数。

总结一下C字符串和SDS之间的区别:

C字符串SDS
获取字符串长度的复杂度为O(N)获取字符串长度的复杂度为O(1)
API是不安全的,可能会造成缓冲区溢出API是安全的,不会造成缓冲区溢出
修改字符串长度N次必然需要执行N次内存重新分配修改字符串长度N次最多需要执行N次内存重新分配
只能保存文本数据可以保存文本或者二进制数据
可以使用所有<string.h>库中的函数可以使用部分<string.h>库中的函数

字符串对象编码

字符串对象的编码可以是intraw或者embstr,整数且可以用long来表示会使用int编码,字符串或者浮点数会使用embstr或者raw来编码,区别是字符串值得长度大于32个字节使用raw,否则使用embstr。

问:那既然raw能保存embstr保存的字符串,为何不只使用raw来编码呢?

答:embstr编码是专门用于保存短字符串的一种优化编码方式,这种编码和raw编码一样都使用redisObject结构和SDShdr结构来表示字符串对象,但raw编码会调用两次内存分配函数来分别创建redisObject结构和SDShdr结构,而embstr编码则通过调用一次内存分配函数来分配一块连续的空间,空间中一次包含redisObject和SDShdr两个结构,如下图所示:

embstr连续空间

使用embstr编码的字符串对象来保存短字符串有以下好处:

  • embstr编码将创建字符串对象所需的内存分配次数从raw编码的两次降为一次。
  • 释放embstr编码的字符串对象只需调用一次内存释放函数,而释放raw编码的字符串对象需要调用两次内存释放函数。
  • 因为embstr编码的字符串对象的所有数据都保存在一块连续的内存里面,所以这种编码的字符串对象比起raw编码的字符串对象能更好地利用缓存带来的优势。

编码的转换:int编码的字符串对象和embstr编码的字符串对象在条件满足的情况下,会被转换为raw编码的字符串对象。

对于int编码的字符串对象来说,如果我们向对象执行了一些命令,使得这个对象保存的不再是整数值,而是一个字符串值,那么字符串对象的编码将从int变为raw。

另外,因为Redis没有为embstr编码的字符串对象编写任何相应的修改程序,所以embstr编码的字符串对象实际上是只读的。当我们对embstr编码的字符串对象执行任何修改命令时,程序会先将对象的编码从embstr转为raw,然后再执行修改命令。因为这个原因,embstr编码的字符串对象在执行修改命令后,总会变成一个raw编码的字符串对象。

embstr编码转为raw

SDS主要操作API

函数作用时间复杂度
sdsnew创建一个包含给定C字符串的SDSO(N),N为给定C字符串长度
sdsempty创建一个不包含任何内容的空SDSO(1)
sdsfree释放给定的SDSO(N),N为被释放SDS的长度
sdslen返回SDS的已使用空间字节数O(1),通过读取len属性获取
sdsavail返回SDS的未使用空间字节数O(1),通过读取free属性获取
sdsdup创建一个给定SDS的副本(copy)O(N),N为给定SDS的长度
sdsclear清空SDS保存的字符串内容O(1),惰性释放
sdscat将给定C字符串拼接到SDS字符串的末尾O(N),N为被拼接C字符串的长度
sdscatSDS将给定SDS字符串拼接到另一个SDS字符末尾O(N),N为被拼接SDS字符串的长度
sdscpy将给定的c次付出复制到SDS里面,覆盖SDS所有的字符串O(N),N为被复制C字符串的长度
sdsgrowzero用空字符串将SDS扩展至给定长度O(N),N为扩展新增的字节数
sdsrange保留SDS给定区间内的数据,不在区间内的数据会被覆盖或者清除O(N),N为被保留数据的字节数
sdstrim接受一个SDS和一个C字符串作为参数,从SDS中溢出所有在C字符串中出现过的字符O(N^2),N为给定C字符串的长度
sdscmp对比两个SDS字符串是否相同O(N),N为两个SDS中较短的那个SDS的长度

字符串主要操作命令

SET key value
GET key
APPEND key value #如果key存在,则在字符串末尾添加value,不存在则同SET命令
INCR key #对存储在指定key的数值执行原子的加1操作,如果key不存在则同SET key 1,如果key不是整数则返回错误
INCRBY key increment #类似INCR,但增加的是一个数值而不是1
INCRBYFLOAT key increment #类似INCRBY,但增加的是浮点数
DECR key #类似INCR,自减1
DECRBY key decrement #类似INCRBY
GETRANGE key start end #返回key对应的字符串value的子串,这个子串是由start和end位移决定的
GETSET key value #自动将key对应到value并且返回原来key对应的value。如果key存在但是对应的value不是字符串,就返回错误。
MGET key [key ...] #返回所有指定的key的value。对于每个不对应string或者不存在的key,都返回特殊值nil
MSET key value [key value ...] #对应给定的keys到他们相应的values上。MSET会用新的value替换已经存在的value,就像普通的SET命令一样
SETEX key seconds value #设置key对应字符串value,并且设置key在给定的seconds时间之后超时过期
SETNX key value #将key设置值为value,如果key不存在,这种情况下等同SET命令。 当key存在时,什么也不做。SETNX是”SET if Not eXists”的简写。
SETRANGE key offset value #覆盖key对应的string的一部分,从指定的offset处开始,覆盖value的长度
STRLEN key #返回key的string类型value的长度。如果key对应的非string类型,就返回错误

列表

列表对象的编码可以是ziplist或者linkedlist。

链表

链表提供了高效的节点重排能力,以及顺序性的节点访问方式,并且可以通过增删节点来灵活地调整链表长度。

实际已弃用,3.2起使用quicklist

Redis构建了自己的链表实现:

typedef struct listNode {
    struct listNode *prev;//前置节点
    struct listNode *next;//后置节点
    void *value;//节点存储的值
} listNode;

typedef struct list {
    listNode *head;//表头节点
    listNode *tail;//表尾节点
    void *(*dup)(void *ptr);//节点值复制函数
    void (*free)(void *ptr);//节点值释放函数
    int (*match)(void *ptr, void *key);//节点值对比函数
    unsigned long len;//链表包含的节点数量
} list;

链表实现图

Redis的链表实现的特性总结如下:

  • 双端:链表节点都带有prev和next指针,获取某个节点的前置节点和后置节点的复杂度都是O(1)。
  • 无环:表头节点的prev指针和表尾节点的next指针都指向null,对列表的访问以null为终点。
  • 带表头指针和表尾指针:通过list结构的head指针和tail指针,程序获取链表的表头节点和表尾节点的复杂度为O(1)。
  • 带链表长度的计数器:程序使用list结构的len属性来对list持有的链表节点进行计数,程序获取链表中节点数量的复杂度为O(1)。
  • 多态:链表节点使用void*指针来保存节点值,并且可以通过list结构的dup、free、match三个属性为节点值设定类型特定函数,所以链表可以用于保存各种不同类型的值。

链表和链表节点的API

函数作用时间复杂度
listSetDupMethod将给定的函数设置为链表的节点值复制函数O(1)
listGetDupMethod返回链表当前正在使用的节点值复制函数O(1),可通过读取链表的dup属性直接获得
listSetFreeMethod将给定的函数设置为链表的节点值释放函数O(1)
listGetFree返回链表当前正在使用的节点值释放函数O(1),可通过读取链表的free属性直接获得
listSetMatchMethod将给定的函数设置为链表的节点值对比函数O(1)
listGetMatchMethod返回链表当前正在使用的节点值对比函数O(1),可通过读取链表的match属性直接获得
listLength返回链表的长度O(1),可通过读取链表的len属性直接获得
listFirst返回链表的表头节点O(1),可通过读取链表的head属性直接获得
listLast返回链表的表尾节点O(1),可通过读取链表的tail属性直接获得
listPrevNode返回给定节点的前置节点O(1),可通过读取节点的prev属性直接获得
listNextNode返回给定节点的后置节点O(1),可通过读取节点的next属性直接获得
listNodeValue返回给定节点目前正在保存的值O(1),可通过读取节点的value属性直接获得
listCreate创建一个不包含任何节点的新链表O(1)
listAddNodeHead将一个包含给定值的新节点添加到给定链表的表头O(1)
listAddNodeTail将一个包含给定值的新节点添加到给定链表的表尾O(1)
listInsertNode将一个包含给定值的新节点添加到给定节点的之前或之后O(1)
listSearchKey查找并返回链表中包含给定值的节点O(N) ,N为链表长度
listIndex返回链表给定索引上的节点O(N),N为链表长度
listDelNode从链表中删除给定节点O(N),N为链表长度
listRotate将链表的表尾节点弹出,然后将被弹出的节点插入到链表的表头,成为新的表头节点O(1)
listDup复制一个给定链表的副本O(N),N为链表长度
listRelease释放给定链表,以及链表中的所有节点O(N),N为链表长度

压缩列表

ziplist编码的列表对象使用压缩列表作为底层实现,每个列表节点(entry)保存了一个列表元素。

压缩列表是Redis为了节约内存而开发的,是由一系列特殊编码的连续内存块组成的顺序型数据结构。一个压缩列表可以包含任意多个节点(entry)保,每个节点可以保存一个字节数组或者一个整数值。

下列图表展示了压缩列表的各个组成部分及各个组成部分的类型、长度以及用途。

列表的ziplist实现图

属性类型长度用途
zlbytesuint_32_t4字节记录整个压缩列表占用的内存字节数:在对压缩列表进行内存重分配,或者计算zlend的位置时使用
zltailuint_32_t4字节记录压缩列表表尾节点距离压缩列表的起始地址有多少字节:通过这个偏移量,程序无需遍历整个压缩列表就可以确定表尾节点的地址
zllenuint_16_t2字节记录了压缩列表包含的节点数量:小于65535时,这个属性值就是压缩列表包含节点的数量;大于则需要遍历整个压缩列表才能计算得出
entryX列表节点不定压缩列表包含的各个节点,节点的长度由节点保存的内容决定
zlenduint_8_t1字节特殊值0xFF(255),用于标记压缩列表的末端

举个例子:下图是一个包含三个节点的压缩列表

三节点压缩列表

zlbytes为0x50(80)代表压缩列表总长度为80字节;zltail为0x3c(60),指的是压缩列表起始位置加上偏移量60字节即可访问表尾;zllen为0x3(3)表示该压缩列表有3个节点。

压缩列表节点的构成

上图中的entry保存了列表一个节点的值,下面通过节点的构成来学一个节点是如何保存这个值的。

压缩列表节点图

节点由三个部分组成,分别是:previous_entry_length、encoding和content组成。previous_entry_length记录了前一个节点的长度,encoding记录了节点content属性所保存数据的类型以及长度,content则负责保存节点的值。

  • previous_entry_length:以字节为单位,记录了压缩列表前一个节点的长度,该属性的长度可以是1字节或者5字节。如果前一节点的长度小于254字节,那么该属性的长度为1字节,且前一节点的长度就保存在这一个字节里;若大于等于254字节,该属性的长度为5字节,且第一个字节会被设置为0xFE(254),后四个字节用于保存前一节点的长度。因为该属性记录了前一个节点的长度,所以程序可以通过指针运算,根据当前节点的起始地址来计算出前一个节点的起始地址。
  • encoding:该属性记录了content属性保存数据的类型和长度,encoding的长度为一个字节、两个字节或者五个字节,值的最高位00、01、10保存的是字节数组,11开头保存的是整数,详细见下表。
  • content:content属性负责保存节点的值,节点值可以是一个字节数组或者整数,值的类型和长度由节点的encoding属性决定。

其中b、x等变量代表实际的二进制数据,下划线为留空。

编码编码长度content属性保存的值
00bbbbbb1字节长度小于等于63字节的数组
01bbbbbb
xxxxxxxx
2字节长度小于等于16383字节的数组
10bbbbbb
aaaaaaaa
bbbbbbbb
cccccccc
dddddddd
5字节长度小于等于4294967295字节的数组
编码编码长度content属性保存的值
110000001字节int16_t类型的整数
110100001字节int32_t类型的整数
111000001字节int64_t类型的整数
111100001字节24位有符号整数
111111101字节8位有符号整数
1111xxxx1字节使用这一编码的节点没有相应的content属性,因为编码本身的xxxx四个位已经保存了一个介于0和12之间的值,所以无需content属性

连锁更新

如前所说,previous_entry_length属性以字节为单位,记录了压缩列表前一个节点的长度,该属性的长度可以是1字节或者5字节。如果前一节点的长度小于254字节,那么该属性的长度为1字节,且前一节点的长度就保存在这一个字节里;若大于等于254字节,该属性的长度为5字节,且第一个字节会被设置为0xFE(254),后四个字节用于保存前一节点的长度。

考虑这种情况:在一个压缩列表中,有多个连续的、长度介于250字节到253字节之间的节点e1至eN,因为这些节点的长度都小于254字节,所以记录这些节点的长度值需要1字节长的previous_entry_length属性,这时,如果将一个长度大于等于254字节的新节点设置为压缩列表的表头节点,因为e1不够空间保存前一新节点的长度,所以需要对压缩列表执行空间重分配操作,并将e1的previous_entry_length属性从1字节扩充至5字节。此时e2因为受到e1的影响也不得不进行空间拓展,这样就造成了级联空间重分配操作,这种特殊情况下产生的连续多次空间扩展操作称之为“连锁更新”。

最坏情况下需要对压缩列表执行N次空间重分配操作,而每次空间重分配的最坏复杂度为O(N),所以连锁更新的最坏复杂度为O(N^2)。

但是实际上,尽管这种连锁更新复杂度高,但它真正造成性能问题的几率是很低的:首先,需要压缩列表里恰好有多个连续的、长度介于250字节至253字节之间的节点,连锁更新操作才能被触发,但是在实际中并不多见;其次,及时出现连锁更新,只要被更新的节点数量不多,就不会对性能造成任何影响。

压缩列表的API

函数作用算法复杂度
ziplistNew创建一个新的压缩列表O(1)
ziplistPush创建一个包含给定值的新节点,并将这个新节点添加到压缩列表的表头或表尾平均(N),最坏O(N^2)
ziplistInsert将包含给定值的新节点插入到给定节点之后平均(N),最坏O(N^2)
ziplistIndex返回压缩列表给定索引上的节点O(N)
ziplistFind在压缩列表中查找并返回包含了给定值的节点因为节点的值可能是一个字节数组,所以检查节点值和给定值是否相同的复杂度为O(N),而查找整个列表的复杂度则为O(N^2)
ziplistNext返回给定节点的下一个节点O(1)
ziplistPrev返回给定节点的上一个节点O(1)
ziplistGet获取给定节点所保存的值O(1)
ziplistDelete从压缩列表中删除给定的节点平均(N),最坏O(N^2)
ziplistDeleteRange删除压缩列表在给定索引上的连续多个节点平均(N),最坏O(N^2)
ziplistBlobLen返回压缩列表目前占用的内存字节数O(1)
ziplistLen返回压缩列表目前包含的节点数量节点数量小于65535时为O(1),大于为O(N)

列表编码转换

当列表对象可以同时满足以下两个条件时:列表对象使用ziplist编码:

  • 列表对象保存的所有字符串元素的长度都小于64字节;
  • 列表对象保存的元素数量小于512个; 不能满足这两个条件的列表对象需要使用linkedlist编码。

列表主要操作命令

LPUSH key value [value ...] #将所有指定的值插入到列表的头部。如果key不存在,那么在进行push操作前会创建一个空列表。如果key对应的值不是一个list的话,那么会返回一个错误
RPUSH key value [value ...] #类似LPUSH,向列表的尾部插入所有指定的值
LPOP key #移除并且返回list的第一个元素
RPOP key #移除并返回list的最后一个元素
LINDEX key index #返回列表里的元素的索引index存储。下标是从0开始索引的,所以0是表示第一个元素……负数索引用于指定从列表尾部开始索引的元素,-1表示最后一个元素,-2表示倒数第二个元素……
LLEN key #返回list的长度。如果key不存在,那么就被看作是空list,并且返回长度为0
LINSERT key BEFORE|AFTER pivot value #把value插入列表中在基准值pivot的前面或后面,当key不存在时,这个list会被看作是空list,任何操作都不会发生
LREM key count value #移除前count次出现的值为value的元素,0为所有值为value的元素,正数为前N个值为value的元素,负数为后N个
LTRIM key start stop #修剪一个已存在的list,这样list就会只包含指定范围的指定元素
LSET key index value #设置index位置的list元素的值为value

哈希

哈希对象的编码可以是ziplist或者hashtable。

hashtable编码哈希

hashtable编码hash简图

typedef struct dictEntry {
    void *key;//键
    union {//值
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    struct dictEntry *next;//指向下个哈希表节点,形成链表
} dictEntry;

typedef 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;

typedef struct dictht {
    dictEntry **table;//哈希表数组
    unsigned long size;//哈希表大小
    unsigned long sizemask;//哈希表大小眼吗,用于计算索引,总是等于size-1
    unsigned long used;//该哈希表已有节点的数量
} dictht;

typedef struct dict {
    dictType *type;//类型特定函数
    void *privdata;//私有数据
    dictht ht[2];//哈希表
    long rehashidx; //rehash索引,不进行rehash时总是等于-1
    int iterators; //正在运行中的迭代器数量
} dict;

hashtable编码hash详细图

哈希算法

当要将一个新的键值对添加到哈希里时,程序需要先根据键值对的键计算出哈希值和索引值,然后再根据索引值,将包含新键值对的哈希节点放到哈希表数组的指定索引上。

# 使用哈希设置的哈希函数,计算key的哈希值
hash=dict->type->hashFunction(key);

# 使用哈希表的sizemask属性和哈希值,计算出索引值
# 根据情况不同,ht[x]可以是ht[0]或者ht[1]
index=hash & dict->ht[x].sizemask;

解决键冲突

当有两个或以上数量的键被分配到了哈希表数组的同一个索引上面时,我们称这些键发生了冲突。

Redis的哈希表使用链地址法来解决哈希冲突,每个哈希表节点都有一个next指针,多个哈希表节点可以用next指针构成一个单向链表,被分配到同一个索引上的多个节点可以用这个单向链表链接起来,这就解决了键哈希冲突的问题。

因为dictEntry节点组成的链表没有指向表尾的指针,所以为了速度考虑,程序总是将新节点添加到链表的表头位置(复杂度为O(1)),排在其他已有的节点前面。(头插法)

rehash

随着操作的不断执行,哈希表保存的键值对会逐渐地增多或减少,为了让哈希表的负载因子维持在一个合理的范围之内,当哈希表保存的键值对数量太多或太少时,程序需要对哈希表的大小进行相应的扩展或者收缩。

扩展和收缩哈希表的工作可以通过执行rehash操作来完成,Redis对哈希表执行rehash的步骤如下:

  1. 为哈希表ht[1]分配空间,这个哈希表的空间大小取决于要执行的操作,以及ht[0]当前包含的键值对数量(也就是ht[0].used属性的值):
    • 如果执行的是扩展操作,那么ht[1]的大小为第一个大于等于ht[0].used*2的2^n(2的n次方幂)
    • 如果执行的是收缩操作,那么ht[1]的大小为第一个大于等于ht[0].used*2的2^n
  2. 将保存在ht[0]中的所有键值对rehash到ht[1]上:rehash指的是重新计算键的哈希值和索引值,然后将键值对放置到ht[1]哈希表的指定位置上。 3.当ht[0]包含的所有键值对都迁移到ht[1]之后,释放ht[0],将ht[1]设置为ht[0],并在ht[1]新创建一个空的哈希表,为下一次rehash做准备。

哈希表的扩展与收缩

当以下条件的任意一个被满足时,程序会自动开始对哈希表执行扩展操作:

  1. 服务器目前没有在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于1。
  2. 服务器目前正在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于5。

其中哈希表的负载因子可以通过以下公式计算:

# 负载因子=哈希表已保存的节点数量 / 哈希表的大小
load_factor=ht[0].used / ht[0].size

另一方面,当哈希表的负载因子小于0.1时,程序会自动开始对哈希表执行收缩操作。

渐进式rehash

如上所说,扩展或收缩哈希表需要将ht[0]里面的所有键值对rehash到ht[1]里,但是这个rehash动作并不是一次性、集中式地完成的,而是分多次、渐进式地完成的。原因在于,如果rehash操作的键值对数量过多,会导致服务器在一段时间内停止服务,因此,为了避免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的过程中,字典会同时使用ht[0]和ht[1]两个哈希表,所以在渐进式rehash进行期间,字典的删除、查找、更新等操作都会在两个哈希表上完成;而添加操作,新添加的键值对一律保存到ht[1]里,这一措施保证了ht[0]包含的键值对只减不增,并随着rehash操作的执行而最终变成空表。

dict的主要操作API

函数作用时间复杂度
dictCreate创建一个新的字典O(1)
dictAdd将给定的键值对添加到字典里O(1)
dictReplace将给定的键值对添加到字典里,如果键已经存在于
字典,则用新值取代旧值
O(1)
dictFetchValue返回给定键的值O(1)
dictGetRandomKey从字典中随机返回一个键值对O(1)
dictDelete从字典中删除给定键对应的键值对O(1)
dictRelease释放给定字典,以及字典中包含的所有键值对O(N)

压缩列表编码哈希

上面学过压缩列表,ziplist编码的哈希对象,每当有新的键值对要加入到哈希对象时,程序会先将保存了键的压缩列表节点推入道压缩列表表尾,然后键值对的值紧随其后。因此,ziplist编码的哈希对象具有如下特性:

  • 保存了同一键值对的两个节点总是紧挨在一起,保存键的节点在前,保存值的节点在后;
  • 先添加到哈希对象中的键值对会被放在压缩列表的表头方向,而后来添加的键值对会被放到压缩列表的表尾方向。

ziplist编码哈希

哈希编码转换

当哈希对象可以同时满足以下两个条件时,哈希对象使用ziplist编码:

  • 哈希对象保存的所有键值对的键和值的字符串长度都小于64字节;
  • 哈希对象保存的键值对数量小于512个。

不能满足这两个条件的哈希对象需要使用hashtable编码。

哈希主要操作命令

HSET key field value #设置 key 指定的哈希集中指定字段的值。如果key指定的哈希集不存在,会创建一个新的哈希集并与key关联。如果字段在哈希集中存在,它将被重写
HGET key field #返回 key 指定的哈希集中该字段所关联的值
HEXISTS key field #返回hash里面field是否存在
HDEL key field [field ...] #从key指定的哈希集中移除指定的域,在哈希集中不存在的域将被忽略
HLEN key #返回key指定的哈希集包含的字段的数量
HGETALL key #返回 key 指定的哈希集中所有的字段和值。返回值中,每个字段名的下一个是它的值,所以返回值的长度是哈希集大小的两倍

集合

集合对象的编码可以是intset或者hashtable。

intset编码集合

intset编码整数集合带obj

typedef struct intset {
    uint32_t encoding;//编码方式
    uint32_t length;//集合包含的元素个数
    int8_t contents[];//保存的元素数组
} intset;

contents数组是整数集合的底层实现:整数集合的每个元素都是contents数组的一个数组项,各个项在数组中按值得大小从小到大有序地排列,并且数组中不包含任何重复项。

length属性记录了整数集合包含的元素数量,即contents数组的长度。

contents数组的真正类型取决于encoding属性的值:

encoding值contents类型
INTSET_ENC_INT16int16_t
INTSET_ENC_INT32int32_t
INTSET_ENC_INT64int64_t

intset编码整数集合

升级

每当我们要将一个新元素添加到整数集合里面,并且新元素的类型比整数集合现有所有元素的类型都长时,整数集合需要先进行升级,然后才能将新元素添加到整数集合里面:

步骤分三步进行:

  1. 根据新元素的类型,扩展整数集合底层数组的空间大小,并为新元素分配空间。
  2. 将底层数组现有的所有元素都转换成与新元素相同的类型,并将类型转换后的元素放置到正确的位上,而且在放置元素的过程中,需要继续维持底层数组的有序性不变。
  3. 将新元素添加到底层数组里面。

升级的好处:整数集合的升级策略有两个好处,一个是提升整数集合的灵活性,另一个是尽可能地节约内存。

  1. 提升灵活性:因为c语言是静态类型语言,为了避免类型错误,我们通常不会将两种不同类型的值放在同一个数据结构里面,但是因为整数集合可以通过自动升级底层数组来适应新元素,所以我们可以随意地将int16_t、int32_t、int64_t类型的整数添加到集合中,而不必担心出现类型错误,这种做法非常灵活。
  2. 节约内存:要让一个数组可以同时保存int16_t、int32_t、int64_t三种类型的值,最简单的方式是直接使用int64_t类型的数组作为整数集合的底层实现。不过这样一来,即使只使用int16_t、int32_t的值,而数组都需要int64_t的空间去保存它们,从而出现浪费内存的情况。而整数集合现在的做法既可以让集合能同时保存三种不同类型的值,又可以确保升级只会在有需要的时候进行,这可以进来节省内存。

降级:整数集合不支持降级,一旦对数组进行了升级,编码就会一直保持升级后的状态。

整数集合API

函数作用时间复杂度
intsetNew创建一个新的压缩列表O(1)
intsetAdd将给定元素添加到整数集合里面O(N)
intsetRemove从整数集合中移除给定元素O(N)
intsetFind检查值是否存在于集合O(logN)
intsetRandom从整数集合中随机返回一个元素O(1)
intsetGet取出底层数组在给定索引上的元素O(1)
intsetLen返回整数集合包含的元素个数O(1)
intsetBlobLen返回整数集合占用的内存字节数O(1)

hashtable编码集合

集合对象的编码也可以是hashtable,实现原理类似java中的hashmap和hashset,即把set的元素作为map的key来存储,这样保证了set元素不重复。

hashtable编码集合

集合编码转换

当集合对象可以同时满足以下两个条件时,对象使用intset编码:

  • 集合对象保存的所有元素都是整数值;
  • 集合对象保存的元素数量不超过512个。

不能满足这两个条件的集合对象需要使用hashtable编码。

集合主要操作命令

SADD key member [member ...] #添加一个或多个指定的member元素到集合的key中
SCARD key #返回集合存储的key的基数 (集合元素的数量)
SISMEMBER key member #返回成员member是否是存储的集合key的成员
SMEMBERS key #返回key集合所有的元素
SRANDMEMBER key [count] #仅提供key参数,那么随机返回key集合中的一个元素,如果count是整数且小于元素的个数,返回含有 count 个不同的元素的数组,如果count是个整数且大于集合中元素的个数时,仅返回整个集合的所有元素,当count是负数,则会返回一个包含count的绝对值的个数元素的数组,如果count的绝对值大于元素的个数,则返回的结果集里会出现一个元素出现多次的情况
SPOP key [count] #从存储在key的集合中移除并返回一个或多个随机元素
SREM key member [member ...] #在key集合中移除指定的元素
SDIFF key [key ...] #返回一个集合与给定集合的差集的元素
SINTER key [key ...] #返回指定所有的集合的成员的交集
SUNION key [key ...] #返回给定的多个集合的并集中的所有成员

有序集合

有序集合的编码可以是ziplist或者skiplist。

跳表

typedef struct zskiplistNode {
    robj *obj;//成员对象
    double score;//分值
    struct zskiplistNode *backward;//后退指针
    struct zskiplistLevel {//层
        struct zskiplistNode *forward;
        unsigned int span;
    } level[];
} zskiplistNode;

typedef struct zskiplist {
    struct zskiplistNode *header, *tail;//表头和表尾节点
    unsigned long length;//表中节点的数量
    int level;//表中层数最大的节点的层数
} zskiplist;

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

skiplist编码有序集合

其中每一个StringObject的详细图如下:

StringObject的详细图

在同一个跳表中,各个节点保存的成员对象必须是唯一的,但是多个节点保存的分值却可以是相同的:分值相同的节点将按照成员对象在字典序中的大小来进行排序,成员对象较小的节点会排在前面,而成员对象较大的节点则会排在后面。

跳表API

函数作用时间复杂度
zslCreate创建一个新的跳表O(1)
zslFree释放给定跳表,以及表中包含的所有节点O(N)
zslInsert将包含给定成员和分值的新节点加入到跳表中平均O(logN),最坏O(N)
zslDelete删除跳表中包含给定成员和分值的节点平均O(logN),最坏O(N)
zslGetRank返回包含给定成员和分值的节点在跳表中的排位平均O(logN),最坏O(N)
zslGetElementByRank返回跳表在给定排位上的节点平均O(logN),最坏O(N)
zslIsInRange给定一个分支范围,如果跳表中至少一个节点在这个范围内就返回1,否则返回0O(1)
zslFirstInRange给定一个分值范围,返回跳表中第一个符合这个范围的节点平均O(logN),最坏O(N)
zslLastInRange给定一个分值范围,返回跳表中最后一个符合这个范围的节点平均O(logN),最坏O(N)
zslDeleteRangeByScore给定一个分值范围,删除跳表中所有在这个范围之内的节点O(N)
zslDeleteRangeByRank给定一个排位范围,删除跳表中所有在这个范围之内的节点O(N)

ziplist编码有序集合

ziplist编码的有序集合对象使用压缩列表作为底层实现,每个集合元素使用两个紧挨在一起的压缩列表节点来保存,第一个节点保存元素的成员,而第二个元素则保存元素的分值。

压缩列表内的集合元素按照分值从小到大进行排序,分值较小的元素被放置在靠近表头的方向,而分值较大的元素则被放置在靠近表尾的方向。

ziplist编码有序集合

有序集合编码转换

当有序集合对象可以同时满足以下两个条件时,对象使用ziplist编码:

  • 有序集合保存的元素数量小于128个;
  • 有序集合保存的所有元素成员的长度都小于64字节。

不能满足以上两个条件的有序集合对象将使用skiplist编码。

有序集合主要操作命令

ZADD key score member [score member ...] #将所有指定成员添加到键为key有序集合里面
ZCARD key #返回key的有序集元素个数
ZCOUNT key min max #返回有序集key中,score值在min和max之间(默认包括score值等于min或max)的成员
ZRANGE key start stop #返回存储在有序集合key中的指定范围的元素。 返回的元素可以认为是按得分从最低到最高排列。 如果得分相同,将按字典排序
ZREVRANGE key start stop #返回有序集key中,指定区间内的成员。其中成员的位置按score值递减(从大到小)来排列
ZRANK key member #返回有序集key中成员member的排名
ZREVRANK key member #返回有序集key中成员member的排名,其中有序集成员按score值从大到小排列
ZREM key member [member ...] #删除有序集key的member成员
ZSCORE key member #返回有序集key中,成员member的score值

总结

以上内容大部分参考《Redis设计与实现》,命令部分参考的是Redis中文官网-命令中心。在这篇文章中,学习到了Redis五种基础数据类型,以及它们的底层实现,还有这些数据类型的常用操作命令。在下一篇文章中将学习Redis单机数据库、持久化机制与事件。