Redis 底层数据结构概述(v6.2)


注意: 本文涉及的源码均已 Redis 6.2 为准,新版本可能会有所变化。

0.前言

Redis(Remote Dictionary Server ),即远程字典服务,是一个使用 ANSI C 编写的开源、支持网络、基于内存、分布式、可选持久性的键值对(key-value) 数据库,与 Memcached 类似,却优于 Memcached。

为什么说 Redis 优于 Memcached 呢,因为 Redis 拥有更丰富的数据结构,更多样的数据操作方式,管道与事务,以及数据持久化的能力等,正因为这些优点,Redis 作为一个 NoSQL 数据库,有效弥补了传统关系型数据库在很多业务场景的乏力,如排行榜的自动排序,高性能缓存和分布式锁等。

Redis 里面的每个键值对都是由对象(object)组成。键总是一个字符串对象(string object),值则是不同数据结构的对象。

说到 Redis 数据结构,我们会很快想到 Redis 5 种基本数据类型:

  • String(字符串)
  • List(列表)
  • Set(集合)
  • Hash(哈希)
  • Sorted Set(有序集合)

另外, Redis 还支持多种不常用的高级数据类型:

  • Bitmap(位图)
  • HyperLogLog
  • Stream(流)
  • Geospatial indices(地理空间索引)

以上都是 Redis 对外暴露的数据结构,通过 API 操作,而组成它们的底层数据结构又是什么呢?

Redis 底层数据结构主要有以下几种:

  • SDS(Simple Dynamic String):简单动态字符串
  • Linkedlist:链表
  • Dictionary:字典
  • Skiplist:跳表
  • Intset:整数集合
  • Ziplist:压缩表
  • Quiklist:快表

我们接下来会一步一步探讨这些数据结构的特点,以及他们是如何构成我们所使用的 Redis 数据类型。

1.简单动态字符串

1.1 简介

字符串可能是我们在使用 Redis 时用的最多数据类型了。我们可能会较为主观的认为 Redis 中的字符串就是采用了 C 语言中的传统字符串,但其实不然。Redis 没有直接使用 C 语言传统的字符串,而是自己构建了一种名为简单动态字符串(simple dynamic string, SDS)的抽象类型,并将 SDS 用作 Redis 的默认字符串的底层标识结构:

redis> SET msg "hello world"
OK

设置一个 key= msg,value = hello world 的新键值对,他们底层是数据结构将会是:

  • 键(key)是一个字符串对象,对象的底层实现是一个保存着字符串“msg” 的 SDS;
  • 值(value)也是一个字符串对象,对象的底层实现是一个保存着字符串“hello world” 的 SDS。

从上述例子,我们可以很直观的看到我们在平常使用 Redis 的时候,创建的字符串到底是一个什么样子的数据类型。除了用来保存字符串以外,SDS 还被用于 AOF(Append Only File,一种持久化机制)模块中的缓冲区。

1.2 SDS 的定义

SDS 的结构定义在 src/sds.h 文件中,SDS 的定义在 Redis 3.2 版本之后有一些改变,由一种数据结构变成了 5 种数据结构,会根据 SDS 存储的内容长度来选择不同的结构,以达到节省内存的效果。

SDS 的结构定义如下:

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

// Redis 6.2

/* Note: sdshdr5 is never used, we just access the flags byte directly.
 * However is here to document the layout of type 5 SDS strings. */
