一文了解Redis底层数据结构

246 阅读14分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

数据结构

Redis底层的数据结构有6种,分别是简单动态字符串、双向链表、压缩列表、哈希表、跳表和整数数组。与数据类型的对应关系如下

这些数据结构都是值的底层实现,键和值本身之间用什么结构组织?

Redis用一个哈希表(全局哈希表)来存储所有键值对

一个哈希表对应的是一个数组,数组中的元素是桶,桶中元素保存的是指向具体指的指针

哈希表的冲突问题和 rehash 可能带来的操作阻塞

哈希表冲突的解决方法是拉链法

当哈希冲突越来越多时,会触发rehash。增加bucket数量,让增多的entry在bucket中分散保存

其中使用了默认两个全局哈希表。先存储数据的是哈希表1,哈希表2没有被分配空间

  • 给哈希表2分配更大的空间
  • 给哈希表1的数据重新映射并拷贝到哈希表2
  • 释放哈希表1的空间

为了防止大量数据搬迁造成线程阻塞,采用了渐进式hash(增量搬迁)

在第二步拷贝数据时,每处理一个请求,从哈希表1的第一个索引位开始,顺带将该索引位上的entry拷贝到哈希表2中,等处理下一个请求时再拷贝下一个索引位的entry

简单动态字符串

redis为了节省内存,针对不同长度的数据采用不同的数据结构。如下共五种,但SDS_TYPE_5并不使用,因为该类型不会存放数据长度,每次都需要进行分配和释放:

#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

Simple dynamic string即SDS,是默认的字符串底层数据结构

  • sds是char*的别名,根据局部性原理可以提高访问速度
  • 数据存储不使用SDS_TYPE_5,因为这样每次有新数据时都要扩充

定义

struct sdshdr {

    // 记录buf数组中已使用字节的数量

    // 等于SDS保存的字符串长度

    int len;

    // 记录buf数组中未使用的字节的数量

    int free;

    // 字节数组,用于保存字符串。

    char buf[];

}

最后一个字节保存'\0',但是不算在SDS的len里面,函数会自动分配多一个字节的空间

与c字符串区别

c字符串的末尾默认是'\0',无法保证Redis对字符串在安全性、效率性及功能方面的要求

  • 常数复杂度获取字符串长度

    • c字符串获取长度的复杂度为ON,需要遍历整个字符串
    • SDS为O1,直接通过len获取就行了
  • 杜绝缓冲区溢出

    • c字符串api不安全,不记录自身长度容易造成缓冲区溢出
    • SDS会先检查空间是否满足修改所需要求,如果不满足就将空间扩展到所需大小
  • 减少修改字符串导致的内存

    • c字符串修改字符串N次必然要执行N次内存重分配。底层数组永远是字符串字符个数+1个字符的长度,因此每次增长、缩短一个字符串都要对底层数组进行一个重分配,否则可能会造成缓冲区溢出或内存泄漏。比较耗时(设计复杂算法、可能执行系统调用)

    • SDS最多需要执行N次内存重分配。底层数组长度包含未使用字节,解决了字符串长度和底层数组长度的关联

      • 空间预分配

        • 用于优化字符串增长长度。
        • 当修改后SDS<1MB,就会分配和len属性同样大小的未使用空间,len==free
        • 当>=1MB,会分配1MB的未使用空间
      • 惰性空间释放

        • 用于优化SDS字符串缩短操作
        • 收缩后并不立即回收,而是用free将他们记录起来,等待将来使用
        • SDS还提供了api在有需要的时候真正释放SDS的未使用空间,无需担心惰性空间释放策略造成内存浪费
  • 二进制安全

    • c字符串只能保存文本数据

      • 必须符合某种编码,并且除了字符串末尾以外,字符串内不允许包含空字符'\0'
    • SDS可以保存文本数据和二进制数据

      • 所有SDSapi会以处理二进制的方式来处理buf数组里的数据,不会做任何限制
  • 兼容c部分字符串函数

    • <string.h>

扩容

  • 当前有效长度>=新增长度,直接返回

  • 更新之后判断新旧类型是否一致

    • 一致使用remalloc,否则使用malloc+free

      • 当前有效长度>=新增长度,直接返回
  • 增长步长

    • 新增后长度小于预分配长度(1024*1024),扩大一倍
    • 新增后长度大于等于预分配长度,每次加预分配长度(减少不必要内存)
