Redis的底层数据结构

目录

Redis对象概述 

Redis对象的类型type和编码encoding

类型type

编码encoding

ptr属性

字符串对象

编码类型

int编码 

embstr编码

raw编码

字符串对象编码转换

字符串对象底层数据结构

简单动态字符串SDS

SDS和C的字符串的区别

列表对象

编码类型

列表对象编码转换

列表对象底层数据结构

链表

压缩列表

哈希对象

ziplist编码

hashtable编码

哈希对象编码转换

哈希对象的底层数据结构

压缩列表

字典(字典实现包括哈希表和哈希表节点)

字典实现

集合对象

编码类型

编码转换

集合对象的底层数据结构

整数集合

字典

有序集合对象

编码类型

ziplist编码

skiplist编码

编码转换

有序集合对象底层数据结构

压缩列表

跳跃表

 总结:

其他拓展

内存回收

对象共享

对象空转时长


Redis对象概述 

Redis 是一个基于内存的键值对(key-value)的分布式存储系统,Redis 数据库里面的每个键值对(key-value pair) 底层都是由对象(redisObject)组成的,其中

  • Redis的key总是一个字符串对象
  • Redis的value可以是字符串(String)、哈希(Hash)、列表(List)、集合(Set)以及有序集合(Zset)五种对象中的其中一种

Redis对象的类型type和编码encoding

Redis使用对象来表示key和value,在Redis数据库中,新创建一个键值对时,至少会创建两个对象,一个键对象,一个值对象

Redis中的每个对象都由redisObject结构表示。redisObject结构与保存数据相关的三个属性分别是:type属性、encoding属性和ptr属性。

typedef struct redisObject {
    // 类型
    unsigned type:4;

    // 编码
    unsigned encoding:4;

    // 底层数据结构的指针
    void *ptr;

    //...
} robj;

类型type

redisObject对象的type属性记录了不同的对象的类型,这个属性值可以是如下的其中之一:

类型常量(type)对象类型名称
REDIS_STRING字符串对象
REDIS_LIST列表对象
REDIS_HASH哈希对象
REDIS_SET集合对象
REDIS_ZSET有序集合对象

Redis数据库中,key总是字符串对象,而value可以是五种对象类型的其中之一,一般我们称Redis的数据类型指的是key对应的value的值数据类型。同样,通过type命令返回的也是数据库key对应的value对象的类型,type命令如下:

TYPE key
返回 key 所储存的值的类型。

时间复杂度:
O(1)

返回值:
none (key不存在)
string (字符串)
list (列表)
set (集合)
zset (有序集)
hash (哈希表)

redis> SET weather "sunny"
OK
redis> TYPE weather
string


redis> LPUSH book_list "programming in scala"
(integer) 1

redis> TYPE book_list
list

编码encoding

对象的ptr指针指向对象的底层数据实现数据结构,数据结构由encoding属性决定。

encoding属性记录对象使用的编码,即对象使用什么数据结构作为对象的底层实现,这个属性值可以是如下的其中之一:

编码常量encoding编码对应的底层数据结构
REDIS_ENCODING_INTlong类型的整数
REDIS_ENCODING_EMBSTRemstr编码的简单动态字符串
REDIS_ENCODING_RAW简单动态字符串
REDIS_ENCODING_HT字典
REDIS_ENCODING_LINKEDLIST双端链表
REDIS_ENCODING_ZIPLIST压缩列表
REDIS_ENCODING_INTSET 整数集合
REDIS_ENCODING_SKIPLIST 跳跃表和字典

 可以看出每种类型的对象都至少使用了两种不同的编码,如下:

类型type编码encoding对象
REDIS_STRINGREDIS_ENCODING_INT使用整数值实现的字符串对象
REDIS_STRINGREDIS_ENCODING_EMBSTR使用embstr编码的简单动态字符串实现的字符串对象
REDIS_STRINGREDIS_ENCODING_RAW使用简单动态字符串实现的字符串对象
REDIS_LISTREDIS_ENCODING_ZIPLIST使用压缩列表实现的列表对象
REDIS_LISTREDIS_ENCODING LINKEDLIST使用双端链表实现的列表对象
REDIS_HASHREDIS_ENCODING_ZIPLIST使用压缩列表实现的哈希对象
REDIS_HASHREDIS_ENCODING_HT使用字典实现的哈希对象
REDIS_SETREDIS_ENCODING_INTSET使用整数集合实现的集合对象
REDIS_SETREDIS_ENCODING_HT使用字典实现的集合对象
REDIS_ZSETREDIS ENCODING ZIPLIST使用压缩列表实现的有序集合对象
REDIS_ZSETREDIS_ENCODING_SKIPLIST使用跳跃表和字典实现的有序集合对象

