深入了解Redis底层设计 之数据结构与对象机制(redis对象在内存中是如何存储的)

Redis底层设计db-redis-object-2-2.png

对象机制

redisObject

redisObject 是 Redis 类型系统的核心, 数据库中的每个键、值, 以及 Redis 本身处理的参数, 都表示为这种数据类型.就是Redis中数据相关的类型都是redisObject类型,类似Controller层的统一返回值R。

/*
 * Redis 对象
 */
typedef struct redisObject {

    // 类型
    unsigned type:4;

    // 编码方式
    unsigned encoding:4;

    // LRU - 24位, 记录最末一次访问时间(相对于lru_clock); 或者 LFU(最少使用的数据:8位频率,16位访问时间)
    unsigned lru:LRU_BITS; // LRU_BITS: 24

    // 引用计数
    int refcount;

    // 指向底层数据结构实例
    void *ptr;

} robj;

结构如下:
db-redis-object-1.png
其中type记录了对象保存的值的类型,也就是这个对象的类型
对应了Redis的5种基本数据结构

/*
* 对象类型
*/
#define OBJ_STRING 0 // 字符串
#define OBJ_LIST 1 // 列表
#define OBJ_SET 2 // 集合
#define OBJ_ZSET 3 // 有序集
#define OBJ_HASH 4 // 哈希表

encoding记录了对象所保存的值的编码,

/*
* 对象编码
*/
#define OBJ_ENCODING_RAW 0     /* Raw representation */
#define OBJ_ENCODING_INT 1     /* Encoded as integer */
#define OBJ_ENCODING_HT 2      /* Encoded as hash table */
#define OBJ_ENCODING_ZIPMAP 3  /* 注意:版本2.6后不再使用. */
#define OBJ_ENCODING_LINKEDLIST 4 /* 注意:不再使用了,旧版本2.x中String的底层之一. */
#define OBJ_ENCODING_ZIPLIST 5 /* Encoded as ziplist */
#define OBJ_ENCODING_INTSET 6  /* Encoded as intset */
#define OBJ_ENCODING_SKIPLIST 7  /* Encoded as skiplist */
#define OBJ_ENCODING_EMBSTR 8  /* Embedded sds string encoding */
#define OBJ_ENCODING_QUICKLIST 9 /* Encoded as linked list of ziplists */
#define OBJ_ENCODING_STREAM 10 /* Encoded as a radix tree of listpacks */`

ptr指针应该指向的底层数据类型就是有type与encoding共同决定的
举个例子, 如果一个redisObject 的type 属性为OBJ_LIST , encoding 属性为OBJ_ENCODING_QUICKLIST ,那么这个对象就是一个Redis 列表(List),它的值保存在一个QuickList的数据结构内,而ptr 指针就指向quicklist的对象;

lru属性: 记录了对象最后一次被命令程序访问的时间

对象机制设计的目的

命令判断

在redis的命令中,用于对键进行处理的命令占了很大一部分,而对于键所保存的值的类型(键的类型),键能执行的命令又各不相同。
如: LPUSH 和 LLEN 只能用于列表键, 而 SADD 和 SRANDMEMBER 只能用于集合键, 等等; 另外一些命令, 比如 DEL、 TTL 和 TYPE, 可以用于任何类型的键;但是要正确实现这些命令, 必须为不同类型的键设置不同的处理方式: 比如说, 删除一个列表键和删除一个字符串键的操作过程就不太一样。
以上的描述说明, Redis 必须让每个键都带有类型信息, 使得程序可以检查键的类型, 并为它选择合适的处理方式.

命令类型检查与多态

当执行一个处理数据类型命令的时候,redis执行以下步骤

  • 根据给定的key,在数据库字典中查找和他相对应的redisObject,如果没找到,就返回NULL;
  • 检查redisObject的type属性和执行命令所需的类型是否相符,如果不相符,返回类型错误;
  • 根据redisObject的encoding属性所指定的编码,选择合适的操作函数来处理底层的数据结构;
  • 返回数据结构的操作结果作为命令的返回值。

db-redis-object-3.png

对象共享

