Redis源码、面试指南(1)跳跃表Skiplist、SDS、字典、Hyperloglog

264 阅读37分钟

Redis源码阅读笔记

2021/3/17 青玉白露

前言

源码阅读方式建议

VScode打开Redis源码,结合黄健宏老师的《Redis设计与实现》,先理解概念,再回归代码。

在阅读到数据库的具体实现时,建议在Linux系统下编译并运行Redis,对其的理解将更为直观。

源码阅读顺序

  1. 阅读数据结构的源码
  2. 阅读内存编码数据结构实现
  3. 阅读数据类型的实现
  4. 阅读单机数据库相关代码
  5. 阅读客户端和服务器相关代码
  6. 阅读多机(Cluster)的实现代码

黄健宏老师的《Redis设计与实现》(第二版)

https://www.w3cschool.cn/hdclil/

部分资料及图片摘抄自网上,并附有参考链接,侵删。

基本数据结构

学习Redis源码建议自底向上,从底层数据结构入手,一步一步感受Redis的设计之巧妙,源码之美妙。

动态字符串SDS

源码文件:sds.h 和 sds.c。

Redis 构建了一种名为简单动态字符串(simple dynamic string,SDS)的抽象类型,并用作默认字符串表示。

PS:传统的C语言字符串在Redis只用来作为字符串字面量(常量),如打印日志等等。

结构定义

先来看看SDS结构的定义:

// 类型别名,用于指向 sdshdr 的 buf 属性
typedef char *sds;
//保存字符串对象的结构
struct sdshdr {
    // buf 中已占用空间的长度
    int len;
    // buf 中剩余可用空间的长度
    int free;
    // 数据空间
    char buf[];
};

由此观之,Redis的SDS其实是基于C语言传统的字符串数组进行封装,以便能够对其更好的操作(后面解释为什么)。一个SDS实例的示意图如下:

img

值得注意的是,SDS也遵循C字符串的空字符结尾,并不计入len中,这么做的好处就是可以使用C相关的字符串库函数

如:

printf("%s", s->buf);

对比C字符串

接着我们思考一个问题:这么封装C字符串有什么好处呢?

**其一、快速获取字符串长度。**通过len属性可以在O(1)的时间复杂度下获取一个字符串的长度,而C字符串需要O(n)。

**其二、杜绝缓冲区溢出。**C字符串由于没有记录自身长度,很可能在一些操作(如strcat等)时,造成缓冲区溢出,见下图:

img

对s1执行拼接操作之后:

img

而SDS的API在对SDS进行修改时,会检查其空间是否充足,如果不够,会先进行扩容,再进行相应的操作。

**其三、**减少修改字符串时带来的内存重分配次数。对于一个包含了 N 个字符的C字符串来说,这个C字符串的底层实现总是一个N+1个字符的数组(额外一个字符用于存空字符)。

如果执行拼接操作(append),那么在执行这个操作之前,程序需要先通过内存重分配来扩展底层数组的空间大小——如果忘了这一步就会产生缓冲区溢出

如果执行截断操作(trim),那么在执行这个操作之后,程序需要通过内存重分配来释放字符串不再使用的那部分空间——如果忘了这一步就会产生内存泄漏

内存重分配可能会造成系统调用,一般程序很少进行字符串长度修改,但是Redis作为数据库,会进行大量的字符串修改。为了避免这种缺陷,SDS通过未使用空间解除了字符串长度和底层数组长度之间的关联: 在SDS中,buf 数组的长度不一定就是字符数量加一,数组里面可以包含未使用的字节而这些字节的数量就由 SDS 的 free 属性记录

通过未使用空间,SDS 实现了空间预分配和惰性空间释放两种优化策略。

**其四、空间预分配策略。**当API对SDS进行修改时,不仅会分配必要空间还会分配额外空间,具体策略如下:

当修改后SDS长度小于1M(默认最大长度),则额外分配len长度

当大于1M,则额外分配1M;

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

**其五、惰性空间释放。**当SDS的API需要缩短SDS保存的字符串时, 程序并不立即使用内存重分配来回收缩短后多出来的字节,而是使用free属性将这些字节的数量记录起来,并等待将来使用。

**其六、支持二进制。**C字符串是以'\0'结尾的,这使得C字符串只能保存文本数据,而不能保存像图片、音频、视频、压缩文件这样的二进制数据。但是Redis需要、也具备这样的特点。

可以说正是诸如此类的小改进,造就了Redis的高效和稳定。

总结一下:

img

接口API

总览

函数作用时间复杂度
sdsnew创建一个包含给定 C 字符串的 SDS 。img , N 为给定 C 字符串的长度。
sdsempty创建一个不包含任何内容的空 SDS 。img
sdsfree释放给定的 SDS 。img
sdslen返回 SDS 的已使用空间字节数。读取 len 属性来直接获得, img
sdsavail返回 SDS 的未使用空间字节数。读取 free 属性来直接获得, img
sdsdup创建一个给定 SDS 的副本(copy)。img , N 为给定 SDS 的长度。
sdsclear清空 SDS 保存的字符串内容。因为惰性空间释放策略,复杂度为 img
sdscat将给定 C 字符串拼接到 SDS 字符串的末尾。img , N 为被拼接 C 字符串的长度。
sdscatsds将给定 SDS 字符串拼接到另一个 SDS 字符串的末尾。img , N 为被拼接 SDS 字符串的长度。
sdscpy将给定的 C 字符串复制到 SDS 里面, 覆盖 SDS 原有的字符串。img , N 为被复制 C 字符串的长度。
sdsgrowzero用空字符将 SDS 扩展至给定长度。img , N 为扩展新增的字节数。
sdsrange保留 SDS 给定区间内的数据, 不在区间内的数据会被覆盖或清除。img , N 为被保留数据的字节数。
sdstrim接受一个 SDS 和一个 C 字符串作为参数, 从 SDS 左右两端分别移除所有在 C 字符串中出现过的字符img , M 为 SDS 的长度, N 为给定 C 字符串的长度。
sdscmp对比两个 SDS 字符串是否相同。img , N 为两个 SDS 中较短的那个 SDS 的长度。