使用OBJECT ENCODING 命令可以查看一个数据库键的值对象的编码

redis> SET game "lol"           # 设置一个字符串
OK
redis> OBJECT ENCODING game     # 字符串的编码方式
"embstr"

redis> SET story "Long long ago there lived a king..."    # 设置一个长字符串
OK
redis> OBJECT ENCODING game     # 字符串的编码方式
"raw"

redis> SET phone 15820123123    # 大的数字也被编码为字符串
OK
redis> OBJECT ENCODING phone
"raw"

redis> SET age 20               # 短数字被编码为 int
OK
redis> OBJECT ENCODING age
"int"

对象可以以多种方式编码:

  • 字符串可以被编码为int (整数)、embstr(embstr编码的SDS)和 raw (SDS)
  • 列表可以被编码为 ziplist 或 linkedlist 。 ziplist 是为节约大小较小的列表空间而作的特殊表示。
  • 集合可以被编码为 intset 或者 hashtable 。 intset 是只储存数字的小集合的特殊表示。
  • 哈希表可以编码为 zipmap 或者 hashtable 。 zipmap 是小哈希表的特殊表示。
  • 有序集合可以被编码为 ziplist 或者 skiplist 格式。 ziplist 用于表示小的有序集合,而 skiplist 则用于表示任何大小的有序集合。

通过encoding属性设定对象使用的编码,而不是为特定类型的对象关联固定的编码,这样极大提高Redis的效率和灵活度,Redis可以根据不同场景为一个对象设置不同的编码,进而提高效率。比如:在列表对象包含的元素比较少时,Redis使用压缩列表作为列表对象的底层实现:

  1. 因为压缩列表比双端链表更节约内存,并且在元素数量较少时,在内存中以连续块方式保存的压缩列表比起双端链表可以更快被载人到缓存中;
  2. 随着列表对象包含的元素越来越多,使用压缩列表来保存元素的优势逐渐消失时,对象就会将底层实现从压缩列表转向功能更强、也更适合保存大量元素的双端链表上面;

ptr属性

对象的ptr指针指向对象的底层数据实现数据结构,Redis的底层数据结构主要有以下几种:

  • 简单动态字符串
  • 链表
  • 字典
  • 跳跃表
  • 整数集合
  • 压缩列表

字符串对象

编码类型

字符串对象的编码可以是int、embstr或者raw

int编码 

如果一个字符串对象保存的是整数值并且这个整数值可以用long类型表示,那么该字符串对象会将整数值保存在字符串对象结构的ptr属性中(将void*转换成long),并将字符串对象编码设置成int

如下set number 10086,就会创建int编码的字符串对象作为number键的值

embstr编码

如果字符串对象保存的是长度小于等于39个字节,编码类型则为embstr ,底层数据结构就是embstr编码的SDS。

embstr编码是专门用于保存短字符串的一种优化编码方式,embstr编码和raw编码一样都是使用redisObject结构和sdshdr结构,执行命令产生的效果是相同的,区别在于:

  • embstr的使用调用一次内存分配函数来分配一块连续内存空间,空间依次包含了redisObejct和sdshdr两个结构,如下图

embstr

embstr编码的好处在于:创建时少分配一次空间,删除时少释放一次空间,因为内存是连续的,所以寻找也方便。坏处是长度增加要重新分配内存,因此一般是只读的。

raw编码

如果字符串对象保存的是长度大于39字节的字符串,此时编码类型即为raw,其底层数据结构是简单动态字符串(SDS);

  • raw的使用需要调用两次内存分配函数分别创建redisObject结构和sdshdr结构

在这里插入图片描述

字符串对象编码转换

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

  • 其中对于int编码来说,当我们修改这个字符串为不再是整数值的时候,此时字符串对象的编码就会从int变为raw;
redis> SET number 10086           
OK
redis> OBJECT ENCODING number
"int"

redis> APPEND number "is a good number"      
(integer)23

redis> GET number 
"10086 is good number"

redis> OBJECT ENCODING number
"raw"
  • 对于embstr编码来说,只要我们修改了字符串的值,此时字符串对象的编码就会从embstr变为raw。因为Redis没有为embstr编码的字符串对账编写任何修改程序,所以实际上embstr编码的字符串对象是只读的,当我们要修改embstr编码字符串时,都是先将转换为raw编码,然后再进行修改。
redis> SET msg "hello world"           
OK
redis> OBJECT ENCODING msg
"embstr"

redis> APPEND msg "again"      
(integer)18

