Redis基础篇(数据结构二)

115 阅读8分钟

简单动态字符串(SDS)

SDS的定义

struct sdshdr { 
    //记录buf数组中已使用字节的数量,等于SDS所保存字符串的长度 
    int len; 
    //记录buf数组中未使用字节的数量 
    int free; 
    //字节数组,用于保存字符串 
    char buf[]; }
;

SDS遵循C字符串以空字符结尾的惯例,保存空字符的1字节空间不计算在SDS的len属性里面,并且为空字符分配额外的1字节空间,以及添加空字符到字符串末尾等操作,都是由SDS函数自动完成的,所以这个空字符对于SDS的使用者来说是完全透明的。遵循空字符结尾这一惯例的好处是,SDS可以直接重用一部分C字符串函数库里面的函数。

示例图: image.png

SDS与C字符串的区别

  1. 对于C字符串,获取字符串长度,程序必须遍历整个字符串,对遇到的每个字符进行计数,直到遇到代表字符串结尾的空字符为止,耗时o(n); 而SDS字符串则是o(1), 直接取len属性值。
  2. 避免缓冲区溢出,c字符串在进行字符串拼接操作时,如果内存分配不足,就会发生缓冲区溢出。
  3. c语言字符串在修改时,需要发生内存重分配,而sds因为存在预分配机制,可以减少连续执行字符串增长操作所需的内存重分配次数,同时,在缩短字符串时,sds并不立即使用内存重分配来回收缩短后多出来的字节,而是使用free属性将这些字节的数量记录起来,并等待将来使用(惰性空间释放极致)。
  4. c字符串必须包含某种编码,只能保存文本数据,而不能保存像图片、音频、视频、压缩文件这样的二进制数据。而sds是二进制安全的。

链表

当一个列表键包含了数量比较多的元素,又或者列表中包含的元素都是比较长的字符串时,Redis就会使用链表作为列表键的底层实现。

除了链表键之外,发布与订阅、慢查询、监视器等功能也用到了链表,Redis服务器本身还使用链表来保存多个客户端的状态信息,以及使用链表来构建客户端输出缓冲区(output buffer)。

链表和链表节点的实现: image.png

Redis的链表实现的特性可以总结如下:

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

哈希表(字典)

字典在Redis中的应用相当广泛,比如Redis的数据库就是使用字典来作为底层实现的,对数据库的增、删、查、改操作也是构建在对字典的操作之上。

typedef struct dictht {
    //哈希表数组
    dictEntry **table;
    //哈希表大小
    unsigned long size;
    //哈希表大小掩码,用于计算索引值
    //总是等于size-1
    unsigned long sizemask;
    //该哈希表已有节点的数量
    unsigned long used;
} dictht;

总结:

  • Redis中的字典使用哈希表作为底层实现,每个字典带有两个哈希表,一个平时使用,另一个仅在进行rehash时使用。
  • 当字典被用作数据库的底层实现,或者哈希键的底层实现时,Redis使用MurmurHash2算法来计算键的哈希值。
  • 哈希表使用链地址法来解决键冲突,被分配到同一个索引上的多个键值对会连接成一个单向链表。
  • 在对哈希表进行扩展或者收缩操作时,程序需要将现有哈希表包含的所有键值对rehash到新哈希表里面,并且这个rehash过程并不是一次性地完成的,而是渐进式地完成的。

跳表

跳跃表(skiplist)是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。实际上,就是一种可以进行二分查找的有序列表,跳表在原有的有序链表上面增加了多级索引,通过索引来实现快速查找。

在大部分情况下,跳跃表的效率可以和平衡树相媲美,且跳跃表的实现比平衡树要来得更为简单。

typedef struct zskiplistNode {
    //层
    struct zskiplistLevel {
        //前进指针
        struct zskiplistNode *forward;
        //跨度
        unsigned int span;
    } level[];
    //后退指针
    struct zskiplistNode *backward;
    //分值
    double score;
    //成员对象
    robj *obj;
} zskiplistNode;

场景题:分值相同的两个元素,如何按照创建时间排序?

Sorted Set 每个元素有两部分组成(member + score),可利用 score 进行排序,正好满足我们的场景。用 score 保存元素的分数,member 保存元素的内容。 既然时间也会影响到排序,那么就需要把时间戳考虑到score中,因为redis相同分数时,是内容的字符串比较排序。

按照这样的思路:

最后score = 原始分数 + ((基准时间 - 当前时间) / 基准时间) ,就实现了分数相同,先达到该分数的排在前面的功能。同理,如果需要倒叙,那么括号内换成加法即可。

场景题:如果引入一个置顶规则,那么又该如何处理?

Redis的sorted set的score存储类型是双精度64位float,能表示的整形范围 - 2^53 ~ 2^53,即能表示的最大范围-9007199254740992 and 9007199254740992.

实际业务的score达不到最大范围时,我们可以把score能表示的值分拆两部分来表示,如9007199254740992最大值,取9那位留作置顶字段值使用,剩余部分给score。

代码示例:

    //置顶标识
    up := 1000000000000000
    score := 20 //假如分数为20
    finalScore = up + score //1000000000000020
    //TODO: zadd key finalScore member

整数集合

整数集合(intset)是集合键的底层实现之一,当一个集合只包含整数值元素,并且这个集合的元素数量不多时,Redis就会使用整数集合作为集合键的底层实现.

typedef struct intset {
    //编码方式
    uint32_t encoding;
    //集合包含的元素数量
    uint32_t length;
    //保存元素的数组
    int8_t contents[];
} intset;

整数升级

每当我们要将一个新元素添加到整数集合里面,并且新元素的类型比整数集合现有所有元素的类型都要长时,整数集合需要先进行升级(upgrade)​,然后才能将新元素添加到整数集合里面。

整数集合的升级策略有两个好处,一个是提升整数集合的灵活性,另一个是尽可能地节约内存。

整数集合不支持降级操作,一旦对数组进行了升级,编码就会一直保持升级后的状态。

压缩列表

压缩列表(ziplist)是列表键和哈希键的底层实现之一。当一个列表键只包含少量列表项,并且每个列表项要么就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来做列表键的底层实现。

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

连续更新问题

压缩列表里恰好有多个连续的、长度介于250字节至253字节之间的节点,当拓展其中某个节点时,导致后续多个节点都要更新。但发生的概率小,而且如果节点数量小,对性能影响也不大。

对象类型

由上面的基本数据结构组成字符串对象(string)、列表对象(list)、哈希对象(hash)、集合对象(set)和有序集合对象(zset)这五种类型的对象.

typedef struct redisObject {
    //类型, 用于表示是字符串还是list、hash、set等
    unsigned type:4;
    //编码,表示使用了什么数据结构作为对象的底层实现,例如:列表对象的编码可以是ziplist或者linkedlist
    unsigned encoding:4;
    //指向底层实现数据结构的指针
    void *ptr;
    // ...
} robj;