sds sdsMakeRoomFor(sds s, size_t addlen) {

    void *sh, *newsh;

    size_t avail = sdsavail(s);

    size_t len, newlen;

    char type, oldtype = s[-1] & SDS_TYPE_MASK;

    int hdrlen;



    /* Return ASAP if there is enough space left. */

    //当前有效长度>=新增长度,直接返回

    if (avail >= addlen) return s;



    len = sdslen(s);

    sh = (char*)s-sdsHdrSize(oldtype);

    newlen = (len+addlen);

    //新增后长度小于预分配长度(1024*1024),扩大一倍;SDS_MAX_PREALLOC=1024*1024

    if (newlen < SDS_MAX_PREALLOC)

        newlen *= 2;

    //新增后长度大于等于预分配的长度,每次加预分配长度

    else

        newlen += SDS_MAX_PREALLOC;



    type = sdsReqType(newlen);



    /* Don't use type 5: the user is appending to the string and type 5 is

     * not able to remember empty space, so sdsMakeRoomFor() must be called

     * at every appending operation. */

    if (type == SDS_TYPE_5) type = SDS_TYPE_8;

   // 新老类型一致使用remalloc,否则使用malloc+freea.当前有效长度>=新增长度,直接返回

    hdrlen = sdsHdrSize(type);

    if (oldtype==type) {

        newsh = s_realloc(sh, hdrlen+newlen+1);

        if (newsh == NULL) return NULL;

        s = (char*)newsh+hdrlen;

    } else {

        /* Since the header size changes, need to move the string forward,

         * and can't use realloc */

        // 不一致则需要重新分配内存

        newsh = s_malloc(hdrlen+newlen+1);

        if (newsh == NULL) return NULL;

        memcpy((char*)newsh+hdrlen, s, len+1);

        // 注意free掉之前的旧内存

        s_free(sh);

        s = (char*)newsh+hdrlen;

        s[-1] = type;

        sdssetlen(s, len);

    }

    sdssetalloc(s, newlen);

    return s;

}

缩容

trim操作,采用惰性空间释放:只是移动和标记并修改数据长

sds sdstrim(sds s, const char *cset) {

    char *start, *end, *sp, *ep;

    size_t len;



    sp = start = s;

    ep = end = s+sdslen(s)-1;

    while(sp <= end && strchr(cset, *sp)) sp++;

    while(ep > sp && strchr(cset, *ep)) ep--;

    len = (sp > ep) ? 0 : ((ep-sp)+1);

    if (s != sp) memmove(s, sp, len);

    s[len] = '\0';

    sdssetlen(s,len);

    return s;

}

真正的删除操作 -- tryObjectEncoding

if (o->encoding == OBJ_ENCODING_RAW &&

        sdsavail(s) > len/10)

    {

        // 释放空间

        o->ptr = sdsRemoveFreeSpace(o->ptr);

    }

压缩列表

当一个列表键只包含少量列表项,并且每个列表项是小整数值或者长度较短的字符串,就会使用压缩列表作为列表键的底层实现。压缩列表没有具体的数据结构实现,而是通过宏定义提取出来的。即使是节点,也是通过宏总结出来的

压缩列表构成

为了节约内存而生,由一系列特殊编码的连续代码块组成的顺序型数据结构。一个压缩列表包含多个节点entry,每个节点可以保存一个字节数组或者一个整数值。适合用于存储小对象和长度有限的数据

表头有三个字段列表长度zlbytes列表尾的偏移量zltail列表中的entry数zllen,表尾还有一个zlend表示列表结束

每个节点entry构成

可存储的值

  • 字符串

  • 整数

组成部分

previous_entry_length(1-5字节)

以字节为单位,记录了压缩列表中前一个节点的长度。如果前一个节点长度小于254字节,那么该字段的长度为1字节,如果大于等于254字节,那么该字段长度会被设置为5字节,第一个字节设置为0xFE(254),后四个字节用于保存前一字节的长度

可以通过指针运算根据当前节点的起始地址来计算出前一个节点的起始地址

Encoding(1、2、5)

记录了结点的content属性所保存数据的类型以及长度

  • 一字节、两字节、五字节长,值为00、01、10的是字节数组编码。表示节点content属性保存着字节数组,长度由编码除去最高两位之后的其他位记录
  • 以字节长,值最高位以11开头的是整数编码。表示节点的content属性保存着整数值,整数值的类型和长度有编码除去最高两位之后的其他位记录

Content

负责保存节点的值,值的类型和长度由节点的encoding属性决定

连锁更新

当一个压缩列表汇总存在多个连续的长度在250字节到253字节的节点,如果此时将一个长度大于等于254的新结点设置为压缩列表的表头节点(在e1前面),需要修改e1的previous_entry_length为5字节,这又会导致e1节点大于等于254字节,e1又要进行扩展,导致连锁更新

删除也可能造成连锁更新

空间最坏复杂度为O(N) ,但是造成性能问题的几率很低

  • 首先要出现多个连续的且长度位于250-253字节之间的节点
  • 即使出现连锁更新,只要被更新的节点不多,也不会影响性能
  • 不会收缩

