Redis不同数据类型的的数据结构实现

Redis不同数据类型的的数据结构实现

我们知道Redis支持五种数据类型,

分别是字符串、哈希表(map)、列表(list)、集合(set)和有序集合,和Java的集合框架类似,不同数据类型的数据结构实也是不一样的。

1.Redis中的redisObject对象

Redis是使用C编写的,内部实现了一个struct结构体redisObject对象,

通过结构体来模仿面向对象编程的“多态”,作为一个底层的数据支持,redisObject代码:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

/*

 * Redis 对象

 */

typedef struct redisObject {

    // 类型

    unsigned type:4;

    // 对齐位

    unsigned notused:2;

    // 编码方式

    unsigned encoding:4;

    // LRU 时间(相对于 server.lruclock)

    unsigned lru:22;

    // 引用计数

    int refcount;

    // 指向对象的值

    void *ptr;

} robj;

 

其中type、encoding、ptr3个属性分别表示:
type:redisObject的类型,字符串、列表、集合、有序集、哈希表
encoding:底层实现结构,字符串、整数、跳跃表、压缩列表等
ptr:实际指向保存值的数据结构

如果一个 redisObject 的 type 属性为 REDIS_LIST , encoding 属性为 REDIS_ENCODING_LINKEDLIST ,
那么这个对象就是一个 Redis 列表,它的值保存在一个双端链表内,而 ptr 指针就指向这个双端链表;
如果一个 redisObject 的 type 属性为 REDIS_HASH , encoding 属性为 REDIS_ENCODING_ZIPMAP ,
那么这个对象就是一个 Redis 哈希表,它的值保存在一个 zipmap 里,而 ptr 指针就指向这个 zipmap 。

下面这张图片中的REDIS_STRING/REDIS_LIST/REDIS_ZSET/REDIS_HASH/REDIS_SET针对的是redisObject中的type,
后面指向的REDIS_ENCODING_LINKEDLIST等针对的是encoding字段。

Redis的底层数据结构有以下几种:
简单动态字符串sds(Simple Dynamic String)
双端链表(LinkedList)
字典(Map)
跳跃表(SkipList)

 

Reids 是一种内存型 k-v 数据库,底层采用 C 语言实现。

提供了五种常用的数据类型:

1. 字符串 - 通过数值或 SDS 实现  

2. 列表 - 通过压缩列表或双端链表实现

3. 哈希 - 通过压缩列表或字典实现

3. 集合 - 通过整数集合或字典实现

4. 有序集合 - 通过压缩列表的有序集合或跳跃表+字典组合的数据结构实现

下面针对五种数据类型,学习相关的底层数据结构。

2.String

如果一个String类型的value能够保存为整数,则将对应redisObject 对象的encoding修改为REDIS_ENCODING_INT,将对应robj对象的ptr值改为对应的数值。
如果不能转为整数,保持原有encoding为REDIS_ENCODING_RAW
因此String类型的数据可能使用原始的字符串存储(实际为sds - Simple Dynamic Strings,对应encoding为REDIS_ENCODING_RAW)或者整数存储

底层实现:

int 8字节长整形;

embstr (基于SDS实现)小于等于39个字节的字符串;raw(基于SDS实现) 大于39个字节的字符串

典型使用场景:缓存数据,计数,共享Session,限速;


 

3.List

列表的底层实现有2种:
REDIS_ENCODING_ZIPLIST
REDIS_ENCODING_LINKEDLIST
ZIPLIST相比LINKEDLIST可以节省内存

底层实现
  ziplist,同哈希,元素个数小于list-max-ziplist-entries配置,且每个元素值小于list-max-ziplist-value配置,采用压缩列表作为内部实现来减少内存使用;
  linkedlist(链表),无法满足ziplist条件时,采用linkedlist;


 

4.Hash

底层实现
  Ziplist(压缩列表):当哈希类型元素个数小于hash-max-ziplist-entries配置(默认512)、同时所有值都小于hash-max-ziplist-value配置(默认64字节)时,使用ziplist作为哈希的内部实现,以紧凑结构实现多个元素的连续存储,所有在节省内存方面比hashtable好。
  Hashtable(哈希表):当哈希类型无法满足ziplist条件时,Redis会使用hashtable作为哈希的内部实现,因为ziplist此时读写效率会下降,而hashtable读写效率时O(1)的,但会消耗更多内存。

 