redis一般会把一些常见的值放到一个共享对象中,这样可使程序避免了重复分配的麻烦,也节约了一些CPU时间。类似integer的常量池
redis预分配的值对象如下

  • 各种命令的返回值,比如成功时返回的OK,错误时返回的ERROR,命令入队事务时返回的QUEUE,等等
  • 包括0 在内,小于REDIS_SHARED_INTEGERS的所有整数(REDIS_SHARED_INTEGERS的默认值是10000)

db-redis-object-4.png

上图*号表示引用了共享对象中的数据

小结

  • redis使用自己实现的对象机制(redisObject)来实现类型判断、命令多态和基于引用次数的垃圾回收;
  • redis会预分配一些常用的数据对象,并通过共享这些对象来减少内存占用,和避免频繁的为小对象分配内存。

底层数据结构

底层数据结构指的是redisObject对象中ptr指针所指向的实际结构(底层设计图中的最后一列)

共有如下结构

  1. 简单动态字符串 - sds
  2. 压缩列表 - ZipList
  3. 快表 - QuickList
  4. 字典/哈希表 - Dict
  5. 整数集 - IntSet
  6. 跳表 - ZSkipList

SDS(简单动态字符串)

虽然Redis是使用C实现的,但Redis中的字符串却不是使用C中的字符串(以空字符结尾的字符数组结构),
而是自己构建了一种动态的字符串SDS,作为自己的默认字符串

  • SDS的总体概览如下图:

db-redis-ds-x-3.png

image.png
其中:

  • len 保存了SDS保存字符串的长度
  • buf[] 数组用来保存字符串的每个元素
  • alloc分别以uint8, uint16, uint32, uint64表示整个SDS, 除过头部与末尾的\0, 剩余的字节数.
  • flags 始终为一字节, 以低三位标示着头部的类型, 高5位未使用.

为什么使用SDS

  • 常数复杂度获取字符串长度

由于 len 属性的存在,我们获取 SDS 字符串的长度只需要读取 len 属性,时间复杂度为 O(1)。
而C需要遍历

  • 杜绝缓冲区溢出

我们知道在 C 语言中使用 strcat 函数来进行两个字符串的拼接,一旦没有分配足够长度的内存空间,就会造成缓冲区溢出。而对于 SDS 数据类型,在进行字符修改的时候,会首先根据记录的 len 属性检查内存空间是否满足需求,如果不满足,会进行相应的空间扩展,然后在进行修改操作,所以不会出现缓冲区溢出。

  • 减少修改字符串的内存重新分配次数

C语言由于不记录字符串的长度,所以如果要修改字符串,必须要重新分配内存(先释放再申请),因为如果没有重新分配,字符串长度增大时会造成内存缓冲区溢出,字符串长度减小时会造成内存泄露。
而对于SDS,由于len属性和alloc属性的存在,对于修改字符串SDS实现了空间预分配惰性空间释放两种策略:
1、空间预分配:对字符串进行空间扩展的时候,扩展的内存比实际需要的多,这样可以减少连续执行字符串增长操作所需的内存重分配次数。
2、惰性空间释放:对字符串进行缩短操作时,程序不立即使用内存重新分配来回收缩短后多余的字节,而是使用 alloc 属性将这些字节的数量记录下来,等待后续使用。(当然SDS也提供了相应的API,当我们有需要时,也可以手动释放这些未使用的空间。)

  • 二进制安全

因为C字符串以空字符作为字符串结束的标识,而对于一些二进制文件(如图片等),内容可能包括空字符串,因此C字符串无法正确存取;而所有 SDS 的API 都是以处理二进制的方式来处理 buf 里面的元素,并且 SDS 不是以空字符串来判断是否结束,而是以 len 属性表示的长度来判断字符串是否结束。

  • 兼容部分 C 字符串函数

虽然 SDS 是二进制安全的,但是一样遵从每个字符串都是以空字符串结尾的惯例,这样可以重用 C 语言库<string.h> 中的一部分函数redis-ds-2.png

ZipList(压缩列表)

压缩列表顾名思义就是将列表进行压缩,使得他在具有完整列表功能的前提下占用内存尽可能小

内存结构