struct __attribute__ ((__packed__)) sdshdr5 {
    unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len; /* used */
    uint8_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr16 {
    uint16_t len; /* used */
    uint16_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
    uint32_t len; /* used */
    uint32_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {
    uint64_t len; /* used */
    uint64_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};

3.2 版本开始,会根据字符串长度选择对应的数据结构:

// sds.c
static inline char sdsReqType(size_t string_size) {
    if (string_size < 1<<5)
        return SDS_TYPE_5;
    if (string_size < 1<<8)
        return SDS_TYPE_8;
    if (string_size < 1<<16)
        return SDS_TYPE_16;
#if (LONG_MAX == LLONG_MAX)
    if (string_size < 1ll<<32)
        return SDS_TYPE_32;
    return SDS_TYPE_64;
#else
    return SDS_TYPE_32;
#endif
}
  • len:记录当前已使用的字节数(不包括 ‘\0’),获取 SDS 长度的复杂度为 O(1)
  • alloc:记录当前字节数组总共分配的字节数量(不包括 ‘\0’)
  • flags:低三位标记当前字节数组的属性,是 sdshdr5、sdshdr8 还是 sdshdr16 等,flags 值的定义可以看下面代码
  • buf:字节数组,用于保存字符串,包括结尾空白字符 ‘\0’
// flags值定义
#define SDS_TYPE_5  0
#define SDS_TYPE_8  1
#define SDS_TYPE_16 2
#define SDS_TYPE_32 3
#define SDS_TYPE_64 4

1.3 SDS 与 C 字符串的区别

传统的 C 字符串使用长度为 N+1 的字符串数组来表示长度为 N 的字符串,这样做在获取字符串长度,字符串扩展等操作的时候效率低下。C 语言使用这种简单的字符串表示方式,并不能满足 Redis 对字符串在安全性、效率以及功能方面的要求。

1.3.1 获取字符串长度时间复杂度为 O(1) —— 效率

获取字符串长度的效率,SDS 为 O(1),而 C 字符串为 O(n)。

传统的 C 字符串 使用长度为 N+1 的字符串数组来表示长度为 N 的字符串,所以为了获取一个长度为 C 字符串的长度,必须遍历整个字符串。

和 C 字符串不同,SDS 的数据结构中,有专门用于保存字符串长度的变量,可以通过获取 len 属性的值,直接知道字符串长度。Redis 将获取字符串长度所需的复杂度从 O(N) 降到了 O(1),确保获取字符串长度的工作不会成为 Redis 的性能瓶颈。

1.3.2 杜绝缓冲区溢出 —— 安全

C 字符串不记录字符串长度,除了获取的时候复杂度高以外,还容易导致缓冲区溢出。

C 字符串不记录自身的长度,每次增长或缩短一个字符串,都要对底层的字符数组进行一次内存重分配操作。如果是拼接append操作之前没有通过内存重分配来扩展底层数据的空间大小,就会产生缓存区溢出;如果是截断trim操作之后没有通过内存重分配来释放不再使用的空间,就会产生内存泄漏

假设程序中有两个在内存中紧邻着的字符串 S1 和 S2,其中 S1 保存了字符串 “redis”,而 S2 则保存了字符串 “MongoDB”:
在这里插入图片描述
如果我们现在将 S1 的内容修改为 “redis cluster”,但是又忘了重新为 S1 分配足够的空间,这时候就会出现以下问题:
在这里插入图片描述
我们可以看到,原本 S2 中的内容已经被 S1 的内容给占领了,S2 现在为 cluster,而不是 MongoDB。

而 Redis 中 SDS 的空间分配策略完全杜绝了发生缓冲区溢出的可能性:当我们需要对一个 SDS 进行修改的时候,Redis 会在执行拼接操作之前,预先检查给定 SDS 空间是否足够,如果不够,会先拓展 SDS 的空间,然后再执行拼接操作。

1.3.3 减少修改字符串时带来的内存重分配次数 —— 效率

C 语言字符串在进行字符串的扩充和收缩的时候,都会面临着内存空间的重新分配问题。

字符串拼接会产生字符串的内存空间的扩充,在拼接的过程中,原来的字符串的大小很可能小于拼接后的字符串的大小,那么这样的话,就会导致一旦忘记申请分配空间,就会导致内存的溢出。

字符串在进行收缩的时候,内存空间会相应的收缩,而如果在进行字符串切割的时候,没有对内存的空间进行一个重新分配,那么这部分多出来的空间就成为了泄露的内存。

sdshdr8举个例子:我们需要对下面的 SDS 进行扩充,则需要进行空间的扩充,这时候 Redis 会将 SDS 的长度由原来的 5 扩展为修改为 13 字节。
在这里插入图片描述
修改字符串的时候会发现 SDS 申请的空间足够使用,因此无须进行空间扩展,可直接扩展字符串的长度。

通过这种预分配策略,SDS 将连续增长 N 次字符串所需的内存重分配次数从必定 N 次降低为最多 N 次。

1.3.4 惰性空间释放

我们在观察 SDS 的结构的时候可以看到里面的 len 和 alloc 属性,alloc 减去 len 便可以得到剩余空间。因为我们在拓展字符串的时候会使用剩余空间,所以在对字符串进行收缩的时候,我们一般不会直接将剩余空间释放。这样做的好处就是避免下次对字符串进行再次修改的时候,需要对字符串的空间进行拓展。然而,我们并不是说不能释放 SDS 中空余的空间,SDS 提供了相应的 API,让我们可以在有需要的时候,自行释放 SDS 的空余空间。

通过惰性空间释放,SDS 避免了缩短字符串时所需的内存重分配操作,并未将来可能有的增长操作提供了优化。

1.3.5 二进制安全

C 字符串中的字符必须符合某种编码,并且除了字符串的末尾之外,字符串里面不能包含空字符,否则最先被程序读入的空字符将被误认为是字符串结尾,这些限制使得 C 字符串只能保存文本数据,而不能保存想图片,音频,视频,压缩文件这样的二进制数据。

但是在 Redis 中,不是靠空字符来判断字符串的结束的,而是通过 len 这个属性。那么,即便是中间出现了空字符对于 SDS 来说,读取该字符仍然是可以的。

例如下面包含空字符的字符串:
在这里插入图片描述

1.3.6 兼容部分 C 字符串函数

虽然 SDS 的 API 都是二进制安全的,但他们一样遵循 C 字符串以空字符串结尾的惯例,目的是为了让保存文本数据的 SDS 可以重用一部分 C 字符串的函数。

1.4 扩容机制

Redis 的扩容由src/sds.c中的sdsMakeRoomFor()函数完成,其实现流程如下:

  1. 判断 sds 预留空闲空间是否大于 addlen,大于 addlen,则直接返回就行了,不需要扩容。
  2. 计算新 sds 的占用空间大小,如果 sds.len+addlen 小于 1M(SDS_MAX_PREALLOC)的话,则进行(sds.len+addlen)*2 的扩容。
  3. 如果 sds.len+addlen 不小于 1M 的话,每次扩容加 1M。
  4. 根据新长度计算新 sds 的类型。
  5. 如果新 sds 类型与旧 sds 类型一致,则进行 s_realloc 动态扩容。
  6. 如果新 sds 类型与旧 sds 类型不一致,则需要申请全新的空间进行 sds 重构,并释放掉原来的 sds 内存空间。
  7. 最后返回 sds。

sdsMakeRoomFor()函数的部分实现:

// sds.h
#define SDS_MAX_PREALLOC (1024*1024)

// sds.c
/* Enlarge the free space at the end of the sds string so that the caller
 * is sure that after calling this function can overwrite up to addlen
 * bytes after the end of the string, plus one more byte for nul term.
 *
 * Note: this does not change the *length* of the sds string as returned
 * by sdslen(), but only the free buffer space we have. */
sds sdsMakeRoomFor(sds s, size_t addlen) {
	...
    if (newlen < SDS_MAX_PREALLOC)
        newlen *= 2;
    else
        newlen += SDS_MAX_PREALLOC;
	...
}

1.5 小结

C 字符串SDS
获取字符串长度的复杂度为 O(n)获取字符串长度的复杂度为 O(1)
API 不安全,可能缓冲区溢出API 安全,不会缓冲区溢出
修改字符串长度 N 次必然需要执行 N 次内存重分配修改字符串长度 N 次最多执行 N 次内存重分配
只能保存文本数据可以保存二进制数据和文本文数据
可以使用所有<string.h>库中的函数可以使用一部分<string.h>库中的函数

2.链表

2.1 概述

链表是一种比较常见的数据结构,链表提供了高效的结点重排能力,以及顺序性的结点访问方式,并且可以通过增删结点来灵活地调整链表的长度,但随机访问困难。许多高级编程语言都内置了链表的实现,但是 C 语言并没有实现链表,所以 Redis 实现了自己的链表数据结构。

链表在 Redis 中应用非常广泛,在 Redis 3.2 版本之前,列表(List)的底层实现是链表和压缩表,之后改由快表(quiklist)实现。此外,Redis 的发布与订阅、慢查询、监视器等功能也用到了链表。

2.2 数据结构

每个链表结点使用一个 listNode 结构表示,其定义在 src/adlist.h 文件中。

typedef struct listNode {
    struct listNode *prev;
    struct listNode *next;
    void *value;
} listNode;

从 listNode 的结构可以看出,有指向前后结点的指针 prevnext,说明其是一个双向链表。链表结点使用 void* 指针来保存结点值。

链表 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;

链表 list 结构为链表提供表头指针head、表尾指针tail、以及链表长度计数器len,还有三个用于实现多态链表特定类型的操作函数。

  • dup:复制链表结点值
  • free:释放链表结点值
  • match:对比链表结点值是否相等

下面是一个链表示例:

在这里插入图片描述

2.3 特性

  • 双向:链表结点带有 prev 和 next 指针,获取某个结点的前置结点和后置结点的时间复杂度都是 O(1)
  • 无环:表头结点的 prev 指针和表尾结点的 next 都指向 NULL,对链表的访问以 NULL 为结束
  • 表头和表尾:因为链表带有 head 和 tail 指针,获取链表头尾结点的时间复杂度为 O(1)
  • 长度计数器:链表中存有链表长度属性 len
  • 多态:链表结点使用 void* 指针保存结点值,可以通过 list 结构的 dup、free、match 三个属性为结点值设置特定类型的操作函数

3.字典

3.1 概述

字典,又称为符号表(Symbol Table)、关联数组(Sssociative Array)或映射(map),是一种用于保存键值对的抽象数据结构。

在字典中,一个键(key)可以和一个值(value)进行关联,字典中的每个键都是独一无二的。在 C 语言中,并没有这种数据结构,但是 Redis 构建了自己的字典。

Redis 的键值对存储就是用字典实现的,哈希(Hashes)的底层实现之一也是字典。

举个简单的例子:

redis > SET msg "hello world"
OK

创建这样的键值对(“msg”,“hello world”)在数据库中就是以字典的形式存储。

3.2 字典的定义

3.2.1 哈希表(dictht)

Redis 的字典底层是使用哈希表实现的,一个哈希表里面可以有多个哈希表结点,每个结点中保存了字典中的一个键值对。

Redis 字典所使用的哈希表 dictht 定义在 src/dict.h 文件中。

/* This is our hash table structure. Every dictionary has two of this as we
 * implement incremental rehashing, for the old to the new table. */
typedef struct dictht {
    dictEntry **table;		// 哈希表
    unsigned long size; 	// 哈希表大小
    unsigned long sizemask;	// 哈希表大小掩码,用于计算索引值
    unsigned long used;		// 该哈希表已有结点的数量
} dictht;

哈希表的初始大小是 4,由下面的宏常量决定。

/* This is the initial size of every hash table */
#define DICT_HT_INITIAL_SIZE     4

所以,一个空的哈希表的结构如下图所示:
在这里插入图片描述
我们可以看到,在结构中存有指向 dictEntry 数组的指针,而我们用来存储数据的空间即是 dictEntry。

3.2.2 哈希表结点( dictEntry )

哈希表是由数组 table 组成,table 中每个元素都是指向 dict.h/dictEntry 结构的指针。哈希表结点 dictEntry 的定义如下:

typedef struct dictEntry {
    void *key; // 键
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v; // 值
    struct dictEntry *next; // 下一个结点
} dictEntry;

其中 key 是我们的键;v 是键值,可以是一个指针,也可以是整数或浮点数;next 属性是指向下一个哈希表结点的指针,可以让多个哈希值相同的键值对形成链表,解决键冲突问题。

在数据结构中,我们清楚 key 是唯一的,但是我们存入里面的 key 并不是直接的字符串,而是一个 hash 值,通过 hash 算法,将字符串转换成对应的 hash 值,然后在 dictEntry 中找到对应的位置。

这时候我们会发现一个问题,如果出现 hash 值相同的情况怎么办?Redis 采用了链地址法:
在这里插入图片描述
插入 k1 时,当 k1 的 hash 值和 k0 相同时,将 k1中的 next 指向 k0 形成一个链表。

3.2.3 字典(dict)

最后就是由哈希表构成的字典结构 dict.h/dict。

typedef struct dict {
    dictType *type; 	// 类型特定函数
    void *privdata;		// 私有数据
    dictht ht[2];		// 新旧两个哈希表
    long rehashidx; /* rehashing not in progress if rehashidx == -1 */
    int16_t pauserehash; /* If >0 rehashing is paused (<0 indicates coding error) */
} dict;

typeprivdata属性是针对不同类型的键值对,用于创建多态的字典。type是指向 dictType 结构的指针,privdata则保存需要传给类型特定函数的可选参数,关于 dictType 结构和类型特定函数可以看下面代码。

typedef struct dictType {
	// 计算哈希值的函数
    uint64_t (*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, void *obj);
    // 判断是否需要扩展(rehash)
    int (*expandAllowed)(size_t moreMem, double usedRatio);
} dictType;

另外属性ht是两个元素的数组,包含两个 dictht 哈希表,一般字典只使用 ht[0] 哈希表,ht[1] 哈希表会在对 ht[0] 哈希表进行 rehash(重哈希)的时候使用,即当哈希表的键值对数量过多的时候,会将键值对迁移到 ht[1] 上。rehashidx也是跟 rehash 相关的,rehash 的操作不是瞬间完成的,rehashidx记录着 rehash 的进度,如果目前没有在进行 rehash,它的值为 -1。pauserehash表明 rehash 操作是否被暂停。

结合上面的几个结构,看一下普通状态下的字典结构示意图(没有在进行 rehash):
在这里插入图片描述

3.2.4 解决哈希冲突

在上述分析哈希节点的时候我们有讲到:在插入一条新的数据时,会进行哈希值的计算,如果出现了 hash 值相同的情况,Redis 中采用了链地址法(Separate Chaining)来解决键冲突。每个哈希表结点都有一个 next 指针,多个哈希表结点可以使用 next 构成一个单向链表,被分配到同一个索引上的多个结点可以使用这个单向链表连接起来解决 hash 值冲突的问题。

举个例子,现在哈希表中有以下的数据:k0 和 k1。
在这里插入图片描述
我们现在要插入k2,通过 hash 算法计算到 k2 的hash 值为 2,即我们需要将 k2 插入到 dictEntry[2] 中:
在这里插入图片描述
在插入后我们可以看到,dictEntry 指向了k2,k2 的 next 指向了k1,从而完成了一次插入操作(这里选择表头插入是因为哈希表节点中没有记录链表尾节点位置)。

3.2.5 Rehash

随着对哈希表的不断操作,哈希表保存的键值对会逐渐地发生改变。为了让哈希表的负载因子维持在一个合理的范围之内,我们需要对哈希表的大小进行相应的扩展或收缩,这时候,我们可以通过 rehash(重新散列)操作来完成。

3.2.5.1 满状态的哈希表

我们可以看到,哈希表中的每个结点都已经使用到了,这时候我们需要对哈希表进行扩展。
在这里插入图片描述

3.2.5.2 为哈希表分配空间

哈希表空间大小的分配规则如下:

  • 如果执行的是扩展操作,那么 ht[1] 的大小为第一个大于等于ht[0].used的 2 的 n 次幂。
  • 如果执行的是收缩操作,那么 ht[1] 的大小为第一个大于等ht[0].used的 2 的 n 次幂。

以扩展为例,新哈希表的大小由如下函数决定:

/* Resize the table to the minimal size that contains all the elements,
 * but with the invariant of a USED/BUCKETS ratio near to <= 1 */
int dictResize(dict *d)
{
    unsigned long minimal;

    if (!dict_can_resize || dictIsRehashing(d)) return DICT_ERR;
    minimal = d->ht[0].used;
    if (minimal < DICT_HT_INITIAL_SIZE)
        minimal = DICT_HT_INITIAL_SIZE;
    return dictExpand(d, minimal);
}

其中函数 dictExpand 最终会调用如下函数,以ht[0].used作为入参,计算出第一个大于等于ht[0].used的 2 的 n 次幂,作为新哈希表的容量。

/* Our hash table capability is a power of two */
static unsigned long _dictNextPower(unsigned long size)
{
    unsigned long i = DICT_HT_INITIAL_SIZE;

    if (size >= LONG_MAX) return LONG_MAX + 1LU;
    while(1) {
        if (i >= size)
            return i;
        i *= 2;
    }
}

当然,新哈希表的容量必须大于旧哈希表的容量。所以这里我们为 ht[1] 分配空间为 8。
在这里插入图片描述

3.2.5.3 数据转移

将 ht[0] 中的数据转移到 ht[1] 中,在转移的过程中,需要对哈希表结点的数据重新进行哈希值计算。

数据转移后的结果:
在这里插入图片描述

3.2.5.4 释放 ht[0]

将 ht[0] 释放,然后将 ht[1] 设置成 ht[0],最后为 ht[1] 分配一个空白哈希表:
在这里插入图片描述

3.2.5.5 渐进式 rehash

上面我们说到,在进行扩展或收缩的时候,可以直接将所有的键值对 rehash 到 ht[1] 中,这是因为数据量比较小。在实际开发过程中,这个 rehash 操作并不是一次性、集中式完成的,而是分多次、渐进式地完成的。

渐进式 rehash 的详细步骤:

  • 为 ht[1] 分配空间,让字典同时持有 ht[0] 和 ht[1] 两个哈希表。
  • 在字典中维持一个索引计数器 rehashidx,并将它的值设置为 0,表示 rehash 开始。
  • 在 rehash 进行期间,每次对字典执行 CRUD 操作时,程序除了执行指定的操作以外,还会将 ht[0] 中的数据 rehash 到 ht[1] 表中,并且将 rehashidx 加一。
  • 当 ht[0] 中所有数据转移到 ht[1] 中时,将 rehashidx 设置成 -1,表示 rehash 结束。

在渐进式 rehash 期间,字典的删除(delete)、查找(find)和更新(update)操作会在两个哈希表上进行。比如查找一个键的话,会先在 ht[0] 里面进行查找, 如果没找到的话, 就会继续到 ht[1] 里面进行查找, 诸如此类。

新加的键值对一律被保存到 ht[1] 里面,而 ht[0] 则不再进行任何添加操作,这一措施保证了 ht[0] 包含的键值对数量会只减不增,并随着 rehash 操作的执行而最终变成空表。

采用渐进式 rehash 的好处在于它采取分而治之的思想,避免了集中式 rehash 带来的庞大计算量,导致长时间的迁移过程中字典不可用。

3.2.5.6 定时辅助 rehash

虽然 Redis 实现了在读写操作时,会进行渐进式 rehash。但是如果服务器比较空闲,Redis 将很长时间内都一直使用两个哈希表。所以在 Redis 周期函数中,如果发现有字典正在进行渐进式 rehash 操作,则会花费1毫秒的时间,帮助一起进行渐进式 rehash。

在 databasesCron() 函数中,实现如下:

void databasesCron(void) {
	...
	/* Rehash */
    if (server.activerehashing) {
        for (j = 0; j < dbs_per_call; j++) {
            int work_done = incrementallyRehash(rehash_db);
            if (work_done) {
                /* If the function did some work, stop here, we'll do
                 * more at the next cron loop. */
                break;
            } else {
                /* If this db didn't need rehash, we'll try the next one. */
                rehash_db++;
                rehash_db %= server.dbnum;
            }
        }
    }
	...
}

前提是配置了 activerehashing,允许服务器在周期函数中辅助进行渐进式 rehash,该参数默认值是1。

4.跳表

4.1 概述

跳表(skiplist),又名跳跃表,是一种有序数据结构,不属于平衡树结构,也不属于 Hash 结构,它通过在每个结点中维持多个指向其他结点的指针,从而达到快速访问结点的目的。

一个普通的单链表查询一个元素的时间复杂度为 O(N),即便该单链表是有序的。使用跳表便可解决单链表查询效率低的问题。跳表是一种随机化的数据,跳表以有序的方式在层次化的链表中保存元素,效率和平衡树媲美 —— CRUD 都可在对数期望时间下完成,并且比起平衡树来说,跳跃表的实现要简单直观得多。

Redis 只在两个地方用到了跳表,一个是实现有序集合,另一个是在集群节点中用作内部数据结构。

4.2 跳表的定义

我们先来看一下整个跳表的完整结构:
在这里插入图片描述
跳表主要由两部分组成:zskiplist(链表)和 zskiplistNode (结点)。

4.2.1 zskiplistNode

在源码 src/server.h 文中,我们可以看到跳表结点 zskiplistNode 的定义。

/* ZSETs use a specialized version of Skiplists */
typedef struct zskiplistNode {
    sds ele;							// 结点值
    double score;						// 分值
    struct zskiplistNode *backward; 	// 后退指针
    struct zskiplistLevel {
        struct zskiplistNode *forward;	// 前进指针
        unsigned long span;				// 跨度
    } level[];							// 层
} zskiplistNode;
  • 分值和成员:跳表中的所有结点都按分值从小到大排序。成员对象是一个使用 SDS 表示的字符串
  • 后退指针:用于从表尾向表头方向访问结点
  • 层:level 数组可以包含多个元素,每个元素都包含一个指向其他节点的指针
  • 前进指针:用于指向表尾方向的前进指针
  • 跨度:用于记录两个节点之间的距离

4.2.2 zskiplist

#define ZSKIPLIST_MAXLEVEL 32 /* Should be enough for 2^64 elements */
#define ZSKIPLIST_P 0.25      /* Skiplist P = 1/4 */

typedef struct zskiplist {
    struct zskiplistNode *header, *tail; // 表头结点和表尾结点
    unsigned long length;				 // 表中节点数量
    int level;							 // 表中层数最大的结点的层数
} zskiplist;

在这里插入图片描述
上图左边第一个表示了 zskiplist 结构,管理整个跳表,右边四个节点描述了 4 个 zskiplistNode 结构,代表了跳表中的结点。

zskiplist 结构中的 header 指向的头结点分值 score 和 ele 无意义,length 字段记录的长度不包含该头结点,level 记录了跳表中目前最高层次节点的层数。

zskiplistNode 结构中 ele 表示结点实际存储的元素(o1,o2,o3),score 表示结点的分值,跳表中结点按分值从小到大排列,backward 指向前结点。level(L1、L2、……、Ln)记录了该结点的各层信息。

(L1、L2、……、Ln)层信息结构为 zskiplistLevel 结构所定义的层信息,其中包含了指向该层下一结点的指针 forward,以及距离本层下一结点的距离 span,相邻结点的距离为 1。因此计算从头结点遍历到某个结点所经过的路径的 span 之和就可以得到该节点的在整个跳表中的排名。

4.3 Redis 中的 Sorted Set

Redis 中的 Sorted Set 是在 skiplist、dict 和 ziplist 基础上构建起来的。

  • 当数据较少时,Sorted Set 是由一个 ziplist 来实现的。
  • 当数据多的时候,Sorted Set 是由一个叫 zset 的数据结构来实现的,这个 zset 包含“一个 dict + 一个skiplist”。dict 用来查询数据到分数的对应关系,而 skiplist 用来根据分数查询数据(可能是范围查找)。
typedef struct zset {
    dict *dict;
    zskiplist *zsl;
} zset;

ziplist 的主要优点是节省内存,但它上面的查找操作只能按顺序查找(可以正序也可以倒序)。因此 sorted set 的各个查询操作,就是在 ziplist 上从前向后(或从后向前)一步步查找,每一步前进两个数据项,跨域一个, score)对。

随着数据的插入,sorted set 底层的这个ziplist就可能会转成 zset 的实现(转换过程详见 t_zset.c 的 zsetConvert)。那么到底插入多少才会转呢?

这个由 Redis 的两个配置决定:

zset-max-ziplist-entries 128
zset-max-ziplist-value 64

这个配置的意思是说,在如下两个条件之一满足的时候,ziplist 会转成 zset(具体的触发条件参见 t_zset.c 中的 zaddGenericCommand 相关代码):