优点

ziplist的每个元素的占有空间是可变的,按照实际数据大小分配节省空间。不会像链表一样用地址指针引用导致大量内存碎片。

压缩列表的设计不是为了查询的,而是为了减少内存的使用和内存的碎片化。比如一个列表中的只保存int,结构上还需要两个额外的指针prev和next,每添加一个结点都这样。而压缩列表是将这些数据集合起来只需要一个prev和next。

链表

定义

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;

实现的特性

  • 双端

  • 无环

    • 以NULL为终点
  • 带表头指针和表尾指针

  • 带长度计数器

  • 多态

    • 用于保存不同类型的值

字典

即map,一种用于保存键值对的抽象数据结构

每个键都是独一无二的

定义

// 哈希表定义

typedef struct dictht {

    // 哈希表数组

    dictEntry **table;

    // 哈希表大小

    unsigned long size;

    // 哈希表大小掩码,用于计算索引值。该属性和哈希值一起决定一个键被放到table数组哪个索引上

    // always == size-1

    unsigned long sizemask;

    // 该哈希表已有节点数量

    unsigned long used;

} dictht;



// 哈希表节点结构体

typedef struct dictEntry {

    // 键

    void *key;

    // 值

    union {

        void *val;

        uint64_tu64;

        int64_ts64;

    } v;

    // 指向下一个哈希表节点,形成链表

    struct dictEntry *next;

} dictEntry;



// 字典结构

typedef struct dict {

    // 类型特定函数, 指向一个dictType结构的指针

    // 每个dictType结构保存了一簇用来操作特定类型键值对的函数

    dictType *type;

    // 私有数据。保存了需要传给那些类型特定函数的可选参数

    void *privdata;

    // 哈希表

    // 新的dictht和旧的dictht,一般只会使用0,ht[1]哈希表只会对ht[0]哈希表进行rehash操作

    dictht ht[2];

    // rehash索引。当rehash不在进行时,值为-1

    // 如果rehashidx==-1表示不会进行rehash

    int trehashidx;

} dict;



// 操作键值对的函数

typedef struct dictType{

    // 计算哈希值

    unsigned int (*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);

} dictType;

哈希算法

3.2, 3.0, 2.8 使用的是murmurhash2.

5.0,4.0 版本使用的siphash.

6.0用的siphash&time33.(可以自定义)

  1. 使用字典设置的哈希函数计算key的hash值

Hash = dict->type->hashFunction(key)

  1. 使用哈希表的sizemask属性和第一步得到的哈希值计算索引值

Index = hash & dict->ht[x].sizemask 即取模运算

键冲突

解决键值对冲突使用的是链地址法+头插式。首部插入节省时间(O(1)),键值对冲突也是放在单链表首部

Rehash

  • 第一类:将rehash操作分散在后续每次增删改查中(以桶为单位),如上代码所示

    • 将ht[0]的rehash到ht[1]上(搬迁键值),然后释放ht[0],将ht[1]改成ht[0],再在ht[0]后面创一个空哈希表用来准备下一次rehash
  • 第二类:针对第一类间接式rehash,存在一个问题:如果a服务器长时间处于空闲状态,会导致哈希表长期的使用0和1两个bucket。为了解决这个问题,在serverCron定时函数中,每次拿出1ms时间来执行rehash操作,每次步长为100,但是需要开启acticverehashing,如下代码所示

void  databasesCron(void) {

     

    /* Perform hash tables rehashing if needed, but only if there are no

     * other processes saving the DB on disk. Otherwise rehashing is bad

     * as will cause a lot of copy-on-write of memory pages. */

    if (server.rdb_child_pid == -1 && server.aof_child_pid == -1) {

        /* We use global counters so if we stop the computation at a given

         * DB we'll be able to start from the successive in the next

         * cron loop iteration. */

        static unsigned int resize_db = 0;

        static unsigned int rehash_db = 0;

        int dbs_per_call = CRON_DBS_PER_CALL;

        int j;



        /* Don't test more DBs than we have. */

        if (dbs_per_call > server.dbnum) dbs_per_call = server.dbnum;



        /* Resize */

        for (j = 0; j < dbs_per_call; j++) {

            tryResizeHashTables(resize_db % server.dbnum);

            resize_db++;

        }



        /* Rehash:渐进式hash */

        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;

                }

            }

        }

    }

}
int incrementallyRehash(int dbid) {

    /* Keys dictionary */

    if (dictIsRehashing(server.db[dbid].dict)) {

        dictRehashMilliseconds(server.db[dbid].dict,1);

        return 1; /* already used our millisecond for this loop... */

    }

    /* Expires */

    if (dictIsRehashing(server.db[dbid].expires)) {

        dictRehashMilliseconds(server.db[dbid].expires,1);

        return 1; /* already used our millisecond for this loop... */

    }

    return 0;

}





