学习《Redis设计与实现》Chapter1

一、简单动态字符串SDS

        Redis只会使用C字符串作为字面量,大多数情况下都是使用自己构建的一种名为SDS的抽象类型作为默认字符串表示,SDS的实现类型根据初始长度的被分为5,8,16,32,62五种类型。其中长度为5的类型比较特殊,其他的SDS实现类型的结构可以简化理解成下图,而sdshdr5没有两个数字类型的属性。根据作者的注释,sdshdr5是不被使用的,因为它在内存重分配时表现的很糟糕,原因在于两个数字类型属性的缺失。但是实际情况下,sdshdr5并不是完全不会使用的,借鉴【Redis源码分析】一个对SDSHDR5是否使用的疑问 - SegmentFault 思否 这篇文章时发现,在存放短长度的key时还是会使用到的,猜测是在设计redis时考虑到key并不会像value一样经常被修改,所以绝大多数情况下并不用去面对内存重分配的短板。

struct sdshdr {
    // 字节数组,用于保存字符串
    char buf[];

    //记录buf数组中已用字节的数量,等于字符串的长度
    unsigned int len;

    //记录buf数组中未使用字节的数量
    unsigned int free;

    //使用了三位,有五位并没用到。因为SDSHDR实现类的表现就是0-4,所以用3位就能表示完。
    unsigned char flags;
}

buf数组的规则和意义:

        值得注意的是,SDS遵循了C字符串以空字符结尾的惯例,且保存空字符的1字节空间不计算在len属性里面。举个例子:有个SDS对象代表的字符串是"redis"且假设free=0,那么它的len=5且buf数组有六个元素,为['r', 'e', 'd', 'i', 's', '\0']。遵循空字符串结尾这一惯例的好处是SDS可以直接重用一部分C字符串函数中的函数。

len字段和free字段的意义:

1.常数复杂度获取字符串长度

        在C语言中,字符串并不会记录自身的长度信息,所以如果要获取一个C字符串的长度,程序必须对整个字符串进行遍历计数,直到遇到代表结尾的空字符串为止,时间复杂度为O(N)。而对于SDS来说,获取长度仅需访问len属性,其复杂度为O(1)。

2.杜绝缓冲区溢出

        比如在C字符串的拼接函数会默认已经为结果字符串分配了足够多的内存,所以在执行的时候会直接对初始字符串做拼接操作。但如果假定的内存足够的前提不成立的话,就会产生缓冲区溢出。比如程序有两个内存相邻的C字符串s1和s2,如果对s1执行拼接函数而未分配内存空间的话,拼接的字符串就会溢出到s2的内存空间中,导致s2保存的内容被意外修改。而在SDS中,其空间分配策略会先检查SDS的空间是否满足修改需要,不满足的话API会自动将SDS的空间扩展再执行实际的修改操作。

3.减少修改字符串时带来的内存重分配次数

        因为C字符串存在着长度+1=底层数组长度的关联性,所以每次C字符串的长度修改都会对数组进行一次内存重分配操作。即,增长长度的操作会先通过内存重分配扩展数组大小,如果忘了的话会产生缓冲区溢出;缩短长度的操作会通过内存重分配将字符串不需要的那部分空间释放,如果忘了的话会产生内存泄漏。

        因为内存重分配是一个复杂的算法且可能需要执行系统调用,所以通常会是一个耗时操作。在一般程序中如果修改字符串长度的情况不常出现自然在接受范围内,但是Redis作为数据库,数据频繁修改的场景极多,且对速度的要求严苛,自然要想办法对可能影响性能的点做出优化。其优化方式为针对增长操作的空间预分配策略(超过1M的字符串增加1M空间,不超过1M的字符串直接double)和针对缩短操作的惰性空间释放策略(缩短时不直接释放空间,提供了API让我们在有需要的时候释放未使用空间)。

二进制安全

        C字符串中的字符必须符合某种编码,并且除末尾外不得包含空字符,否则会被误拆分。Redis为了确保可以适用于不同的场景,SDS的API都是二进制安全的,其buf数组实际上保存的是一系列二进制数据。