挑几个API来看看源码。

sdslen

/*
 * 返回 sds 实际保存的字符串的长度
 *
 * T = O(1)
 */
static inline size_t sdslen(const sds s) {
    struct sdshdr *sh = (void*)(s-(sizeof(struct sdshdr)));
    return sh->len;
}

请大家结合sds的结构体定义,先想想,为什么这个API可以获取SDS长度?

在结构体里面, char* buf 和char buf[1]的效果差不多(对齐的情况),占4个字节;char buf[0] 和char buf[]是一样的,不占内存

参考链接:www.cnblogs.com/hpplinux/ar…

所以(void*)(s-(sizeof(struct sdshdr)))其实就将内存地址转移到了SDS结构体地址,于是就可以通过这个地址直接访问其成员变量

sdsnewlen初始化字符串

   //根据初始化字符串(无符号)及长度建立sds
   //这是很多API所都需要的
 sds sdsnewlen(const void *init, size_t initlen) {
    struct sdshdr *sh;
    // 根据是否有初始化内容,选择适当的内存分配方式
    // T = O(N)
    if (init) {
    // zmalloc 不初始化所分配的内存(这个函数之后介绍)
    sh = zmalloc(sizeof(struct sdshdr)+initlen+1);
    } else {
    // zcalloc 将分配的内存全部初始化为 0
    sh = zcalloc(sizeof(struct sdshdr)+initlen+1);
    }
    // 内存分配失败,返回
    if (sh == NULL) return NULL;
    // 设置初始化长度
    sh->len = initlen;
    // 新 sds 不预留任何空间
    sh->free = 0;
    // 如果有指定初始化内容,将它们复制到 sdshdr 的 buf 中
    // T = O(N)
    if (initlen && init)
    memcpy(sh->buf, init, initlen);
    // 以 \0 结尾
    sh->buf[initlen] = '\0';
    // 返回 buf 部分,而不是整个 sdshdr
    //因为我们要的是char
    return (char*)sh->buf;
}

sdsclear惰性删除策略

//以O(1)的时间完成字符串的“清空”
//只需要将终止符放在0即可
void sdsclear(sds s) {
// 取出 sdshdr
    struct sdshdr *sh = (void*) (s-(sizeof(struct sdshdr))); 
    // 重新计算属性
    sh->free += sh->len;
    sh->len = 0; 
    // 将结束符放到最前面(相当于惰性地删除 buf 中的内容)
    sh->buf[0] = '\0';
}

sdsMakeRoomFor

这个函数对sds的free进行扩充,2倍原大小或者加上1M的额外空间

sds sdsMakeRoomFor(sds s, size_t addlen) {
    struct sdshdr *sh, *newsh;
    
    // 获取 s 目前的空余空间长度
    size_t free = sdsavail(s);
    size_t len, newlen;
    
    // s 目前的空余空间已经足够,无须再进行扩展,直接返回
    if (free >= addlen) return s;
    
    // 获取 s 目前已占用空间的长度
    len = sdslen(s);
    sh = (void*) (s-(sizeof(struct sdshdr)));
    
    // s 最少需要的长度
    newlen = (len+addlen);
    
    // 根据新长度,为 s 分配新空间所需的大小
    if (newlen < SDS_MAX_PREALLOC)
    // 如果新长度小于 SDS_MAX_PREALLOC 
    // 那么为它分配两倍于所需长度的空间
    newlen *= 2;
    else
    // 否则,分配长度为目前长度加上 SDS_MAX_PREALLOC
    newlen += SDS_MAX_PREALLOC;
    // T = O(N)
    newsh = zrealloc(sh, sizeof(struct sdshdr)+newlen+1);
    
    // 内存不足,分配失败,返回
    if (newsh == NULL) return NULL;   
    // 更新 sds 的空余长度
    newsh->free = newlen - len; 
    // 返回 sds
    return newsh->buf;
}

链表List

源码文件:adlist.h 和 adlist.c。

Redis实现的是双端链表,其被广泛用于实现 Redis 的各种功能,比如列表键,发布与订阅等等。通过将链表的void *value设置为不同的类型,Redis的链表可以用于保存各种不同类型的值。

结构定义

/*
 * 双端链表结构定义
 */
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;

其中链表节点定义如下:

// 双端链表节点
typedef struct listNode {
    // 前置节点
    struct listNode *prev;
    // 后置节点
    struct listNode *next;
    // 节点的值
    void *value;
} listNode;

这里有一个迭代器值得注意,它被用作遍历双端链表,利用next返回遍历到的链表节点。

//双端链表迭代器
typedef struct listIter {
    // 当前迭代到的节点
    listNode *next;
    // 迭代的方向(前向还是后向)
    int direction;
} listIter;

一个链表的实例如下图所示:

img

接口API

总览:

listSetDupMethod将给定的函数设置为链表的节点值复制函数。img
listGetDupMethod返回链表当前正在使用的节点值复制函数。复制函数可以通过链表的 dup 属性直接获得, img
listSetFreeMethod将给定的函数设置为链表的节点值释放函数。img
listGetFree返回链表当前正在使用的节点值释放函数。释放函数可以通过链表的 free 属性直接获得, img
listSetMatchMethod将给定的函数设置为链表的节点值对比函数。img
listGetMatchMethod返回链表当前正在使用的节点值对比函数。对比函数可以通过链表的 match 属性直接获得, img
listLength返回链表的长度(包含了多少个节点)。链表长度可以通过链表的 len 属性直接获得, img
listFirst返回链表的表头节点。表头节点可以通过链表的 head 属性直接获得, img
listLast返回链表的表尾节点。表尾节点可以通过链表的 tail 属性直接获得, img
listPrevNode返回给定节点的前置节点。前置节点可以通过节点的 prev 属性直接获得, img
listNextNode返回给定节点的后置节点。后置节点可以通过节点的 next 属性直接获得, img
listNodeValue返回给定节点目前正在保存的值。节点值可以通过节点的 value 属性直接获得, img
listCreate创建一个不包含任何节点的新链表。img
listAddNodeHead将一个包含给定值的新节点添加到给定链表的表头。img
listAddNodeTail将一个包含给定值的新节点添加到给定链表的表尾。img
listInsertNode将一个包含给定值的新节点添加到给定节点的之前或者之后。img
listSearchKey查找并返回链表中包含给定值的节点。img , N 为链表长度。
listIndex返回链表在给定索引上的节点。img , N 为链表长度。
listDelNode从链表中删除给定节点。img
listRotate将链表的表尾节点弹出,然后将被弹出的节点插入到链表的表头, 成为新的表头节点。img
listDup复制一个给定链表的副本。img , N 为链表长度。
listRelease释放给定链表,以及链表中的所有节点。img , N 为链表长度。

来挑几个API看看其具体实现:

**listAddNodeHead:**插入节点到头部

list *listAddNodeHead(list *list, void *value)
{
    listNode *node;
    // 为节点分配内存
    if ((node = zmalloc(sizeof(*node))) == NULL)
    return NULL;
    // 保存值指针
    node->value = value;
    // 添加节点到空链表
    if (list->len == 0) {
        list->head = list->tail = node;
        node->prev = node->next = NULL;  
    } 
    // 添加节点到非空链表
    else {
        node->prev = NULL;
        node->next = list->head;
        list->head->prev = node;
        list->head = node;
    }   
    // 更新链表节点数
    list->len++;
    return list;
}

**listGetIterator:**产生一个迭代器

 /* 为给定链表创建一个迭代器,
 * 之后每次对这个迭代器调用 listNext 都返回被迭代到的链表节点
 * direction 参数决定了迭代器的迭代方向:
 *  AL_START_HEAD :从表头向表尾迭代
 *  AL_START_TAIL :从表尾想表头迭代
 */
listIter *listGetIterator(list *list, int direction)
{
    // 为迭代器分配内存
    listIter *iter;
    if ((iter = zmalloc(sizeof(*iter))) == NULL) return NULL;
    // 根据迭代方向,设置迭代器的起始节点
    if (direction == AL_START_HEAD)
    iter->next = list->head;
    else
    iter->next = list->tail;   
    // 记录迭代方向
    iter->direction = direction; 
    return iter;
}

**listSearchKey:**在链表中查找指定值的节点

listNode *listSearchKey(list *list, void *key)
{
    listIter *iter;
    listNode *node;
    
    // 迭代整个链表
    iter = listGetIterator(list, AL_START_HEAD);
    while((node = listNext(iter)) != NULL) {
        // 对比
        if (list->match) {
            if (list->match(node->value, key)) {
                listReleaseIterator(iter);
                // 找到
                return node;
            }
        } else {
            if (key == node->value) {
            listReleaseIterator(iter);
            // 找到
            return node;
            }
            }
        }
        listReleaseIterator(iter);
    // 未找到
    return NULL;
}
  • 话说list迭代器有什么用?

从源码上来看,list的迭代器主要是为了方便实现对list双向前进。迭代器作为一种设计模式,可以使得list适应不同的算法。

字典Dict

源码文件dict.h 和 dict.c。

Redis中字典使用哈希表实现。同时,为了实现渐进式Rehash的操作,每一个字典都有两个hash表(新/旧)。

结构定义

Redis中字典使用哈希表实现。同时,为了实现渐进式Rehash的操作,每一个字典都有两个hash表(新/旧)。

// 字典定义
typedef struct dict {
    // 类型特定函数
    // 这一指针指向的结构体中存储了hash表常用的函数指针
    dictType *type;
    // 私有数据:保存了需要传给那些类型特定函数的可选参数(见后文)
    void *privdata;
    // 哈希表
    dictht ht[2];
    // 该值表示rehash进行到的下标索引位置
    // 当 rehash 不在进行时,值为 -1
    // 开始时值为0
    // 正在进行中时,值处于0到size之间
    int rehashidx;     
    int iterators; // 目前正在运行的安全迭代器的数量
} dict;

其中的hash表定义如下:

//每个字典都使用两个哈希表,从而实现渐进式 rehash 。
typedef struct dictht {
    // 哈希表数组(存储的是指向节点指针数组的指针)
    // 都使用指针的方式可以节省空间
    dictEntry **table;
    // 哈希表大小
    unsigned long size;
    // 哈希表大小掩码,用于计算索引值
    // 总是等于 size - 1
    unsigned long sizemask;
    // 该哈希表已有节点的数量
    unsigned long used;
} dictht;

hash表的节点(Entry)定义如下,Redis是采用开链法来处理hash冲突的:

//哈希表节点
typedef struct dictEntry {
    // 键
    void *key;
    // 值
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
    } v;
    // 指向下个哈希表节点,形成链表
    struct dictEntry *next;
} dictEntry;