  • 当 sorted set 中的元素个数,即(数据,score)对的数目超过 128 的时候,也就是 ziplist 数据项超过 256 的时候。
  • 当 sorted set 中插入的任意一个元素的长度超过 64 的时候。

4.4 为什么 ZSET 用跳表而不是用 B+ 树?

Redis 的作者 @antirez 是这么说的:

There are a few reasons:

  1. They are not very memory intensive. It’s up to you basically. Changing parameters about the probability of a node to have a given number of levels will make then less memory intensive than btrees.
  2. A sorted set is often target of many ZRANGE or ZREVRANGE operations, that is, traversing the skip list as a linked list. With this operation the cache locality of skip lists is at least as good as with other kind of balanced trees.
  3. They are simpler to implement, debug, and so forth. For instance thanks to the skip list simplicity I received a patch (already in Redis master) with augmented skip lists implementing ZRANK in O(log(N)). It required little changes to the code.

原文出处:https://news.ycombinator.com/item?id=1171423

大概的意思是:

  • 跳表占用更少的内存。
  • 对范围查找的支持,跳表的缓存局部性与平衡树一样好
  • 它们更易于实现、易于调试等。

4.5 小结

跳表其实可以把它理解为多层的链表,它有如下的性质:

  • 多层的结构组成,每层是一个有序链表。
  • 最底层(level 1)的链表包含所有的元素。
  • 跳表的查找次数近似于层数,时间复杂度为 O(logn),插入、删除也为 O(logn)。

在 Redis 中:

  • 跳表是有序集合的底层实现之一。
  • 跳表主要有 zskiplist 和 zskiplistNode 两个结构组成。
  • 在同一个跳表中,多个结点可以包含相同的分值。
  • 在跳表中可以很方便地计算出每个元素的排名(rank),通过 span 跨度来实现。
  • 结点按照分值大小升序排列。如果分值相同,则按成员字典序排列。
  • 第1层链表不是一个单向链表,而是一个双向链表。这是为了方便以倒序方式获取一个范围内的元素。
  • 跳表是一种随机化的数据结构,每个跳表结点的层高都是 1 至 32 之间的随机数(通过抛硬币来决定层数)。其层高的计算伪码如下:
randomLevel()
    level := 1
    // random()返回一个[0...1)的随机数
    while random() < p and level < MaxLevel do
        level := level + 1
    return level

randomLevel() 的伪码中包含两个参数,一个是 p,一个是 MaxLevel,Redis 中取值如下:

p = ZSKIPLIST_P = 1/4
MaxLevel = 32
  • 在 zset 中可以直接获取元素的分数,通过查询字典,时间复杂度为 O(1)。

下图是学生代数课成绩表,展示了 Redis 中一个 skiplist 的可能结构:
在这里插入图片描述

5.整数集合

5.1 概述

《Redis 设计与实现》 中这样定义整数集合:“整数集合是集合建的底层实现之一,当一个集合中只包含整数,且这个集合中的元素数量不多时,Redis 就会使用整数集合 intset 作为集合的底层实现。”

我们可以这样理解整数集合,他就是一个特殊的集合,里面存储的数据只能够是整数,并且数据量不能过大。

5.2 整数集合的实现

在源码 src/intset.h 文件中,可以看到 intset 的定义。

typedef struct intset {
    uint32_t enconding; // 编码方式
    uint32_t length;	// 集合包含的元素数量
    int8_t contents[];	// 保存元素的数组
} 
  • encoding:用于定义整数集合的编码方式,如 INTSET_ENC_INT16、INTSET_ENC_INT32、INTSET_ENC_INT64
  • length:用于记录整数集合中变量的数量
  • contents:用于保存元素的数组,虽然我们在数据结构图中看到,intset 将数组定义为 int8_t,但实际上数组保存的元素类型取决于 encoding

5.3 整数集合的升级

在上述数据结构定义中我们可以看到,intset 在默认情况下会帮我们设定整数集合中的编码方式,但是当我们存入的整数不符合整数集合中的编码格式时,就需要使用到 Redis 中的升级策略来解决。

Intset 中升级整数集合并添加新元素共分为三步进行:

  • 根据新元素的类型,扩展整数集合底层数组的空间大小,并为新元素分配空间
  • 将底层数组现有的所有元素都转换成新的编码格式,并将转换后的元素放到正确的位置,且要保持数组的有序性
  • 将新元素加入到底层数组中

比如,我们现在有如下的整数集合:
在这里插入图片描述
我们现在需要插入一个32位的整数,这显然与整数集合不符合,我们将进行编码格式的转换,并为新元素分配空间:

在这里插入图片描述
第二步,将原有数据的类型转换为与新数据类型,并按序放回数组:

在这里插入图片描述
第三步,将新数据添加到数组中:

在这里插入图片描述
整数集合升级存在原因是 Redis 总是采用最省空间的编码方式来存储整数,当新加入的整数与现有整数的编码方式不同时,那么便需要进行编码升级。编码方式一旦被升级,不会再降级。

5.4 小结

整数集合是集合的底层实现之一。

整数集合的底层实现为数组,这个数组以有序,无重复的范式保存集合元素。在有需要时,程序会根据新添加的元素类型改变这个数组的类型。

升级操作为整数集合带来了操作上的灵活性,并且尽可能地节约了内存。

整数集合只支持升级操作,不支持降级操作。

6.压缩列表

6.1 概述

压缩列表(ziplist)是为了节约内存而设计的,是由一系列特殊编码的连续内存块组成的顺序性(Sequential)数据结构,一个压缩列表可以包含多个结点,每个结点可以保存一个字节数组或一个整数值。

压缩列表是列表和哈希的底层实现之一。 当一个列表只有很少量列表项,并且每个列表项要么是小整数,要么是长度比较短的字符串,那么 Redis 就会使用压缩列表来做列表的底层实现。(在 3.2 版本之后是使用 quicklist 实现)

6.2 结构

一个压缩列表的组成如下:

在这里插入图片描述

字段类型长度说明
zlbytesuint32_t4B用于记录整个压缩列表占用的内存字节数。在对压缩列表进行内存重分配或计算 zlend 的位置时使用
zltailuint32_t4B记录尾结点距离压缩列表的起始地址有多少字节。通过该偏移量,无需遍历整个压缩列表就可以确定尾结点的地址
zllenuint16_t2B记录了压缩列表包含的结点数。当该属性值小于 UINT16_MAX(65535)时,这个属性值就是压缩列表包含的结点数。当这个值等于 UINT16_MAX 时,结点数需要遍历整个压缩列表才能算出
entryX列表结点不定压缩列表包含的各个结点,结点的长度由结点保存的内容决定
zlenduint8_t1B特殊值 0xFF 用于标记压缩列表的末端

在源码文件 src/ziplist.c 中,我们可以看到生成 ziplist 的函数。

/* Create a new empty ziplist. */
unsigned char *ziplistNew(void) {
    unsigned int bytes = ZIPLIST_HEADER_SIZE+ZIPLIST_END_SIZE;
    unsigned char *zl = zmalloc(bytes);
    ZIPLIST_BYTES(zl) = intrev32ifbe(bytes);
    ZIPLIST_TAIL_OFFSET(zl) = intrev32ifbe(ZIPLIST_HEADER_SIZE);
    ZIPLIST_LENGTH(zl) = 0;
    zl[bytes-1] = ZIP_END;
    return zl;
}

/* The size of a ziplist header: two 32 bit integers for the total
 * bytes count and last item offset. One 16 bit integer for the number
 * of items field. */
#define ZIPLIST_HEADER_SIZE     (sizeof(uint32_t)*2+sizeof(uint16_t))

/* Size of the "end of ziplist" entry. Just one byte. */
#define ZIPLIST_END_SIZE        (sizeof(uint8_t))

6.3 压缩列表结点

压缩列表的结点 ziplistEntry 定义在 src/ziplist.h 中可以看到。

/* Each entry in the ziplist is either a string or an integer. */
typedef struct {
    /* When string is used, it is provided with the length (slen). */
    unsigned char *sval;
    unsigned int slen;
    /* When integer is used, 'sval' is NULL, and lval holds the value. */
    long long lval;
} ziplistEntry;

6.4 小结