二、链表

1.adlist

通过adlist的结构可以看出来,adlist几乎可以理解成一个简单的双向链表的实现。所以它既具备双向链表的优势(即修改类操作方便),但也具备了双向链表的劣势(顺序查询、占用更多内存)。

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

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;

2.ziplist

首先在ziplist.c文件的开头看看作者对这个数据结构的解释。

The ziplist is a specially encoded dually linked list that is designed to be very memory efficient. It stores both strings and integer values, where integers are encoded as actual integers instead of a series of characters. It allows push and pop operations on either side of the list in O(1) time.However, because every operation requires a reallocation of the memory used by the ziplist, the actual complexity is related to the amount of memory used by the ziplist.

The general layout of the ziplist is as follows:

<zlbytes> <zltail> <zllen> <entry> <entry> ... <entry> <zlend>

ziplist是一种经过特殊编码的双链表,其设计非常节省内存。它同时存储字符串和整数值,其中整数被编码为实际整数,而不是一系列字符。它允许在O(1)时间内对列表的任意一侧执行推送和弹出操作。但是,由于每个操作都需要重新分配ziplist使用的内存,因此实际的复杂性与ziplist使用的内存量有关。

通常ziplist的结构如下:

<ZIPLIST_BYTES> <ZIPLIST_TAIL_OFFSET> <ZIPLIST_LENGTH> <zlentry> <zlentry> ... <zlentry> <ZIPLIST_END_SIZE>

 ziplist的结构分为以下几个部分:

        · zlbytes:32位无符号整型,表示ziplist所占空间大小,为了重置时O(1)复杂度获取大小。

        · zltail:32位无符号整型,表示最后一个entry所在偏移量,方便尾部开始操作。

        · zllen:16位无符号整型,表示entry总数。但最多可表示2^16-2个,当值为2^16-1时,表

                      示超过了2^16-2个,需要遍历才能确定数量。

        · zlentry:指定了头节点和尾节点。具体结构见下面代码代码分析。

        · zlend:8位无符号整型,表示ziplist的末尾,固定值位255。

/* 我们用这个结构来接收ziplist中entry的信息。这个结构并不是
 * 数据真正的编码格式,而是为了方便操作,通过函数填充的内容。 */
typedef struct zlentry {
    /* 前一个entry的长度,主要是为了entry之间跳转。 */
    unsigned int prevrawlen;

    /* 存储prevrawlen字段消耗的字节数 */
    unsigned int prevrawlensize;

    /* 存储真实entry的字节数。对于字符串,这个值代表字符串的长度;
     * 对于数字,这个值根据数值类型可能为0,1,2,3,4,8。     */
    unsigned int len;

    /* 存储len字段消耗的字节数 */
    unsigned int lensize;

    /* prevrawlensize + lensize. */
    unsigned int headersize;     

    /* 根据entry的编码格式设置为ZIP_STR_* or ZIP_INT_*。 
     * 但是对于4bit的数字来说,这可以假定好值的范围并且必须进行范围检查。 */
    unsigned char encoding;

    /* 数据域的指针 */
    unsigned char *p;            /* Pointer to the very start of the entry, that
                                    is, this points to prev-entry-len field. */
} zlentry;


/* ziplist中entry的实际编码格式,它并不是单纯的一个字符串或者数字。 */
typedef struct {
    /* 当使用字符串时,会记录字符串的值和长度。 */
    unsigned char *sval;
    unsigned int slen;

    /* 当使用数字且sval为null时,该字段直接持有数值。 */
    long long lval;
} ziplistEntry;

所以ziplist在逻辑上是一个双向链表,但它存储在一大块连续的内存空间上。所以与其说ziplist是一个数据结构,不如说是一种双向队列的序列化方式,是在内存中的存储格式,看到的ziplist只是一组指向数据域的指针。且由于ziplist在内存中是高度密集的存储,如果发生修改类的操作,都意味着需要分配新的内存来存储新的ziplist。所以如果修改类的操作发生在一个很长的ziplist上,带来的性能代价非常大。