redis> OBJECT ENCODING msg
"raw"

拓展:为什么raw和embstr的区分长度是39个字节

redisObject的长度是16,sds的长度是 9 + 字符串长度。因此embstr的长度正好是: 16 + 9 + 39 = 64字节

使用long double类型保存浮点型

可以用long double类型表示浮点数,在Redis中也是作为字符串值保存的,程序会将浮点数转换成字符串值,保存转换后的字符串值

redis> SET pi 3.14     
OK
redis> OBJECT ENCODING pi
"embstr"

redis> INCRBYFLOAT pi 2.0    
"5.14

redis> OBJECT ENCODING pi
"embstr"

总结: 

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

字符串对象底层数据结构

简单动态字符串SDS

Redis 是用 C 语言写的,但是对于Redis的字符串,却不是 C 语言中的字符串(以空字符结尾的字符数组),而是自己构建了一种名为简单动态字符串(simple dynamic string SDS)的抽象类型,并将SDS用作Redis 的默认字符串表示,使用sdshdr数据结构表示SDS:

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

SDS遵循了C字符串以空字符结尾的惯例,保存空字符的1字节不会计算在len属性里面。例如,Redis这个字符串在SDS里面的数据可能是如下形式:

sdshdr

这个1个字节的空字符串对于SDS使用者来说完全是透明的,之所以遵循空串结尾的好处SDS是可以直接重用一部分C字符串函数库的函数

SDS和C的字符串的区别

C语言使用长度为N+1的字符数组来表示长度为N的字符串,并且字符串的最后一个元素是空字符\0。Redis采用SDS相对于C字符串有如下几个优势:

  • 常数复杂度获取字符串长度

因为C字符串并不记录自身的长度信息,所以为了获取字符串的长度,必须遍历整个字符串,时间复杂度是O(N);而SDS使用len属性记录了字符串的长度,因此获取SDS字符串长度的时间复杂度是O(1)

  • 杜绝缓冲区溢出

C字符串不记录自身长度带来的另一个问题是很容易造成缓存区溢出。比如使用字符串拼接函数(stract)的时候,很容易覆盖掉字符数组原有的数据。与C字符串不同,SDS的空间分配策略完全杜绝了发生缓存区溢出的可能性。当SDS进行字符串扩充时,首先会检查当前的字节数组的长度是否足够,如果不够的话,会先进行自动扩容,然后再进行字符串操作。

  • 减少修改字符串时带来的内存重分配次数

C字符串不记录自身长度,底层是一个N+1字符长的数组,C字符串的长度和底层数据是紧密关联的,所以每次增长或者缩短一个字符串,程序都要对这个数组进行一次内存重分配:

如果是增长字符串操作,需要先通过内存重分配来扩展底层数组空间大小,不这么做就导致缓存区溢出。

如果是缩短字符串操作,需要先通过内存重分配来来回收不再使用的空间,不这么做就导致内存泄漏。

因为内存重分配涉及复杂的算法,并且可能需要执行系统调用,所以通常是个比较耗时的操作。对于Redis来说,字符串修改是一个十分频繁的操作,如果每次都像C字符串那样进行内存重分配,对性能影响太大了,显然是无法接受的。

SDS通过空闲空间解除了字符串长度和底层数据之间的关联。在SDS中,数组中可以包含未使用的字节,这些字节数量由free属性记录。通过空闲空间,SDS实现了空间预分配和惰性空间释放两种优化策略。

1.空间预分配:空间预分配是用于优化SDS字符串增长操作的,简单来说就是当字节数组空间不足触发重分配的时候,总是会预留一部分空闲空间。这样的话,就能减少连续执行字符串增长操作时的内存重分配次数。有两种预分配的策略:

                len小于1MB时:每次重分配时会多分配同样大小的空闲空间;
                len大于等于1MB时:每次重分配时会多分配1MB大小的空闲空间。

2.惰性空间释放:惰性空间释放是用于优化SDS字符串缩短操作的,简单来说就是当字符串缩短时,并不立即使用内存重分配来回收多出来的字节,而是用free属性记录,等待将来使用。SDS也提供直接释放未使用空间的API,在需要的时候,也能真正的释放掉多余的空间。
 

  • 二进制安全

C字符串中的字符必须符合某种编码,并且除了字符串末尾之外,其它位置不允许出现空字符,否则被程序读入的空字符串将被误认为是字符串结尾,这些限制使得C字符串只能保存文本数据。而无法保存像图片、音频、视频这样的二进制数据,但是对于Redis来说,不仅仅需要保存文本,还要支持保存二进制数据。因为SDS使用len属性的值来判断字符串是否结束而非空字符。同样SDS的API全部做到了二进制安全(binary-safe)。