整个ziplist在内存中的存储格式如下:db-redis-ds-x-6.png

  • zlbytes字段的类型是uint32_t, 这个字段中存储的是整个ziplist所占用的内存的字节数
  • zltail字段的类型是uint32_t, 它指的是ziplist中最后一个entry的偏移量. 用于快速定位最后一个entry, 以快速完成pop等操作
  • zllen字段的类型是uint16_t, 它指的是整个ziplit中entry的数量. 这个值只占2bytes(16位): 如果ziplist中entry的数目小于65535(2的16次方), 那么该字段中存储的就是实际entry的值. 若等于或超过65535, 那么该字段的值固定为65535, 但实际数量需要一个个entry的去遍历所有entry才能得到.
  • zlend是一个终止字节, 其值为全F, 即0xff. ziplist保证任何情况下, 一个entry的首字节都不会是255、
Entry结构

第一种情况(非int):一般结构
prevlen:前一个entry的大小
encoding:不同的情况下值不同,用于表示当前entry的类型和长度;
entry-data:真是用于存储entry表示的数据;
第二种情况(int):在entry中存储的是int类型时,encoding和entry-data会合并在encoding中表示,此时没有entry-data字段;
redis中,在存储数据时,会先尝试将string转换成int存储,节省空间;
此时entry结构:

为什么ZipList特别省内存(重要)

ZipList节省内存是相较于普通List而言的,对于一个普通的List,每个节点的大小都是固定了的(不管你是否会用完),是需要预留空间的

而ZipList在设计时就考虑到尽可能让每个元素按照实际大小存储,所以增加了encoding字段,针对不同的encoding字段具体细化使用空间大小。

由于每一个entry大小不同,所以在遍历时就无法定位下一个元素,所以为了解决遍历,就需要记录下上一个元素的长度length,用以定位,所以增加了prelen字段

在移除节点后会立即缩容

缺点

换取了空间,则一定会消耗掉时间,典型的时间换空间的方式
由于不预留空间和立即缩容的性质,代表了每次写操作都会需要进行内存分配

QuickList(快表)

它是一种以ziplist为结点的双端链表结构. 宏观上, quicklist是一个链表, 微观上, 链表中的每个结点都是一个ziplist。
快表目前作为list的默认实现(之前是ZipList)

内存结构

数据实际还是存储在ZipList的entry中。
db-redis-ds-x-4.png

typedef struct quicklistNode {
    struct quicklistNode *prev; //上一个node节点
    struct quicklistNode *next; //下一个node
    unsigned char *zl;            //保存的数据 压缩前ziplist 压缩后压缩的数据
    unsigned int sz;             /* ziplist size in bytes */
    unsigned int count : 16;     /* count of items in ziplist */
    unsigned int encoding : 2;   /* RAW==1 or LZF==2 */
    unsigned int container : 2;  /* NONE==1 or ZIPLIST==2 */
    unsigned int recompress : 1; /* was this node previous compressed? */
    unsigned int attempted_compress : 1; /* node can't compress; too small */
    unsigned int extra : 10; /* more bits to steal for future usage */
} quicklistNode;
  • prev: 指向链表前一个节点的指针。

  • next: 指向链表后一个节点的指针。

  • zl: 数据指针。如果当前节点的数据没有压缩,那么它指向一个ziplist结构;否则,它指向一个quicklistLZF结构

  • sz: 表示zl指向的ziplist的总大小(包括zlbytes, zltail, zllen, zlend和各个数据项)。需要注意的是:如果ziplist被压缩了,那么这个sz的值仍然是压缩前的ziplist大小。

  • count: 表示ziplist里面包含的数据项个数。这个字段只有16bit。稍后我们会一起计算一下这16bit是否够用。

  • encoding: 表示ziplist是否压缩了(以及用了哪个压缩算法)。目前只有两种取值:2表示被压缩了(而且用的是LZF压缩算法),1表示没有压缩。

  • container: 是一个预留字段。本来设计是用来表明一个quicklist节点下面是直接存数据,还是使用ziplist存数据,或者用其它的结构来存数据(用作一个数据容器,所以叫container)。但是,在目前的实现中,这个值是一个固定的值2,表示使用ziplist作为数据容器。

  • recompress: 当我们使用类似lindex这样的命令查看了某一项本来压缩的数据时,需要把数据暂时解压,这时就设置recompress=1做一个标记,等有机会再把数据重新压缩。

  • attempted_compress: 这个值只对Redis的自动化测试程序有用。我们不用管它。

  • extra: 其它扩展字段。目前Redis的实现里也没用上。

