《Redis设计与实现》(一)数据结构
一. 简单动态字符串(SDA,simple dynamic string)
1. 定义
struct sdshdr {
int len; // 记录buf中已使用字节数量,等于SDS所保存字符串的长度
int free; // 记录buf中未使用字节数量
char buf[]; // 用于保存字符串
};
SDS遵循C字符串以空字符串结尾的惯例,保存空字符'\0'表示结尾,并不计算在len属性中,而C语言则会在长度属性计入最后一个空字符串'\0';
2. SDS和C字符串的区别
在Redis中,C字符串只会作为字符串字面量用在一些无需对字符串值进行修改的地方;而SDS用途更广泛;
i. 常量复杂度获取字符串长度
c字符串获取长度时间复杂度为O(N);因为有len字段,SDS为O(1)。
ii. 杜绝缓存区溢出
当SDS API需要对SDS进行修改时,API会先检查SDS的空间是否满足修改所需的要求,如果不满足的话,API会自动将SDS的空间扩展至执行修改所需的大小,然后才执行实际的修改操作,不需要手动修改。
iii. 减少修改字符串时带来的内存重分配次数
C字符串在每次进行拼接和截断操作时,都需要对内存进行重新分配和释放;SDS如果这样做会对性能产生影响;
- 空间预分配,当需要分配空间(分配之后为p)时,会额外预分配一些空间(t);
分配策略为:p < 1MB, t = len; p >= 1MB, t= 1MB; (空间还要加1Byte'\0') - 惰性空间释放,当SDS的API需要缩短SDS保存的字符串时,程序并不会立即使用内存重分配来回收缩短后多出来的字节,而是使用free属性将这些字节的数量记录起来,并等待将来使用。
iv. 二进制安全
SDS的API都是使用处理二进制的方式来处理SDS存放在buf数组里的数据,不会出现读到'\0'提前结束字符串等现象的出现,所以SDS不仅可以保存文本数据,还可以保存任意格式的二进制数据。
v. 兼容部分C字符串函数
可以使用一部分<string.h>库中的函数。
二. 链表:用途广泛,列表键、发布订阅、慢查询、监视器等
1. 定义
typedef struct listNode {
struct listNode *prev;
struct listNode *next;
void * value;
}listNode;
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;
2. 特性
双向、无环、带链表长度和多态(用void *指针来保存节点值);
三. 字典:用途广泛,包括数据库和哈希键
1. 定义
//哈希表
typedef struct dictht{
dictEntry **table; // 哈希表数组
unsigned long size; // 哈希表大小
unsigned long sizemask; // 哈希表大小掩码,计算索引用,为size-1
unsigned long used; // 表中已有节点数量
}dictht;
// 哈希表节点
typedef struct dictEntry {
void *key; // 键
union{
void *val;
uint64_t u64;
int64_t s64;
} v; // 值
struct dictEntry *next; // 指向下一个节点,形成链表(用于解决冲突)
};
typedef struct dict{
dictType *type; // 类型特定函数
void *privdata; // 包含有一些特定的私有函数,用于计算哈希值、复制键、复制值、对比键、销毁键和销毁值
dictht ht[2]; // 哈希表,一个用来存数据,一个用来rehash
int trehashidx; // rehash索引
}dict;
2. hash算法
hash = dict->type->hashFunction(key);
index = hash &dict->ht[x].sizemask;
redis使用MurmurHash2算法;
3. 解决冲突
添加到next上。
4. rehash
通过哈希表的负载因子决定是否需要rehash;
负载因子: load_factor = ht[0].used / ht[0].size
未收到BGSAVE或BGREWRITEAOF时load_factor > 1, 收到时load_factor > 5; 达到上述条件,进行扩展;扩展至第一个大于等于ht[0].used*2的2^n;
load_factor < 0.1,进行收缩,收缩为第一个大于等于ht[0].used的2^n。
先扩展,再在ht[1]上进行rehash,再将ht[0]释放,将ht[1]设置为ht[0],在ht[1]上放一个空表。
5. 渐进式rehash
用rehashidx记录当前rehash的位置,在每次添加、删除、查找或更新时,同时附带将ht[0]中rehashidx的值更新到ht[1]中,并将rehashidx++,并且rehash期间的添加、删除、查找或更新实在ht的两个表上进行的,一个表上未找到还需要找另一个表。当所有键值对rehash后,rehashidx设为-1,rehash结束。
四. 跳表:有序集合的底层实现
与二分查找类似,跳跃表能够在 O(㏒n)的时间复杂度之下完成查找,与红黑树等数据结构查找的时间复杂度相同,但是相比之下,跳跃表能够更好的支持并发操作,而且实现这样的结构比红黑树等数据结构要简单、直观许多。
跳跃表以有序的方式在层次化的链表中保存元素,效率和平衡树媲美:查找、删除、添加等操作都可以在对数期望时间下完成。跳跃表体现了“空间换时间”的思想,从本质上来说,跳跃表是在单链表的基础上在选取部分结点添加索引,这些索引在逻辑关系上构成了一个新的线性表,并且索引的层数可以叠加,生成二级索引、三级索引、多级索引,以实现对结点的跳跃查找的功能。
与二分查找类似,跳跃表能够在 O(㏒n)的时间复杂度之下完成查找,与红黑树等数据结构查找的时间复杂度相同,但是相比之下,跳跃表能够更好的支持并发操作,而且实现这样的结构比红黑树等数据结构要简单、直观许多。
五. 整数集合:集合质保函整数值元素且元素不多
1. 定义
typedef struct intset{
uint32_t encoding; // 编码方式
uint32_t length; // 包含元素数量
int8_t contents[]; // 保存元素
}intset;
contents保存的数据类型由encoding决定,并非int8_t;有可能int16_t, int32_t, int64_t;
普通添加元素和移除元素的时间复杂度为O(N);
2. 升级
当新添加的元素大小大于原有元素时,需要升级,分三步:
a) 扩展空间 b)将原来的元素挪到正确位置上(所有元素转化为新元素大小) c)添加新元素
3. 降级
和升级类似。
六. 压缩列表:列表建和哈希建底层实现
1. 压缩列表构成
zlbytes | zltail | zllen | entry1 | ... | entryN | zlend |
---|---|---|---|---|---|---|
记录压缩列表占用内存数 | 表尾到起始地址有多少字节 | 包含节点数量(大于65536时需要遍历得知) | 各个节点 | ... | 各个节点 | 特殊值表示尾端 |
2. 压缩列表节点构成
previous_entry_length | encoding | content |
---|---|---|
记录前一节点的长度(单位:字节),如果前一节点长度小于254,该字段长度为1,否则长度为5 | 记录节点的content所保存的数据类型和长度 | 表示负责记录的值:字节数组或整数 |
假设我们有指向某一节点node的指针ptr,则node前一节点指针为:ptr - node.previous_entry_length
所以,可以从尾向头遍历压缩列表,用zltail直接找到表尾节点的指针,然后通过上述方式从后往前遍历。
3. 连锁更新
由压缩列表ziplist的部分节点的previous_entry_length都为1,且节点长度且与250与253字节之间,有可能会引发连锁更新。(因为previous_entry_length的变化导致本节点的长度大于253,下一节点的previous_entry_length也需要更新长度,以此类推)
上述情况发生最坏情况下时间复杂度为O(N^2),但上述情况很难发生,所以评价时间复杂度为O(N)(就是普通的数组插入元素)