总结:

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

列表对象

编码类型

列表对象的编码可以是linkedlist编码或者ziplist编码,对应的底层数据结构是链表和压缩列表。

列表对象编码转换

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

  1. 列表对象保存的所有字符串长度都小于64个字节
  2. 列表对象保存的元素数量小于512个

不能满足这两个条件的列表对象都是用linkedlisted编码,当然这两个上限值可以通过配置文件hash-max-ziplist-value选项和hash-max-ziplist-entry选项修改

列表对象底层数据结构

链表

链表是一种非常常见的数据结构,提供了高效的节点重排能力以及顺序性的节点访问方式。在Redis中,每个链表节点使用listNode结构表示:

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

 多个listNode通过prev和next指针组成双端链表,如下图所示:

listnode

为了操作起来比较方便,Redis使用了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;

list结构为链表提供了表头指针head、表尾指针tail,以及链表长度计数器len,而dup、free和match成员则是实现多态链表所需类型的特定函数。其中:

  • dup函数用于赋值链表节点所保存的值
  • free函数用于释放链表节点锁保存的值
  • match函数则用于对比链表节点所保存的值和另一个输入值是否相等

以下是一个由list结构和三个listNode结构组成的链表

list

Redis链表实现的特性

  1. 双端:链表节点带有prev和next指针,获取某个节点的前置节点和后置节点的复杂度都是O(n)。
  2. 无环:表头节点的prev指针和表尾节点的next指针都指向NULL,对链表的访问以NULL为终点。
  3. 带表头指针和表尾指针:通过list结构的head指针和tail指针,程序获取链表的表头节点和表尾节点的复杂度为O(1)。
  4. 带链表长度计数器:程序使用list结构的len属性来对list持有的节点进行计数,程序获取链表中节点数量的复杂度为O(1)。
  5. 多态:链表节点使用void*指针来保存节点值,可以保存各种不同类型的值。

压缩列表

压缩列表不仅是列表键的底层实现之一,同样也是哈希键的底层实现之一,当列表项中只包含少量列表项并且每个列表项要么就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来做列表键的底层实现,如下:

redis> LPUSH lst 1,3,4,12306,"hello", "world"
(integer) 6
redis> OBJECT ENCODING lst 
"ziplist"

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

在这里插入图片描述

压缩列表各个组成部分详细说明

属性类型长度用途
zlbytesuint_32_t4字节记录整个压缩列表占用的内存字节数
zltailuint_32_t4字节记录压缩列表表尾节点距离起始地址有多少字节,通过这个偏移量,程序无需遍历整个压缩列表就能确定表尾节点地址
zlenuint_16_t2字节记录压缩列表包含的节点数量
entryX列表节点不定压缩列表的各个节点,节点长度由保存的内容决定
zlenduint_8_t1字节特殊值(0xFFF),用于标记压缩列表末端

如下图:

  • 列表zybytes属性值为0x50(十进制80),表示压缩列表总长80个字节
  • 列表zltail属性的值为0x3c(十进制60),表示如果有一个指向压缩列表起始地址的指针p,那么只要这个指针加上60偏移量就可以计算表尾节点entry3的地址
  • 列表zllen属性为0x3(十进制3),表示压缩列表包含三个节点

压缩列表节点组成

每个压缩列表的节点可以保存一个字节数组或者一个整数值,其中字节数组可以是以下三种长度其中一:

  • 长度小于等于63(2^6–1)字节的字节数组
  • 长度小于等于16383(2^14–1)字节的字节数组
  • 长度小于等于4294967295(2^32–1)字节的字节数组;

而整数值则可以是以下六种长度的其中一种:

  • 4位长,介于0至12之间的无符号整数;
  • 1字节长的有符号整数;
  • 3字节长的有符号整数;
  • int16_t类型整数;
  • int32_t类型整数;
  • int64_t类型整数

每个压缩列表节点都由previous_entry_length、encoding、content三个部分组成

previous_entry_length

节点的previous_entry_length属性以字节为单位,记录了压缩列表中前一个节点的长度。长度可以是1字节或者5字节

  • 如果前一节点的长度小于254字节,那么previous_entry_length属性的长度为1字节,此时前一节点的长度就保存在这一个字节里面
  • 如果前一节点的长度大于等于254字节,那么previous_entry_length属性的长度为5字节:其中属性的第一字节会被设置为0xFE(十进制值254),而之后的四个字节则用于保存前一节点的长度

