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

Posted 恋喵大鲤鱼

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了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 种基本数据类型:

  • Strings(字符串)
  • Lists(列表)
  • Sets(集合)
  • Hashes(哈希)
  • Sorted Sets(有序集合)

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

  • Bitmaps and HyperLogLogs
  • Streams
  • Geospatial indexes

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

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

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

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

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[1] 里面,而 ht[0] 则不再进行任何添加操作,这一措施保证了ht[0]包含的键值对数量会只减不增,并随着 rehash 操作的执行而最终变成空表。

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

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

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 小结

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

  • 多层的结构组成,每层是一个有序的链表
  • 最底层(level 1)的链表包含所有的元素
  • 跳跃表的查找次数近似于层数,时间复杂度为O(logn),插入、删除也为 O(logn)
  • 跳跃表是一种随机化的数据结构,每个跳表结点的层高都是 1 至 32 之间的随机数(通过抛硬币来决定层数)

在 Redis 中:

  • 跳表是有序集合的底层实现之一
  • 跳表主要有 zskiplist 和 zskiplistNode 两个结构组成
  • 在同一个跳表中,多个结点可以包含相同的分值
  • 结点按照分值的大小从大到小排序,如果分值相同,则按成员对象大小排序

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 底层数据结构概述(v6.2)的主要内容,如果未能解决你的问题,请参考以下文章

Redis分布式缓存剖析及大厂面试精髓v6.2.6

Redis底层结构概述

Redis 设计与实现(第六章) -- 整数集合(intset)

聊一聊redis十种数据类型及底层原理

数据存储Redis第二章:底层数据结构

数据存储Redis第二章:底层数据结构