  • 压缩列表是一种为了节约内存而开发的顺序型数据结构。
  • 压缩列表被用作列表和哈希的底层实现之一,不过在 3.2 版本之后,列表改用快表来实现了。
  • 压缩列表可以包含多个结点,每个结点可以保存一个字节数组或者整数值。
  • 添加新节点到压缩列表,可能会引发连锁更新操作,更新效率较低。

7.快表

7.1 概述

考虑到链表的附加空间相对太高,prev 和 next 指针就要占去 16 个字节(64bits 系统的指针是 8 个字节)。另外每个节点的内存都是单独分配,会加剧内存的碎片化,影响内存管理效率。因此 Redis 3.2 版本开始,对列表数据结构进行了改造,使用快表(quicklist)代替了 ziplist 和 linkedlist。

7.2 结构

quicklist 实际上是 ziplist 和 linkedlist 的混合体,它将 linkedlist 按段切分,每一段使用 ziplist 来紧凑存储,多个 ziplist 之间使用双向指针串接起来。如此既保留 ziplist 的空间高效性,又能避免大量链表指针带来的内存消耗,还避免 ziplist 更新导致的大量性能损耗,将大的 ziplist 化整为零。
在这里插入图片描述

7.2.1 quicklist

在源码文件 src/quicklist.h 中可以找到 quicklist 的定义。

/* quicklist is a 40 byte struct (on 64-bit systems) describing a quicklist.
 * 'count' is the number of total entries.
 * 'len' is the number of quicklist nodes.
 * 'compress' is: 0 if compression disabled, otherwise it's the number
 *                of quicklistNodes to leave uncompressed at ends of quicklist.
 * 'fill' is the user-requested (or default) fill factor.
 * 'bookmakrs are an optional feature that is used by realloc this struct,
 *      so that they don't consume memory when not used. */
typedef struct quicklist {
    quicklistNode *head;
    quicklistNode *tail;
    unsigned long count;        /* total count of all entries in all ziplists */
    unsigned long len;          /* number of quicklistNodes */
    int fill : QL_FILL_BITS;              /* fill factor for individual nodes */
    unsigned int compress : QL_COMP_BITS; /* depth of end nodes not to compress;0=off */
    unsigned int bookmark_count: QL_BM_BITS;
    quicklistBookmark bookmarks[];
} quicklist;

/* Bookmarks are padded with realloc at the end of of the quicklist struct.
 * They should only be used for very big lists if thousands of nodes were the
 * excess memory usage is negligible, and there's a real need to iterate on them
 * in portions.
 * When not used, they don't add any memory overhead, but when used and then
 * deleted, some overhead remains (to avoid resonance).
 * The number of bookmarks used should be kept to minimum since it also adds
 * overhead on node deletion (searching for a bookmark to update). */
typedef struct quicklistBookmark {
    quicklistNode *node;
    char *name;
} quicklistBookmark;
  • head:指向头结点(左侧第一个节点)的指针
  • tail:指向尾结点(右侧第一个节点)的指针
  • count:所有 ziplist 数据项的个数总和
  • len:quicklist 结点个数
  • fill:单个结点的填充因子,表示 ziplist 大小的设置,存放 list-max-ziplist-size 参数的值。64 位环境下占用 16bit
  • compress:压缩深度,0 表示不压缩,否则就表示从两端开始有多少个节点不压缩。64 位环境下占用 16bit
  • bookmark_count:bookmarks 数组的大小。bookmarks 是一个可选字段,用来 quicklist 重新分配内存空间时使用。占用 4 位
  • bookmarks:链表首结点指针数组

quicklist 默认的压缩深度是 0,也就是不压缩,否则就表示从两端开始有多少个结点不压缩。压缩的实际深度由配置参数 list-compress-depth 决定。为了支持快速的 push/pop 操作,quicklist 的首尾两个 ziplist 不压缩,此时深度就是 1;如果深度为2,就表示 quicklist 的首尾第一个 ziplist 以及首尾第二个 ziplist 都不压缩。

7.2.2 quicklistNode

在源码 src/quicklist.h 文件中可以找到快表结点 quicklistNode 的定义。

/* Node, quicklist, and Iterator are the only data structures used currently. */

/* quicklistNode is a 32 byte struct describing a ziplist for a quicklist.
 * We use bit fields keep the quicklistNode at 32 bytes.
 * count: 16 bits, max 65536 (max zl bytes is 65k, so max count actually < 32k).
 * encoding: 2 bits, RAW=1, LZF=2.
 * container: 2 bits, NONE=1, ZIPLIST=2.
 * recompress: 1 bit, bool, true if node is temporary decompressed for usage.
 * attempted_compress: 1 bit, boolean, used for verifying during testing.
 * extra: 10 bits, free for future use; pads out the remainder of 32 bits */
typedef struct quicklistNode {
    struct quicklistNode *prev;
    struct quicklistNode *next;
    unsigned char *zl;
    unsigned int sz;             /* ziplist size in bytes */
    unsigned int count : 16;     /* count of items in ziplist */
    unsigned int encoding : 2;   /* RAW==1 or LZF==2 */
    unsigned int container : 2;  /* NONE==1 or ZIPLIST==2 */
    unsigned int recompress : 1; /* was this node previous compressed? */
    unsigned int attempted_compress : 1; /* node can't compress; too small */
    unsigned int extra : 10; /* more bits to steal for future usage */
} quicklistNode;
  • prev:指向链表前一个结点的指针
  • next:指向链表后一个结点的指针
  • zl:数据指针。如果当前结点的数据没有压缩,那么它指向一个 ziplist 结构;否则,它指向一个 quicklistLZF 结构。
  • sz:表示 zl 指向的 ziplist 的总大小(包括 zlbytes, zltail, zllen, zlend 和各个数据项)。需要注意的是:如果 ziplist 被压缩了,那么这个 sz 的值仍然是压缩前的 ziplist 大小
  • count:表示 ziplist 里面包含的数据项个数。这个字段只有 16bit。稍后我们会一起计算一下这 16bit 是否够用
  • encoding:表示 ziplist 是否压缩了,以及用了哪个压缩算法。目前只有两种取值:2 表示被压缩了且压缩算法是 LZF,1 表示没有压缩。
  • container:是一个预留字段。本来设计是用来表明一个 quicklist 结点下面是直接存数据,还是使用 ziplist 存数据,或者用其它的结构来存数据(用作一个数据容器,所以叫 container)。但是目前的实现,该值固定为 2,表示使用 ziplist 作为数据容器。
  • recompress:当我们使用类似 LINDEX 这样的命令查看了某一项本来压缩的数据时,需要把数据暂时解压,这时就设置为 1 做一个标记,等有机会再把数据重新压缩。
  • attempted_compress:这个值只对 Redis 的自动化测试程序有用
  • extra: 其它扩展字段。目前 Redis 的实现里没有用上

8.小结

本文以 Redis 6.2 为例,简单介绍了 Redis 5 种基本数据类型所使用的底层数据结构。对应关系如下:

数据类型数据结构
字符串1.字符串可以转成整数,使用整数存储
2.其他情况使用 SDS
列表1.Redis 3.2 版本前为 ziplist+linkedlist
2.Redis 3.2 版本及之后,使用 quicklist
哈希1.键和值的字符串长度都 <= 64字节且键值对数量 <= 512,使用 ziplist
2.其他情况:dictionary
集合1.集合对象保存的所有元素都是整数值且元素数量 <= 512,使用 intset
2.其他情况:dictionary
有序集合1.有序集合保存的元素数量 <= 128 且元素长度都 <= 64,使用 ziplist(相邻两个结点表示 member 和 score)
2.其他情况:skiplist + dictionary(成员到分值的映射)

随着 Redis 的不断迭代,数据类型对应的相关的底层结构可能会发生变化。相关的发布变更,详见官方的发布清单 releases


参考文献

Redis data types
Redis 设计与实现
Redis和Memcached到底有什么区别?
一文理解Redis底层数据结构
Redis数据结构——快速列表(quicklist)
[redis学习笔记]redis渐进式rehash机制
Redis内部数据结构详解(6)——skiplist