3.quicklist

Redis3.2版本之后引入了quicklist作为list的底层实现,不再使用ziplist和linkedlist来实现。quicklist的设计思想非常简单,将一个大的ziplist拆分为多个小的ziplist来减少每次发生修改类操作时的性能损耗,所有小的ziplist则是通过链表连在一起。所以quicklist可以理解为ziplist和adlist结合的产物,既可以避免adlist大量链表指针带来的内存消耗,也可以避免ziplist修改类操作时的大量性能消耗。但是换一种思路看这个特征,在两极情况下,quicklist其实就等效于ziplist或adlist。所以quicklist作为优化,其实是一种折中的策略。

quicklist的结构如下:

<head><tail><count><len><fill><compress><bookmark>

typedef struct quicklistNode {
    struct quicklistNode *prev;
    struct quicklistNode *next;

    /* 对应的ziplist */
    unsigned char *entry;

    /* ziplist的字节数 */
    size_t sz;

    /* ziplist的zlentry数 */
    unsigned int count : 16;

    /* 编码类型,RAW=1,LZF=2 */
    unsigned int encoding : 2;

    /* NODE=1,ZIPLIST=2 */
    unsigned int container : 2;

    /* 是否曾经被压缩过 */
    unsigned int recompress : 1;

    /* 节点是否能压缩,如果不能的话就是因为太小了 */
    unsigned int attempted_compress : 1;

    /* 空着留作备用的10位 */
    unsigned int extra : 10;
} quicklistNode;


typedef struct quicklist {
    quicklistNode *head;
    quicklistNode *tail;

    /* 在所有ziplist中zlentry的数量 */
    unsigned long count;

    /* quicklistNode的数量 */
    unsigned long len;

    /* 每个quicklistNode的最大容量,为负数时代表4倍fill的字节数,为正数时代表个数 */
    signed int fill : QL_FILL_BITS;

    /* 两端quicklistNode不压缩的个数,考虑到数组两端的节点才会频繁参与操作,减少解压缩的次数 */
    unsigned int compress : QL_COMP_BITS;

    /* bookmark数组的大小 */
    unsigned int bookmark_count: QL_BM_BITS;

    /* 可选字段,用来给quicklist重新分配空间时使用,不使用时不占用空间 */
    quicklistBookmark bookmarks[];
} quicklist;

4.skiplist

先说经典的多层链表结构。基于查找问题的解法分为两个大类:基于各种平衡树,基于哈希表。但多层链表比较特殊,它没法归于两个大类。

经典多层链表示例:
基于以下一个有序链表
null  -->  3  -->  7  --> 11  -->  19  -->  22  -->  28  -->  38  -->  null
增加层级结构
null  -------------------------->  19  ----------------------------->  null
null  ---------->  7  ---------->  19  ----------->  28  ----------->  null
null  -->  3  -->  7  --> 11  -->  19  -->  22  -->  28  -->  38  -->  null

通过这样一个多层链表的构建,来跳过很多下层节点来加快查找速度,非常类似于一个二分查找,时间复杂度O(log n)。
但是这种结构在插入数据时有很大问题。新插入或删除一个节点就会打乱上下相邻两层节点个数严格1:2的对应关系。如果要维持这种关系,就必须像树的左旋右旋那样同步去更新后面的所有节点直至达到新的平衡。这会让时间复杂度退回O(n)。

skiplist为了避免这一个问题,不再严格要求上下相邻两层链表的节点个数有严格的对应关系,而是为每个节点随机出一个层数。
还是基于上面那个三层skiplist,现在要插入一个值为27随机层数为2的数据。
null  -------------------------->  19  -------------------------------------->  null
null  ---------->  7  ---------->  19  ----------->  27  -->  28  ----------->  null
null  -->  3  -->  7  --> 11  -->  19  -->  22  -->  27  -->  28  -->  38  -->  null
这样的话,一个节点的增删并不会影响到其它节点的层级,降低了插入操作的复杂度,这也是skiplist一个很重要的特性,让它在插入性能上明显优于平衡树。