5.Set

底层实现
  intset(整数集合),当集合中元素都是整数且元素个数小于set-max-inset-entries配置时采用,可以减少内存使用;
  hashtable(哈希表),无法满足intset条件时采用

 

6.Sorted Set

有序集合的底层实现也是2种:
  ziplist(压缩列表):当有序集合的元素个数小于zset-max-ziplist-entries配置(默认128),同时每个元素的值都小于zset-max-ziplist-value配置,会采用ziplist来减少内存使用;
  skiplist(跳跃表):当ziplist条件不满足时采用;

 

数据结构实现

2.1 简单字符串(simple dynamic string,SDS)

       Redis没有直接采用C语言传统的字符串表示,而是自己构造了简单动态字符串(SDS)的抽象类型作为默认字符串表示,embstr和raw都是基于此实现。每个sds.h/sdshdr结构表示一个SDS值:

 

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

       SDS遵循C字符串以空字符'\0'结尾的惯例,保存空字符的1字节空间不计算在SDS的len属性内,并且为空字符分配额外的1字节空间,以及添加空字符到字符串末尾等都是SDS函数自动完成的。
SDS与C字符串的区别

  • O(1)获取字符串长度:C字符串并不记录自身长度信息,获取长度需遍历字符串,对每个字符进行计算直到遇到空字符结尾,复杂度是O(n),而SDS自身记录了长度属性len,复杂度是O(1);
  • 杜绝缓冲区溢出:C字符不记录自身长度带来的另一个问题就是容易造成缓冲区溢出,在对字符串进行扩展时由于空间分配已经完成,所以扩展会对额外空间进行占用,会有修改已存在变量值的可能性;而SDS进行修改时会先检查空间是否满足修改所需的要求,不满足会先自动扩展空间,然后才执行修改,也就不存在缓存区溢出的问题;
  • 减少修改字符串时带来的内存重分配次数:C字符串的长度和底层数组的长度之间存在关联,每次对字符串的修改(增长/缩短)都需要内存重分配,不然会造成缓冲区溢出/内存泄露; SDS通过未使用空间解除了字符串长度和底层数组长度之间的关联,SDS通过未使用空间实现了空间预分配和惰性空间释放;
    空间预分配
    用于优化SDS的字符串增长操作:对一个SDS的字符串进行扩展(如拼接等操作),且需要空间扩展,程序不仅会为SDS分配修改所必须要的空间,还会额外分配未使用空间,当修改后SDS的len<1Mb,则free=len,当修改后SDS的len>=1Mb,则len=1Mb,如果未使用空间足够支持扩展则直接扩展字符串而无需进行空间扩展;
    惰性空间释放
    用于优化SDS的字符串缩短操作:对一个SDS的字符串进行缩短时,程序并不立即使用内存重分配来回收多余字节,而是使free属性记录起来,并等待将来使用;
  • 二进制安全:C字符串的字符必须符合某种编码,并且除末尾外不能包含空字符,因此只能存储文本数据,不能存储图片、音频、视频、压缩文件等二进制数据;而SDS是二进制安全的,会以处理二进制的方式来处理SDS存放在buf数组里面的数据,不会对数据进行任何限制、过滤,Redis在buf数组中保存的是二进制数据,并通过len属性来判断是否结束;
  • 兼容部分C字符串函数:SDS一样遵循C字符串以空字符结尾的惯例,在buf分配空间时多分配一个字节来容纳空字符,也就可以重用一部分<string.h>库定义的函数,如strcasecmp进行字符串比较;

2.2 链表

       当一个列表键包含过多元素/包含的元素较长时,Redis采用链表作为列表键的底层实现。 除了链表键外,发布与订阅、慢查询、监视器等也用到了链表,服务器本身还使用链表来保存多个客户端的状态信息。

 

