《Redis设计与实现》读书笔记 第一部分

第一部分从第二章到第八章,主要解决下面几个问题:

  • redis的底层是由哪些数据结构实现的,它们的应用场景又是什么
  • redis的五大对象分别用了哪些数据结构实现
  • redis的内存回收机制

SDS简单动态字符串

struct sdshdr {
    int len;
    int free;
    char buf[];
}

sds

`SDS`遵循`C`中以空字符串结尾的惯例,可以方便重用`C`中的相关函数。但不同的是:
  • 因为len的存在,获取字符串长度的复杂度为O(1)
  • 同时利用free,实现了空间预分配和惰性空间释放两种优化策略。
    • 空间预分配:修改之后SDS长度小于1MB,将分配与len同样大小的未使用空间,此时lenfree属性相同;否则,将会分配1MB的未使用空间。空间预分配可以减少连续执行字符串增长所需的内存重分配次数,也可以杜绝缓冲区溢出。
    • 惰性空间释放:当需要缩短SDS保存的字符串时,不立即使用内存分配回收多余字节,而是使用free属性将字节数量记录起来,避免缩短字符串时所需的内存分配,为将来可能的增长操作提供优化。
  • SDSAPI是二进制安全的,程序不会对其中的数据做任何限制、过滤或者假设。SDS使用len属性的值而不是空字符来判断字符串是否结束。

链表

除了列表键,发布订阅/慢查询/监视器/服务器保存客户端信息/客户端输出缓冲区,都使用链表作为底层实现。

struct list{
    listNode * head;
    listNode * tail;
    unsigned long len;
    ...
}

listNode是一个双链表,包含了prevnext指针,可以保存不同类型的值。

list

优点:

  • 获取表头和表尾节点的复杂度为o(1)
  • 获取链表中节点数量的复杂度为o(1)

字典

由于C中没有字典,因此redis实现了自己的字典。它可以用来表示数据库,也是哈希键的底层实现之一。底层使用哈希表作为实现,一个哈希表由多个哈希表节点组成,每个哈希表节点保存字典中的键值对。
哈希表的结构:

struct dictht{
    // 每个dicEntry保存一个键值对
    dicEntry **table;
    // 大小/掩码
    unsigned long size;
    unsigned long sizemask;
    unsigned long used;
}

dicEntry的结构:

struct dictEntry{
    void *key;
    union {
        void *val;
        unit64_tu64;
        int64_ts64;
    }
    // 指向另一个哈希表节点的指针,用来解决冲突
    struct dictEntry *next;
}

由于没有指向表尾的指针,因此每当出现了冲突,都会将新节点添加到链表的表头位置。

字典的结构:

struct dict{
    ...
    // 通常使用ht[0],ht[1]在rehash时使用
    dictht ht[2];
    // 没有rehash时,值为-1
    int rehashidx;
}

结构图如下:

字典的表示.jpeg

当哈希表保存的键值对太多或者太少,需要进行相应的扩展或者收缩,即进行rehash,步骤如下:

  1. ht[1]分配空间,大小取决于ht[0]
  2. ht[0]中的所有键值对rehashht[1]中,采用渐进式rehash
  3. ht[0]包含的所有ht[1]之后,释放ht[0],将ht[1]设置为ht[0]

当服务器1)没有执行BGSAVE或者BGREWRITEAOF且负载因子(used/size)大于1;2)执行BGSAVE或者BGREWRITEAOF且负载因子大于5时会自动进行扩展;
当服务器负载因子小于0.1时,会自动进行收缩。

渐进式rehash需要注意的是:

  • 每次进行rehash工作完成后,rehashidx属性值增一;
  • rehashidx属性值为-1,表示rehash完成;
  • 字典的增删改查会在两个哈希表上进行,比如查找键时,会首先在ht[0]中查找;新添加键一律保存到ht[1]中。

跳跃表

通过在每个节点中维持多个指向其他节点的指针,达到快速访问节点的目的,支持平均O(logN),最坏O(N)的复杂度进行查找,实现比平衡树更为简单。
redis中它只用于实现有序集合键,以及集群节点中用作内部结构
zskiplist的结构定义如下:

struct zskiplist{
    struct zskiplistNode * header, * tail;
    // 跳跃表的长度,可以在O(1)时间内返回
    unsigned long length;
    // 跳跃表的最大层高,表头节点并不包含在内
    int level;
}

它包括指向zskiplistNode的表头表尾指针:

struct zskiplistNode{
    // 每个层都带有两个属性:前进指针和跨度
    struct zskiplistLevel{
        // 访问表尾方向的其他节点,一次可以跳过多个节点
        stuct zskiplistNode *forward;
        // 记录当前前进指针与当前节点之间的距离
        unsigned int span;
    }level[];
    // 用于从表尾向表头遍历时使用,每次只能后退一个节点
    struct zskiplistNode *backward;
    // 节点保存的分值,从小到大排列
    double score;
    // 节点中所保存的成员对象
    robj * obj;
}

需要注意的是,每创建一个新的节点时,都会随机生成一个介于132之间的值作为level数组的大小。
跨度实际上是用来计算排位的,在查找某个节点的过程中,将沿途访问过的所有层的跨度累计,就是目标节点的排位。