/* Rehash in ms+"delta" milliseconds. The value of "delta" is larger 

 * than 0, and is smaller than 1 in most cases. The exact upper bound 

 * depends on the running time of dictRehash(d,100).*/

int dictRehashMilliseconds(dict *d, int ms) {

    if (d->pauserehash > 0) return 0;



    long long start = timeInMilliseconds();

    int rehashes = 0;



    while(dictRehash(d,100)) {

        rehashes += 100;

        if (timeInMilliseconds()-start > ms) break;

    }

    return rehashes;

}

扩展分配空间大小

  • 如果是扩展则是ht[0].used*2的2^n
  • 如果是收缩,则是ht[1].used的2^n

扩展。负载因子:哈希表保存节点数量/哈希表大小

  • 没有执行BGSAVE\BGREWRITEAOF命令,且负载因子>=1
  • 正在执行BGSAVE\BGREWRITEAOF命令,负载因子>=5

用于优化子进程的使用效率,在子进程存在期间提高执行扩展操作需要的负载因子,尽量避免子进程存在期间进行扩展。避免不必要的内存写入操作,节约内存

  1. 节约内存
  • 缩容

由两处决定:htNeedsResize和dictResize

超过了初始值且填充率小于10%,说明需要缩容

/* Hash table parameters */

#define HASHTABLE_MIN_FILL        10      /* Minimal hash table fill 10% */

/* This is the initial size of every hash table */

#define DICT_HT_INITIAL_SIZE     4



int htNeedsResize(dict *dict) {

    long long size, used;



    size = dictSlots(dict);

    used = dictSize(dict);

    return (size > DICT_HT_INITIAL_SIZE &&

            (used*100/size < HASHTABLE_MIN_FILL));

}

发生时机在每次数据移除和serverCron定时任务中

渐进式hash

同时持有两个哈希表,维持一个索引计数器遍历rehashidx,每次对dict增删改查的时候都顺带将ht[0]在rehashidx索引上所有键值对rehash,然后计时器自增一。如果全部rehash完毕则设为-1

  1. 避免集中式rehash的庞大计算量

在rehash过程dictAdd中,直插入ht[1],确保ht[0]只减不增

TODO为什么持久化模式下要尽量减少resize

迭代器

分为

  • 安全迭代器 -- safe=1

    • 不支持rehash,但是支持增删改查

  • 非安全迭代器 -- safe == 0

    • 支持rehash,只支持只读操作。对于曾航爱茶等方法可能造成不可预知的问题,比如重复遍历、漏遍历等

如果允许遍历出现重复元素,则选择非安全迭代器 如scan,否则都用安全,如bgaofwrite、bgsave、keys

跳表

一个有序的数据结构,一个节点存储多个指向多个其他节点的指针

平均O(logN),最坏O(N)

当有序集合中元素较多的时候,或元素是较长的字符串的时候会使用跳表作为底层数据结构

定义

  • 特点
  1. 根据score排序,如果相等就按照ele排序
  2. 平均查询时间复杂度为O(logn)

实现

整数集合 intset

整数集合(intset)是redis用于保存整数值的集合抽象数据结构,他可以保存类型为16、32或者64位的整数值,且保证集合中不会出现重复元素,数据也是从小到大存储

可以存放任意不同类型的整数,同时也是按照有序排列的

  • 类型选择
#define INTSET_ENC_INT16 (sizeof(int16_t))

#define INTSET_ENC_INT32 (sizeof(int32_t))

#define INTSET_ENC_INT64 (sizeof(int64_t))

/* Return the required encoding for the provided value. */

static uint8_t _intsetValueEncoding(int64_t v) {

    if (v < INT32_MIN || v > INT32_MAX)

        return INTSET_ENC_INT64;

    else if (v < INT16_MIN || v > INT16_MAX)

        return INTSET_ENC_INT32;

    else

        return INTSET_ENC_INT16;

}
  • 数据查找

用的折半查找,复杂度O(logn)

  • 插入与升级

  • 优化

进行首尾比较,如果不满足(为负数),则直接放到第一位。不需要进行二分查找

优点

  1. 提高灵活性

    1. 能够自动升级底层数组类型来适应新元素
  2. 节约内存

    1. 不同类型使用不同类型的空间存储,减少浪费
  • 不支持降级
  • 添加和删除较慢,均要使用remalloc操作

参考:

《Redis设计与实现》

Redis 核心技术与实战 (geekbang.org)