redis数据结构

Redis使用的C语言没有内置一些需要的数据结构,redis构建了一些自己的数据结构实现,比如简单动态字符串(SDS)、双端链表、字典、压缩列表、整数集合等等,下面说下其中几种的数据结构的实现。

字符串

Redis是用C语言实现的,但没有直接用C的字符串,而是自定义了一种字符串数据结构。

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

以下是C和SDS字符串的区别

C 字符串SDS字符串
获取字符串长度的复杂度为 O(N) 。获取字符串长度的复杂度为 O(1) 。
API 是不安全的,我们在赋值时需要先确认已经给其分配足够的空间,不然会出现缓冲区溢出。API 是安全的,不会造成缓冲区溢出。
修改字符串长度 N 次必然需要执行 N 次内存重分配。修改字符串长度 N 次最多需要执行 N 次内存重分配。
C 字符串中的字符必须符合某种编码(比如 ASCII),并且除了字符串的末尾之外,字符串里面不能包含空字符, 否则最先被程序读入的空字符将被误认为是字符串结尾 —— 这些限制使得 C 字符串只能保存文本数据,而不能保存像图片、音频、视频、压缩文件这样的二进制数据。可以保存文本或者二进制数据。
可以使用所有 <string.h> 库中的函数。可以使用一部分 <string.h> 库中的函数。

SDS字符串不会出现溢出的与SDS字符串的空间分配策略有关。
为了避免 C 字符串增长或缩短所带来的内存重分配的耗时操作, SDS 通过未使用空间解除了字符串长度和底层数组长度之间的关联: 在 SDS 中, buf 数组的长度不一定就是字符数量加一, 数组里面可以包含未使用的字节, 而这些字节的数量就由 SDS 的 free 属性记录。

  1. 空间预分配
    当我们对字符串进行修改时,修改后的长度大于当前的数组长度,就需要进行空间扩展。
  • 增长后的字符串长度 len < 1 MB,则 free = len,即分配与字符串同样大小的未使用空间
  • 增长后的字符串长度 len >= 1 MB,则 free = 1 MB,即分配 1 MB的未使用空间

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

  1. 惰性空间释放
    当我们进行字符串缩短操作后,free = 字符串缩短的长度,即将未使用空间的大小设为字符串缩短的长度。并没有完全将缩短的那部分空间释放掉。
    通过惰性空间释放策略, SDS 避免了缩短字符串时所需的内存重分配操作, 并为将来可能有的增长操作提供了优化。
    与此同时, SDS 也提供了相应的 API , 让我们可以在有需要时, 真正地释放 SDS 里面的未使用空间, 所以不用担心惰性空间释放策略会造成内存浪费。

字典

字典在 Redis 中的应用相当广泛, 比如 Redis 的数据库就是使用字典来作为底层实现的;字典还是哈希键的底层实现之一: 当一个哈希键包含的键值对比较多, 又或者键值对中的元素都是比较长的字符串时, Redis 就会使用字典作为哈希键的底层实现。
Redis 的字典使用哈希表作为底层实现, 一个哈希表里面可以有多个哈希表节点, 而每个哈希表节点就保存了字典中的一个键值对。
Redis字典结构示例
redis-dict
在这里插入图片描述
哈希表:

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

哈希表节点:

typedef struct dictEntry {
    // 键
    void *key;
    // 值
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
    } v;
    // 指向下个哈希表节点,形成链表
    struct dictEntry *next;
} dictEntry;

字典:

typedef struct dict {
    // 类型特定函数
    dictType *type;
    // 私有数据
    void *privdata;
    // 哈希表数组,其中有两个哈希表
    dictht ht[2];
    // rehash 索引
    // 当 rehash 不在进行时,值为 -1
    int rehashidx; /* rehashing not in progress if rehashidx == -1 */

} dict;

哈希冲突

当有两个或以上数量的键被分配到了哈希表数组的同一个索引上面时,我们称这些键发生了冲突(collision)。
Redis 的哈希表使用链地址法(separate chaining)来解决键冲突:每个哈希表节点都有一个 next 指针,多个哈希表节点可以用 next 指针构成一个单向链表,被分配到同一个索引上的多个节点可以用这个单向链表连接起来,这就解决了键冲突的问题。
因为 dictEntry 节点组成的链表没有指向链表表尾的指针,所以为了速度考虑,程序总是将新节点添加到链表的表头位置(复杂度为 O(1)),排在其他已有节点的前面。

rehash

随着哈希表的键值越来越多,为了让哈希表的负载因子(load factor)维持在合理的范围内,程序需要对哈希表的大小进行相应的扩展或收缩。扩展和收缩的工作Redis采用rehash(重新散列)操作来完成。
扩展或收缩的条件