previoust_entry_length属性记录前一个节点长度,所以程序可以通过指针根据当前节点地址计算出前一个节点的起始长度

encoding

记录了节点的content属性所保存数据的类型以及长度,字节数组编码如下表:

编码编码长度content属性保存的值
00bbbbbb1字节长度小于等于63(2^6 -1)字节的字节数组
01bbbbbb xxxxxxxx2字节长度小于等于16383(2^14 -1)的字节数组
10_ _ _ _ __ aaaaaaaa bbbbbbbb cccccccc dddddddd5字节长度小于等于4294967295(2^32 -1)的字节数组

整数编码如下:

编码编码长度content属性保存的值
110000001字节int16_t类型整数
110100001字节int32_t类型整数
111000001字节int64_t类型整数
111100001字节24位有符号证书
111111101字节8位有符号证书
1111xxxx1字节这一编码没有响应的content属性,因为编码本身的xxxx四位已经保存了一个介意0-12之间的值,无需在保存到content

content

节点content属性负责保存节点的值,可以是一个字节数组或者证书,值的类型和长度由节点的encoding属性决定,如下:

哈希对象

哈希对象的编码可以是ziplist或者hashtable。对应的底层数据结构是压缩列表和哈希表

ziplist编码

ziplist编码的哈希对象使用也是使用压缩列表的编码形式,每当有新得键值对要加入到哈希对象时候,先将保存了键的节点推入到压缩列表表尾,然后再将保存了值的节点推入到压缩列表表尾,比如执行如下三条HSET命令:

HSET profile name "tom"
HSET profile age 25
HSET profile career "Programmer"

如果此时使用profile键的值使用的是ziplist编码,那么该对象在内存中的结构如下:
在这里插入图片描述

可以看出第一个添加的哈希对象对靠近压缩列表表头,后来添加的哈希对象放到压缩列表的表尾,同时,同一哈希对象中,保存键的节点再前,保存值的节点在后。

hashtable编码

hashtable编码的哈希对象使用字典作为底层实现,哈希对象中每个键值对都使用一个字典键值来保存,当一个哈希键包含的键值对比较多,或者键值对重的元素都是比较长的字符串,Redis就会使用字典作为哈希键的底层实现

  • 字典的每个键都是一个字符串对象,对象中保存了键值对的键
  • 字典的每个值都是一个字符串对象,对象中保存了键值对的值

哈希对象编码转换

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

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

无法同时满足上述两个条件的哈希对象都需要使用hashtable编码,当然这两个上限值可以通过配置文件hash-max-ziplist-value选项和hash-max-ziplist-entry选项修改

哈希对象的底层数据结构

压缩列表

同列表对象底层数据结构,元素在压缩列表的位置如上图

字典(字典实现包括哈希表和哈希表节点)

哈希表

Redis中字典使用哈希表作为底层实现,哈希表由dictht结构表示

typedef struct dictht{
    // 哈希表数组
    dictEntry **table;

    // 哈希表大小
    unsigned long size;

    // 哈希表大小掩码,用于计算索引值
    // 总是等于 size-1
    unsigned long sizemask;

    // 该哈希表已有节点数量
    unsigned long used;
} dictht

table属性是一个数组,数组中的每个元素都是一个指向dictEntry结构的指针,每个dictEntry结构保存着一个键值对。size属性记录了哈希表的大小,即table数组的大小。used属性记录了哈希表目前已有节点数量。sizemask总是等于size-1,这个值主要用于数组索引。比如下图展示了一个大小为4的空哈希表。

dictht

哈希表节点

哈希表节点 哈希表节点使用dictEntry结构表示,每个dictEntry结构都保存着一个键值对:

typedef struct dictEntry {
    // 键
    void *key;

    // 值
    union {
        void *val;
        unit64_t u64;
        nit64_t s64;
    } v;

    // 指向下一个哈希表节点,形成链表
    struct dictEntry *next;
} dictEntry;

key属性保存着键值对中的键,而v属性则保存了键值对中的值。值可以是一个指针,一个uint64_t整数或者是int64_t整数。next属性指向了另一个dictEntry节点,在数组桶位相同的情况下,将多个dictEntry节点串联成一个链表,以此来解决键冲突问题。(链地址法)

字典实现

Redis中的字典由dict结构表示:

typedef struct dict {

    // 类型特定函数
    dictType *type;

    // 私有数据
    void *privdata;

    // 哈希表
    dictht ht[2];

    //rehash索引
    // 当rehash不在进行时,值为-1
    int rehashidx;
}