一个hash表的实例见下:

img

PS:在redis的hash表结构中,如果出现hash冲突,新的key-value是在旧的key-value前面的,这么做也很好理解:如果插入到链表最后,那么还需要一个遍历链表的操作,O(N)的复杂度

一个普通状态下的字典实例见下

img

接下来我们看看其中的重要API,比如计算hash值、处理hash冲突或者是rehash。

接口API

总览

函数作用时间复杂度
dictCreate创建一个新的字典。img
dictAdd将给定的键值对添加到字典里面。img
dictReplace将给定的键值对添加到字典里面, 如果键已经存在于字典,那么用新值取代原有的值。img
dictFetchValue返回给定键的值。img
dictGetRandomKey从字典中随机返回一个键值对。img
dictDelete从字典中删除给定键所对应的键值对。img
dictRelease释放给定字典,以及字典中包含的所有键值对。img , N 为字典包含的键值对数量。

**dictAddRaw:**新增键值对

dictEntry *dictAddRaw(dict *d, void *key)
{
    int index;
    dictEntry *entry;
    dictht *ht;

    // 如果条件允许的话,进行单步 rehash
    // T = O(1)
    if (dictIsRehashing(d)) _dictRehashStep(d); 
    
    // 计算键在哈希表中的索引值
    // 如果值为 -1 ,那么表示键已经存在
    if ((index = _dictKeyIndex(d, key)) == -1)
        return NULL;
    // 如果字典正在 rehash ,那么将新键添加到 1 号哈希表
    // 否则,将新键添加到 0 号哈希表
    ht = dictIsRehashing(d) ? &d->ht[1] : &d->ht[0];
    // 为新节点分配空间
    entry = zmalloc(sizeof(*entry));
    // 将新节点插入到链表表头
    entry->next = ht->table[index];
    ht->table[index] = entry;
    // 更新哈希表已使用节点数量
    ht->used++;
    // 设置新节点的键
    // T = O(1)
    dictSetKey(d, entry, key);
    return entry;
}

在其中有个函数:dictkeyindex()——计算该字典中可以插入该键值对的index,如果标志符号dict_can_resize为正,那么会在hash表的size和used比率大于1(即负载因子)时(没有执行BGSAVE)进行rehash或者大于5(执行BGSAVE)时进行强制rehash

计算hash值时是这样的:

index = hash & dict->ht[0].sizemask

即是利用计算出的hash值跟sizemask相与,这个hash算法被称为MurmurHash2(目前有3了,但是Redis没用),这种算法的优点在于,即使输入的键是有规律的,算法仍能给出一个很好的随机分布性,并且算法的计算速度也非常快。

  • 为什么负载因子一个是1一个是5?

根据 BGSAVE 命令或 BGREWRITEAOF 命令是否正在执行,服务器执行扩展操作所需的负载因子并不相同,这是因为在执行BGSAVE命令或BGREWRITEAOF 命令的过程中,Redis 需要创建当前服务器进程的子进程, 而大多数操作系统都采用写时复制(copy-on-write)技术来优化子进程的使用效率,所以在子进程存在期间,服务器会提高执行扩展操作所需的负载因子,**从而尽可能地避免在子进程存在期间进行哈希表扩展操作,**这可以避免不必要的内存写入操作,最大限度地节约内存。

// 指示字典是否启用 rehash 的标识
static int dict_can_resize = 1;
// 强制 rehash 的比率(强制不可被上面的标志所阻止)
static unsigned int dict_force_resize_ratio = 5;

rehash的具体操作见接下来的这个API。

Rehash

int dictRehash(dict *d, int n) {
    // 只可以在 rehash 进行中时执行
    if (!dictIsRehashing(d)) return 0;
    // 进行 N 步迁移
    // T = O(N)
    while(n--) {
        dictEntry *de, *nextde;
        
        // 如果 0 号哈希表为空,那么表示 rehash 执行完毕
        // T = O(1)
        if (d->ht[0].used == 0) {
        // 释放 0 号哈希表
        zfree(d->ht[0].table);
        // 将原来的 1 号哈希表设置为新的 0 号哈希表
        d->ht[0] = d->ht[1];
        // 重置旧的 1 号哈希表
        _dictReset(&d->ht[1]);
        // 关闭 rehash 标识
        d->rehashidx = -1;
        // 返回 0 ,向调用者表示 rehash 已经完成
        return 0;
        }
    // 确保 rehashidx 没有越界
    assert(d->ht[0].size > (unsigned)d->rehashidx);
    
    // 略过数组中为空的索引,找到下一个非空索引
    // hash表的entry中第一个就是void* key,所以可以直接访问其是否为空
    // 来判断该出是否存在键值对
    while(d->ht[0].table[d->rehashidx] == NULL) d->rehashidx++;
    // 指向该索引的链表表头节点
    de = d->ht[0].table[d->rehashidx];
    // 将链表中的所有节点迁移到新哈希表
    // T = O(1)
    while(de) {
            unsigned int h;    
            // 保存下个节点的指针
            nextde = de->next;           
            /* Get the index in the new hash table */
            // 计算新哈希表的哈希值,以及节点插入的索引位置
            h = dictHashKey(d, de->key) & d->ht[1].sizemask;
            // 插入节点到新哈希表
            de->next = d->ht[1].table[h];
            d->ht[1].table[h] = de;
            // 更新计数器
            d->ht[0].used--;
            d->ht[1].used++;            
            // 继续处理下个节点
            de = nextde;
            }
        // 将刚迁移完的哈希表索引的指针设为空
        d->ht[0].table[d->rehashidx] = NULL;
        // 更新 rehash 索引
        d->rehashidx++;
        }
    return 1;
}

