《Redis设计与实现》—— Redis底层原理与实现(上)

本文详细介绍了Redis中的数据结构,包括简单动态字符串(SDS)、链表、字典(哈希表)、跳跃表、整数集合、压缩列表和对象系统。重点讲解了SDS与C字符串的区别、链表实现、字典的哈希算法和解决键冲突的方法、渐进式rehash策略以及跳跃表的查询过程。内容深入浅出,帮助理解Redis的内部运作机制。

第一部分 数据结构与对象

一、简单动态字符串

​ Redis采用SDS来标识字符串值,键与键值都是;SDS还被用作AOF模块的AOF缓冲区以及客户端状态中的输入缓冲区,具体在AOF篇介绍;

举个例子:

redis> SET msg "hello world"
OK
redis> RPUSH fruits "apple" "banana" "cherry"
(integer) 3

1.1 SDS的定义

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

在这里插入图片描述

SDS以空字符’\0’结尾,但的1字节空间不计算在len长度中

1.2 SDS与C 字符串区别

  1. 常数复杂度获取字符串长度
SDS在len属性中记录了长度,复杂度为O(1)
C需要遍历字符串长度,复杂度为O(n)
  1. 杜绝缓冲区溢出
需要对SDS进行修改时。API会先检查SDS的空间是否满足要求,不够则进行空间扩展,不会出现C那样直接调用strcat()函数可能出现溢出情况
  1. 减少修改字符串时带来的内存重分配次数
在C中修改一个字符长度,例如拼接和截断,都需要重新分配内存空间,SDS通过未使用空间解除了字符串长度和底层数组长度之间的关联,实现了空间预分配和惰性空间释放两种优化策略:

空间预分配:当SDS的API对一个SDS进行修改并需要进行空间扩展时,程序不仅分配修改所必须的空间,还会为SDS分配额外的未使用空间,未使用空间数量公式:
> 修改后的len < 1MB,则free = len,buff长度 len + free + 1
> 修改后的len >= 1MB,则free = 1MB,buff长度 len + 1MB + 1byte

惰性空间释放:当SDS的API对一个SDS进行修改并需要进行空间缩短时,程序使用free属性将这些多出的字节数量记录起来,等待将来使用。
  1. 二进制安全
C的字符串中不能包含空字符,否则会被误认为字符串结尾;程序则不会对SDS中的数据做过滤等,写入时的数据和读出的数据完全一致。
  1. 兼容部分C字符串函数

二、链表

​ 链表在Redis中的应用很广泛,比如列表键的底层实现、发布与订阅、慢查询、监视器等,Redis服务器也使用链表保存多个客户端的状态信息。

举个例子:

redis> LLEN integers
(integer) 1024
# integers列表键的底层实现就是一个链表
redis> LRANGE integers 0 10
1)"1"
2)"2"
3)"3"
....
11)"11"

2.1 链表和节点实现

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

多个listNode组成的双向链表:

在这里插入图片描述

使用adlist.h/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结构和三个listNode结构组成的链表如下:

在这里插入图片描述

三、字典

​ 字典又称为符号表、关联数组或映射(map),是一种用于保存键值对的抽象数据结构,一个键可以和一个值进行关联。字典中的键都是唯一的。字典还是哈希键的底层实现之一。

举个例子:

# 创建的msg -> hello world键值对就保存在数据库的字典里面
redis> SET msg "hello world"
OK

3.1 字典实现

  1. 哈希表

dict.h/dictht结构定义:

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

在这里插入图片描述

如图所示是一个大小为4的空哈希表(没有包含任何键值对)

  1. 哈希表节点

每个dictEntry结构都保存着一个键值对:

typedef struct dictEntry {
    //键
    void *key;
    
    //值
    union{
    	void *val;
        uint64_tu64;
        int64_ts64;
    }v;
    
    //指向下个哈希表节点,形成链表
    struct dictEntry *next;
}dictEntry;

其中的v属性保存着键值对中的值,其中值可以是一个指针,或者一个 uint64_t整数,又或者是一个int64_t整数。

next属性是指向另一个哈希表节点的指针,这个指针可以将多个哈希值相同的键值对连接在一起,来解决键冲突问题;

在这里插入图片描述

  1. 字典

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

typedef struct dict {
    //类型特定函数
    dictType *type;
    
    //私有数据
    void *privdata;
    
    //哈希表
    dictht ht[2];
    
    //rehash索引
    //当rehash不在进行时,值为-1
    int trehashidx;
}dict

type属性是一个指向dictType结构的指针,每个dictType结构保存了一簇用于操作特定类型键值对的函数。用途不同的字典会设置不同的类型特定函数;

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, const void *obj)
}dictType;