其中type属性和privdata属性是针对不同类型的键值对,为创建多态字典而设置的,ht是大小为2,且每个元素都指向dictht哈希表。一般情况下,字典只会使用ht[0]哈希表,ht[1]哈希表只会在对ht[0]哈希表进行rehash时使用。rehashidx记录了rehash的进度,如果目前没有进行rehash,值为-1。

dict

哈希算法和键冲突

Redis中新的键值添加到字典时,会根据字典设置的哈希函数计算key哈希值,同时根据哈希表的sizemask属性和哈希值计算出索引值,最后将哈希表节点放到哈希表数组的指定索引上面。

当有两个或以上的键被分配到同一个哈希表数组的同一索引上,Redis使用链地址法来解决冲突,每个哈希表节点都有一个next指针,多个哈希表节点通过next指针构成一个单向链表

Rehash

随着不断操作,哈希表保存的键值对会逐渐的增多或者减少,为了让哈希表的负载因子维持在一个合理区间,需要对哈希表进行响应的扩展或者收缩,即需要rehash操作,步骤如下:

1.为ht[1]哈希表分配空间

        1.如果是扩展操作,那么ht[1]的大小为第一个大于等于ht[0].used*2的下一个2的n次幂。比如                 ht[0].used=5,那么此时ht[1]的大小就为16

        2.如果是收缩操作,那么ht[1]的大小为第一个大于ht[0].used的下一个2的n次幂。比如                         ht[0].used=5,那么此时ht[1]的大小就为8。

2.将保存在ht[0]中的所有键值对rehash到ht[1]中。

3.迁移完成之后,释放掉ht[0],并将现在的ht[1]设置为ht[0],在ht[1]新创建一个空白哈希表,为下一次rehash做准备

哈希表的扩展和收缩时机:

  • 当服务器没有执行BGSAVE或者BGREWRITEAOF命令时,负载因子大于等于1触发哈希表的扩展操作。
  • 当服务器在执行BGSAVE或者BGREWRITEAOF命令,负载因子大于等于5触发哈希表的扩展操作。
  • 当哈希表负载因子小于0.1,触发哈希表的收缩操作。

渐进式rehash

上面提到的扩展或者收缩需要将ht[0]里面的元素全部rehash到ht[1]中,但是这个rehash动作不是一次性集中式完成,而是可以分多次和渐进式的完成,避免在大量键值对全部rehash时造成的性能影响,具体步骤如下:

  1. 为ht[1]分配空间。,字典同时持有ht[0]和ht[1]两个哈希表
  2. 在字典中维持一个索引计数器变量rehashidx,并将它的值设置为0,表示rehash正式开始。
  3. 在rehash进行期间,每次对字典执行添加、删除、查找或者更新时,除了会执行相应的操作之外,还会顺带将ht[0]在rehashidx索引位上的所有键值对rehash到ht[1]中,rehash完成之后,rehashidx值加1。
  4. 随着字典操作的不断进行,最终会在啊某个时刻迁移完成,此时将rehashidx值置为-1,表示rehash结束。

渐进式rehash一次迁移一个桶上所有的数据,设计上采用分而治之的思想,将原本集中式的操作分散到每个添加、删除、查找和更新操作上,从而避免集中式rehash带来的庞大计算。 因为在渐进式rehash时,字典会同时使用ht[0]和ht[1]两张表,所以此时对字典的删除、查找和更新操作都可能会在两个哈希表进行。比如,如果要查找某个键时,先在ht[0]中查找,如果没找到,则继续到ht[1]中查找。

集合对象

编码类型

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

编码转换

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

  1. 集合对象保存的所有元素都是整数值
  2. 集合对象保存的元素数量不超过512个

不能满足上述条件的集合对象需要使用hashtable编码,同样第二个上限值可以通过配置文件中的set-max-intsetentries选项修改

集合对象的底层数据结构

整数集合

inset 编码的集合对象使用整数集合作为底层实现,集合对象包含的所有元素都被保存在整数集合里面

redis> SADD numbers 1,3,5
(integer) 3
redis> OBJECT ENCODING numbers
"intset"

整数集合(intset)是Redis用于保存整数值的集合抽象数据结构,它可以保存类型为int16_t、int32_t或者int64_t的整数值,并且保证集合中的数据不会重复。Redis使用intset结构表示一个整数集合,如下:

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

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

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

虽然intset将contents属性声明为int8_t类型的数组,但实际上,contents数组不保存任何int8_t类型的值,数组中真正保存的值类型取决于encoding。

如果encoding属性值为INTSET_ENC_INT16,那么contents数组就是int16_t类型的数组,数组里面每个项都是一个int16_t类型的整数值(最小值是-32768,最大值是32767),依次类推INTSET_ENC_INT32和INTSET_ENC_INT64也是同理

