跟我速览Redis底层六大数据结构!

大纲:

  1. 简单动态字符串SDS
  2. 链表
  3. 字典
  4. 跳跃表
  5. 整数集合
  6. 压缩列表

阅读本文你将收货什么:

  1. 了解Redis底层的六种数据结构。
  2. 了解每种数据结构的实现方式以及设计上的优点。

Redis为什么这么快?

作为高速KV数据库,Redis的速度已经经过各大小公司的实战考验了,至于为什么这么快,各个理由从google上一搜大同小异,今天我们来聊一聊其底层实现的六大数据结构。

Redis的高效与其基本的数据结构也是密不可分的,为了满足效率和安全这些需求,Redis根据自身需要量身定制了数据结构。注:Redis基于这些数据结构创建了字符串对象,列表对象,哈希对象,集合对象和有序集合对象的对象系统,以此实现键值对数据库。

一.简单动态字符串(simple dynamic string,SDS)

SDS:每个sdshdr结构表示一个SDS值
struct sdshdr {
    // 记录buf数组中已使用字节的数量
    // 等于SDS所保存字符串的长度
    int len;

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

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

图1·SDS示例

  • free属性值为0,表示这个SDS没有分配任何未使用空间。
  • len属性值为5,表示这个SDS保存了一个五字节长的字符串。
  • buf属性是一个char类型的数组,以’\0’结尾,不计算在len属性中。
优点:
  1. 以’\0’结尾可以直接使用C字符串函数库里的函数。
  2. 常数复杂度获取字符串长度O(1),只需要访问len属性即可。
  3. 杜绝缓冲区溢出,当SDS API需要对SDS进行修改时,会先检查SDS的空间是否满足需要,不满足则自动扩容,避免溢出。
  4. 减少修改字符串时带来的内存重分配次数。因为内存重分配涉及复杂的算法,并且可能需要执行系统调用,所以它通常是一个比较耗时的操作。
    • 空间预分配
      如果对SDS修改后,其长度小于1M将分配和len属性同样大小的未使用空间,这时候len属性与free属性值相同。
      如果对SDS修改后其长度大于1M,那么程序会分配1M的未使用空间。
    • 惰性空间释放
      惰性空间释放用于优化SDS的字符串缩短操作,当SDS的API需要缩短其保存的字符串时,程序不立即使用内存重新分配来回收多出来的字节,而是使用free属性将其记录,留待以后使用。
  5. 二进制安全。所有SDS API都会以处理二进制的方式来处理SDS存放在buf数组里的数据,数据在写入时是什么样的,它被读取时就是什么样。

二.链表

listNode:每个链表节点用一个listNode结构来表示
typedef struct listNode {
        // 前置节点
        struct listNode *prev;
        
        // 后置节点
        struct listNode *next;
        
        // 节点的值
        void *value

}listNode;
list:虽然多个listNode结构可以组成链表,但由list来持有链表操作方便许多
typedef struct list {
        // 表头结点
        listNode *head;

        // 表尾节点
        listNode *tail;

        // 链表所包含的节点数量
        unsigned long len;

        // 节点值复制函数
        void *(*dup)(void *prt);

        // 节点值释放函数
        void (*free)(void *ptr);

        // 节点值对比函数
        int (*match) (void *ptr, void *key);

}list;

图2·由list结构和listNode结构组成的链表
图2·由list结构和listNode结构组成的链表

特性总结:
  1. 双端:链表节点带有prev和next指针,获取某个节点的前置节点和后置节点的复杂度为O(1)
  2. 无环:表头节点的pre和表尾节点的next都指向NULL,对链表的访问以NULL为终点。
  3. 带表头和表尾指针:通过list结构的head指针和tail指针,获取链表头尾节点的复杂度为O(1).
  4. 带链表长度技术器,使用list结构的len属性来对list持有的链表节点计数,获取链表中节点数量的复杂度为O(1)。
  5. 多态:链表节点使用void* 指针来保存节点的值,并且可以通过list结构的dup、free、match三个属性为节点值设置类型特定的函数,用来保存各种不同类型的值。
    ###三.字典

三.字典

哈希表:Redis字典所使用的哈希表由dictht结构定义
typedef struct dictht {
        // 哈希表数组
        dictRntry **table;

        // 哈希表大小
        unsigned long size;

        // 哈希表大小掩码,用于计算索引值
        // 总是等于size-1
        unsigned long sizemask;

        // 该哈希表已有节点的数量
        unsigned long used;
}dictht;

图3·一个空的哈希表
图3·一个空的哈希表

