Redis学习二:设计与实现之数据结构与对象

Redis学习二:设计与实现之数据结构与对象

  • 前言
  • 简单动态字符串SDS
    • SDS的结构
    • 好处
    • 空间优化策略
      • 空间预分配
      • 惰性空间释放
  • 链表
    • 链表的结构
    • 链表的特性
  • 哈希表
    • 哈希表节点
    • 哈希算法
    • 键冲突
    • 哈希表的扩展和收缩
  • 跳跃表
    • 跳跃表的结构
  • 整数集合
    • 整数集合的结构
    • 升级操作
      • 升级步骤
      • 升级的好处
  • 压缩列表
  • 对象
    • 对象结构
    • 不同对象的编码
      • 列表对象
      • 哈希对象
      • 集合对象
      • 有序集合
    • Redis的类型检查与命令多态
    • 内存回收机制
    • 对象共享
    • redis对象的空转时长

前言

本文来源于《Redis的设计与实现》第一章节的学习,是这本书的简要读书笔记,仅作为记录所用,希望以后能常常温故知新~~~

简单动态字符串SDS

SDS的结构

简单动态字符串被广泛用于redis内部的数据结构中,例如redis的键值对相应的键是SDS实现,值的相关也由SDS保存实现;AOF模块中的AOF缓冲区,以及客户端状态下的输入缓冲区都是由SDS实现的;它的结构如下:

/*
 * 保存字符串对象的结构
*/
struct sdshdr {
    // buf 中已占用空间的长度
    int len;

    // buf 中剩余可用空间的长度
    int free;

    // 数据空间
    char buf[];
};

好处

  • 令获取Redis字符串长度的复杂度降低到O(1);
  • 杜绝缓冲区溢出;
  • 减少修改字符串时带来的内存重新分配的次数。

空间优化策略

由于有free,即未使用空间的存在,SDS实现了空间预分配以及惰性空间释放两种优化策略;兼容二进制数据;并兼容部分C字符串函数。

空间预分配

额外分配的未使用空间数量由以下公式决定:

(1) 如果对SDS 进行修改之后,SDS 的长度(也即是len 属性的值) 将小于1MB,那么程序分配和len 属性同样大小的未使用空间,这时SDS len 属性的值将和 free 属性的值相同。举个例子,如果进行修改之后,SDS 的len 将变成13 字节,那么程序也会分配13 字节的未使用空间,SDS 的 buf 数组的实际长度将变成13+13+1=27 字节(额外的一字节用于保存空字符)。
(2) 如果对SDS 进行修改之后,SDS 的长度将大于等于1MB,那么程序会分配1MB 的未使用空间。举个例子,如果进行修改之后,SDS 的len 将变成30MB,那么程序会分配1MB 的未使用空间,SDS 的buf 数组的实际长度将为30 MB + 1MB + 1byte。

在扩展SDS 空间之前,SDS API 会先检查未使用空间是否足够,如果足够的话,API就会直接使用未使用空间,而无须执行内存重分配。通过这种预分配策略,SDS 将连续增长N 次字符串所需的内存重分配次数从必定N 次降低为最多N 次~~~

惰性空间释放

惰性空间释放用于优化SDS 的字符串缩短操作:当SDS 的API 需要缩短SDS 保存的字符串时,程序并不立即使用内存重分配来回收缩短后多出来的字节,而是使用free 属性将这些字节的数量记录起来,并等待将来使用。

链表

链表的结构

链表作为一种常用的数据结构,被内置在很多高级编程语言中。Redis中链表是双端链表,被广泛用于实现Redis 的各种功能,比如列表键、发布与订阅、慢查询、监视器等。它定义在adlist.h/list中:

/*
* 双端链表结构
*/
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;

/*
 1. 双端链表节点结构
*/
typedef struct listNode { 
    // 前置节点 
    struct listNode *prev; 
    // 后置节点 
    struct listNode *next; 
    // 节点的值 
    void *value; 
} listNode;

