Redis数据结构

Redis数据结构

结合《黑马Redis》和《redis设计与实现》

7种数据结构:

1.SDS(Simple Dynamic String)

struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len; /* used */
    uint8_t alloc; /* buf申请的总字节数,不包含结束标识 */
    unsigned char flags; /* 记录SDS的头类型,来控制SDS的大小 */
    char buf[];
};

len, alloc, flags都属于header

一个SDS结构:header(len + alloc + flags)+ 一个字符串数组

reids中只有在一些无需对字符串修改的地方,才会去用C字符串。如一些日志的打印…

而在一个可能被修改的字符串的时候,底层会去用SDS实现。如数据类型为字符串的key或value…

SDS遵循了C语言字符串以空字符结尾的惯例,但保存空字符的一个字节不被len计算在内,所以这一字节对使用者来说就是透明的

那么这么做的目的是什么? SDS可以直接重用一部分C字符串函数库的函数

SDS优点

  1. 获取字符串长度的时间复杂度为O(1),直接查len,无需遍历数组

  2. SDS可以杜绝缓冲区溢出的问题:

    • C字符串在一些拼接字符串的情况下,如果忘了检查剩余空间,可能会引起缓冲区溢出
    • 而SDS的API会在修改SDS时自动进行检查free和拓展等操作,所以SDS既方便又安全
  3. SDS减少了修改字符串带来的内存重分配次数:

    • C字符串的长度和底层数组长度之间存在一个字节的关联性,所以每次修改字符,都需要对数组进行一次内存重分配去拓展或释放空间
    • 内存分配涉及复杂的算法,并且可能需要执行系统调用,所以它通常比较耗时
    • SDS通过使用alloc - len解除了字符串长度和底层数组长度的关系,通过这个未使用空间,SDS实现了空间预分配和惰性空间释放两种优化策略,可以减少了修改字符串带来的内存重分配次数