typedef struct listnode{
       //前置节点
       struct listNode *prev;
       //后置节点
       struct listNode *next;
       //节点的值
       void *value;
};

       虽然仅仅使用多个listNode结构就可以组成链表,但使用adlist.h/list来持有链表更方便:

 

typedef struct list{
       //表头
       listNode *head;
       //表尾
       listNode *tail;
       //链表所包含的节点数
        unsigned long len;
       //节点复制函数
       void *(*dup)(void *ptr)
       //节点值释放函数
       void (*free)(void *ptr);
       //节点值对比函数
       void (*match) (void *ptr,void *key);
   }

Redis的链表实现特性:

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

2.3 字典

       字典用于保存键值对的抽象数据结构,同时字典也是哈希键的底层实现之一。Redis的字典采用哈希表作为底层实现,一个哈希表里面包含多个哈希表节点,一个哈希表节点代表一个键值对。
哈希表

 

typedef struct dictht {
    //哈希表数组,数组中元素指向一个dictEntry结构的指针,每个dictEntry结构保存一个键值对  
    dictEntry **table;
    //哈希表大小,即table数组的大小
    unsigned long size;
    //哈希表大小掩码,用于计算索引值 sizemask = size - 1;
    unsigned long sizemask;
    //该哈希表已有节点的数量
    unsigned long used;
}

哈希表节点

 

typedef struct dictEntry{
    // 键
    void *key;
    // 值,可以使一个指针、uint64_t整数或者int64_t整数
    union{
        void *val;
        uint64_tu64;
        int64_ts64;
    } v;
    // 指向下个哈希表节点,形成链表,将多个哈希值相同的键值对链接在一起,解决键冲突
    struct disEntry *next;
} dictEntry;

字典

 

typedef struct dict{
    //类型特定函数,指向dictType的指针,dictType结构保存了一簇用于操作特定类型键值对的函数  
    dictType *type;  
    //私有数据,保存了需要传给特定函数的可选参数  
    void *privdate;
    //哈希表,ht[0]即为哈希表,ht[1]只在rehash时使用
    dictht ht [2];
    //rehash索引,记录rehash目前的进度,当rehash不在进行时,值为-1;
    int rehashidx;
} dict;

typedef struct dictType{
    //计算哈希值
    unsigned int (*hashFunction) (const void *key);
    //复制键函数
    void *(*keyDup) (void *privdate, const void *key);
    //复制值的函数
    void *(*valDup) (void *privdate,const void *obj);
    //对比键的函数
   int (*keyCompare) (void *privdate,const void *key1, const void *key2);
    //销毁键的函数  
    void (*keyDestructor) (void *privdate,void *key);
    //销毁值的函数
    void (*valDestructor) (void *privdate,void *obj);
}

哈希算法
       根据键计算出哈希值和索引值,再根据索引值将包含键值对的哈希表节点放置到哈希表数组的指定索引上。
使用字典设置的哈希函数,计算键key的哈希值
hash = dict -> type -> hashFunction(key);
使用哈希表的sizemask属性和哈希值,计算出索引值

 

index = hash & dict->ht[x].sizemask  

键冲突
       当有两个或以上数量的键被分配到哈希数组同一个索引上面时,键发生冲突;Redis哈希表采用拉链法解决键冲突。
渐进式rehash
       随着键值对的增多和减少,为维持负载因子(已保存节点数/哈希表大小),需要对哈希表大小进行相应的扩展和收缩,如果直接将ht[0]的键值对rehash到ht[1]由于数据量过大可能对服务器性能造成影响。所以通过渐进式rehash分多次,将rehash键值对所需的工作平摊到字典的每个增删改查操作上,避免了集中式rehash带来的庞大计算量,步骤如下:

  • 为ht[1]分配空间,如是扩展,则大小为大于等于h[0].used*2的2次幂;如是收缩,则大小为大于等于h[0].used的2次幂;
  • 字典同时维持2个哈希表ht[0]和ht[1],并维持一个索引计数器rehashidx,设置为0,表示rehash工作正式开始;
  • 在rehash期间,每次对字典执行增删改查时,程序除执行指定操作外,会将ht[0]中所有键值对重新计算哈希值和索引值,放置到ht[1]指定位置上,然后rehashidx加1;
  • 随着字典操作不断执行,最终ht[0]所有键值对都迁移到ht[1]后,释放ht[0],将ht[1]设置为ht[0],并在ht[1]创建空白哈希表,rehashidx置为-1;