skiplist

整数集合

intsetredis用于保存整数值的集合抽象数据结构,可以保存int16_tint32_t或者int64_t的整数值,并且不会重复,结构如下:

struct intset{
    unit32_encoding;
    uint32_t length;
    // 底层实现,每个元素都是contents数组的数组项
    int8_t contents[];
}

虽然contents声明为int8_t类型的数组,但是它真正保存的值取决于encoding的值。

intset

当需要将新元素添加到现有的整数集合里,并且新元素的类型比现有元素的类型都要长时,整数集合需要先进行升级

  1. 根据新元素类型,扩展底层数组空间大小
  2. 将原有元素的类型转换成为新元素类型,并且从后往前分配到新位置处,最后将新添加的元素放在末尾或者开头(因此添加一个新元素的时间复杂度为O(N))
  3. 修改encoding

由此可见,升级可以提升灵活性和节约内存。整数集合不支持降级,一旦升级,将永远保持

压缩列表

ziplist可以用于实现列表键和哈希键,由一系列特殊编码的连续内存块组成的顺序性结构。它可以包含任意多个节点(entry),每个节点可以保存一个字节数组或者一个整数值:

struct ziplist{
    uint32_t zlbytes;
    uint32_t zltail;
    unit16_t zllen;
    unit8_t zlend;
    若干节点,可以为字节数组或者整数值;
}

每个节点都由previous_entry_length(前一个节点的长度,因而可以根据当前节点的起始地址计算前一个节点的起始地址,长度可以是1字节或者5字节)、encoding(content所保存数据的类型及长度,例如001011表示保存的是一个长度为11的字节数组)、content三个部分组成。

ziplist

由于previous_entry_length的长度是记录前一个节点的长度,因此添加或者删除节点都可能导致连锁更新,最坏复杂度为O(N^2),只是这种情况比较少出现。

对象

redis包含了5种类型的对象:字符串对象列表对象哈希对象集合对象有序集合对象。每一种都用到了至少一种基本数据结构。
对象的基本数据结构:

struct redisObject{
    unsigned type:4;
    // 通过encoding设定对象使用编码,可以提升灵活性和效率
    unsigned encoding:4;
    void *ptr;
    // 引用计数
    int refcount;
    // 对象最后一次被访问的时间,用以计算对象的空转时间
    unsigned lru:22;
}

type可以是REDIS_STRINTREDIS_LISTREDIS_HASHREDIS_SETREDIS_ZSET的一种。对于redis来说,键总是字符串对象,而值可以使上面五种对象中的一种。

字符串对象

字符串对象是唯一一种会被其他四种类型对象嵌套的对象。
编码可以为:
int:整数值,且可以使用long类型表示;

但是long double类型的浮点数仍然是使用字符串表示的

raw:字符串值,且长度大于32字节,使用SDS保存;
embstr:字符串,且长度小于32字节。相比raw,虽然也使用sds结构,但是它只需要调用一次内存(raw需要调用两次)分配函数分配空间,空间中依次包含redisObjectsdshdr

列表对象

编码可以为:
ziplist: 所有字符串元素长度小于64字节,且个数小于512
linkedlist: 不能用ziplist保存的。

哈希对象

编码可以为:
ziplist: 所有键和值字符串元素长度小于64字节,且个数小于512

采用压缩列表保存时,每当有新的键值对需要加入,总是现将键的压缩列表节点推入,再保存值的压缩列表节点。因此,保存同一键值对的节点总是紧挨在一起,并且键值对之间的顺序根据时间先后排列。

hashtable: 不能使用ziplist保存的。

集合对象

编码可以为:
intset: 所有保存的对象都是整数值,且个数小于512
hashtable: 不能使用intset保存的。字典中的每一个键都是字符串对象,字典的值全部设置为NULL

有序集合对象

编码可以为:
ziplist: 每个元素成员的长度都小于64字节,且元素数量小于128

每个集合元素使用两个紧挨一起的节点保存,第一个节点保存成员,第二个节点保存分数,集合元素按照分值从小到大进行排序

skiplist: 不能使用ziplist保存的。

使用包含了一个字典和一个跳跃表的zset作为底层实现:

struct zset{
    // 每个跳跃表节点的object都保存了元素的成员,score属性保存元素的分值,可以实现`zrank`/`zrange`等命令
    zskiplist *zsl;
    // 为有序集合创建了从成员到分值的映射:键保存了成员,值保存了分值,可以实现O(1)复杂度查找给定成员的分值
    dict * dict;
}

zsldict会通过指针共享相同元素的成员和分值,为了能让有序集合的查找和范围型操作都可以尽快执行,需要这两种数据结构实现有序集合。

类型检查和命令多态

类型检查主要是通过redisObject结构中的type属性实现。而命令多态主要根据redisObject中的encoding来实现,它会调用指定数据结构的API

内存回收和对象共享

内存回收主要依靠引用计数实现,同时refcount还可以用于实现对象共享,节约内存。
一般情况下,redis会在初始化服务器时,创建0-9999的所有整数值作为共享对象(受到cpu的时间限制,只对整数值的字符串对象进行共享)。

posted @ 2020-10-25 15:38  yuyinzi  阅读(150)  评论(0)    收藏  举报