可以看出,skiplist的第一层链表是持有全部数据的,而上层链表逐渐稀疏。

public class SkipList {

    // 这里就不取论文的参考值了,取一下Redis官方设定的值吧
    private static final float SKIPLIST_P = 0.25f;
    private static final int MAX_LEVEL = 32;

    // 目前的层数
    private int levelCount = 1;

    // 头节点
    private Node head = new Node();

    public Node find(int value) {
        Node p = head;
        // p.forwards[i]表示节点p到第i层的下一个节点
        for (int i = levelCount - 1; i >= 0; --i) {
            while (p.forwards[i] != null && p.forwards[i].data < value) {
                p = p.forwards[i];
            }
        }
        // 遍历到最底层了或者在某一层找到了等值的元素,直接去第一层拿数据判断
        if (p.forwards[0] != null && p.forwards[0].data == value) {
            return p.forwards[0];
        } else {
            return null;
        }
    }

    public void insert(int value) {
        int level = randomLevel();
        Node newNode = new Node();
        newNode.data = value;
        newNode.maxLevel = level;
        Node update[] = new Node[level];
        for (int i = 0; i < level; ++i) {
            update[i] = head;
        }

        // 在随机到的层数内的每一层,找到最大的那个小于插入值的节点位置
        Node p = head;
        for (int i = level - 1; i >= 0; --i) {
            while (p.forwards[i] != null && p.forwards[i].data < value) {
                p = p.forwards[i];
            }
            update[i] = p;// use update save node in search path
        }

        // 根据上面找到的位置插入
        for (int i = 0; i < level; ++i) {
            newNode.forwards[i] = update[i].forwards[i];
            update[i].forwards[i] = newNode;
        }

        // 更新目前的层数
        if (levelCount < level) levelCount = level;
    }

    public void delete(int value) {
        Node[] update = new Node[levelCount];
        Node p = head;
        for (int i = levelCount - 1; i >= 0; --i) {
            while (p.forwards[i] != null && p.forwards[i].data < value) {
                p = p.forwards[i];
            }
            update[i] = p;
        }

        if (p.forwards[0] != null && p.forwards[0].data == value) {
            for (int i = levelCount - 1; i >= 0; --i) {
                if (update[i].forwards[i] != null 
                  && update[i].forwards[i].data == value) {
                    update[i].forwards[i] = update[i].forwards[i].forwards[i];
                }
            }
        }

        while (levelCount > 1 && head.forwards[levelCount] == null) {
            levelCount--;
        }

    }

    // 因为这里每一层的晋升概率是25%。对于每一个新插入的节点,都需要调用该方法生成一个合理的层数。
    // 该 randomLevel 方法会随机生成 1~MAX_LEVEL 之间的数,且 :
    // 1-p的概率返回 1
    // p(1-p)的概率返回 2
    // p(1-p)p的概率返回 3 
    // ...
    // 因此一个节点的平均层数为1/(1-p),也就是一个节点包含指针的平均数。平均时间复杂度为O(log n)
    // 至于查询时间复杂度的计算,自行搜索William Pugh的论文《Skip Lists: A Probabilistic       
    // Alternative to Balanced Trees》

    private int randomLevel() {
        int level = 1;

        while (Math.random() < SKIPLIST_P && level < MAX_LEVEL)
            level += 1;
        return level;
    }

    public void printAll() {
        Node p = head;
        while (p.forwards[0] != null) {
            System.out.print(p.forwards[0] + " ");
            p = p.forwards[0];
        }
        System.out.println();
    }

    public class Node {
        private int data = -1;
        private Node forwards[] = new Node[MAX_LEVEL];
        private int maxLevel = 0;