# 负载因子 = 哈希表已保存节点数量 / 哈希表大小
load_factor = ht[0].used / ht[0].size

当以下条件中的任意一个被满足时, 程序会自动开始对哈希表执行扩展操作:

  • 服务器目前没有在执行 BGSAVE 命令或者 BGREWRITEAOF 命令, 并且 load factor >= 1 ;
  • 服务器目前正在执行 BGSAVE 命令或者 BGREWRITEAOF 命令, 并且 load factor >= 5 ;
    执行 BGSAVE 命令或者 BGREWRITEAOF 命令的过程中,Redis会创建子进程来执行复制工作,在此期间提高负载因子,尽可能避免进行哈希扩展的内存写入操作,最大限度地节约内存。

当 load factor < 0.1 时,程序自动开始对哈希表执行收缩操作

扩展或收缩的过程

  1. 为字典的 ht[1] 哈希表分配空间,这个哈希表的空间大小取决于要执行的操作,以及 ht[0] 当前包含的键值对数量 (也即是 ht[0].used 属性的值):
    如果执行的是扩展操作,那么 ht[1].size 为第一个大于等于 ht[0].used * 2 的 2^n( 2 的 n 次方幂);
    如果执行的是收缩操作,那么 ht[1].size 为第一个大于等于 ht[0].used 的 2^n 。
  2. 将保存在 ht[0] 中的所有键值对 rehash 到 ht[1] 上面:rehash 指的是重新计算键的哈希值和索引值,然后将键值对放置到 ht[1] 哈希表的指定位置上。
  3. 当 ht[0] 包含的所有键值对都迁移到了 ht[1] 之后(ht[0] 变为空表),释放 ht[0] ,将 ht[1] 设置为 ht[0] ,并在 ht[1] 新创建一个空白哈希表,为下一次 rehash 做准备。

渐进式rehash
一次性将所有的键值对迁移到另一个哈希表上显示不是好的想法,更多键值对的时候计算量会很庞大,所有Redis采用 渐进式rehash 来避免rehash对服务器性能造成的影响。

  1. 为 ht[1] 分配空间, 让字典同时持有 ht[0] 和 ht[1] 两个哈希表。
  2. 在字典中维持一个索引计数器变量 rehashidx , 并将它的值设置为 0 , 表示 rehash 工作正式开始。
  3. 在 rehash 进行期间, 每次对字典执行添加、删除、查找或者更新操作时, 程序除了执行指定的操作以外, 还会顺带将 ht[0] 哈希表在 rehashidx 索引上的所有键值对 rehash 到 ht[1] , 当 rehash 工作完成之后, 程序将 rehashidx 属性的值增一。
  4. 随着字典操作的不断执行, 最终在某个时间点上, ht[0] 的所有键值对都会被 rehash 至 ht[1] , 这时程序将 rehashidx 属性的值设为 -1 , 表示 rehash 操作已完成。
    准备开始rehash,为 ht[1] 分配空间

在这里插入图片描述
rehash索引0上的键值对
在这里插入图片描述

跳表

跳表在Redis中就俩用途:实现有序集合,在集群节点中用作内部数据结构。
跳表在大部分情况下是一种可以替代平衡树的数据结构。跳表使用概率性的平衡而不是严格的强制性的平衡,结果在跳表中插入和删除的操作变得更加简单而且比等价的平衡树算法明显要快。
以下是Redis的跳跃表示例:
在这里插入图片描述
位于图片最左边的是 zskiplist 结构, 该结构包含以下属性:

typedef struct zskiplist {
    // 表头节点和表尾节点
    struct zskiplistNode *header, *tail;
    // 表中节点的数量
    unsigned long length;
    // 表中层数最大的节点的层数
    int level;
} zskiplist;

位于 zskiplist 结构右方的是四个 zskiplistNode 结构, 该结构包含以下属性:

typedef struct zskiplistNode {
    // 后退指针
    struct zskiplistNode *backward;
    // 分值
    double score;
    // 成员对象
    sds ele;
    // 层
    struct zskiplistLevel {
        // 前进指针
        struct zskiplistNode *forward;
        // 跨度
        unsigned int span;
    } level[];
} zskiplistNode;
  • 层(level):节点中用 L1 、 L2 、 L3 等字样标记节点的各个层, L1 代表第一层, L2 代表第二层,以此类推。每个层都带有两个属性:前进指针和跨度。前进指针用于访问位于表尾方向的其他节点,而跨度则记录了前进指针所指向节点和当前节点的距离。在上面的图片中,连线上带有数字的箭头就代表前进指针,而那个数字就是跨度。当程序从表头向表尾进行遍历时,访问会沿着层的前进指针进行。
  • 后退指针(backward):节点中用 BW 字样标记节点的后退指针,它指向位于当前节点的前一个节点。后退指针在程序从表尾向表头遍历时使用。
  • 分值(score):各个节点中的 1.0 、 2.0 和 3.0 是节点所保存的分值。在跳跃表中,节点按各自所保存的分值从小到大排列。
  • 成员对象(ele):各个节点中的 o1 、 o2 和 o3 是节点所保存的成员对象。
    注意表头节点和其他节点的构造是一样的: 表头节点也有后退指针、分值和成员对象, 不过表头节点的这些属性都不会被用到, 所以图中省略了这些部分, 只显示了表头节点的各个层。