privdata属性保存了需要传给那些类型特定函数的可选参数。

ht属性是一个包含两个项的数组,数组中的每个项都是一个dictht哈希表,一般情况下,字典只使用ht[0]哈希表,ht1哈希表只会在对ht[0]进行rehash时使用;

rehashidx记录了rehash目前的进度,如果没有在进行rehash,其值为-1。

在这里插入图片描述

如图所示为普通状态下(没有rehash)的字典。

3.2 哈希算法

​ 一个新的键值对添加到字典中,需要计算哈希值和索引值,由索引值,将包含新键值对的哈希表节点放到哈希表数组指定的索引上面。

举个例子:假设将一个键值对k0和v0添加到字典中,过程如下:

在这里插入图片描述

1、计算键k0哈希值 hash = dict->type->hashFunction(k0);

2、假设hash = 8,那么继续使用语句:(x可以是0或者1)

index = hash & dict->ht[x].sizemask = 8 & 3 = 0;

3、计算出的索引值为0,所以将节点放置到哈希表数组的索引0位置上

3.3 解决键冲突

​ 当有两个或以上键被分配到哈希表数组的同一个索引上面时,使用链地址法,通过每个哈希表节点的next指针,将这些节点构成一个单向链表,如下图所示:(使用链表解决 k2 和 k1的冲突)

在这里插入图片描述

3.4 rehash

​ 为让哈希表的负载因子位置一个合理范围,当哈希表保存的键值太多或少时,程序会对哈希表的大小进行扩展或收缩,步骤如下:

1、为ht1->table分配空间,空间大小为:

  • 若为扩展,则ht1大小为第一个大于等于ht[0].used*2的 2^n;
  • 若为收缩,则ht1大小为第一个大于等于ht[0].used的2^n;

2、将保存在ht[0]中的所有键值对rehash到ht1上,rehash是重新计算键的哈希值和索引值;

3、当全部迁移后,释放ht[0],将ht1设置为ht[0],并在ht1新建一个空白哈希表,为下次rehash做准备。

哈希表扩展与收缩条件:

以下任意条件一个满足就会进行扩展:

1、服务器没有在执行BGSAVE或BGREWRITEAOF,并且哈希表负载因子大于等于1;

2、服务器正在执行BGSAVE或BGREWRITEAOF,并且哈希表负载因子大于等于5;

负载因子计算公式:load_factor = ht[0].used / ht[0].size;

当哈希表负载因子小于0.1时,程序自动开始哈希表收缩操作。

3.5 渐进式 rehash

扩展或收缩哈希表需要将ht[0]中的所有键值rehash到ht1,这个过程不是一次性完成,二十分多次,渐进式完成;这样是为了避免哈希表中的节点太多,一次迁移会导致服务器在一段时间内停止服务;

步骤:

1、为ht1分配空间,让字典同时持有ht[0] 和ht1两个哈希表。

2、在字典中维持一个索引计数器变量rehashidx,并将它的值设置为0,表示rehash工作正式开始。

3、在rehash进行期间,每次对字典执行添加、删除、查找或者更新操作时,程序除了执行指定的操作以外,还会顺带将ht[0]哈希表在rehashidx索引上的所有键值对rehash到ht1,当rehash工作完成之后,程序将rehashidx属性的值增一。

4、随着字典操作的不断执行,最终在某个时间点上,ht[0]的所有键值对都会被rehash至ht1,这时程序将rehashidx属性的值设为-1,表示rehash操作已完成。

​ 渐进式rehash的好处在于它采取分而治之的方式,将rehash键值对所需的计算工作均摊到对字典的每个添加、删除、查找和更新操作上,从而避免了集中式rehash而带来的庞大计算量。

​ 渐进式rehash执行期间的哈希表操作:该期间,新添加到字典的值会被保存到ht1中,保证了ht[0]包含的键值数量只会减不增,并随着rehash操作执行最终变为空表。

四、跳跃表

​ 跳跃表是一个有序数据结构,通过在每个节点中维持多个指向其他节点的指针,达到快速访问节点的目的;如果一个有序集合包含的元素数量比较多,又或者有序集合中元素的成员是比较长的字符串时,Redis就会使用跳跃表来作为有序集合键的底层实现,例如Zset。

举个例子:

redis> ZRANGEBYSCORE mykey -inf +inf withscores # 同时输出对应的value
1) "one"
2) "1"
3) "two"
4) "2"
5) "three"
6) "3"

4.1 跳跃表的实现

  1. 跳跃表

zskiplist结构的定义如下:

typedef struct zskiplist {
    //表头节点和表尾节点
    structz skiplistNode *header, *tail;
    
    //表中节点的数量(表头节点不计算在内)
    unsigned long length;
    
    //表中层数最大的节点的层数(表头节点的层数不计算在内)
    int level;
}