        @Override
        public String toString() {
            StringBuilder builder = new StringBuilder();
            builder.append("{ data: ");
            builder.append(data);
            builder.append("; levels: ");
            builder.append(maxLevel);
            builder.append(" }");

            return builder.toString();
        }
    }

}

skiplist与平衡树的比较:
1.平衡树做范围查找更复杂。在平衡树上,我们找到指定范围的小值后,还需要以中序遍历的方式继续寻找其它不超过大值的节点,不改造的话这并不容易实现,但这skiplist上对第一层链表进行若干步遍历就行了。
2.平衡树的插入和删除可能导致子树的调整。
3.内存占用上,skiplist更灵活一些。正常来说平衡树每个节点包含指向左右子树的两个指针,而skiplist包含1/(1-p)个。像Redis里p=0.25,平均每个节点包含1.33个指针。
 

三、字典

        从某种程度上来讲,Redis的字典和JDK中的HashMap有着一些极为相似的地方。

/* hash表中的实体,保存KV信息 */ 
typedef struct dictEntry {
    /* key值 */
    void *key;
    /* dictEntry在不同格式时存储不同类型的数据的value */
    union {   
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    /* 下一个相同hash值的dictEntry的指针,用来处理hash冲突 */
    struct dictEntry *next; 
    /* 附加数据,例如Cluster */
    void *metadata[];
} dictEntry;

/* 为了让dict支持各种数据类型提出的数据结构,可以理解为Java中的接口类 */
typedef struct dictType {
    /* 对key生成hash值 */
    uint64_t (*hashFunction)(const void *key);
    /* 对key进行拷贝 */
    void *(*keyDup)(void *privdata, const void *key);
    /* 对val进行拷贝 */
    void *(*valDup)(void *privdata, const void *obj);
    /* 两个key的对比函数 */
    int (*keyCompare)(void *privdata, const void *key1, const void *key2);
    /* key的销毁 */
    void (*keyDestructor)(void *privdata, void *key);
    /* val的销毁 */
    void (*valDestructor)(void *privdata, void *obj);
    /* 检查内存块分配 */
    int (*expandAllowed)(size_t moreMem, double usedRatio); 
    /* 返回元数据的长度。分配内存时的初始值为0 */
    size_t (*dictEntryMetadataBytes)(dict *d);
} dictType;
    
/* 保存每个hashtable的数据结构 */
typedef struct dictht {
    /* hashtable数组 */
    dictEntry **table;
    /* hashtable数组的大小 */
    unsigned long size;
    /* 哈希表大小掩码,用于计算索引值,值为size-1 */
    unsigned long sizemask;
    /* 已存储的数据个数 */
    unsigned long used;
} dictht;

typedef struct dict {
    /* dictType结构的指针 */
    dictType *type;
    /* 两个hash表,正常使用的是ht_table[0], rehash 才会使用到ht_table[1] */
    dictEntry **ht_table[2];
    /* 对应两个hash表中,元素个数 */
    unsigned long ht_used[2];
    /* 增量hash过程过程中记录rehash执行到第几个bucket了,当rehashidx == -1表示没有在做rehash */
    long rehashidx;
    /* 如果大于0,rehash暂停;如果小于0,发生编码错误 */
    int16_t pauserehash;
    /* size的指数,size = 1<<exp */
    signed char ht_size_exp[2];
} dict;

        从存储上来讲,都是通过数组+链表的形式来存储数据。存储的原理上,都是先对要存储的K-V对的key做一次hash,然后根据hash值计算出该K-V对要存储到数组的索引值并存到链表中。这个链表就是使用了链地址法来解决键冲突的情况。随着保存到K-V对逐渐的增多或减少,为了让哈希表的负载因子维持在一个合理的范围之内,就会进行rehash操作。将负载因子维持在一个合理的范围之内,既是为了保证数据插入时减少哈希冲突的发生频率,也是为了查询效率的优化。

单说Redis的rehash。

执行rehash的前置条件有两个:

1.服务器目前没有在执行BGSAVE命令或者BGREWRITEAOF命令,且哈希表的负载因子>=1;

2.服务器正在执行BGSAVE命令或者BGREWRITEAOF命令,且哈希表的负载因子>=5.

        之所以根据执行命令分为两种情况,是因为执行BGSAVE命令或者BGREWRITEAOF命令时会从主进程中fork出一个子进程来,而子进程会是采用写时复制技术来优化。所以为了尽可能的避免子进程存在期间进行哈希表的扩展操作,子进程存在时判断是否执行rehash的负载因子条件会增大。

执行rehash的过程:

        首先在Redis的哈希对象的数据结构中,它有一个长度为2的二维数组ht,其中ht[0]就是用来存储数据的数组,ht[1]一般情况下都是空的,只有在rehash时才会分配到内存空间。

1.分配空间在给ht[1]分配内存空间时,都是根据ht[0]已使用的空间大小决定的,进行扩展(收缩)操作时,ht[1]的空间大小为第一个大于(小于)等于ht[0]已使用空间大小的2^n。

2.重新计算键的哈希值和索引值,然后将K-V对放到ht[1]的指定位置上

这个时候就体现了扩(缩)容时为什么要用2^n的好处。

        由于没有经历过rehash的字典的ht[0]默认长度是4,所以无论之后进行过多少次rehash操作,长度大小一定都是2^n。而Redis中获取桶号的方法是除留余数法,因此取模可以直接做位运算,提高效率,且2^n-1的二进制会全为1,位运算时可以充分散列,减少哈希冲突。而且扩容后只需要判断高位是否为1,是的话则移位。

        原理其实就是位运算的巧妙运用。先说取模,当被除数是2^n时,对于除数来说其实就是向右移了n位,而右移n位的值,就是余数;a&(2^n-1)其实就是a的后n-1位和n个1的二进制数做与操作,即取a的后n-1位。所以就9%4=9&(4-1)=1。再说移位,比如一开始数组大小是4,扩容后成了8,每个元素对应的桶号由hashCode%4的值变为hashCode%8的值,每个桶号的二进制值增加了一位,增加的一批桶的桶号与之前存在的那批桶的桶号只有高位不一样。

3.当ht[0]的所有K-V对都迁移到ht[1]后,释放ht[0],将ht[1]设置为ht[0],并在ht[1]新创建一个空白哈希表,为下一次rehash作准备。

渐进式rehash:

        为了避免rehash时对服务器可能造成影响,服务器不是一次性将ht[0]中的K-V对全部rehash到ht[1],而是分多次、渐进式地将ht[0]中的K-V对慢慢的rehash到ht[1]中。

1.在字典中维护了一个索引计数器变量rehashidx来记录渐进式rehash执行到了ht[0]的哪个索引,初始时置为0表示rehash工作正式开始;

2.在rehash进行期间,每次对字典执行增删改查操作,程序除了执行指定的操作外,还会顺带将ht[0]哈希表在rehashidx索引上的K-V对rehash到ht[1],并将rehashidx的值加1。其中,增删改操作都是直接在ht[1]数组中执行,查操作会先去ht[0]中查,没有查到的话再到ht[1]中查;

3.随着字典的不断执行,最终在某个时间点上ht[0]上的K-V对都会被rehash到ht[1],这时将rehashidx的值置为-1,表示rehash操作已完成。

四、整数集合intset

通过其数据结构可以看出,intset只支持整数类型的存储。

typedef struct intset {
    /* value的编码类型,int16_t、int32_t、int64_t */
    uint32_t encoding;
    /* 柔性数组,存放value的 */
    int8_t contents[];
    /* contents数组的长度 */
    uint32_t length;
} intset;

intset本身持有一个整体存储的编码格式的属性,当要传入一个更大类型的整数时,会发生升级,将contents数组所有元素的存储编码格式换为更大的编码格式。但一旦升级了就不会再降级了。

intset的每次增删都意味着一次内存重分配,显然它不适合做大量数据的存储。

5.HLL

HLL有HLL_DENSE和HLL_SPARSE两种编码格式,分别对应的是密集存储型和稀疏存储型。当往这个结构里ADD一个字符串时,先做一次hash得到一个64位的数,然后取后14位算出一个值,这个值代表了桶号num,在前50位中从后往前找到第一个1出现的位置pos。如果桶号num对应的桶中的数字比pos小,就将桶号num的数字设置为pos,否则不变。

所以可以看出,每个HLL都有2^14也就是16384个桶,而且pos的取值为1到50。

当需要计算基数时,计算16384个桶记录的数的调和平均数再乘以16384,得到的就是不同元素的个数。这样算误差肯定是有的,但是很小,原理参考伯努利实验。

稀疏存储型

稀疏存储型有三种操作码:

· ZERO:一个字节,格式为00xxxxx。后六位xxxxxx+1来表示为连续为0的寄存器的个数。所以可以表示1到64。

· XZERO:两个字节,格式为01xxxxx yyyyyyyy。后十四为位xxxxxxyyyyyyyy+1来表示为连续为0的寄存器的个数。所以可以表示1到2^14,也就是1到16384。

· VAL:一个字节。格式为1vvvvvxx。vvvvv5位来表示寄存器的值,xx+1表示为连续为vvvvv的寄存器的个数。所以取值范围在1到32,个数为1到4。

举个例子,一个HLL中有四个不为0的值,位置分别是5、1000、1001、1500,值分别是10、20、20、14,其它的值全为0。那HLL的HLL_SPARSE表示为:

1.ZERO:0000100。0到4号寄存器的值为0。

2.VAL:10101001。1+01010+01。从5号开始,有一个寄存器的值为10。

3.XZERO:954。6到999号寄存器的值为0。

4.VAL:11010010。1+10100+10。从1000号开始,有两个寄存器的值为20。

5.XZERO:498。1002号到1499号寄存器的值为0。

6.VAL:10111001。1+01110+01。从1500号寄存器开始,有一个寄存器的值为14。

7.XZERO:14883。从1501到16383号寄存器的值为0。

所以,当基数比较小时,HLL有很高的利用率,用10个字节表示所有的寄存器,也就是16384个桶。但是这种处理方式已经限定了寄存器的最大值为32,而且如果插入的数很多位置又分散的话,要用到的内存也不小。

密集存储型

密集存储型固定使用12kb的内存,其使用的寄存器固定都是6位,并且是连续排列。因为一个字节是8位,所以每个字节至少都会持有两个寄存器的部分或全部的位。dense形式的寄存器都是LSB到MSB进行编码,即从低到高存放。

举个例子,三个字节表示四个寄存器:

11000000 22221111 33333322

1.第一个字节的后六位保存0号寄存器的值

2.第一个字节的前两位和第二个字节的后四位保存1号寄存器的值

3.第二个字节的前四位和第三个字节的后两位保存2号寄存器的值

4.第三个字节的前六位保存3号寄存器的值

所以用这种处理方式的话,12*1024*8/6的结果对应的就是16384个桶,而6位可以表示0到64囊括了1到50。

struct hllhdr {
    /* "HYLL,表明是HLL对象 */
    char magic[4];
    /* 编码格式:HLL_DENSE或者HLL_SPARSE */
    uint8_t encoding;
    /* 保留字段 */
    uint8_t notused[3];
    /* 当前缓存的hll基数,如果第一位是1则缓存失效,为0则缓存有效。
     * 作用就是缓存,只有访问这个值是才可能会去重新计算基数。
     * 任意一个桶的值变化就把第一位设置为1,表示需要重新计算基数。*/
    uint8_t card[8]; 
    /* 连续寄存器,用来存储元素,也就是dense或sparse形式的数据 */
    uint8_t registers[];
};

6.Stream

为了应对发布订阅模式不支持消息持久化的问题,stream借鉴了kafka的设计,实现了redis的消息队列,同时标准化的解决了redis其它数据结构实现的队列的各种弊端。

/* 通过插入时的时间+序列号给每个消息赋予一个唯一id标识 */
typedef struct streamID {
    /* 毫秒 */
    uint64_t ms;
    /* 序列号 */
    uint64_t seq;
} streamID;


typedef struct stream {
    /* 持有消息队列的树形结构radix */
    rax *rax;
    /* stream中的节点数 */
    uint64_t length;
    /* 最近一条消息的id,如果stream为空值为0 */
    streamID last_id;
    /* 消费组,字典结构,key是name,value是streamGC */
    rax *cgroups;
} stream;

/* 消费组 */
typedef struct streamCG {
   
    streamID last_id;       /* Last delivered (not acknowledged) ID for this
                               group. Consumers that will just ask for more
                               messages will served with IDs > than this. */
    rax *pel;               /* Pending entries list. This is a radix tree that
                               has every message delivered to consumers (without
                               the NOACK option) that was yet not acknowledged
                               as processed. The key of the radix tree is the
                               ID as a 64 bit big endian number, while the
                               associated value is a streamNACK structure.*/
    rax *consumers;         /* A radix tree representing the consumers by name
                               and their associated representation in the form
                               of streamConsumer structures. */
} streamCG;

算了先不写了,用的人太少了,要搞明白花的时间又多,性价比低了,下次一定。

7.ROBJ

作为Redis对外暴露的第一层面的数据结构,底层实现所对应的数据结构通过encoding区分,可以把这个数据结构认为是两个层面的数据结构的桥梁,或者说是接口。作用:
1.用于为多种数据类型提供统一的表示方式
2.允许同一类型的数据采用不同的内部表示,从而在某些情况下尽量节省内存
3.支持对象共享和引用计数,共享时只占用一份内存拷贝,节省内存,同时兼备了自动gc功能
4.为数据淘汰保存了必要的信息

typedef struct redisObject {
    /* 对象的数据类型,取值有5种:OBJ_STRING, OBJ_LIST, OBJ_SET, OBJ_ZSET, OBJ_HASH,分别对应Redis对外暴露的5种数据结构 */
    unsigned type:4;
    /* 编码格式 */
    unsigned encoding:4;
    /* LRU替换算法用 */
    unsigned lru:LRU_BITS;
    /* 引用计数,允许robj对象在某些情况下被共享 */
    int refcount;
    /* 数据指针 */
    void *ptr;
} robj

映射关系

其实对ROBJ对象来说,对应的是应用层和编码层之间的关系,但是这个先跳过,直接说数据结构与应用层的映射关系。

字符串:

Long或SDS,根据数据类型。

列表:

adlist或ziplist。

编码转换:
当保存的所有元素的长度都小于64个字节,且保存的数量小于512个时,用ziplist,否则使用adlist。
这两个限定值是可以在配置里修改的,但是不重要,知道有这么个事就行了。

哈希对象:

ziplist:
键值对作为两个元素插入到ziplist里,永远是键在前值在后紧紧挨在一起。

dict:
字典的键就是键值对的键,字典的键对应的值就是键值对的值。

编码转换:

当保存的所有元素的长度都小于64个字节,且保存的数量小于512个时,用ziplist,否则使用dict。

集合

intset:
只存整数

dict:
字典的每个键都是集合的元素,字典的值全为null。

编码转换:
保存的元素全为整数且元素个数小于512时使用intset,否则使用dict。

有序集合

ziplist:
有序集合的元素和元素对应的分数作为两个紧挨的节点存放。

skiplist+dict:
skiplist的每个节点是一个元素和元素分值的结构体。
dict的以键值对的方式保存元素和元素分值的对应关系。

分析:
其实这两个结构都是各自实现了一个有序集合。之所以用两个结构结合的方式,还是取决于数据结构的优劣性。dict单个查找O(1)复杂度,skiplist适合范围查询。

编码转换:
元素个数小于128且元素长度都小于64个字节,用ziplist,否则用skiplist + dict。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值