​ 1. 空间预分配(SDS增长 :用于优化SDS增长的操作,拓展空间不仅为SDS分配修改所需的空间,还会分配额外的未使用空间。

​ ​ 如果SDS修改后的长度(len)< 1MB(1MB等于1048576字节),则新空间为扩展后字符串长度len的两倍 +1byte

​ ​ 如果SDS修改后的长度(len)>= 1MB,则新空间为扩展后字符串长度len + 1M +1byte

​ ​ 以上两种情况皆是基于alloc不足的条件,alloc如果多的很,那还分配个毛啊

​ ​ 空间预分配的意义:SDS将连续增长N次字符串所需的内存重分配次数从必定N次控制在最多N次

       2.**惰性空间释放**(SDS缩短:用于优化SDS缩短的操作,程序不立即使用内存重分配来回收缩短后多出来的字节,而是将这些字节数量记录,等待将来使用

       ​	与此同时,SDS提供了相应的API,让我们可以在有需要时,真正释放未使用的空间,不用担心此策略会造成内存浪费
       ​	惰性空间释放的**意义:SDS避免了缩短字符串时所需的内存重分配操作,并为将来的增长操作提供了优化**

  4. **二进制安全**:
     - C字符串内部不能有空格,防止误认为是末尾,限制它只能保存文本数据,不能储存流媒体文件
     - SDS的API都是二进制安全的,,所有SDS API都会以处理二进制的方式处理SDS存放在buf数组里的数据,程序不会对其中的数据做任何限制、过滤、假设,数据在写入的时候是什么样子,读取的时候就是什么样子
     - 使用SDS保存内部空格的数据是没问题的,因为SDS是通过len来判断是否结束,而不是空字符

  5. SDS**兼容**部分C字符串函数(之前也谈到了SDS末尾也保留了空字符,只是没有纳入len,给放进buf字节数组了

2.IntSet

typedef struct intset {
    uint32_t encoding;/*编码方式,支持存放16位、32位、64位整数*/
    uint32_t length;/*元素个数*/
    int8_t contents[];/*整数数组,保存集合数据/
} intset;

intset是reids用于保存整数值的集合抽象数据结构,它可以保存int16_t,int32_t,int64_t整数值,并且保证集合中不出现重复元素

虽然intset将contens声明为int8_t类型的数组,但contents数组不会保存任何int8_t类型的值,contents数组的真正类型决定于encoding

方便查找,Redis会将IntSet中所有整数按照升序依次保存在contents数组中

intset的升级:

  • 当加入的值大于现有的encoding时,intset会去升级encoding

  • 升级步骤:

    1. 先拓展到足够的空间
    2. 再去将现有元素的类型转换一下,保证现在的顺序,从后往前放置
  • 升级策略的好处:

    1. 提升了IntSet的灵活性

    2. 尽可能地节约内存

      升级后就不能降级了哦

IntSet特点

  1. 元素唯一、有序

  2. 具备类型升级机制,节省空间

  3. 底层采用二分查找来查询

3.Dict

Redis是键值型数据库,而键与值的映射关系正是通过Dict实现的

  • 字典就是map,一种保存键值对的抽象数据结构

  • ·但C语言也没有内置map,因此redis自己构建的字典

Dict由三部分组成,分别是哈希表dictht、哈希节点dictEntry、字典dict

typedef struct dict {
    //下面两个在hash运算时起作用
    dictType *type;
    void *privdata;
 
    dictht ht[2];//一个Dict包含两个哈希表,其中一个是当前数据,另一个一般是空(rehash时使用
    long rehashidx;//rehash的进度,-1 表示未进行rehash
    int16_t pauserehash; //rehash是否暂停,1表示暂停,0表示继续
} dict;
ypedef struct dictht {
    dictEntry **table;//entry数组
    unsigned long size;//哈希表大小
    unsigned long sizemask;//哈希掩表,size - 1
    unsigned long used;//entry个数
} dictht;
typedef struct dictEntry {
    void *key;//键
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;//值
    struct dictEntry *next;//下一个entry指针
} dictEntry;

哈希算法

  • 使用字典设置的哈希函数,计算key的哈希值

  • 再利用hash & sizemask来计算元素应该存储到数组中的哪个索引位置

  • 根据情况不同,选择哈希表数组加入,ht[x]可能时ht[0]或者ht[1]

  • MurmurHash算法是08年发明的,优点在于, 即使输入的键是非常有规律的,但算法仍然可以给出很好的随机分布性,计算速度也非常快

  • MurmurHash最新的版本是MurmurHash3,redis使用的是MurmurHash2

解决键冲突

  • 当有两个以上的键被分配到了哈希表数组的同一个索引上了,我们称这些键发生了冲突
  • reids的哈希表使用链地址法来解决键冲突,每个哈希表节点都有一个next指针,分配在同一个索引上的多个节点可以通过next指针构成一个链表
  • 因为dictEntry节点组成的链表没有指向链表表尾的指针,所以为速度考虑,程序总是将新节点添加到表头,时间复杂度为O(1)

在这里插入图片描述

Dict中的HashTable是数组加链表实现的,当集合中元素太多,必然会导致哈希冲突,链表就会变长,查询效率就会大大降低(因此,Dict会进行扩展,相应的也有收缩

rehash(重新散列):

不管是扩容还是收缩,必定会创建新的哈希表,导致size和sizemask变化,而key的查询与sizemask有关。因此必须对哈希表中的每一个key重新计算索引,插入新的哈希表,这个过程就称为rehash

哈希表的扩展与收缩:

所以每次新增键值时都会检查负载因子(LoadFactor = used/size),满足以下两种情况会触发哈希扩容

  1. LoadFactor >= 1,并且后台没有执行BGSAVE或BGREWRITEAOF
  2. LoadFactor > 5,直接扩容

每次删除元素时,也会对LoadFactor 进行检查,当LoadFactor < 0.1时,会做哈希表收缩

渐进式rehash:

但是Dict的rehash不是一次性完成的,要是Dict包含数百万的entry,要在一次rehash完成,极可能导致主线程阻塞,庞大的计算量会使服务器在一段时间内停止服务。因此Dict的rehash是多次进行的,称为渐进式rehash

  1. 计算新hash表中的realSize,取决于当前是要做扩容还是收缩:
    • 如果是扩容,则新size为第一个 >= dict.ht[0].used + 1 的 2的n次方
    • 如果是收缩,则新size为第一个 >= dict.ht[0].used 的 2的n次方(不得小于4)
  2. 按照新的size申请内存空间,创建dictht,并赋值给dict.ht[1]
  3. 设置dict.rehashidx = 0,表示正在rehash
  4. 每次执行新增、查询、修改、删除操作时,都检查一下dict.rehashidx是否大于 -1,如果是rehash状态,就将dict.ht[0].table[rehashidx]的entry链表rehash到dict.ht[1],并且将rehashidx++。直至dict.ht[0]的所有数据全都rehash到dict.ht[1]
  5. 最后,dict.ht[1]赋值到dict.ht[0],给dict.ht[1]初始化为空哈希表,释放原来的dict.ht[0]的内存
  6. 将rehashidx赋值为-1,代表rehash结束
  7. 在rehash过程中,新增操作,则直接写入到dict.ht[1],查询、修改和删除则会在dict.ht[0]和dict.ht[1]依次查找并执行。这样可以确保dict.ht[0]的数据只减不增,随着rehash最终为空

4.ZipList

少了很多Dict中的很多指针,内存更连续,少了很多内存碎片,更加节省内存

ziplist是一种特殊的“双端链表”,由一系列特殊编码的连续内存块组成。可以在任意一端进行压入/弹出操作,时间复杂度为O(1)

ZipList 结构:

在这里插入图片描述

zlbytes、zltail、zllen、zlend占固定长度

entry长度不定,由节点保存的内容决定,本身有三部分:

  • previous entry length:前一节点的长度,占1个或5个字节。

    • 如果前一节点的长度小于254字节,则采用1个字节来保存这个长度值
    • 如果前一节点的长度大于254字节,则采用5个字节来保存这个长度值,第一个字节为0xfe,后四个字节才是真实长度数据
  • encoding:编码属性,记录content的数据类型(字符串还是整数)以及长度,占用1个、2个或5个字节

  • contents:负责保存节点的数据,可以是字符串或整数

所以一个entry单可以靠自身的previous entry length 和自己的地址去计算出上一个enrty的起始地址

也可以通过自身的三部分结构的所占字节数来推算出后一个entry

因此,无需指针,节省大量的空间

ZipList的连锁更新问题:

​ 这个问题的导致原因是previous entry length:前一节点的长度,占1个或5个字节。

​ 在一种非常极限的情况下,可能一发动千钧,导致多个entry的previous entry length都扩展为5字节

​ 新增和删除都可能导致连锁更新的发生

5.QuickList

ZipList的连续内存,既是优点也是缺点,免去了很多内存碎片,但是决定了ZipList不能存储大量数据,因为可能找不到一块足够大的连续内存

但是我们就是要存储大量数据,怎么办?

  • 创建多个ZipList来分片存储数据

数据拆分后比较分散,不方便管理和查找,这多个ZipList如何建立联系?

  • 引入QuickList,它是一个双端链表,链表中的每一个节点都是ZipList

在这里插入图片描述

typedef struct quicklist {
    quicklistNode *head;//头节点指针
    quicklistNode *tail;//尾结点指针
    unsigned long count;//所有ZipList的entry总数量
    unsigned long len;//ZipList的总数量
    int fill : QL_FILL_BITS;//ZipList的entry上限
    unsigned int compress : QL_COMP_BITS;//首尾不压缩的节点数量
    unsigned int bookmark_count: QL_BM_BITS;//一般用不到
    quicklistBookmark bookmarks[];
} quicklist;
typedef struct quicklistNode {
    struct quicklistNode *prev;//前一个结点指针
    struct quicklistNode *next;//下一个节点指针
    unsigned char *zl;//当前结点的ZipList指针
    unsigned int sz;//当前节点的ZipList的字节大小
    unsigned int count : 16;//当前节点的ZipList的entry个数
    unsigned int encoding : 2;//编码方式
    unsigned int container : 2;  //数据容器类型
    unsigned int recompress : 1; //是否被解压缩
    unsigned int attempted_compress : 1; //测试用
    unsigned int extra : 10; //预留字段
} quicklistNode;

在这里插入图片描述

为了避免QuickList中的每个ZipList的entry过多,redis提供list-max-ziplist-size来限制

QuickList还可以对节点的ZipList做压缩,通过list-compress-depth来控制,因为链表一般从首尾访问较多,所以首尾不会压缩,这个参数是控制首尾不压缩的节点个数

6.SkipList

crud与红黑树效率基本一致,但实现更简单

redis只在两个地方使用到了跳表,一个是实现有序集合键,另一个实在集群节点中用作内部数据结构

SkipList首先是双向链表,但与传统的链表相比有几点差异:

  • 元素按照升序排列存储
  • 节点可能包含多个指针,指针跨度不同
typedef struct zskiplist {
    struct zskiplistNode *header, *tail;//头尾节点指针
    unsigned long length;//节点数量
    int level;//最大的索引层次
} zskiplist;
typedef struct zskiplistNode {
    sds ele;//节点存储的值
    double score;//节点分数,排序、查找用
    struct zskiplistNode *backward;//前一个节点指针
    struct zskiplistLevel {
        struct zskiplistNode *forward;//下一个节点指针
        unsigned long span;//索引跨度
    } level[];//多级索引数组
} zskiplistNode;

在这里插入图片描述

7.RedisObject

redis并没有直接使用之前学的数据结构来实现键值对数据库,而是基于这些数据结构创建了一个对象系统

Redis中的任意数据类型的键和值都会被封装为一个RedisObject,有叫Redis对象

typedef struct reidsObject {
    //对象类型,分别是string、hash、list、set、zest
    unsigned type: 4;
    //编码方式,共有11种,根据存储的数据类型来选择编码
    unsigned encoding: 4;
    //lru表示该对象最后一次被访问的时间
    unsigned lru:LRU_BITS;
    //对象引用计数器
    int refcount;
    //指向底层实现数据结构的指针
    void *ptr;  
} robj

5种数据类型:

1.String

String是Redis中最常见的数据存储类型:

  • 基本编码类型是RAW,基于SDS实现,存储上限是512mb

  • 如果存储的SDS < 44字节,则会采用EMBSTR编码,此时object head与SDS是一段连续空间。申请内存只需要调用一次内存分配函数,效率更高

  • 如果存储的字符串是整数值,并且大小在LONG_MAX范围内(8字节),则会采用INT编码:直接将数据包保存在RedisObject的ptr指针位置(刚好8字节,不再需要SDS了

2.List

在Redis3.2之前,采用ZipList和LinkedList来实现List,当元素数量 > 512 且 < 64字节时采用ZipList,超过则采用LinkedList

在Redis3.2之后,统一采用QuickList来实现List

3.Set

Set是Redis中的单列集合:

  • 不保证有序性
  • 元素为唯一

编码方式:

  • Set这个数据类型的底层是HT编码(Dict)去实现的,但是Set是单列,所以Dict中的Key用来存储元素,value统一为null
  • 当存储的元素都是整数,且元素数量不超过set-max-intset-entries时,Set会采用IntSet编码,以节省内存

4.ZSet

ZSet也就是SortedSet,其中每一个元素都需要指定一个score值和一个member值

  • 可以根据score值排序
  • member必须唯一
  • 可以根据member来查询score

因此,zset底层数据结构必须满足键值存储、键必须唯一、可排序这几个需求。之前学习的哪种编码结构可以满足?

  • SkipList:可以排序,并且可以同时存储score和ele值(member)
  • HT(Dict):可以键值存储,并且可以根据key找value
  • 两个数据结构相互弥补

在这里插入图片描述

当元素数量不多时,HT和SkipList的优势不明显,而且更耗内存。因此zset还会采用ZipList结构来节省内存,不过需要同时满足两个条件:

  • 元素数量小于zset_max_ziplist_entries,默认值128
  • 每个元素都小于zset_max_ziplist_value字节,默认值64

ziplist本身没有排序功能,而且没有键值对的概念,因此需要有zset通过编码实现:

  • ZipList是连续内存,因此score和element是紧挨在一起的两个entry, element在前,score在后
  • score越小越接近队首,score越大越接近队尾,按照score值升序排列

在这里插入图片描述

5.Hash

Hash结构与Redis中的Zset非常类似:

  • 都是键值存储
  • 都需求根据键获取值
  • 键必须唯一

区别如下:

  • zset的键是member,值是score;hash的键和值都是任意值

  • zset要根据score排序;hash则无需排序、

因此,Hash底层采用的编码与Zset也基本一致,只需要把排序有关的SkipList去掉即可

Hash结构默认采用ZipList编码,用以节省内存。 ZipList中相邻的两个entry 分别保存field和value

当数据量较大时,Hash结构会转为HT编码,也就是Dict,触发条件有两个:

  • ZipList中的元素数量超过了hash-max-ziplist-entries(默认512)
  • ZipList中的任意entry大小超过了hash-max-ziplist-value(默认64字节)

Redis的hash之所以这样设计,是因为当ziplist变得很⼤的时候,它有如下几个缺点:

  • 每次插⼊或修改引发的realloc操作会有更⼤的概率造成内存拷贝,从而降低性能。
  • ⼀旦发生内存拷贝,内存拷贝的成本也相应增加,因为要拷贝更⼤的⼀块数据。
  • 当ziplist数据项过多的时候,在它上⾯查找指定的数据项就会性能变得很低,因为ziplist上的查找需要进行遍历。

排序、

因此,Hash底层采用的编码与Zset也基本一致,只需要把排序有关的SkipList去掉即可

Hash结构默认采用ZipList编码,用以节省内存。 ZipList中相邻的两个entry 分别保存field和value

当数据量较大时,Hash结构会转为HT编码,也就是Dict,触发条件有两个:

  • ZipList中的元素数量超过了hash-max-ziplist-entries(默认512)
  • ZipList中的任意entry大小超过了hash-max-ziplist-value(默认64字节)

Redis的hash之所以这样设计,是因为当ziplist变得很⼤的时候,它有如下几个缺点:

  • 每次插⼊或修改引发的realloc操作会有更⼤的概率造成内存拷贝,从而降低性能。
  • ⼀旦发生内存拷贝,内存拷贝的成本也相应增加,因为要拷贝更⼤的⼀块数据。
  • 当ziplist数据项过多的时候,在它上⾯查找指定的数据项就会性能变得很低,因为ziplist上的查找需要进行遍历。

总之,ziplist本来就设计为各个数据项挨在⼀起组成连续的内存空间,这种结构并不擅长做修改操作。⼀旦数据发⽣改动,就会引发内存realloc,可能导致内存拷贝。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值