升级

当新插入元素的类型比整数集合现有类型元素的类型大时,整数集合必须先升级,然后才能将新元素添加进来。这个过程分以下三步进行:

  1. 根据新元素类型,扩展整数集合底层数组空间大小。并为新元素分配空间
  2. 将底层数组现有所有元素都转换为与新元素相同的类型,并且维持底层数组的有序性。
  3. 将新元素添加到底层数组里面。

如下:有一个INTSET_ENC_INT16编码的整数集合,集合中包含三个int16_t类型的元素

https://chentianming11.github.io/images/redis/set-intset.png

因为每个元素占用16位空间(4个字节16位),所以整数集合的底层数组的大小为3*16=48,位置如下:

假设此时将类型为int32_t的整数值65535添加到证书集合,此时就需要先对整数集合进行升级,按照整数集合米钱的三个元素加上65535这个元素,整数集合需要分配四个元素的空间,每个int42_t整数值需要占用32位空间,底层数组的大小为4*32=128,然后转换之前的三个元素类型为int32_t类型,并将转换的元素保证有序的情况下放置在正确的位置即可。

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

字典

hashtable编码的集合对象使用字典作为底层实现,字典的每个键都是一个字符换对象,每个字符串对象包含一个集合元素,而字典的值则全部被设置为null,当我们执行SADD fruits "apple" "banana" "cherry"向集合对象插入数据时,该集合对象在内存的结构如下:

在这里插入图片描述

有序集合对象

编码类型

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

ziplist编码

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

压缩列表内的集合元素按分值从小到大进行排序,分值较小的靠近表头方向,分值较大的靠近表尾方向,如果我们执行ZADD price 8.5 apple 5.0 banana 6.0 cherry命令,向有序集合插入元素,该有序集合在内存中的结构如下:

zset-ziplist

skiplist编码

skiplist编码的有序集合对象使用zset结构作为底层实现,一个zset结构同时包含一个字典和一个跳跃表。

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

zset结构中包括字典和跳跃表,其中:

跳跃表zsl

按分值从小到大保存了所有集合元素,每个跳跃表节点都保存一个集合元素:跳跃表节点的object属性保存了元素的成员,而跳跃表的score属性则保存了元素的分值,通过跳跃表,可以对有序集合进行范围性操作,比如ZRANK、ZRANGE等命令

字典dict

dict中字典为有序集合创建了一个从成员到分值的映射,字典中的每个键值对都保存一个集合元素:字典的键保存了元素的成员,字典的值保存了元素的分数,通过字典可以用O(1)的复杂度查找给定成员的分值,ZSOCRE命令就是基于这一特性实现。

有序集合的每个元素的成员都是一个字符串对象,而每个元素的分值都是一个double类型的浮点数,虽然zset结构同时使用跳跃表和字典来保存有序集合元素,但是这两种数据结构都会通过指针来共享相同元素的成员和分数,不会产生任何重复的成员或分值并且浪费内存

编码转换

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

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

不能同时满足上述两个条件的有序集合使用skiplist编码,可以通过zset-max-ziplist-entries选项和zset-max-ziplist-value选项的说明

有序集合对象底层数据结构

压缩列表

数据结构同列表对象介绍,插入元素的顺序如上图

跳跃表

Redis使用跳跃表作为有序集合建的底层实现之一,当有序集合元素数量比较多,又或者元素成员member是比较长的字符串时候使用跳跃表作为有序集合底层实现

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

skiplist编码的有序集合对象使用zset结构作为底层实现,一个zset结构同时包含一个字典和一个跳跃表。其中Redis的跳跃表由zskiplist和zskiplistNode两个结构定义,

其中:zskiplist保存跳跃表节点相关信息,比如节点的数量,以及指向表头和表尾节点的指针等