Redis学习二:设计与实现之数据结构与对象_第1张图片

链表的特性

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

哈希表

哈希表节点

哈希表用于实现redis中的字典,字典通常由一个哈希表数组组成,哈希表节点使用 dictEntry 结构表示,每个dictEntry都对应一个键值对,结构如下:

/*
* 哈希表节点
*/
typedef struct dictEntry {
    // 键
    void *key;

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

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

哈希算法

当要将一个新的键值对添加到字典里面时, 程序需要先根据键值对的键计算出哈希值和索引值, 然后再根据索引值, 将包含新键值对的哈希表节点放到哈希表数组的指定索引上面。Redis 计算哈希值和索引值的方法如下:

# 使用字典设置的哈希函数,计算键 key 的哈希值 
hash = dict->type->hashFunction(key); 
# 使用哈希表的 sizemask 属性和哈希值,计算出索引值 # 根据情况不同, ht[x] 可以是 ht[0] 或者 ht[1] 
index = hash & dict->ht[x].sizemask;

键冲突

Redis解决键冲突的时候,当有两个或以上数量的键被分配到了哈希表数组的同一个索引上面时, 我们称这些键发生了冲突(collision)。

Redis的哈希表使用链地址法解决键冲突:每个哈希表节点都有一个 next 指针, 多个哈希表节点可以用 next 指针构成一个单向链表, 被分配到同一个索引上的多个节点可以用这个单向链表连接起来, 这就解决了键冲突的问题。为了方便起见,程序总是会将新节点添加到链表表头的位置,排在其他已有节点前面;
举个例子, 假设程序要将键值对 k2 和 v2 添加到哈希表里面, 并且计算得出 k2 的索引值为 2 , 那么键 k1 和 k2 将产生冲突, 而解决冲突的办法就是使用 next 指针将键 k2 和 k1 所在的节点连接起来,如下图所示。
Redis学习二:设计与实现之数据结构与对象_第2张图片
因为 dictEntry 节点组成的链表没有指向链表表尾的指针, 所以为了速度考虑, 程序总是将新节点添加到链表的表头位置(复杂度为 O(1)), 排在其他已有节点的前面。

哈希表的扩展和收缩

首先,为字典中的哈希表分配空间,空间大学取决于执行的操作,以及当前哈希表包含的键值对数量:

  • 扩展——哈希表的大小=第一个>=已使用空间*2的2n
  • 收缩——第一个大于等于已使用空间的1/2n

其次,将h[0]所有键值对rehash到h[1]的指定位置上;
最后,将h[1]设置为h[0],新建一个空白的哈希表,为下一次rehash做准备;

那么,什么情况下会进行哈希表的扩展与收缩呢?

扩展

  • 服务器未执行BGSAVE 或者 BGREWRITEAOF 命令,并且哈希表的负载因子>=1;
  • 服务器在执行BGSAVE 或者 BGREWRITEAOF 命令,并且哈希表的负载因子>=5;
# 负载因子 = 哈希表已保存节点数量 / 哈希表大小 
load_factor = ht[0].used / ht[0].size

收缩

  • 当哈希表的负载因子<0.1时,程序自动执行收缩操作;

当redis哈希表中的键值对数量过多的时候,在扩展与收缩的时候,rehash是渐进式的,分多次完成;它的优点是:将rehash键值对所需的计算工作均摊到对字典的添加,删除,查找与更新操作上,避免了集中式rehash带来的庞大计算量;

跳跃表

跳跃表是有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的;跳跃表支持平均O(logN),最坏O(N)复杂度的节点查找,还可以通过顺序性操作来批量处理节点;它是有序集合键的底层实现之一。如果一个有序集合包含的元素数量比较多,或者有序集合中元素的成员是较长的字符串是,就会作为有序集合键的底层实现;

跳跃表的结构

Redis学习二:设计与实现之数据结构与对象_第3张图片
位于最左边的是zskiplist结构,包含以下属性:

  • header :指向跳跃表的表头节点;
  • tail :指向跳跃表的表尾节点;
  • level :记录目前跳跃表内,层数最大的那个节点的层数(表头节点的层数不计算在内);
  • length :记录跳跃表的长度,也即是,跳跃表目前包含节点的数量(表头节点不计算在内)。
/*
* 跳跃表节点
*/
typedef struct zskiplistNode {
    // 成员对象
    robj *obj;

    // 分值
    double score;

    // 后退指针
    struct zskiplistNode *backward;

    // 层
    struct zskiplistLevel {
        // 前进指针
        struct zskiplistNode *forward;
        // 跨度
        unsigned int span;
    } level[];

} zskiplistNode;

/*
* 跳跃表
*/
typedef struct zskiplist {
    // 表头节点和表尾节点
    struct zskiplistNode *header, *tail;

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

    // 表中层数最大的节点的层数
    int level;
} zskiplist;
  1. 节点的分值 score 是一个double类型的浮点数,跳跃表中所有节点都按照分值从小到大来进行排序;
  2. 节点的成员对象obj是一个指针,指向字符串对象,对象中保存着一个SDS值;
  3. level数组可以包含多个元素,每个元素都包含一个指向其他节点的指针,程序可以通过这些层来加快访问其他节点的速度,一般来说,层的数量越多,访问其他节点的速度就越快。每次创建一个新跳表节点的时候,程序将根据幂次定律随机生成一个介于1与32之间的值作为level数组的大小;

整数集合

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

整数集合的结构

typedef struct intset {

    // 编码方式
    uint32_t encoding;

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

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

} intset;

需要注意的是,虽然intset结构中声明contents是int8_t类型的,但实际上contents数组不保存任何int8_t类型的值,它的真正类型取决于encoding属性的值;例如:如果encoding 属性的值为 INTSET_ENC_INT16 , 那么 contents 就是一个 int16_t 类型的数组, 数组里的每个项都是一个 int16_t 类型的整数值 (最小值为 -32,768 ,最大值为 32,767 )。例如:
Redis学习二:设计与实现之数据结构与对象_第4张图片

升级操作

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

升级步骤

升级整数集合并添加新元素共分为三步进行:

  1. 根据新元素的类型, 扩展整数集合底层数组的空间大小, 并为新元素分配空间。
  2. 将底层数组现有的所有元素都转换成与新元素相同的类型, 并将类型转换后的元素放置到正确的位上, 而且在放置元素的过程中, 需要继续维持底层数组的有序性质不变。
  3. 将新元素添加到底层数组里面。

因为每次向整数集合添加新元素都可能会引起升级, 而每次升级都需要对底层数组中已有的所有元素进行类型转换, 所以向整数集合添加新元素的时间复杂度为 O(N) 。

升级的好处

  1. 提升灵活性; 2. 节约内存;

需要注意的是,redis中的整数集合并不支持降级操作~~~

压缩列表

压缩列表是 Redis 为了节约内存而开发的, 由一系列特殊编码的连续内存块组成的顺序型(sequential)数据结构。一个压缩列表可以包含任意多个节点(entry), 每个节点可以保存一个字节数组或者一个整数值。
在这里插入图片描述
它通常被作为列表键和哈希键的底层实现之一,当添加新节点到压缩列表, 或者从压缩列表中删除节点, 可能会引发连锁更新操作, 但这种操作出现的几率并不高;

对象

redis使用对象来表示数据库中的键与值,当在redis的数据库中新创建一个键值对的时候,至少会创建两个对象,一个对象用于键值对的键,另一个用于键值对的值;

对象结构

typedef struct redisObject { 
    // 类型 
    unsigned type:4; 

    // 编码 
    unsigned encoding:4; 

    // 指向底层实现数据结构的指针 
    void *ptr; 

    // ... 
} robj;

其中,type记录了对象类型,可以是字符串对象,列表对象,哈希对象,集合对象以及有序集合对象之一;键类型与值类型可以不相同,对某个数据库键执行type命令,返回的结果应该是数据库键对应的值对象类型;ptr指针指向对象的底层实现数据结构,这些数据结构由encoding属性决定;

不同对象的编码

Redis学习二:设计与实现之数据结构与对象_第5张图片
需要注意的是,redis字符串对象中不单可以存储是string类型的值,它可以用long类型保存的整数,可以是long double类型保存的浮点数,也可以是字符串值;

列表对象

它的编码可以是ziplist,也可以是linkedlist,ziplist编码的对象列表使用压缩列表为底层实现,每一个压缩列表节点保存了一个列表元素,而linkedlist编码的列表对象使用双端链表作为底层实现,每一个链表节点都保存了一个字符串对象,而每一个字符串对象都保存了列表元素;

哈希对象

它的编码可以是ziplist,也可以是hashtable;

  • 如果使用ziplist实现,程序会先将保存了键的压缩列表节点推入到压缩列表表尾, 然后再将保存了值的压缩列表节点推入到压缩列表表尾,因此:1.保存了同一键值对的两个节点总是紧挨在一起, 保存键的节点在前, 保存值的节点在后;2.先添加到哈希对象中的键值对会被放在压缩列表的表头方向, 而后来添加到哈希对象中的键值对会被放在压缩列表的表尾方向。
  • 如果使用字典作为底层实现,哈希对象中的每个键值对都使用一个字典键值对来保存:字典的每个键都是一个字符串对象, 对象中保存了键值对的键;字典的每个值都是一个字符串对象, 对象中保存了键值对的值。

那么什么情况下会进行编码转换呢???
使用ziplist编码时,哈希对象保存的所有键值对的键和值的字符串长度都小于 64 字节;并且哈希表中保存的键值对数量<512个;无法满足条件即需要用hashtable编码

集合对象

它的编码可以是intset,也可以是hashtable;
intset 编码的集合对象使用整数集合作为底层实现, 集合对象包含的所有元素都被保存在整数集合里面。另一方面, hashtable 编码的集合对象使用字典作为底层实现, 字典的每个键都是一个字符串对象, 每个字符串对象包含了一个集合元素, 而字典的值则全部被设置为 NULL;

那么什么情况下会进行编码转换呢???
当集合对象可以同时满足以下两个条件时, 对象使用 intset 编码:1. 集合对象保存的所有元素都是整数值;2. 集合对象保存的元素数量不超过 512 个;无法满足条件即需要用hashtable编码;

有序集合

它的编码可以是ziplist,也可以是skiplist;
ziplist 编码的有序集合对象使用压缩列表作为底层实现, 每个集合元素使用两个紧挨在一起的压缩列表节点来保存, 第一个节点保存元素的成员(member), 而第二个元素则保存元素的分值(score)。压缩列表内的集合元素按分值从小到大进行排序, 分值较小的元素被放置在靠近表头的方向, 而分值较大的元素则被放置在靠近表尾的方向。
skiplist编码的有序集合对象使用zset结构作为底层实现,一个 zset 结构同时包含一个字典和一个跳跃表:

typedef struct zset { 
    zskiplist *zsl; 
    dict *dict; 
} zset;

zsl 跳跃表按分值从小到大保存了所有集合元素, 每个跳跃表节点都保存了一个集合元素: 跳跃表节点的 object 属性保存了元素的成员, 而跳跃表节点的 score 属性则保存了元素的分值。dict 字典为有序集合创建了一个从成员到分值的映射, 字典中的每个键值对都保存了一个集合元素: 字典的键保存了元素的成员, 而字典的值则保存了元素的分值。 通过这个字典, 程序可以用O(1)复杂度查找给定成员的分值;
有序集合可以单独使用字典或者跳跃表的其中一种数据结构来实现, 但无论单独使用字典还是跳跃表, 在性能上对比起同时使用字典和跳跃表都会有所降低。如果我们只使用字典来实现有序集合, 那么虽然以O(1)复杂度查找成员分值,但字典会以无序状态存储元素,排序时至少需要O(NlogN)的时间复杂度以及O(N)的内存空间 (因为要创建一个数组来保存排序后的元素),另一方面, 如果我们只使用跳跃表来实现有序集合, 那么跳跃表执行范围型操作的所有优点都会被保留, 但因为没有了字典, 所以根据成员查找分值这一操作的复杂度将从O(1)上升到O(logN);
那么什么情况下会进行编码转换呢???
当有序集合对象同时满足以下两个条件时,对象使用ziplist编码:1. 有序集合保存的元素数量小于 128 个;2. 有序集合保存的所有元素成员的长度都小于 64 字节;无法满足条件就会使用skiplist来进行编码;

Redis的类型检查与命令多态

redis中用于操作键的命令基本上可以分为两种类型,其中一种命令对任何类型的键执行,例如:del命令,expire命令,type命令等;另一种只能对特定类型的键执行,例如set 与 get只能对字符串键执行;

类型检查则是通过redisObject 结构的 type 属性来实现的: 在执行一个类型特定命令之前, 服务器会先检查输入数据库键的值对象是否为执行命令所需的类型, 如果是的话, 服务器就对键执行指定的命令;否则, 服务器将拒绝执行命令, 并向客户端返回一个类型错误。

内存回收机制

redis 在自己的对象系统中构建了一个引用计数(reference counting)技术实现的内存回收机制, 通过这一机制, 程序可以通过跟踪对象的引用计数信息, 在适当的时候自动释放对象并进行内存回收。

typedef struct redisObject { 
      // ... 

      // 引用计数 
      int refcount; 

      // ... 
} robj;

对象的引用计数信息会随着对象的使用状态而不断变化:

  • 在创建一个新对象时, 引用计数的值会被初始化为 1 ;
  • 当对象被一个新程序使用时, 它的引用计数值会被增一;
  • 当对象不再被一个程序使用时, 它的引用计数值会被减一;
  • 当对象的引用计数值变为 0 时, 对象所占用的内存会被释放。

对象共享

redis的对象共享只共享整数值对象;
举例,如果A键创建了一个包含整数值100的字符串对象作为值对象,而B也要创建100的字符串对象,那么为了节约内存,可以让多个键共享使用一个对象;在 Redis 中, 让多个键共享同一个值对象需要执行以下两个步骤:1. 将数据库键的值指针指向一个现有的值对象;2. 将被共享的值对象的引用计数增一。目前来说, Redis 会在初始化服务器时, 创建一万个字符串对象, 这些对象包含了从 0 到 9999 的所有整数值, 当服务器需要用到值为 0到 9999 的字符串对象时, 服务器就会使用这些共享对象, 而不是新创建对象;

那为什么redis不共享包含字符串的对象呢?当服务器考虑将一个共享对象设置为键的值对象时, 程序需要先检查给定的共享对象和键想创建的目标对象是否完全相同, 只有在共享对象和目标对象完全相同的情况下, 程序才会将共享对象用作键的值对象, 而一个共享对象保存的值越复杂, 验证共享对象和目标对象是否相同所需的复杂度就会越高, 消耗的 CPU 时间也会越多;如果共享对象是保存整数值的字符串对象, 那么验证操作的复杂度为 O(1);如果共享对象是包含了多个值(或者对象的)对象, 比如列表对象或者哈希对象, 那么验证操作的复杂度将会是O(N2);

redis对象的空转时长

redisObject 结构包含的最后一个属性为 lru 属性, 该属性记录了对象最后一次被命令程序访问的时间,这个时间可以用于计算对象的空转时间。

你可能感兴趣的:(redis,缓存)