通过上述源码我们可以得出:

  • Redis的hash表采用渐进式分步hash的方法
  • rehash的过程中字典的**两个hash表同时存在,并且在迭代、更新、删除键的时候都需要考虑这两个hash表,**值得注意的是:字典的删除(delete)、查找(find)、更新(update)等操作会在两个哈希表上进行,但插入只会在第二个表中,rehash完毕之后会重置第一个表。
  • 在rehash时,如果扩展操作,那么ht[1]的大小为第一个大于等于 ht[0].used*2的2^n;如果收缩操作(负载因子小于0.1),那么ht[1]的大小为第一个大于等于ht[0].used的2^n
  • rehsh时,相关API会经常进行单步rehash,数据库操作主要是调用int dictRehashMilliseconds(dict *d, int ms) **,**即在指定时间内执行rehash,时间到了就返回已迭代到的index。

迭代器

跟链表一样,Redis字典中也存在迭代器,主要是为了实现遍历一个字典。

其定义如下:

安全迭代器

dictIterator *dictGetSafeIterator(dict *d) {
    dictIterator *i = dictGetIterator(d);
    // 设置安全迭代器标识
    i->safe = 1;
    return i;
}

非安全迭代器

dictIterator *dictGetIterator(dict *d)
{
    dictIterator *iter = zmalloc(sizeof(*iter));

    iter->d = d;
    iter->table = 0;
    iter->index = -1;
    iter->safe = 0;
    iter->entry = NULL;
    iter->nextEntry = NULL;
    return iter;
}

两种迭代器都共用同一个函数接口:

dictEntry *dictNext(dictIterator *iter)

该接口返回迭代器指向的当前节点。在如果是安全迭代器调用该函数,会更新字典的iterator计数器(安全迭代器);如果是非安全迭代器调用该函数,会计算此时字典的fingerprint,以确定用户没有违规操作。

有关迭代器的重点函数是:****dictScan()详见源码注释

跳跃表skiplist

源码文件:t_zset.c 中所有以 zsl 开头的函数。

跳跃表是一种可以对有序链表进行近似二分查找的数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。

跳跃表支持平均O(logN) 、最坏O(N)复杂度的节点查找,还可以通过顺序性操作来批量处理节点

Redis在两个地方用到了跳跃表,一个是实现有序集合,另一个是在集群节点中用作内部数据结构(后文会将,见Redis多机数据库)

​ 在大部分情况下,跳跃表的效率可以和平衡树相媲美,并且因为跳跃表的实现比平衡树要来得更为简单,所以有不少程序都使用跳跃表来代替平衡树。

通过下面这个图,来看看跳表这个数据结构:

img

跳表其实是空间换时间的结构,因为每隔一定的点都需要建立一个链表索引。

它的查询过程如下:

img

先同层查找,而后往下一层找直到最后的找到节点,类似一个二分的过程

另外,我们想要为跳表插入或者删除数据,我们首先需要找到插入或者删除的位置,然后执行插入或删除操作,前边我们已经知道了,跳表的查询的时间复杂度为 O(logn),因为找到位置之后插入和删除的时间复杂度很低,为 O(1),所以最终插入和删除的时间复杂度也为 O(longn)

想进一步了解可参考:

zhuanlan.zhihu.com/p/68516038

结构定义

跳跃表包含头尾节点、节点数目以及最大的层数,定义如下:

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

值得注意的是,在计算level/length时头结点不计算在内

其中的跳跃表节点定义如下

typedef struct zskiplistNode {

    // 成员对象(存储的对象类型,见下)
    robj *obj;
    // 分值(按照这个大小,升序排列)
    double score;
    // 后退指针(用作链表的倒序遍历)
    struct zskiplistNode *backward;
    // 层
    struct zskiplistLevel {
        // 前进指针
        struct zskiplistNode *forward;
        // 跨度
        unsigned int span;
    } level[];
} zskiplistNode;

跳表每个节点都具有多个层标记,每一个层标记带有两个属性:前进指针和跨度,前进指针表示访问其之后(score比它大)的其他节点,跨度表示当前节点跟后续节点的距离

跳跃表 level 层级完全是随机的(插入节点时会随机生成1-32的数字)。一般来说,层级越多,访问节点的速度越快

在跳跃表中,节点按各自所保存的分值从小到大排列

obj是指向一个字符串对象,而字符串对象则保存着一个SDS值

注意:在同一个跳跃表中,各个节点保存的成员对象必须是唯一的,但是多个节点保存的分值却可以是相同的:分值相同的节点将按照成员对象的大小(见后)来进行排序,成员对象较小的节点会排在前面(靠近表头的方向),而成员对象较大的节点则会排在后面(靠近表尾的方向)。

其中的Redis成员对象定义如下:

typedef struct redisObject {
    // 类型(日后会说)
    unsigned type:4;
    // 编码(日后会说)
    unsigned encoding:4;
    // 对象最后一次被访问的时间(日后会说)
    unsigned lru:REDIS_LRU_BITS; /* lru time (relative to server.lruclock) */
    // 引用计数(日后会说)
    int refcount;
    // 指向实际值的指针
    void *ptr;
} robj;

注:成员对象的有关知识,参考Redis源码阅读(三)。

查找、删除、添加节点

那么Redis节点添加/插入操作是怎样的呢

我们以一个实际例子,来展现在跳跃表中,究竟是以何种方式进行节点的查找、删除和添加的。

  • 添加节点

假设一个跳跃表刚开始为空,那么其实它就是一个简单的空链表结构:

img