在这里插入图片描述

level属性记录跳跃表中层数最大的那个节点的层数,注意:表头结点的层高不算其内。

  1. 跳跃表节点

zskiplistNode 结构的定义如下:

typedef struct zskiplistNode {
    //层
    struct zskiplistLevel {
        //前进指针
        struct zskiplistNode *forward;
        //跨度
        unsigned int span;
    }level[];
    
    //后退指针
    struct zskiplistNode *backward;
    
    //分值
    double score;
    
    //成员对象
    robj *obj;
}zskiplistNode;
  • 层:节点的level数组可包含多个元素,每个元素包含一个指向其他节点的指针;每次创建一个新跳跃表节点的时候,程序都根据幂次定律(越大的数出现的概论越低)随机生成一个介于1和32之间的值作为level数组的大小;
  • 前进指针:用于从表头向表尾方向访问节点;
  • 跨度:记录两个节点之间的距离;(指向NULL的所有前进指针的跨度都为0)
  • 后退指针:用于从表尾向表头方向访问节点,每个节点只有一个后退指针,所以每次只能后退前一个节点;
  • 分值和成员:跳跃表中的所有节点都按score从小到大排序;obj属性是一个指针,指向一个字符串对象,字符串对象保存着一个SDS值。

4.2 跳跃表查询过程

可通过该篇博客查看查询过程:

五、整数集合

​ 当一个集合只包含整数值元素,并且这个集合的元素数量不多时,Redis就会使用整数集合作为集合键的底层实现。

举个例子:

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

5.1 整数集合的实现

整数集合(intset)可以保存类型为int16_t、int32_t或者int64_t的整数值,并保证集合中不会出现重复元素;

每个intset.h/inset结构表示一个整数集合:

typedef struct intset {
    //编码方式
    uint32_t encoding;
    
    //集合包含的元素数量
    uint32_t length;
    
    //保存元素的数组,各项按值从小到大排序
    int8_t contents[];
}intset;

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

encoding属性为INTSET_ENC_INT16、INTSET_ENC_INT32、INTSET_ENC_INT64,则分别对应的contents类型为int16_t、int32_t、int64_t

在这里插入图片描述

5.2 升级

当一个新元素添加到整数集合中,若新元素的类型比现有所有元素类型都要长,则整数集合需要先进行升级,再添加元素。

步骤:

1、由新元素类型->扩展集合数组空间->新元素分配空间;

2、将原元素转换为新元素类型,维持底层数组的有序性不变;

3、新元素添加到底层数组中;

升级的好处:

1、提升灵活性,避免出现类型错误情况;

2、节约内存

注意:

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

六、压缩列表

​ 压缩列表(ziplist)是列表键和哈希键的底层实现之一。当一个列表键只包含少量列表项,并且每个列表项要么就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来做列表键的底层实现。另外,当一个哈希键只包含少量键值对,比且每个键值对的键和值要么就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来做哈希键的底层实现。

举个例子:

# 列表键
redis> RPUSH lst 1 3 5 10086 "hello" "world"
(integer) 6
redis> OBJECT ENCODING lst
"ziplist"

# 哈希键
redis> HMSET profile "name" 3 5 10086 "hello" "world"
OK
redis> OBJECT ENCODING profile
"ziplist"

6.1 压缩列表的构成

​ 压缩列表是由一系列特殊编码的连续内存块组成的顺序型数据结构,一个压缩列表可以包含任意多个节点,每个节点可以保存一个字节数组或一个整数值。

压缩列表各组成部分如下所示:
在这里插入图片描述

各组成部分说明:

在这里插入图片描述

6.2 压缩列表节点的构成

压缩列表节点的各个组成部分:

在这里插入图片描述

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

1、长度小于等于63 (2^6-1 )字节的字节数组;
2、长度小于等于16383 (2^I4-1 )字节的字节数组;
3、长度小于等于4294967295 (2^32-1 )字节的字节数组;

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

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

previous_entry_length:记录前一个节点的长度,可计算出前一个结点的起始地址;

encoding:记录了节点content属性所保存数据的类型以及长度;

content:保存节点的值。

6.3 连锁更新

连锁更新示意图:

new为新节点,且长度大于等于254字节,原本e1…eN长度都小于254字节

在这里插入图片描述

七、对象

​ Redis基于前面的数据结构创建一个对象系统,这个系统包含字符串对象、列表对象、哈希对象、集合对象和有序集合对象五种类型的对象。

7.1 对象的类型与编码

Redis中的每个对象都由一个redisObject表示,其数据结构为:

typedef struct redisObject {
    //类型
    unsigned type:4;
    
    //编码
    unsigned encoding:4;
    
    //指向底层实现数据结构的指针
    void *ptr;
    
    //...
}robj;

