简单动态字符串
Redis没有直接使用C语言传统的字符串表示,而是自己构建了一种简单动态字符串(simple dynamic string,下面简称 SDS)的类型,并将SDS作用Redis的默认字符串表示。以下是SDS的结构图
struct sdshrd {
//记录buf数组中已使用字节的数量
//等于SDS所保存字符串的长度
int len;
//记录buf数组中未使用字节的数量
int free;
//字节数组,用于保存字符串
char buf[ ];
}
- free 属性的值为0,表示这个SDS没有分配任务未使用空间
- len 属性的值为5,表示这个SDS保存了一个五字节长的字符串
- buf 属性是一个char类型的数组,数组的前五个字节分别保存了 ’r‘、’e‘、’d‘、’i‘、’s‘ 五个字符,而最后一个字符则保存了空字符 ‘\0’.
注意: SDS遵从C字符串以空字符结尾的惯例,保存空字符的1字节空间不计算在SDS的len属性里面,并且为空字符串分配额外的1字节空间,以及添加空字符到字符串末尾等操作,都是由SDS函数自动
完成的。
使用SDS的好处
① :无需遍历,直接获取字符串长度
② :杜绝缓冲区溢出
因为SDS使用free属性记录着剩余可用空间,在尚未有足够大的空间去存储追加的字符串时,他首先会先申请足够大的空间,然后再存放追加的字符串
③:减少修改字符串时带来的内存重分配
程序都总要对保存这个字符串的数组进行一次内存重分配的操作:
1. 如果程序执行的是增长字符串的操作,比如拼接操作(append),那么再着行这个操作之前,程序需要先通过内存重分配来扩展底层数组的空间大小——如果忘了这一步就会禅城缓冲区溢出
2. 如果程序执行的是缩短字符串的操作,比如截断操作(trim),那么再执行这个操作之后,程序需要通过内存重分配来释放字符串不再使用的那部分空间——如果忘了这一步就会产生内存泄漏。
redis作为数据库,经操被用于速度要求严苛、数据被频繁修改的场合,如果每次修改字符串的长度都需要执行一次内存重分配的话,那么光执行内存重分配的时间就会占去修改字符串所用的一大部分时间,如果这种修改频繁地发生的话,必定会对性能造成一定的影响。
空间预分配
空间预分配用于优化SDS的字符串增长操作,当SDS的API对一个SDS进行修改,并且需要对SDS进行空间扩展的时候,程序不仅会为SDS分配修改所必须要的空间,还会为SDS分配额外的未使用空间。
其中,额外分配的未使用空间数量由以下公式决定:
1. 如果对SDS进行修改之后,SDS的长度(也即是len属性的值)将少于1MB(1MB=110241024=1048576 B(字节)),那么程序分配和len属性同样大小的未使用空间,这是SDS的len属性的值将和free属性的值相同。举个列子,如果进行修改之后,SDS的len将变为13字节,那么程序也会分配13字节的未使用空间,SDS的buf数组的实际长度将变成 13 + 13 + 1 = 27 字节(额外的一字节用于保存空字符)。如下图过程
再次追加字符
2. 如果对SDS进行之后,SDS的长度将大于等于1MB (1 * 1024 * 1024 = 1048576 B(字节)),那么程序会分配1MB的未使用空间,举个例子,如果进行修改之后,SDS的len变为30M,那么程序会分配1MB的未使用空间,SDS的buf数组的实际长度将为 30MB + 1MB + 1 byte (额外的一字节用于保存空字符)
惰性空间释放
惰性空间释放用于优化SDS的字符串缩短操作:当SDS的API需要缩短SDS保存的字符串时,程序并不立即使用内存重分配来回收缩短后多出的字符串,而是使用free属性,将这些字符的数量记录起来,并等待使用。
如此同时,SDS也提供了相应的API,让我们可以在有需要时,真正地释放SDS的未使用空间,所以不用担心惰性空间释放策略会造成内存浪费。
链表
typedef struct listNde {
// 前置节点
struct listNode *prev;
//后置节点
struct listNode *next;
// 节点的值
void *value;
}listNode;
虽然仅仅使用多个ListNode结构就可以组成链表,但是需要一个记录链表信息的类型,list
typedef struct list{
// 表头节点
listNode *head;
// 表尾节点
listNode *tail;
// 链表所包含的节点数量
unsigned long len;
// 节点值赋值函数
void *(*dup) (void *ptr);
// 节点释放函数
void *(*free) (void *ptr);
// 节点值对比函数
void *(*match) (void *ptr,void *key);
}list;
Redis的链表实现的特性可以总结如下:
1. 双端: 链表节点带有prev和next指针,获取某个节点的前置节点和后置节点的复杂度都是0(1)。
2. 无环:表头节点的prev指针和表尾节点的next指针都指向NULL,对链表的访问以NULL为终点。
3. 带表头指针和表尾指针:通过list结构的head指针和tail指针,程序获取链表的头尾节点的复杂度为0(1)。
4.带链表长度计数器:程序使用list结构的len属性来对list持有的链表节点进行计数,程序获取链表中节点数量的复杂度为0(1)。
5.多态:链表节点使用void*指针来保存节点值,并且可以通过list结构的dup、free、match三个属性为节点设置类型特定函数,所以链表可以用于保存各种不同类型的值
字典
Redis的字典使用哈希作为底层实现,一个哈希表里面可以有多个哈希表节点,而每个哈希表节点就保存了字典中的一个键值对。
哈希表
typedef struct dictht {
// 哈希表数组
dictEntry **table
// 哈希表大小
unsigned long size;
// 哈希表大小掩码,用于计算索引值
// 总是等于 size - 1
unsigned long sizemask;
// 该哈希表已有节点的数量
unsigned long used;
} dictht
解析一下每个属性的意思
- dictEntry **table :table是一个数组,数组中的每个元素都是指向 dictEntry 结构的指针。
- long size:size属性记录了哈希表的大小,也即时table数组的大小。
- long sizemask:size属性的值总是等于size - 1,这个属性和 哈希值 一起决定一个键应该被放到table数组的那个索引上
- long used:used属性则记录了哈希表目前已有节点(键值对)的数量。
哈希表节点
typedef struct dictEntry {
// 键
void *key
// 值
union {
void *val
uint64_tu64;
int64_ts64;
} v;
// 指向下一个哈希表节点,形成链表
struct dictEntry *next
} dictEntry;
key属性保存着键值对中的键,而v属性则保存着键值对中的值,其中键值对的值可以是一个指针,或者是个uint64_t整数,又或者是一个int64_t整数。
next属性是指向另一个哈希表节点的指针,这个指针可以将多个哈希值相同的键值对连接在一起,以此来解决冲突(collision)的问题。
字典
typedef struct dict {
// 类型特定函数
dictType *type;
// 私有数据
void *privdata;
// 哈希表
dictht ht[2];
// rehash索引
// 当rehash不在进行时, 值为-1
int tarhashidx; /* rehashing not in progress if rehashidx == -1 */
} dict;
type 属性 和privdata 属性是针对不同类型的键值对,为创建多态字典而设置的:
- type 属性是一个指向dictType结构的指针,每个dictType结构保存了一簇用操作特定类型键值对的函数, Redis会为不同用途的字典设置不同的类型特定函数。
- privdata 属性则保存了需要传给那些类型特定函数的可选参数
typedef struct dictType {
// 计算哈希值的函数
unsigned int (*hashFunction) (const void *key);
// 复制键的函数
void *(*keyDup) (void *privdata, const void *key);
// 复制值的函数
void *(*keyDup) (void *privdata, const void *obj);
// 对比键的函数
int (*keyCompare) (void *privdata, const void *key1, const void *key2);
// 销毁键的函数
void *(*keyDestructor) (void *privdata, const void *key);
// 销毁值的函数
void *(*keyDestructor) (void *privdata, const void *obj);
} dict;
- ht 属性是一个包含两个项的数组,数组中的每个项都是一个dictht哈希表,一般情况下,字典只使用ht[0]哈希表,ht[1]哈希表只会在对ht[0]哈希表进行rehash时候使用
- rehash属性,它记录rehash目前的进度,如果目前没有在进行rehash,那么它的值为-1。
如下图,一个普通状态下(没有进行rehash)的字典
注意: Redis在碰到哈希碰撞的时候,采用头部插入的拉链方法来解决冲突
rehash 步骤
- 为字典的ht[1]哈希表分配空间,这个哈希表的空间大小取决于要执行的操作,以及h[0]当前包含的键值对数量(即是 ht[0].uesd属性的值):
- 如果要执行的是扩展操作,那么ht[1]的大小为第一个大于等于 ht[0].used* 2 的 2^n(2的n次方幂)。简单来说就是为了减少哈希碰撞,所以需要要 2的幂次方大小
- 如果要执行的是收缩操作,那么ht[1]的大小为第一个大于等于 ht[0].used的 2^n(2的n次方幂)
2.将保存在ht[0] 中的所有键值对rehash到ht[1]上面:rehash指的是重新计算键的哈希值和索引值,然后将键值对放置到ht[1]哈希表的指定位置上。
- 当ht[0]包含的所有键值对都迁移到了ht[1]之后(ht[0]变为空表),释放ht[0], 将ht[1]设置为ht[0],并在ht[1]新创建一个空表的哈希表,为下次rehash做准备
rehash过程
对如下结果的hash表进行扩展操作
① ht[0].used当前的值为4, 4 * 2 = 8 刚还是 2 的 3 次方幂 (4 * 2 = 2^3)。所以程序决定将ht[1]哈希表的大小设置为8.
② 通过对hash数组的链表头键值对重新计算 hash值和索引值,移动至ht[1]中去
③ 释放ht[0],并将ht[1]设置为ht[0],然后为ht[1]分配一个空白的哈希表。如下图,至此,对于hash表的扩展操作执行完毕
触发哈希表的扩展与收缩
当一下条件的任意一个被满足时,程序会自动开始对哈希表执行扩展操作:
- 服务器目前 没有 在执行BGSABE命令(后台线程 执行保存RDF文件)或者 BGREWEITRAOF(后台线程 执行保存AOF文件),并且哈希表的负载因子大于 1.
- 服务器目前 正在 在执行BGSABE命令(后台线程 执行保存RDF文件)或者 BGREWEITRAOF(后台线程 执行保存AOF文件),并且哈希表的负载因子大于 5.
负载因子 = 哈希表已经保存节点数数量 / 哈希表大小
load_factor = ht[0].used / ht[0].size
另一方面,等哈希表的负载因子小于0.1时,程序自动开始对hash表执行收缩操作
渐进式 rehash
我们知道 rehash 是一个十分繁琐的操作,一次性完成整一个rehash操作变得不切实际。Redis中是分多次,渐进式地完成。
图我就不画了,还记得我们之前看到 dict 字典当中的 rehashidx 属性吗 ? 他就是记录当前哈希数组完成情况。我还是画一下把。
上图 rehashidx 值 为 1,也就是说哈希表索引下标 0 、1 的值 已经完成rehash操作,接下来要处理 数组下标 为 2 的 哈希链表。在整个rehash过程当中,该哈希表还是能进行全有操作, 添加 操作 只会在 ht[1]中进行,然而其他操作 删除 ,查找 ,更改,会先根据hash算法 算出来的 索引下标值,通过和rehashidx进行对比,来判断到底应该去哪个 哈希表进行操作。
跳跃表
和链表、字典等数据结构广泛地引用在Redis内部不同,Redis只在两个地方用到跳跃表,一个是实现有序集合键,另一个是在集群节点中用作内部数据结构,除此之外,跳跃表在Redis里面没有其他用途图。
跳跃表节点
typedef struct zskiplistNode {
// 层
struct zskiplistLevel {
// 前进指针
struct zskiplistNode *forward;
// 跨度
unsigned int span;
} level[];
// 后退指针
struct zskiplistNode *backward;
// 分值
double score;
// 成员对象
robj *obj;
} zskiplistNode;
- 层(level): 节点中用L1、L2、L3等字样标记节点的各层结构,L1代表第一层,L2代表第二层,以此类推。每层都带有两个属性:前进指针和跨度。前进指针用于访问位于表尾方向的其他节点,而跨度则记录了前进指针所指向节点和当前节点的距离。
- 后退(backward)指针:节点中用BW字样标记节点的后退指针,他只想位于当前节点的前一个节点。后退指针在程序从表尾向表头遍历使用。
- 分值(score):各个节点的中1.0、2.0 和3.0是节点所保存的分值。在跳跃表中,节点按各自所保存的分值从小到大排列。
- 成员对象(obj):各个节o1、o2和o3保存的成员对象。
跳跃表
typedef struct zskiplist {
// 表头节点和表尾节点
struct skiplistNode *header, *tail;
// 表头节点的属性
unsigned long length;
// 表头层数最大的节点层数
int level
}
// todo
跳跃表基本使用,我将会在之后。数据结构与算法专栏以 代码 手撕的方式。深入,这里就不详细展开他的查找,添加,删除过程。有兴趣的朋友,可以先自行深入学习
整数集合
整数集合(intset)是集合键的底层实现之一,当一个集合只包含整数值元素,并且这个集合的元素数量不多时,Redis就会使用整数集合作为集合键的底层实现。
type struct intset {
// 编码方式
uint32_t encoding
// 集合中包含的元素数量
unit32_t length;
// 保存元素的数组
int8_t contents[ ];
}
- contens 属性:数组是真个整数集合的底层实现,整数聚合的每个元素都是contents数组的一个数组项,各个项在数组中按值得大小从小到大有序地排列,并且数组中不存在任何重复值。
- length 属性:记录了整数集合包含的元素数量,也即是contents数组的长度。
- encoding 属性:虽然上述代码 contens[ ] 用 int8_t类型的数组,但是实际上contents数组真正的类型取决于encoding的属性值
-
- 如果encoding属性的值为INTSET_ENC_INT16,那么contents就是一个 int16_t 类型数组, 每个整数可以用 2 个字节表示 (最小值 -32768, 最大值 32767)
-
- 如果encoding属性的值为INTSET_ENC_INT32,那么contents就是一个 int32_t 类型数组, 每个整数可以用 4 个字节表示
-
- 如果encoding属性的值为INTSET_ENC_INT64,那么contents就是一个 int64_t 类型数组, 每个整数可以用 8 个字节表示
升级
每当我们要将一个新元素添加到整数集合里面,并且新元素的类型比整数集合现在所有元素的类型都要长时,整数集合需要先进行升级。然后才能把新元素添加到整数集合里面。
- 根据新元素的类型,扩展整数集合底层数组的空间大小,并未新元素分配空间
- 将底层数组现有的所有元素都转变成与新元素相同的类型,并将类型转换后的元素放置到正确的位上,而且在放置元素的过程中,需要维持地城数组的有序性质不变
- 将新元素添加到底层数组里面
添加一个 123489 int 32类型的整数
通过计算看看需要添加多少位。已知道 原来16位,数组长度为 5,要变成 32位,数组长度为 6
6 * 32 - 5 * 16 = 112 位
好处:第一点:提高了灵活度,让集合能不同类型的值在同一个数据结构中。第二点:节约内存,本可以简单用64位保存 64/32/16位的数据,通过升级,不仅能保证可以保持3种类型数据,在特定情况下还能节约内存空间。
降级
不支持降级,(#.#)
压缩列表
压缩列表是列表键和哈希键的底层实现之一。当一个列表键只包含少量列表项,并且每个列表项要么就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来做列表键的底层实现。
压缩列表的构成
压缩列表是Redis为了节约内存而开发的,是由一系列特殊编码的连续内存块组成的顺序性数据结构。一个压缩列表可以包含任意多个节点,每个节点可以保存一个字节数组或者一个整数值。
属性 | 类型 | 长度 | 用途 |
---|---|---|---|
zlbytes | uint32_t | 4字节 | 记录整个列表占用的内存字节数:在对压缩列表进行内存重分配,或者计算zlend的位置时使用 |
zltail | uint32_t | 4字节 | 记录压缩列表尾结点距离压缩列表起始地址有多少个字节:通过这个偏移值,程序无需遍历整个压缩列表就可以确定表尾结点的地址 |
zllen | uint16_t | 2字节 | 记录了压缩列表包含的结点数量:当这个属性的值小于UNIT16_MAX(65535)时,这个属性的值就是压缩列表包含节点的数量;当这个值等于UNIT16_MAX时,节点的真实数量需要遍历整个压缩列表才能计算得出 |
entryX | 列表节点 | 不定 | 压缩列表包含的各个节点,节点的长度由节点保存的内容决定 |
zlend | uint8_t | 1字节 | 特殊值 0xFF (十进制255),用来标记压缩列表的末端 |
举个例子
- 列表zlbytes 属性的值为 0x50( 十进制 80),表示压缩列表的总长为80字节
- 列表zltail 属性的值为 0x3c( 十进制 60),表示如果我们有一个指向压缩列表起始地址的指针p,那么只要用指针p加上偏移量60,就可以计算出表尾节点entry3的地址
- 列表zllen 属性的值为 0x3( 十进制 3),表示压缩列表包含3个节点(entry 1 , 2 ,3)。
压缩列表节点的构成 entry
previous_entry_length
节点的previous_entry_length 属性以字节为单位,记录了压缩列表中前一个节点的长度。previous_enrty_length 属性的长度可以是 1字节 或者 5字节。
- 如果前一个节点的长度小于254字节,那么previous_entry_length 属性的长度为 1字节:前一节点的长度就保存在这一个字节里面
- 如果前一个节点的长度大于等于254字节,那么previous_entry_length属性的长度为 5字节: 其中属性的第一个字节会设置为0xFE( 十进制254),而之后的四个字节则用于保存前一节点的长度。
假设节点大于254
encoding
节点的encoding属性记录了节点的content属性所保存数据的类型以及长度:
- 一字节、两字节或者五字节长,值得最高位为00、01或者10的字节数组编码:这种编码表示节点的content属性保存字节数组,数组的长度由编码除去最高两位之后的其他位记录
字节数组编码:
编码 | 编码长度 | content 属性保存的值 |
---|---|---|
00 _ _ _ _ _ _ (6位) | 1 字节 | 长度小于等于63字节的字节数组 |
01 _ _ _ _ _ _ _ _ _ _ _ _ _ _ (14 位) | 2 字节 | 长度小于等于16 383字节的字节数组 |
11 _ _ _ _ _ _ _ _ _ _ _ _ _ _ …(38 位) | 5 字节 | 长度小于等于4 294 967 295 字节的字节数组 |
- 一字节长,值得最高位以 1 1 开头的是整数编码:这种编码表示节点的content属性保存着整数值,整数值的类型和长度由编码除去最高两位之后的其他位记录
整数编码:
编码 | 编码长度 | content 属性保存的值 |
---|---|---|
1100 0000 | 1 字节 | int16_t 类型的整数 |
1101 0000 | 1 字节 | int32_t 类型的整数 |
1110 0000 | 1 字节 | int64_t 类型的整数 |
1111 0000 | 1 字节 | 24位有符号整数 |
1111 1110 | 1 字节 | 8位有符号整数 |
1111 xxxx | 1 字节 | 使用这一编码的节点没有对应的content属性,因为编码本身的 xxxx 四个位已经保存了一个介于 0 和 12 之间的值,所以它无须content 属性 |
content
encoding : 00开头表示小于等于63字节的字节数组 1011表示数组长度为长度为 11(十进制)字节长度。
content : hello (5 个字节)空格 (一个字节)world(1个字节)