2.4 跳跃表

       跳跃表是一种有序数据结构,通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。支持平均O(logN),最坏O(N)的查找。跳跃表只在有序集合和集群节点内部数据结构中被用到, 通由跳跃表节点zskiplistNode和保存跳跃表节点信息的zskiplist两个结构定义。

zskiplist结构包含以下属性:

  • header:指向跳跃表表头节点;
  • tail:指向跳跃表表尾节点;
  • level:记录目前跳跃表内,层数最大的那个节点的层数(表头节点层数不计算在内);
  • length:记录跳跃表的长度,即目前包含的节点数(表头节点层数不计算在内);

zskiplistNode结构包含以下属性:

  • 层(level):Lx代表第x层(1-32,创建节点时随机生成),每层都包含指向其他节点的指针,层数量越多,访问其他节点的速度越快,还包含2个属性:前进指针和跨度,前进指针用于访问位于表尾方向的其他节点,跨度记录了前进指针所指节点与当前节点的距离;
  • 后退指针:节点中用BW标记节点的后退指针,指向位于当前节点的前一个节点;
  • 分值:一个double类型的浮点数,跳跃表中分值按各自所保存的分值从小到大排列;
  • 成员对象:指向一个字符串对象,存着SDS值,每个节点保存的节点对象必须唯一;

2.5 整数集合

       整数集合(intset)是Redis用于保存整数值的集合抽象数据结构,保存类型为int16_t、int32_t和int64_t的整数值。

 

typedef struct intset {
    //编码方式
    uint32_t encoding;
    //集合包含的元素个数
    uint32_t length;
    //保存元素的数组,类型由encoding属性的值确定,元素从小到大排列,无重复元素
    int8_t contents[];
}

升级
       当添加新元素,且新元素类型比现有元素类型都长时,集合需要先进行升级:

  • 根据新元素的类型,扩展整数集合底层数组空间的大小,为新元素分配空间;
  • 将现有元素转换为新元素相同的类型,并将转换后的元素放到正确位置,保持底层数组有序性不变;
  • 添加新元素到底层数组;

       所以向整数集合添加新元素的时间复杂度为O(N),同时采用升级还能提升整数集合的灵活性,可以随意添加不同类型的整数而不用担心出现类型错误,也尽可能的节约了内存,升级操作只会在需要的时候进行,而不是一开始就预先分配大量的空间。
降级
       整数集合不支持降级,一旦对数组进行了升级,编码会一直保持升级后的状态。

2.6 压缩列表

       压缩列表是Redis为了节约内存而开发的,由一系列特殊编码的连续内存块组成的顺序型数据结构。

属性类型长度用途
zlbytesuint32_t4字节记录整个压缩列表占用的内存字节数:内存重分配和计算zlend位置是使用
zltailuint32_t4字节记录压缩列表表尾阶段距离压缩列表起始地址有多少个字节
zllenuint16-t2字节记录压缩列表包含的节点数:如果大于65535则需要遍历计算
entryX列表节点不定压缩列表包含的各个节点,节点长度由节点保存的内容决定
zlenduint8_t1字节特殊值0xFF,用于标记压缩列表的末端

压缩列表节点由previous_entry_length、encoding、content三部分组成:

  • previous_entry_length:以字节为单位,记录了压缩列表前一个节点的长度,可以是1字节和5字节;
  • encoding:记录节点的content属性所保存数据的类型以及长度;
  • content:属性负责保存节点的值,可以是一个字节数组或者整数;

连锁更新
       由于previous_entry_length属性都记录了前一个节点的长度,长度小于254字节用1字节空间记录否则用5字节空间,那么当一个压缩列表中有多个连续且长度介于250-253字节的节点,添加一个大于等于254字节的节点到头节点时,导致后续节点都需要空间重分配,扩展出4个字节构成5字节来记录前一节点的长度,进而自身超出254字节,引起了后续节点的连锁更新;在删除节点时也可能会导致连锁更新。

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值