Dict(字典)

字典本质上就是hash表

结构

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

db-redis-ds-x-13.png

Dict中table指向了一个hash桶数组,桶内部则是一个链表
每个dictEntry(链表节点)都是由键值加指针构成

entry结构

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

要点

  1. 使用了拉链解决hash冲突
  2. 扩容和收缩:当哈希表保存的键值对太多或者太少时,就要通过 rerehash(重新散列)来对哈希表进行相应的扩展或者收缩。
  3. 渐近式 rehash

扩容和收缩操作不是一次性、集中式完成的,而是分多次、渐进式完成的。当数据量过大后,rehash操作会导致redis在一段时间内无法进行其他操作(单线程)这是不被允许的,所以使用渐进式的rehash。rehash期间,字典的删除查找更新等操作可能会在两个哈希表上进行,第一个哈希表没有找到,就会去第二个哈希表上进行查找。但是进行 增加操作,一定是在新的哈希表上进行的。

IntSet(整数集)

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

内部结构

typedef struct intset {
    uint32_t encoding;
    uint32_t length;
    int8_t contents[];
} intset;

db-redis-ds-x-8.png

  • encoding 表示编码方式,的取值有三个:INTSET_ENC_INT16, INTSET_ENC_INT32, INTSET_ENC_INT64

  • length 代表其中存储的整数的个数

  • contents 指向实际存储数值的连续内存区域, 就是一个数组;整数集合的每个元素都是 contents 数组的一个数组项(item),各个项在数组中按值得大小从小到大有序排序,且数组中不包含任何重复项。(虽然 intset 结构将 contents 属性声明为 int8_t 类型的数组,但实际上 contents 数组并不保存任何 int8_t 类型的值,contents 数组的真正类型取决于 encoding 属性的值)

由于数组里面的数据类型是由encoding决定的(int字长),如果原来是16位int,新插入了一个32位int,那么就会进行intSet升级

整数集合的升级

当在一个int16类型的整数集合中插入一个int32类型的值,整个集合的所有元素都会转换成32类型。 整个过程有三步:

  • 根据新元素的类型(比如int32),扩展整数集合底层数组的空间大小,并为新元素分配空间。
  • 将底层数组现有的所有元素都转换成与新元素相同的类型, 并将类型转换后的元素放置到正确的位上, 而且在放置元素的过程中, 需要继续维持底层数组的有序性质不变。
  • 最后改变encoding的值,length+1。

但是删除掉刚刚加入的32位int却不会降级,主要是性能方面的权衡

ZSkipList(跳表)

跳表在Redis中的唯一使用场景就是在有序列表(Zset)中使用。跳跃表的性能可以保证在查找,删除,添加等操作的时候在对数期望时间内完成,跳跃表的缺点就是需要的存储空间比较大,属于利用空间来换取时间的数据结构。

什么是跳表

单链表的查找复杂度是O(n)
db-redis-ds-x-9.png

db-redis-ds-x-10.png

Redis跳跃表的设计

redis跳跃表并没有在单独的类(比如skplist.c)中定义,而是其定义在server.h中, 如下:

/* ZSETs use a specialized version of Skiplists */
typedef struct zskiplistNode {
    sds ele;
    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;
  • 头节点不持有任何数据, 且其level[]的长度为32

  • 每个结点

  • ele字段,持有数据,是sds类型

  • score字段, 其标示着结点的得分, 结点之间凭借得分来判断先后顺序, 跳跃表中的结点按结点的得分升序排列.

  • backward指针, 这是原版跳跃表中所没有的. 该指针指向结点的前一个紧邻结点.

  • level字段, 用以记录所有结点(除过头节点外);每个结点中最多持有32个zskiplistLevel结构. 实际数量在结点创建时, 按幂次定律随机生成(不超过32). 每个zskiplistLevel中有两个字段

    • forward字段指向比自己得分高的某个结点(不一定是紧邻的), 并且, 若当前zskiplistLevel实例在level[]中的索引为X, 则其forward字段指向的结点, 其level[]字段的容量至少是X+1. 这也是上图中, 为什么forward指针总是画的水平的原因.
    • span字段代表forward字段指向的结点, 距离当前结点的距离. 紧邻的两个结点之间的距离定义为1.
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值