1 数据结构与对象
1.1 SDS(simple dynamic string)
redis使用SDS表示字符串、缓冲区(AOF缓冲区)以及客户端状态中的输入缓冲区。
/*
* 保存字符串对象的结构
*/
struct sdshdr {
// buf 中已占用空间的长度
int len;
// buf 中剩余可用空间的长度
int free;
// 数据空间
char buf[];
};
- free属性的值为0,表示这个SDS没有分配任何未使用空间
- len属性的值为5,表示这个SDS保存了一个5字节长的字符串
- buf属性是一个char类型的数组,最后以’\0’结尾。空字符不计算在SDS的len中
1.1.2 SDS与C字符串的区别
C字符串不能满足Redis对字符串在安全性、效率以及功能方面的要求
1.1.2.1 常数复杂度获取字符串长度
C字符串不记录自身的长度信息,因此获取一个C字符串的长度,需要遍历整个字符串,时间复杂度为 O(n), 而SDS里有len字段保存了字符串本身的长度,时间复杂度为O(1),确保了获取字符串长度不会成为redis的性能瓶颈
1.1.2.2 杜绝缓冲区溢出
C字符串不记录自身自身长度,因此容易造成缓冲区溢出,如:
# 如果用户为dest分配的空间不够,就会产生溢出,可能会导致src的内容被覆盖
char *strcat(char *dest, const char *src);
当使用SDS的API对SDS进行修改时,API会首先检查SDS的空间是否满足修改所需的要求,如果不满足的话,API会自动将SDS的空间扩展至执行修改所需的大小,然后才执行实际的修改操作
1.1.2.3 减小内存重分配次数
SDS为了避免每次修改都要进行一次内存分配,使用free字段(未使用空间)来解除字符串长度和底层数组长度之间的关联,实现了空间预分配和惰性空间释放两种优化策略。
- 空间预分配
- 如果对SDS进行修改后,SDS的长度(len的值)小于 1MB,那么程序将分配2倍的len,即len和free的值相等,buf的实际长度为2len + 1
- 否则(len>=1MB),程序将分配len+1MB,buf的实际长度为len+1MB+1
- 惰性空间删除
当SDS的API需要缩短SDS保存的字符串时,程序并不立即使用内存重新分配来回多出来的空间,而是使用free属性将这些字节的数量记录起来,并等待将来使用
1.1.2.4 二进制安全
C字符串里不能包含空字符串(’\0’),而SDS使用len来判断字符串是否结束,因此SDS的buf可以存放二进制数据
1.1.2.5 兼容部分C字符串函数
SDS的API虽然都是二进制安全的,但是一样遵循C字符串以空字符串结尾的惯例。这些API总是为SDS保存的数据的末尾设置空字符,并且总会在为buf数组分配空间时多分配一个字节来容纳这个空字符。
好处:
- redis不必自己专门写函数来对比SDS与C字符串了,可以直接使用string.h的函数库,如:
strcat(c_string, sds->buf);
1.2 链表
链表用于redis的链表键、发布与订阅、慢查询、监视器等等。
多个listnode可以通过prev和next指针组成双端链表。
/*
* 双端链表节点
*/
typedef struct listNode {
// 前置节点
struct listNode *prev;
// 后置节点
struct listNode *next;
// 节点的值
void *value;
} listNode;
虽然仅仅使用多个listNode结果就可以组成链表,但使用adlist.h/list来持有链表的话,操作起来会更方便:
/*
* 双端链表结构
*/
typedef struct list {
// 表头节点
listNode *head;
// 表尾节点
listNode *tail;
// 节点值复制函数
void *(*dup)(void *ptr);
// 节点值释放函数
void (*free)(void *ptr);
// 节点值对比函数
int (*match)(void *ptr, void *key);
// 链表所包含的节点数量
unsigned long len;
} list;
- dup函数用于复制链表节点所保存的值
- free函数用于释放链表节点所保存的值
- match函数则用于对比链表节点所保存的值和另一个输入值是否相对
redis的链表实现特效:
- 双端:链表节点带有prev和next指针,获取某个节点的前置节点和后置节点的复杂度都为O(1)
- 无环:表头节点的prev指针和表尾节点的next指针都指向NULL,对链表的访问以NULL为终点
- 带表头指针和表尾指针:通过list结构的head指针和tail指针,程序获取链表的表头节点和表尾节点的复杂度为O(1)
- 代链表长度计数器:程序使用list结构的len属性来对list持有的链表节点进行计数,获取节点数量的复杂度为O(1)
- 多态:链表节点使用void*指针来保存节点的值,且可以通过dup、free、match为节点值设置类型特定函数,所以链表可以用于保存各种不同类型的值
1.3 字典
redis的数据库就是使用字典来作为底层实现的,对数据库的增、删、改、查操作是构建在对字典的操作之上的。
redis使用哈希表作为字典的底层实现,一个哈希表里可以有多个哈希表节点,而每个哈希节点就是保存了一个字典中的键值对。
1.3.1 字典的实现
/* This is our hash table structure. Every dictionary has two of this as we
* implement incremental rehashing, for the old to the new table. */
/*
* 哈希表
*
* 每个字典都使用两个哈希表,从而实现渐进式 rehash 。
*/
typedef struct dictht {
// 哈希表数组
dictEntry **table;
// 哈希表大小
unsigned long size;
// 哈希表大小掩码,用于计算索引值
// 总是等于 size - 1
unsigned long sizemask;
// 该哈希表已有节点的数量
unsigned long used;
} dictht;
- table是一个数组,数组中的每个元素都是一个指向dict.h/dictEntry结构的指针,每个dictEntry结构保存着一个键值对
- size记录了哈希表的大小,即table数组的大小
- sizemask的值总是为size - 1,它和哈希值一起决定了一个键应该放到table数组的哪个索引上面
- used为哈希表目前已有节点(键值对)的数量
1.3.2 哈希表节点
/*
* 哈希表节点
*/
typedef struct dictEntry {
// 键
void *key;
// 值
union {
void *val;
uint64_t u64;
int64_t s64;
} v;
// 指向下个哈希表节点,形成链表
struct dictEntry *next;
} dictEntry;
- key保存了键值对中的键
- v保存了键值对中的值,即键值对中的值可以是一个指针或者uint64_t,或者int64_t
- next指向另一个哈希表节点的指针,这个指针可以将多个哈希值相同的键值对连接在一起,以此来解决键冲突的问题
1.3.3 字典
type
/*
* 字典
*/
typedef struct dict {
// 类型特定函数
dictType *type;
// 私有数据
void *privdata;
// 哈希表
dictht ht[2];
// rehash 索引
// 当 rehash 不在进行时,值为 -1
int rehashidx; /* rehashing not in progress if rehashidx == -1 */
// 目前正在运行的安全迭代器的数量
int iterators; /* number of iterators currently running */
} dict;
- type和private字段是针对不同类型的键值对,为创建多态字典而设置的
- type字段是一个指向dictType结构的指针,每个dictType结构保存了一簇用于操作特定类型键值对的函数,redis会为用途不同的字典设置不同的类型特定函数
- private保存了需要传给那些类型特定函数的可选参数
- ht是一个包含了2个项的数组,数组中的每个项都是一个dictht哈希表,一般情况下,字典只使用ht[0]哈希表,ht[1]哈希表只会在对ht[0]哈希表进行rehash时使用
- 除了ht[1]之外,另一个和rehash有关的字段就是rehashidx,它记录了rehash目前的进度。如果目前没有进行rehash,它的值为-1
1.3.4 哈希算法
添加一个新的键值对时,先根据键值对的键计算出哈希值和索引值,然后在根据索引值,将包含新键值对的哈希表节点放到哈希表数组的指定索引上面。
# 使用字典设置的哈希函数,计算键key的哈希值
hash = dict->type->hashFunction(key);
# 使用哈希表的sizemask和哈希值,计算出索引值
# 根据情况不同,ht[x]可以是ht[0]或者ht[1]
index = hash & dicet->ht[x].sizemask;
- redis使用murmurHash2算法来计算键的哈希值
- murmurHash2算法的优点:即使输入的键是有规律,算法仍能给出一个很好的随机分布性,并且算法的计算速度也非常快
1.3.5 解决键冲突
- 冲突:有两个或以上的键被分配到了哈希表数组的同一个索引上面
- redis的哈希表使用链地址法来解决键冲突,每个哈希表节点都有一个next指针,多个哈希表节点可以用next指针构成一个单向链表,被分配到同一个索引上的多个节点可以用这个单向链表连接起来,这样来解决冲突问题。
注意:因为dictEntry节点组成得链表没有指向链表表尾的指针,所以为了速度考虑,程序总是将新节点添加到链表的表头位置(复杂度为O(1)),排在其他已有节点的前面
1.3.6 rehash
随着操作的不断进行,哈希表保存的键值对会逐渐地增多或者减少,为了让哈希表的负载因子维持在一个合理的范围之内,当哈希表保存的键值对数量太多或者太少时,程序需要对哈希表的大小进行相应的扩展后者收缩。
rehash步骤:
- 为字典的ht[1]哈希表分配空间,这个哈希表的空间大小取决于要执行的操作,以及ht[0]当前包含的键值对数量(也即ht[0]。used属性的值):
- 如果执行的是扩展操作,那么ht[1]的大小为第一个>=ht[0].used2的 2 n 2^{n} 2n(2的n次方),比如used为4, 42=8,恰好是第一个大于等于4的2的n次方。
- 如果执行的是收缩操作,那么ht[1]的大小为第一个>=ht[0].used的 2 n 2^{n} 2n
- 将保存在ht[0]中的所有键值对rehash到ht[1]上面:rehash指的是重新计算键的哈希值和索引值,然后将键值对放置到ht[1]哈希表的指定位置上
- 当ht[0]包含的所有键值对都迁移到了ht[1]之后(ht[0]变为空表),释放ht[0],将ht[1]设置为ht[0],并在ht[1]新创建一个空白哈希表,为下一次rehash做准备。
哈希表的扩展和收缩
当以下条件中的任意一个被满足时,程序会自动开始对哈希表进行扩展操作:
- 服务器目前没有执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子>=1
- 服务器目前正则执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子>=5
其中hash表的负载因子计算公式如下:
l o a d _ f a c t o r = h t [ 0 ] . u s e d / h t [ 0 ] . s i z e load\_factor = ht[0].used / ht[0].size load_factor=ht[0].used/ht[0].size - 当负载因子小于0.1时,程序自动开始对哈希表执行收缩操作
问题:
为啥在执行BGSAVE命令或者BGREWRITEAOF命令,负载因子要>=5,才执行扩展操作?
答:因为执行BGSAVE命令或者BGREWRITEAOF命令时,redis需要创建当前服务器进程的子进程,而大多数操作系统都采用写时复制(copy-on-write)技术来优化子进程的使用效率,所以在子进程存在期间,服务器会提高执行扩展操作所需的负载因子,从而尽可能避免在子进程进行哈希表扩展操作,这样可以避免不必要的内存写入操作,最大限度地节约内存
1.3.7 渐进式rehash
rehash动作并不是一次性的、集中式地完成的,而是分多次、渐进式地完成的。
哈希表渐进式rehash的详细步骤:
- 为ht[1]分配空间,让字典同时持有ht[0]和ht[1]两个哈希表
- 在字典中维护了一个索引计数器变量rehashidx,并将它的值设置为0,表示rehash工作正式开始
- 在rehash进行期间,每次对字典执行添加、删除、查找或者更新操作时,程序除了执行指定的操作以外,还会顺带将ht[0]哈希表在rehashidx索引上的所有键值对rehash到ht[1],当rehash工作完成后,程序将rehashidx属性的值增1
- 随着字典操作的不断执行,最终在某个时间点上,ht[0]的所有键值对都会被rehash至ht[1],这时程序将rehashidx属性的值设置为-1,表示rehash操作已完成。
注意:
- rehash期间,字典的删除、查找、更新等操作会在2个哈希表(ht[0]和ht[1])上进行,如,要在字典里查找一个键的话,程序会现在ht[0]里面进行查找,如果没找到的话,就会继续到ht[1]里面进行查找
- 在rehash期间,新添加到字典的键值对一律被保存到ht[1]里面,而ht[0]则不会进行任何操作,保证ht[0]包含的键值对数量只减不增,并随着rehash操作的执行最终变成空表。
好处:
采取分而治之的方式,将rehash键值对所需的计算工作均摊到对字典的每个添加、删除、查找和更新操作上,从而避免了集中式rehash而带来的庞大计算量
1.4 跳跃表
redis使用跳跃表作为有序集合键的底层实现之一,如果一个有序集合包含的元素数量比较多,又或者有序集合中元素的成员是比较长的字符串,redis会使用跳表来实现。
一种有序数据结构,通过在每个节点中维护多个指向其他节点的指针,从而达到快速访问节点的目的。大部分情况下,跳跃表的效率可以和平衡树相媲美,并且因为跳跃表的实现比平衡树要来的更为简单,所以有不少程序都使用跳跃表来代替平衡树。
复杂度:
支持平均O(logN)、最坏O(N)复杂度的节点查找,还可以通过顺序性操作来批量处理。
1.4.1 跳表的实现
/*
* 跳跃表
*/
typedef struct zskiplist
{
// 表头节点和表尾节点
struct zskiplistNode *header, *tail;
// 表中节点的数量
unsigned long length;
// 表中层数最大的节点的层数
int level;
} zskiplist;
/*
* 跳跃表节点
*/
typedef struct zskiplistNode
{
// 成员对象
robj *obj;
// 分值
double score;
// 后退指针
struct zskiplistNode *backward;
// 层
struct zskiplistLevel
{
// 前进指针
struct zskiplistNode *forward;
// 跨度
unsigned int span;
} level[];
} zskiplistNode;
- header:指向跳跃表的表头节点
- tail:指向跳跃表的表尾节点
- level:记录目前跳跃表内,层数最大的节点的层数(表头节点的层数不计算在内)
- length:记录跳跃表的长度,即跳跃表目前包含节点的数量(表头节点不计算在内)
- level:节点中用L1、L2、L3等标记节点的各个层,L1代表第一层,L2代表第二层(层高都是1~32之间的随机数)。每个层带有2个属性:前进指针和跨度。前进指针:用于访问表尾方向的其他节点。跨度:记录了前进指针所指向节点和当前节点的距离,用来计算节点排位的,如在查找某个节点过程中,将沿途访问过的所有层的跨度累计起来,得到的结果就是目标节点在跳跃表中的排位
- 后退指针:指向位于当前节点的前一个节点(没有多个,所以不会跳着指向,只能后退至前一个节点)。在程序从表尾向表头遍历时使用
- 分值:各个节点时保存了各自的分值,节点按照各自保存的分值从小到大排列
- 成员对象:各个节点所保存的成员对象
1.5 整数集合
整数集合是集合键的底层实现之一,当一个集合只包含整数值元素,且这个结合的元素数量不多时,redis就会使用整数集合作为集合键的底层实现
1.5.1 整数集合的实现
typedef struct intset {
// 编码方式
uint32_t encoding;
// 集合包含的元素数量
uint32_t length;
// 保存元素的数组
int8_t contents[];
} intset;
- encoding值为INSET_ENC_INT16时,那么contents就是一个int16_t类型的数组,encoding的可能值有INSET_ENC_INT32、INSET_ENC_INT64
- length:记录了整数集合的元素数量,也就是contents数组的长度
- contents:数组集合的每个元素都是contents数组的一个数组项,各个项在数组中按值的大小从小到大有序地排列,并且数组中不包含任何重复项,
- 假设:encoding为INSET_ENC_INT16,length为5, contents的数组大小:sizeof(int16_t) * 5 = 16 * 5 = 80位
1.5.2 升级
当增加一个新元素到整数集合里,且新元素的类型比整数集合里现有所有元素的类型都要长时,整数集合需要先进行升级,然后才能将新元素添加到整数集合里
升级整数集合并且添加新元素分为3步:
- 根据新元素的类型,扩展整数集合底层数组空间大小,并为新元素分配空间
- 将底层数组现有的所有元素都转成为与新元素相同的类型,并将类型转换后的元素放置在正确位置上,而且在放置元素的过程中,需要继续维持底层数组的有序性质不变
- 将新元素添加到底层数组里面(新元素小于所有元素的情况下,会被放置在底层数组的索引0的位置上,新元素大于所有元素的情况下,会被放置在末尾,索引length-1)
1.5.3 升级的好处
- 提升灵活性
可以随意将int16_t、int32_t以及int64_t类型的整数添加到集合里,不必担心出现类型错误 - 节约内存
比如只有int16_t元素时,底层实现就会一直用int16_t类型的数组
1.5.4 降级
不支持降级操作,一旦对数据进行了升级,即使后续删除了类型大的整数,依然会用升级后的编码
1.6 压缩队列
压缩队列是列表键和哈希键的底层实现之一。当一个列表键只包含少量列表项,并且每个列表项要么就是小整数值,要么就是长度比较短的字符串时,那么redis就会使用压缩队列来做列表键的底层实现
1.6.1 压缩列表的构成
压缩队列是redis为了节约内存而开发的,是由一系列特殊编码的连续内存块组成的顺序型数据结构,一个压缩队列可以包含任意多个节点,每个节点可以保存一个字节数组或一个整数值
/*
空白 ziplist 示例图
area |<---- ziplist header ---->|<-- end -->|
size 4 bytes 4 bytes 2 bytes 1 byte
+---------+--------+-------+-----------+
component | zlbytes | zltail | zllen | zlend |
| | | | |
value | 1011 | 1010 | 0 | 1111 1111 |
+---------+--------+-------+-----------+
^
|
ZIPLIST_ENTRY_HEAD
&
address ZIPLIST_ENTRY_TAIL
&
ZIPLIST_ENTRY_END
非空 ziplist 示例图
area |<---- ziplist header ---->|<----------- entries ------------->|<-end->|
size 4 bytes 4 bytes 2 bytes ? ? ? ? 1 byte
+---------+--------+-------+--------+--------+--------+--------+-------+
component | zlbytes | zltail | zllen | entry1 | entry2 | ... | entryN | zlend |
+---------+--------+-------+--------+--------+--------+--------+-------+
^ ^ ^
address | | |
ZIPLIST_ENTRY_HEAD | ZIPLIST_ENTRY_END
|
ZIPLIST_ENTRY_TAIL
*/
- zlbytes (uint32_t) : 记录整个压缩列表占用的内存字节数:在对压缩列表进行内存重新分配,或者计算zlend的位置时使用
- zltail(uint32_t):记录压缩列表尾节点距离压缩队列的起始地址多少字节:通过这个偏移量,程序无须遍历整个压缩队列就可以确定表尾节点的地址
- zllen(uint16_t):记录了压缩队列包含的节点数量
- entryX: 压缩累表包含的各个节点,节点的长度有节点保存的内容决定
- zlend(uint8_t): 特殊值0xff(十进制255),用于标记列表的末端