当需要插入一个key=3的节点,得到一个随机值level = 3(随机抛出来的),那么该节点就具有level3的属性,此时跳跃表的结构如下:

img

·插入key = 2,随机的level = 1,如下:

img

···

·插入key = 100, 随机的level=2,如下:

img

okay,以上我们就完成了整个跳跃表的插入过程,那如果我们想查找66这个值,如何进行?

跳跃表的查询是从顶层往下找,那么会先从第顶层(即最高的level)开始找,查找方式就是一个二分查找,**如过该层找不到指定值,**就会跳到下一层,继续遍历,直到找到对应节点。

  • 查找具体过程如下:

(level3时)66比1大,那就往同层的右边走,5还是比66小,再往右走就倒表尾了,因此下沉,从5的level3下沉到level2

(level2)时,5的右边100比66大,所以从5的level2下沉到level1。

(level1)时,5的右边66恰好等于,于是返回这个节点。

  • 删除操作呢?

其实跟查找操作差不多,找到删除节点之后,进行单向链表的删除操作,唯一的区别就是需要在多个level进行列表节点的删除。

以上部分参考链接:

blog.csdn.net/weixin_4162…

接口API

总览

函数作用时间复杂度
zslCreate创建一个新的跳跃表。img
zslFree释放给定跳跃表,以及表中包含的所有节点。img , N 为跳跃表的长度。
zslInsert将包含给定成员和分值的新节点添加到跳跃表中。平均 img , N 为跳跃表长度。
zslDelete删除跳跃表中包含给定成员和分值的节点。平均 img , N 为跳跃表长度。
zslGetRank返回包含给定成员和分值的节点在跳跃表中的排位。平均 img , N 为跳跃表长度。
zslGetElementByRank返回跳跃表在给定排位上的节点。平均 img , N 为跳跃表长度。
zslIsInRange给定一个分值范围(range), 比如 0 到 15 , 20 到 28,诸如此类, 如果给定的分值范围包含在跳跃表的分值范围之内, 那么返回 1 ,否则返回 0 。通过跳跃表的表头节点和表尾节点, 这个检测可以用 img 复杂度完成。
zslFirstInRange给定一个分值范围, 返回跳跃表中第一个符合这个范围的节点。平均 img 。 N 为跳跃表长度。
zslLastInRange给定一个分值范围, 返回跳跃表中最后一个符合这个范围的节点。平均 img 。 N 为跳跃表长度。
zslDeleteRangeByScore给定一个分值范围, 删除跳跃表中所有在这个范围之内的节点。img , N 为被删除节点数量。
zslDeleteRangeByRank给定一个排位范围, 删除跳跃表中所有在这个范围之内的节点。img , N 为被删除节点数量。

其中,Redis中的跳表最大层级及概率定义如下:

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

创建新跳跃表如下:

zskiplist *zslCreate(void) {
    int j;
    zskiplist *zsl;
    // 分配空间
    zsl = zmalloc(sizeof(*zsl));
    // 设置高度和起始层数
    zsl->level = 1;
    zsl->length = 0;

    // 初始化表头节点
    // T = O(1)
    zsl->header = zslCreateNode(ZSKIPLIST_MAXLEVEL,0,NULL);
    for (j = 0; j < ZSKIPLIST_MAXLEVEL; j++) {
        zsl->header->level[j].forward = NULL;
        zsl->header->level[j].span = 0;
    }
    zsl->header->backward = NULL;

    // 设置表尾
    zsl->tail = NULL;

    return zsl;
}

从上诉代码可以看出,跳跃表的表头节点跟其他的跳跃表节点一样,只不过忽略了BW指针和分值、成员对象等信息。

创建新节点如下(请仔细思考源码的实现)

/*
 * 创建一个成员为 obj ,分值为 score 的新节点,
 * 并将这个新节点插入到跳跃表 zsl 中。
 * 函数的返回值为新节点。
 * T_wrost = O(N^2), T_avg = O(N log N)
 */