  • table属性是一个数组,数组中的每个元素都是一个指向dictEntry结构的指针,每个dictEntry结构保存着一个键值对。

  • size属性记录了哈希表的大小,也即table数组的大小,而used属性记录了哈希表目前已有节点(键值对)的数量。

  • sizemask属性的值总是等于size-1,这个属性和哈希值一起决定一个键应该被放到table数组的哪个索引上面。

哈希表节点:哈希表节点使用dictEntry结构表示,每个结构保存着一个键值对
typedef struct dictEntry {
        // 键
        void *key;

      // 值
        union {
            void *val;
            uint64_t u64;
            int64_t s64;    
        }v;

        //  指向下个哈希表节点,形成链表
        struct dictEntry *next;

}dictEntry;

图4·连在一起的键K1和键K0
图4·连在一起的键K1和键K0

  • key属性保存着键值对中的键,而v属性保存着键值对中的值,值可以是一个指针,或uint64_t整数,或int64_t整数。
  • next属性是指向另一个哈希表节点的指针,这个指针可以将多个哈希值相同的键值对连接在一起,一次解决键冲突的问题。
字典:Redis中的字典由dict结构表示
typedef struct dict {

        //    类型特定函数
        dictType *type;

        //    私有数据
        void *privdata;

        //    哈希表
        dictht ht[2];

        //    rehash索引
        //    当rehash不在进行时,值为-1
        int treashidx;
}dict;
  • type属性是一个指向dictType结构的指针,每个dictType结构保存了一簇用于操作特定类型键值对的函数,Redis会为用途不同的字典设置不同类型的函数。
  • privadata属性保存了需要传给那些类型特定函数的可选参数。
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 *key, const void *key2);

        //    销毁键的函数
        void (*keyDestructor) (void *privdata, void *key);

        // 销毁值的函数
        void (*valDestructor) (void *privdata, void *obj);
}dictType;

图五·普通状态下的字典
图五·普通状态下的字典

  • 哈希算法:
    当要将一个新的键值对添加到字典里时,程序需要先根据键值对的键计算出哈希值和索引值,然后再根据索引值,将包含新键值对的哈希表节点放到哈希表数组指定索引上面。
  • 解决键冲突:
    Redis的哈希表使用链地址法来解决键冲突,每个哈希表节点都有一个next指针,多个哈希表节点用next指针构成一个单向链表。
  • rehash:
    1.为字典的ht[1]哈希表分配空间,这个哈希表的空间大小取决于要执行的操作,以及ht[0]当前包含的键值对数量。
    • 扩展:那么ht[1]的大小为第一个大于等于ht[0].used*2的2^n;
    • 收缩:那么ht[1]的的大小为第一个大于等于ht[0].used的2^n.
    1. 将保存在ht[0]中的所有键值对rehash到ht[1]上面。
    2. 当ht[0]包含的所有键值对都迁移到ht[1]后,释放ht[0],将ht[1]设置为ht[0],并在ht[1]新创建一个空白哈希表,为下一次rehash做准备。
  • 渐进式rehsah:
    1.为ht[1]分配空间,让字典同时持有ht[0]和ht[1]两个哈希表。
    2.在字典中维持一个索引计数器变量rehashidx,并设置0,表示rehash工作开始。
    3.在rehash期间,每次对字典进行增删改查外,顺带将ht[0]哈希表在rehashidx索引上所有键值对rehash到ht[1],当rehash完成后,将rehashidx增一。
    4. 当rehash完成时,将rehashidx设置为-1,表示rehash操作完成。