1、类型

在这里插入图片描述

redis> SET msg "hello world"
OK
redis> TYPE msg
string

在这里插入图片描述

2、编码

在这里插入图片描述

7.2 字符串对象

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

1、int

字符串对象为整数值,且可用long类型来表示,则ptr将转换成long,编码为int

redis> SET number 10086
OK
redis> OBJECT ENCODING number
"int"

在这里插入图片描述

2、raw

字符串对象为字符串值,且长度>32字节,则字符串对象将使用一个SDS来保存字符串值,编码为raw

redis> SET stroy "Long, long ...."
OK
redis> STRLEN stroy
(integer) 37
redis> OBJECT ENCODING story
"raw"

在这里插入图片描述

3、embstr

字符串对象为字符串值,且长度<=32字节,则字符串对象将使用embstr编码来保存字符串值

在这里插入图片描述

在这里插入图片描述

可以用long double类型表示浮点数在Redis中也是作为字符串值来保存。浮点数保存规则:浮点数->字符串,有需要的时候,会将字符串值转换为浮点数,执行某些操作。

4、编码转换

可以通过APPEND等命令,将一个整数值变成字符串值;

embstr编码的字符串执行任何修改都会变为raw编码。

7.3 列表对象

列表对象编码可以为ziplist或linkedlist

1、ziplist

ziplist编码的列表对象使用压缩列表作为底层实现

在这里插入图片描述

2、linkedlist

linkedlist编码的列表对象使用双端链表作为底层实现

在这里插入图片描述

图中所示的字符串对象StringObject即是7.2中的字符串对象;

3、编码转换

列表对象保存所有字符串元素长度<64字节且元素数量<512个,则使用ziplist编码,否则使用linkedlist编码。

7.4 哈希对象

哈希对象编码可以为ziplist或hashtable

1、ziplist

ziplist编码的哈希对象使用压缩列表作为底层实现

在这里插入图片描述

2、hashtable

hashtable编码的哈希对象使用字典作为底层实现
在这里插入图片描述

图中所示的字符串对象StringObject即是7.2中的字符串对象;

3、编码转换

哈希对象保存所有键值对的键和值的字符串长度<64字节且元素数量<512个,则使用ziplist编码,否则使用linkedlist编码。

7.5 集合对象

集合对象编码可以为intset或hashtable

1、intset

intset编码的集合对象使用整数集合作为底层实现

在这里插入图片描述

2、hashtable

hashtable编码的集合对象使用字典作为底层实现,字典的键都是一个字符串对象,字典的值都为NULL

在这里插入图片描述

图中所示的字符串对象StringObject即是7.2中的字符串对象;

3、编码转换

集合对象保存所有元素都是整数值,且元素数量<512个,则使用intset编码,否则使用hashtable编码。

一旦intset条件出现不满足,就会转换为hashtable

7.6 有序集合对象

有序集合对象编码可以为ziplist或skiplist

1、ziplist

ziplist编码的有序集合对象使用压缩列表作为底层实现

在这里插入图片描述

2、skiplist

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

在这里插入图片描述

图中所示的字符串对象StringObject即是7.2中的字符串对象;

3、编码转换

有序集合对象保存所有元素成员的长度<64字节且元素数量<128个,则使用ziplist编码,否则使用skiplist编码。

7.7 内存回收

每个对象的引用计数信息由redisObject结构的refcount属性记录:

typedef struct redisObject {
    // ...
    
    //引用计数
    int refcount;
    
    // ...
} robj;

计数值变化过程:

  • 对象新创建,refcount = 1;
  • 对象被新程序使用,refcount += 1;
  • 对象不再被一个程序使用,refcount -= 1;
  • refcount == 0时,对象所占内存被释放;

7.8 对象共享

引用计数属性还用于对象共享作用,对于相同的值对象,Redis会采用对象共享,而不是新创建一个对象。

在这里插入图片描述

举个例子:

# Redis初始化服务器时会创建一万个字符串对象,包含了从0~9999的所有整数值
redis> SET A 100
OK
redis> OBJECT REFCOUNT A
(integer) 2

7.9 对象空转时长

redisObject还包括一个lru属性,记录了最后一次被命令程序访问的时间:

typedef struct redisObject {
    // ...
    
    unsigned lru:22;
    
    // ...
} robj;
redis> SET msg "hello"
OK
# 等待一段时间
redis> OBJECT IDLETIME msg
(integer) 180
# 访问该键值
redis> GET msg
"hello"
# 键处于活跃状态,则空转时长为0(空转时长 = 当前时间 - lru)
redis> OBJECT IDLETIME msg
(integer) 0
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值