zskiplistNode *zslInsert(zskiplist *zsl, double score, robj *obj) {
    zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x;
    unsigned int rank[ZSKIPLIST_MAXLEVEL];
    int i, level;
    redisAssert(!isnan(score));
    // 在各个层查找节点的插入位置
    x = zsl->header;
    for (i = zsl->level-1; i >= 0; i--) {
        // 如果 i 不是 zsl->level-1 层
        // 那么 i 层的起始 rank 值为 i+1 层的 rank 值
        // 各个层的 rank 值一层层累积
        // 最终 rank[0] 的值加一就是新节点的前置节点的排位
        // rank[0] 会在后面成为计算 span 值和 rank 值的基础
        rank[i] = i == (zsl->level-1) ? 0 : rank[i+1];
        // 沿着前进指针遍历跳跃表
        // T_wrost = O(N^2), T_avg = O(N log N)
        while (x->level[i].forward &&
            (x->level[i].forward->score < score ||
                // 比对分值
                (x->level[i].forward->score == score &&
                // 比对成员, T = O(N)
                compareStringObjects(x->level[i].forward->obj,obj) < 0))) {
            // 记录沿途跨越了多少个节点
            rank[i] += x->level[i].span;
            // 移动至下一指针
            x = x->level[i].forward;
        }
        // 记录将要和新节点相连接的节点
        update[i] = x;
    }
     /* 
     // zslInsert() 的调用者会确保同分值且同成员的元素不会出现,
     * 所以这里不需要进一步进行检查,可以直接创建新元素。
     */
    // 获取一个随机值作为新节点的层数level
    // T = O(N)
    level = zslRandomLevel();
    // 如果新节点的层数比表中其他节点的层数都要大
    // 那么初始化表头节点中未使用的层,并将它们记录到 update 数组中
    // 将来也指向新节点
    if (level > zsl->level) {
        // 初始化未使用层
        // T = O(1)
        for (i = zsl->level; i < level; i++) {
            rank[i] = 0;
            update[i] = zsl->header;
            update[i]->level[i].span = zsl->length;
        }
        // 更新表中节点最大层数
        zsl->level = level;
    }
    // 创建新节点
    x = zslCreateNode(level,score,obj);
    // 将前面记录的指针指向新节点,并做相应的设置
    // T = O(1)
    for (i = 0; i < level; i++) {
        // 设置新节点的 forward 指针
        x->level[i].forward = update[i]->level[i].forward;
        // 将沿途记录的各个节点的 forward 指针指向新节点
        update[i]->level[i].forward = x;
        /* update span covered by update[i] as x is inserted here */
        // 计算新节点跨越的节点数量
        x->level[i].span = update[i]->level[i].span - (rank[0] - rank[i]);
        // 更新新节点插入之后,沿途节点的 span 值
        // 其中的 +1 计算的是新节点
        update[i]->level[i].span = (rank[0] - rank[i]) + 1;
    }
    /* increment span for untouched levels */
    // 未接触的节点的 span 值也需要增一,这些节点直接从表头指向新节点
    // T = O(1)
    for (i = level; i < zsl->level; i++) {
        update[i]->level[i].span++;
    }
    // 设置新节点的后退指针
    x->backward = (update[0] == zsl->header) ? NULL : update[0];
    if (x->level[0].forward)
        x->level[0].forward->backward = x;
    else
        zsl->tail = x;
    // 跳跃表的节点计数增一
    zsl->length++;
    return x;
}

其中计算节点随机level的函数如下:

int zslRandomLevel(void) {
    int level = 1;
    //ZSKIPLIST为0.25
    while ((random()&0xFFFF) < (ZSKIPLIST_P * 0xFFFF))
        level += 1;

    return (level<ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}

返回值介乎 1 和 ZSKIPLIST_MAXLEVEL 之间(包含ZSKIPLIST_MAXLEVEL),根据随机算法所使用的幂次定律,越大的值生成的几率越小

补充:有序集合

Redis的有序集合数据结构是结合字典跟跳跃表实现的,其定义如下:

/*
 * 有序集合
 */
typedef struct zset {

    // 字典,键为成员,值为分值
    // 用于支持 O(1) 复杂度的按成员取分值操作
    dict *dict;

    // 跳跃表,按分值排序成员
    // 用于支持平均复杂度为 O(log N) 的按分值定位成员操作
    // 以及范围操作
    zskiplist *zsl;

} zset;

ZSET同时使用两种数据结构来持有同一个元素,从而提供 O(log(N)) 复杂度的有序数据结构的插入和移除操作。哈希表将 Redis 对象映射到分值上。而跳跃表则将分值映射到 Redis 对象上,以跳跃表的视角来看,可以说 Redis 对象是根据分值来排序的

在Redis源码阅读(3)中会对zset的进一步介绍。

HyperLogLog

源码文件:hyperloglog.c。

Redis 在 2.8.9 版本添加了 HyperLogLog 结构。HyperLogLog 是用来做基数统计的算法,优点是,在输入元素的数量或者体积非常非常大时,计算基数所需的空间总是固定的、并且是很小的

在 Redis 里面,每个 HyperLogLog 键只需要花费 12 KB 内存,就可以计算接近 2^64 个不同元素的基数。这和计算基数时,元素越多耗费内存就越多的集合形成鲜明对比

但是,因为 HyperLogLog 只会根据输入元素来计算基数,而不会储存输入元素本身,所以 HyperLogLog 不能像集合那样,返回输入的各个元素

PS:什么是基数?

比如数据集 {1, 3, 5, 7, 5, 7, 8}, 那么这个数据集的基数集为 {1, 3, 5 ,7, 8}, 基数(不重复元素)为5。 基数估计就是在误差可接受的范围内,快速计算基数。

算法细节

它内部维护了 16384 个桶(bucket)来记录各自桶的元素数量。当一个元素到来时,它会散列到其中一个桶,以一定的概率影响这个桶的计数值。因为是概率算法,所以单个桶的计数值并不准确,但是将所有的桶计数值进行调合均值累加起来,结果就会非常接近真实的计数值

img

为了便于理解HyperLogLog算法,我们先简化它的计数逻辑。因为是去重计数,如果是准确的去重,肯定需要用到 set 集合,使用集合来记录所有的元素,然后获取集合大小就可以得到总的计数。因为元素特别多,单个集合会特别大,所以将集合打散成 16384 个小集合。当元素到来时,通过 hash 算法将这个元素分派到其中的一个小集合存储,同样的元素总是会散列到同样的小集合。这样总的计数就是所有小集合大小的总和。使用这种方式精确计数除了可以增加元素外,还可以减少元素。

但是,集合打散并没有什么明显好处,因为总的内存占用并没有减少。HyperLogLog算法中每个桶所占用的空间实际上只有 6 个 bit这 6 个 bit 自然是无法容纳桶中所有元素的,它记录的是桶中元素数量的对数值。

对数?怎么突然提到对数了?等等···Hyperloglog,log···难道对数才是这个数据结构的灵魂?!

先想想:一个随机的整数值,这个整数的尾部有一个 0 的概率是 50%,要么是 0 要么是 1(这里说的是二进制)。同样,尾部有两个 0 的概率是 25%,有三个零的概率是 12.5%,以此类推,有 k 个 0 的概率是 2^(-k)。如果我们随机出了很多整数,整数的数量我们并不知道,但是我们记录了整数尾部连续 0 的最大数量 K。我们就可以通过这个 K 来近似推断出整数的数量,这个数量就是 2^K!!

当然结果是非常不准确的,因为可能接下来你随机了非常多的整数,但是末尾连续零的最大数量 K 没有变化但是估计值还是 2^K。你也许会想到要是这个 K 是个浮点数就好了每次随机一个新元素,它都可以稍微往上涨一点点,那么估计值应该会准确很多。

HyperLogLog通过分配 16384 个桶,然后对所有的桶的最大数量 K 进行调合平均来得到一个平均的末尾零最大数量 K# ,K# 是一个浮点数,使用平均后的 2^K# 来估计元素的总量相对而言就会准确很多。不过这只是简化算法,真实的算法还有很多修正因子,因为涉及到的数学理论知识过于繁多,这里就不再精确描述。

下面我们看看Redis HyperLogLog 算法的具体实现。我们知道一个HyperLogLog实际占用的空间大约是 13684 * 6bit / 8 = 12k 字节。但是在计数比较小的时候,大多数桶的计数值都是零。如果 12k 字节里面太多的字节都是零,那么这个空间是可以适当节约一下的。Redis 在计数值比较小的情况下采用了稀疏存储稀疏存储的空间占用远远小于 12k 字节。相对于稀疏存储的就是密集存储密集存储会恒定占用 12k 字节。

不论是稀疏存储还是密集存储,Redis 内部都是使用字符串位图来存储 HyperLogLog 所有桶的计数值。

密集存储

密集存储的结构非常简单,就是连续 16384 个 6bit 串成的字符串位图。

img

那么给定一个桶编号,如何获取它的 6bit 计数值呢?这 6bit 可能在一个字节内部,也可能会跨越字节边界。我们需要对这一个或者两个字节进行适当的移位拼接才可以得到计数值。

假设桶的编号为idx,这个 6bit 计数值的起始字节位置偏移用 offset_bytes表示,它在这个字节的起始比特位置偏移用 offset_bits 表示。我们有:

offset_bytes = (idx * 6) / 8
offset_bits = (idx * 6) % 8

需要注意的是字节位序是左边低位右边高位,而通常我们使用的字节都是左边高位右边低位,我们需要在脑海中进行倒置。

img

稀疏存储

稀疏存储适用于很多计数值都是零的情况。下图表示了一般稀疏存储计数值的状态。

img

当多个连续桶的计数值都是零时,Redis 使用了一个字节来表示接下来有多少个桶的计数值都是零:00xxxxxx。前缀两个零表示接下来的 6bit 整数值加 1 就是零值计数器的数量,注意这里要加 1 是因为数量如果为零是没有意义的。比如 00010101表示连续 22 个零值计数器。6bit 最多只能表示连续 64 个零值计数器,所以 Redis 又设计了连续多个多于 64 个的连续零值计数器,它使用两个字节来表示:01xxxxxx yyyyyyyy,后面的 14bit 可以表示最多连续 16384 个零值计数器。这意味着 HyperLogLog 数据结构中 16384 个桶的初始状态,所有的计数器都是零值,可以直接使用 2 个字节来表示

稀疏存储的一般状态如下:

img

回答上面的问题,何时从稀疏到非稀疏转换?

当稀疏存储的某个计数值需要调整到大于 32 (如33)时,Redis 就会立即转换 HyperLogLog 的存储结构,将稀疏存储转换成密集存储

或者,稀疏存储占用的总字节数超过 3000 字节,这个阈值可以通过 hll_sparse_max_bytes 参数进行调整。

以上两种情况发生一种,就会触发稀疏到密集的转换,且不可逆

计数缓存

前面提到 HyperLogLog 表示的总计数值是由 16384 个桶的计数值进行调和平均后再基于因子修正公式计算得出来的。它需要遍历所有的桶进行计算才可以得到这个值,中间还涉及到很多浮点运算。这个计算量相对来说还是比较大的

所以 Redis 使用了一个额外的字段来缓存总计数值,这个字段有 64bit,最高位如果为 1 表示该值是否已经过期,如果为 0, 那么剩下的 63bit 就是计数值

当 HyperLogLog 中任意一个桶的计数值发生变化时,就会将计数缓存设为过期,但是不会立即触发计算(惰性计算)。而是要等到用户显示调用 pfcount 指令时才会触发重新计算刷新缓存。缓存刷新在密集存储时需要遍历 16384 个桶的计数值进行调和平均,但是稀疏存储时没有这么大的计算量。也就是说只有当计数值比较大时才可能产生较大的计算量。另一方面如果计数值比较大,那么大部分 pfadd 操作根本不会导致桶中的计数值发生变化

这意味着在一个极具变化的 HLL 计数器中频繁调用 pfcount 指令可能会有少许性能问题。关于这个性能方面的担忧在 Redis 作者 antirez 的博客中也提到了。不过作者做了仔细的压力的测试,发现这是无需担心的,pfcount 指令的平均时间复杂度就是 O(1)

API接口

HHL数据结构暂时只需要理解其原理,代码分析等待内存编码源码阅读完毕再看。(这一部分疲了,等看了编码再回来)

文章链接

Redis源码、面试指南(2)整数集合、压缩列表 #掘金文章#

juejin.cn/post/694955…

Redis源码、面试指南(3)对象系统、引用计数、有序集合 #掘金文章# juejin.cn/post/694981…

参考链接:

基本介绍

www.runoob.com/redis/redis…

文中具体算法细节转载自(强烈推荐)

cloud.tencent.com/developer/a…