第一部分 数据结构与对象
一、简单动态字符串
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 字符串区别
- 常数复杂度获取字符串长度
SDS在len属性中记录了长度,复杂度为O(1)
C需要遍历字符串长度,复杂度为O(n)
- 杜绝缓冲区溢出
需要对SDS进行修改时。API会先检查SDS的空间是否满足要求,不够则进行空间扩展,不会出现C那样直接调用strcat()函数可能出现溢出情况
- 减少修改字符串时带来的内存重分配次数
在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属性将这些多出的字节数量记录起来,等待将来使用。
- 二进制安全
C的字符串中不能包含空字符,否则会被误认为字符串结尾;程序则不会对SDS中的数据做过滤等,写入时的数据和读出的数据完全一致。
- 兼容部分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 字典实现
- 哈希表
dict.h/dictht结构定义:
typedef struct dictht {
//哈希表数组
dictEntry **table;
//哈希表大小
unsigned long size;
//哈希表大小掩码,用于计算索引值
//总是等于size-1
unsigned long sizemask;
//该哈希表已有节点的数量
unsigned long used;
}dictht;

如图所示是一个大小为4的空哈希表(没有包含任何键值对)
- 哈希表节点
每个dictEntry结构都保存着一个键值对:
typedef struct dictEntry {
//键
void *key;
//值
union{
void *val;
uint64_tu64;
int64_ts64;
}v;
//指向下个哈希表节点,形成链表
struct dictEntry *next;
}dictEntry;
其中的v属性保存着键值对中的值,其中值可以是一个指针,或者一个 uint64_t整数,又或者是一个int64_t整数。
next属性是指向另一个哈希表节点的指针,这个指针可以将多个哈希值相同的键值对连接在一起,来解决键冲突问题;

- 字典
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分配空间,空间大小为:
2、将保存在ht[0]中的所有键值对rehash到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 跳跃表的实现
- 跳跃表
zskiplist结构的定义如下:
typedef struct zskiplist {
//表头节点和表尾节点
structz skiplistNode *header, *tail;
//表中节点的数量(表头节点不计算在内)
unsigned long length;
//表中层数最大的节点的层数(表头节点的层数不计算在内)
int level;
}

level属性记录跳跃表中层数最大的那个节点的层数,注意:表头结点的层高不算其内。
- 跳跃表节点
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
本文详细介绍了Redis中的数据结构,包括简单动态字符串(SDS)、链表、字典(哈希表)、跳跃表、整数集合、压缩列表和对象系统。重点讲解了SDS与C字符串的区别、链表实现、字典的哈希算法和解决键冲突的方法、渐进式rehash策略以及跳跃表的查询过程。内容深入浅出,帮助理解Redis的内部运作机制。
4580

被折叠的 条评论
为什么被折叠?