typedef struct zskiplist {

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

跳跃表节点zskiplistNode结构定义如下:

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

 其中主要包括以下几个属性:

  • :每次创建一个新的跳跃表节点的时候,会根据幂次定律(越大的数出现的概率越低)随机生成一个1-32之间的值作为当前节点的"层高"。每层元素都包含2个数据,前进指针和跨度。
  • 前进指针:每层都有一个指向表尾方向的前进指针,用于从表头向表尾方向访问节点。
  • 跨度:层的跨度用于记录两个节点之间的距离。
  • 后退指针(BW):节点的后退指针用于从表尾向表头方向访问节点,每个节点只有一个后退指针,所以每次只能后退一个节点。
  • 分值和成员:节点的分值(score)是一个double类型的浮点数,跳跃表中所有节点都按分值从小到大排列。节点的成员(obj)是一个指针,指向一个字符串对象。在跳跃表中,各个节点保存的成员对象必须是唯一的,但是多个节点的分值确实可以相同。

需要注意的是,表头节点不存储真实数据,并且层高固定为32,从表头节点第一个不为NULL最高层开始,就能实现快速查找

 上述是一个左边zskiplist结构,包括以下属性

  1. header:指向跳跃表的表头节点
  2. tail:指向跳跃表的表尾节点
  3. level:记录目前跳跃表内层数最高的那个节点的层数(表头节点不计算在内)
  4. length:跳跃表包含节点数量(表头节点不计算在内)

上述是一个左边zskiplistNode结构,包括以下属性

  1. 层(level):节点中L1、L2、L3等字样标记节点的各个层,L1代表第一层,依次类推,每个层都有两个属性:前进指针和跨度, 前进指针访问表尾方向下一个节点,跨度则记录前进指针指向节点当当前节点距离
  2. 后退(backward)指针:节点中用BW字样标记节点的后退指针,指向当前节点的前一个节点,往表头方向
  3. 分值(socre):各个节点中的1.0、2.0、3.0是节点所保存的分值,跳跃表中节点按照分值从小到大排列
  4. 成员对象(obj):各个节点中的O1、O2、O3是节点所保存的成员对象

当然表头节点也和其他节点构造一样,有后退指针、分值、成员对象等,但是这些属性不会被用到,故省略

skiplist编码的有序集合对象使用zset结构作为底层实现,一个zset结构同时包含一个字典和一个跳跃表。 假如还是执行ZADD price 8.5 apple 5.0 banana 6.0 cherry命令向zset保存数据,如果采用skiplist编码方式的话,该有序集合在内存中的结构如下:

 总结:

总的来说,Redis底层数据结构主要包括:简单动态字符串(SDS)、链表、字典、跳跃表、整数集合和压缩列表六种类型,并且基于这些基础数据结构实现了字符串对象、列表对象、哈希对象、集合对象以及有序集合对象五种常见的对象类型。每一种对象类型都至少采用了2种数据编码,不同的编码使用的底层数据结构也不同。
 

其他拓展

内存回收

C语言不具备自动内存回收功能,Redis构建引用计数器来实现内存回收,每个对象的引用计数信息由redisObeject结构的refcount属性记录

typedef struct redisObejct{
   
    // ...
    int refcount;
    // ...
} robj;

对象引用计数信息随着对象使用状态而不断变化

  • 创建对象时候,引用计数器被初始化为1
  • 对象被一个新程序使用时候,引用计数器+1
  • 对象不再被一个程序使用,引用计数器-1
  • 引用计数器为0时,对象所占的内存会被释放

对象共享

对象的引用计数属性除了用于内存回收机制之外,还带有对象共享的作用。比如:键A创建了一个包含整数值100的字符串对象作为值对象,如果这时键B也要创建一个同样保存了整数值100的字符串对象作为值对象,那么服务器有以下两种做法:

  1. 为键B新创建一个包含整数值100的字符串对象;
  2. 让键A和键B共享同一个字符串对象;

很明显是第二种方法更节约内存。在Redis中,让多个键共享同一个值对象需要执行以下两个步骤:

  1. 将数据库键的值指针指向一个现有的值对象;
  2. 将被共享的值对象的引用计数增一。

可以看到,除了对象的引用计数从之前的1变成了2之外,其他属性都没有变化。共享对象机制对于节约内存非常有帮助,数据库中保存的相同值对象越多,对象共享机制就能节约越多的内存,目前来说Redis会在初始化服务时,创建一万个字符串对象,这些对象包含0-9999的所有整数值,当需要使用这些字符串对象时候,使用的是共享对象。

可以通过redis.h/REDIS_SHARED_INTEGERS常量来修改

对象空转时长

redisObject对象中除了type、encoding、ptr、refcount四个属性外,还有一个lru属性,记录对象最后一次被命令程序访问的时间,

typedef struct redisObejct{
   
    // ...
    unsigned lru:22;
    // ...
} robj;

可以通过OBJECT IDLETIME命令打印给定键的空转时长,空转时长通过当前时间减去键的值对象的lru时间计算得到,当键处于活跃状态,空转时长为0,

键的空转时长的作用:

在服务器打开maxmemory选项,并且服务器用于回收内存的算法为volatile-lru或者allkeys-lru,那么当服务器占用内存超过maxmemory选项所设置的上限值时,空转时长较高的那部分键优先被服务器释放,从而回收内存

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值