整数集合

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

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

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

整数集合的每个元素都是 contents 数组的一个数组项(item), 各个项在数组中按值的大小从小到大有序地排列, 并且数组中不包含任何重复项。

虽然 intset 结构将 contents 属性声明为 int8_t 类型的数组, 但实际上 contents 数组并不保存任何 int8_t 类型的值 —— contents 数组的真正类型取决于 encoding 属性的值:

  • 如果 encoding 属性的值为 INTSET_ENC_INT16 , 那么 contents 就是一个 int16_t 类型的数组, 数组里的每个项都是一个 int16_t 类型的整数值 (最小值为 -32768 ,最大值为 32767 )。
  • 如果 encoding 属性的值为 INTSET_ENC_INT32 , 那么 contents 就是一个 int32_t 类型的数组, 数组里的每个项都是一个 int32_t 类型的整数值 (最小值为 -2147483648 ,最大值为 2147483647 )。
  • 如果 encoding 属性的值为 INTSET_ENC_INT64 , 那么 contents 就是一个 int64_t 类型的数组, 数组里的每个项都是一个 int64_t 类型的整数值 (最小值为 -9 223 372 036 854 775 808 ,最大值为 9 223 372 036 854 775 807 )。
    在这里插入图片描述

升级

假设现在有一个 INTSET_ENC_INT16 类型的集合,在其中添加一个整数65536,原来16位的空间显然不够,此时需要将 encoding 改为 INTSET_ENC_INT32 ,并且将content数组中的数据全都扩容为32位。
整数集合不支持降级操作。如果我们删除了集合中的所有32位的数据,集合的 encoding 也不会降级为 INTSET_ENC_INT16 。

压缩列表

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

比如说, 执行以下向右插入链表数据的命令将创建一个压缩列表实现的列表键:

redis> RPUSH lst 1 3 5 10086 "hello" "world"
(integer) 6
redis> OBJECT ENCODING lst
"ziplist"

压缩列表是 Redis 为了节约内存而开发的, 由一系列特殊编码的连续内存块组成的顺序型(sequential)数据结构。
一个压缩列表可以包含任意多个节点(entry), 每个节点可以保存一个字节数组或者一个整数值。
在这里插入图片描述
压缩列表各个组成部分的详细说明

属性类型长度用途
zlbytesuint32_t4 字节记录整个压缩列表占用的内存字节数:在对压缩列表进行内存重分配, 或者计算 zlend 的位置时使用。
zltailuint32_t4 字节记录压缩列表表尾节点距离压缩列表的起始地址有多少字节: 通过这个偏移量,程序无须遍历整个压缩列表就可以确定表尾节点的地址。
zllenuint16_t2 字节记录了压缩列表包含的节点数量: 当这个属性的值小于 UINT16_MAX (65535)时, 这个属性的值就是压缩列表包含节点的数量; 当这个值等于 UINT16_MAX 时, 节点的真实数量需要遍历整个压缩列表才能计算得出。
entryX列表节点不定压缩列表包含的各个节点,节点的长度由节点保存的内容决定。
zlenduint8_t1 字节特殊值 0xFF (十进制 255 ),用于标记压缩列表的末端。

压缩列表节点
压缩列表节点EntryX字段说明

  • previous_entry_length:记录前一节点的长度。
    如果前一节点的长度小于254字节,那么previous_entry_length占用1字节空间;
    如果前一节点的长度大于等于254字节,那么previous_entry_length占用5字节空间;
    因此假如将长度大于254字节的新节点设置为表头节点,此时其后的节点也就是原来的表头结点previous_entry_length字段为1字节,必然要扩展previous_entry_length字段长度,且假设压缩列表中的节点长度都在 250-254 之间,后续的节点在前一个节点长度增大到254字节后都要扩展previous_entry_length字段长度,这种连续的多次空间扩展称为连锁更新,但这种情况出现几率并不高。
  • encoding:记录结点content属性所保存数据的类型编码和长度。
  • content:存放节点值
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值