!!!渐进式rehash的好处在于它采取分而治之的方式,将rehash键值对所需的计算工作均摊到每个增删改查操作上,避免集中式rehash带来的庞大计算量

渐进式rehash期间hash表操作:删、查、改操作先ht[0]后ht[1],新增直接在ht[1]上。

四.跳跃表

跳跃表节点:由zskiplistNode结构定义
typedef struct zskiplistNode {

        // 后退指针
        struct zskiplistNode *backward;

        // 分值
        double score;

        // 成员对象
        robj *obj;

        // 层
        struct zskiplistLevel {

                // 前进指针
                struct zskiplistNode *forward;

                // 跨度
                unsigned int span;
        }level[];
}zskiplistNode;

图六·带不同层高的节点
图六·带不同层高的节点

  1. 层:
    跳跃表节点的level数组可以包含多个元素,每个元素都包含一个指向其他节点的指针,程序可以通过这些层来加快访问其他节点的速度,一般来说,层越多,访问其他节点的速度就越快。
    2.前进指针:
    每个层都有一个指向表尾方向的前进指针(level[i].forward),用于从表头向表尾方向访问节点。
  2. 跨度:
    层的跨度(level[i].span)用于记录两个节点之间的距离。跨度与操作无关,只是用于记算排位:在查找某个节点的过程中,将沿途访问过的所有层的跨度累计起来,得到结果就是目标节点在跳跃表中的排位。
  3. 后退指针:
    后退指针(backward)用于从表尾向表头方向访问节点,每次只能后退至前一个节点。
  4. 分值和成员:
    • 节点的分值(score)是一个double类型的浮点数,跳跃表中所有节点都按分值从小到大排序。
    • 节点的成员对象(obj)是一个指针,指向一个字符串对象,字符串对象则保存着一个SDS值。
    • 在同一个跳跃表中,各个节点保存的成员对象必须是唯一的,但是多个节点保存的分值可以相同。
跳跃表
typedef struct zskiplist {
        // 表头结点和表尾节点
        struct skiplistNode *header, *tail;    

        // 表中节点的数量
        unsigned long length;

        // 表中层数最大的节点的层数
        int level;
}zskiplist;

图七·带有zskiplist结构的跳跃表
图七·带有zskiplist结构的跳跃表

  • header和tail指针分别指向跳跃表的表头和表尾节点,通过这两个指针,程序定位表头节点和表尾节点的复杂度为O(1)。
  • 通过length属性记录节点的数量,获取跳跃表长度的复杂度为o(1)。
  • level属性用于在O(1)复杂度内获取跳跃表中层高最大的那个节点的层数量。

五.整数集合

typedef struct intset {
        //     编码方式
        uint32_t encoding;  

        //    集合包含的元素数量
        uint32_t length;

        //    保存元素的数组
        int8_t contents[];
}intse;

图八·一个包含五个int16_t类型整数值的整数集合
图八·一个包含五个int16_t类型整数值的整数集合

  • contents数组是整数集合的底层实现:整数集合的每个元素都是contents数组的一个数组项(item),各个项在数组中按值从小到大排列,不重复。
  • length属性记录了整数集合包含的元素数量。
  • contents数组真正类型取决于encoding属性的值。
升级

当添加新元素到整数集合中,新元素比整数集合现有元素都要长,则进行升级。

  1. 根据新元素的类型,扩展整数集合底层数组的空间大小,并为新元素分配空间。
  2. 将底层数组现有所有元素都转换成新元素的类型,并将类型转换后的元素放置到正确的位置上。
  3. 将新元素添加到底层数组里面。