  • 4
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 7
    评论
提供的源码资源涵盖了Java应用等多个领域,每个领域都包含了丰富的实例和项目。这些源码都是基于各自平台的最新技术和标准编写,确保了在对应环境下能够无缝运行。同时,源码中配备了详细的注释和文档,帮助用户快速理解代码结构和实现逻辑。 适用人群: 适合毕业设计、课程设计作业。这些源码资源特别适合大学生群体。无论你是计算机相关专业的学生,还是对其他领域编程感兴趣的学生,这些资源都能为你提供宝贵的学习和实践机会。通过学习和运行这些源码,你可以掌握各平台开发的基础知识,提升编程能力和项目实战经验。 使用场景及目标: 在学习阶段,你可以利用这些源码资源进行课程实践、课外项目或毕业设计。通过分析和运行源码,你将深入了解各平台开发的技术细节和最佳实践,逐步培养起自己的项目开发和问题解决能力。此外,在求职或创业过程中,具备跨平台开发能力的大学生将更具竞争力。 其他说明: 为了确保源码资源的可运行性和易用性,特别注意了以下几点:首先,每份源码都提供了详细的运行环境和依赖说明,确保用户能够轻松搭建起开发环境;其次,源码中的注释和文档都非常完善,方便用户快速上手和理解代码;最后,我会定期更新这些源码资源,以适应各平台技术的最新发展和市场需求。 所有源码均经过严格测试,可以直接运行,可以放心下载使用。有任何使用问题欢迎随时与博主沟通,第一时间进行解答!

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 7
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值