升级的好处:

  1. 提升灵活性:C语言是静态类型语言,避免类型错误,通常不会将两种不同类型的值放在同一个数据结构中。
  2. 节约内存:让一个数组可以同时保存int16_t、int32_t、int64_t三种类型最简单的做法就是直接使用int64_t作为整数集合的底层实现。整数集合技能保存不同类型的整数,又可以确保升级操作只会在必要的时候进行,这可以尽量节约内存。
降级

整数集合不支持降级。

六.压缩列表

压缩列表的构成

压缩列表是Redis为了节约内存而开发的,有一系列特殊编码的连续内存块组成的顺序型数据结构。一个压缩列表可以包含任意多个节点(entry),每个节点可以保存一个字节数组或者一个整数值。
图九·包含三个节点的压缩列表
图九·包含三个节点的压缩列表

  • 列表zlbytes属性值为0x50(十进制80),表示压缩列表总长为80字节。
  • 列表zltail属性值为0x3c(十进制60),这表示如果我们有一个指向压缩列表起始指针的P,只要P加上偏移量60就能计算出表尾节点entry3的地址。
  • 列表zllen属性值为0x3(十进制3),表示压缩列表包含三个节点。
压缩列表节点的构成

图十·前一节点长度为5字节
图十·前一节点长度为5字节

  • previous_entry_length
    节点的previous_entry_length属性以字节为单位,记录了压缩列表中前一个字节的长度,其长度可为1或5:

    • 如果前一节点长度小于254字节,那么previous_entry_length长度为一个字节,前一个节点的长度就保存在这个字节里。
    • 如果前一个节点的长度大于254字节,那么previous_entry_length属性的长度为5字节,第一个字节被设为0xFE,之后四个字节用于保存前一节点的长度。
      因为节点的previous_entry_length属性记录了前一个节点的长度,所以程序可以通过指针运算,根据当前节点的其实地址计算出前一个节点的起始地址。
  • encoding
    节点encoding属性记录了节点的content属性所保存数据的类型以及长度。

字节数组编码:

编码编码长度content属性保存的值
00bbbbbb1字节长度小于等于63字节的字节数组
01bbbbbb xxxxxxxx2字节长度小于等于16 383字节的字节数组
10_ _ _ _ _ _ aaaaaaa bbbbbbbb cccccccc5字节长度小于等于4 294 967 295字节的字节数组

整数编码:

编码编码长度content属性保存的值
110000001字节int16_t 类型的数组
110100001字节int32_t 类型的数组
111000001字节int64_t 类型的数组
111100001字节24位有符号整数
111111101字节8位有符号整数
1111xxxx1字节无意义
  • content
    节点的content属性负责保存节点的值,节点值可以是一个字节数组或者整数,值的类型和长度有节点的encoding属性决定。
连锁更新

图十一·添加新节点到压缩列表
图十一·添加新节点到压缩列表

  • 因为每个节点的previous_entry_length属性都记录了前一个节点的长度
  • 假设在一个压缩列表中所有节点长度都小于254字节,当插入一长度大于254字节的新节点并设置为表头节点,那么他下一个节点e1的previous_entry_length只有1个字节,没法保存大于254字节的长度,需要扩展。
  • e1更新后e2也需要扩展,扩展e2也会引发对e3的扩展,e4······直到每个节点previous_entry_length都符合压缩列表对节点的要求。

因为连锁更新在最坏的情况下需要对压缩列表执行N次空间重分配操作,每次空间分配的最坏复杂度为O(N),所以连锁更新的最坏复杂度为O(N^2)。
尽管连锁更新的复杂度较高,但真正造成性能问题的几率很低

总结:

以上就是Redis的六种底层数据的各种实现分析,总结于《Redis设计与实现》,用于自己速览,也希望能帮助到对于redis感兴趣的各位!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值