Redis设计与实现--数据结构与对象(二)

  • 整数集合
    • 简介
    • 升降级
  • 压缩列表
    • 简介
    • 列表节点
    • 连锁更新
  • 对象
    • 简介
    • 类型与编码
    • 字符串对象
    • 列表对象
    • 哈希对象
    • 集合对象
    • 有序集合对象
    • 内存回收与对象共享

整数集合

简介

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

整数集合定义在intset.h/intset中:

typedef struct intset {
    // 编码方式
    uint32_t encoding;
    // 集合包含的元素数量
    uint32_t length;
    // 保存元素的数组
    int8_t contents[];
} intset;

contents数组是整数集合的底层实现,集合中的每个元素都是数组的一个数组项,各个项在数组中按照值得大小从小到大排列,且不包含任何重复项;length即为数组的长度;contents数组的真正类型取决于encoding属性的值:

  1. 如果encoding为INTSET_ENC_INT16,那么contents就是一个int16_t类型的数组,数组里的每一项都是int16_t类型的数值。
  2. 如果encoding为INTSET_ENC_INT32,那么contents就是一个int32_t类型的数组,数组里的每一项都是int32_t类型的数值。
  3. 如果encoding为INTSET_ENC_INT64,那么contents就是一个int64_t类型的数组,数组里的每一项都是int64_t类型的数值。

一个可能的整数集合结构如下:
Redis设计与实现--数据结构与对象(二)_第1张图片
encoding为INTSET_ENC_INT16,元素数目为3,则contents数组的大小等于sizeof(int16_t)*length=16*3=48

升降级

当我们在整数集合中添加的元素类型比现有元素类型都要长时,需要先对整数集合进行升级,升级分为以下三个步骤:

  1. 根据新元素的类型,扩展底层数组的空间大小,并未新元素分配空间
  2. 将底层元素转换为与新元素相同的类型,并将装转换后的元素放置到正确的位置上
  3. 将新元素添加进去

新元素要么大于所有元素,要么小于所有元素:

  • 新元素大于所有元素时,新元素会被放置到底层数组的末尾
  • 新元素小于所有元素时,新元素会被放置到底层数组的头部

引入升级机制,有以下两点好处:

  • 提升灵活性
  • 节约内存

但是整数集合不支持降级操作,一旦对数组进行了升级,编码就会一直保持升级后的状态。

压缩列表

简介

压缩列表是列表键和哈希键的底层实现之一,是redis为了节约内存而开发的,一个压缩列表包含任意多个节点,每个节点可以保存一个字节数组或者一个整数值。

一个压缩列表的各个组成部分如下图所示:
在这里插入图片描述
其中,各个组成部分详细说明如下:

属性 类型 长度 用途
zlbytes uint32_t 4字节 记录整个压缩列表占用的内存字节数
zltail uint32_t 4节 记录压缩列表表尾节点距离列表的起始地址有多少字节
zllen uint16_t 2字节 记录压缩列表节点数量
entryX 列表节点 不定 压缩列表中的节点
zlend uint8_t 1字节 特殊值,用于标记压缩列表末端

列表节点

压缩列表节点定义如下:

typedef struct zlentry {
    // prevrawlen :前置节点的长度
    // prevrawlensize :编码 prevrawlen 所需的字节大小
    unsigned int prevrawlensize, prevrawlen;
    // len :当前节点值的长度
    // lensize :编码 len 所需的字节大小
    unsigned int lensize, len;
    // 当前节点 header 的大小
    // 等于 prevrawlensize + lensize
    unsigned int headersize;
    // 当前节点值所使用的编码类型
    unsigned char encoding;
    // 指向当前节点的指针
    unsigned char *p;
} zlentry;

prevrawlen用来记录前置节点的长度,如果前置节点长度小于254字节,那么prevrawlen属性的长度就是1字节,前置节点的长度就保存在这一字节中;如果前置节点长度不小于254字节,那么prevrawlen属性的长度就是5字节,其中,prevrawlen的第一字节会被设置为0xFE,而之后的4个字节则会保存前置节点的长度。

因为节点记录了前置节点的长度,所以程序可以通过指针运算,根据当前节点的起始地址来计算出前直节点的起始地址,从而可以实现列表的反向遍历。

对于zlentry结构体,可以将压缩列表节点抽象为以下结构:
在这里插入图片描述
其中,previous_entry_length记录了前置节点的长度,encoding记录了数据类型以及长度:

  • 1字节、2字节或者5字节,值得最高位为00、01或者10的是字节数组编码,数组长度由编码除去最高两位之后的其他位记录
  • 1字节长,值得最高位以11开头的是整数编码,表示整数值,整数值的类型和长度由编码除去最高两位之后的其他位记录

content负责保存当前节点的值。
Redis设计与实现--数据结构与对象(二)_第2张图片
该节点编码的最高两位是00,表示保存的是字节数组,编码后续1011表示字节数组的长度是11,content记录了值“hello world”。

Redis设计与实现--数据结构与对象(二)_第3张图片
该节点编码的最高两位是11,表示保存的是整数值,编码11000000表示整数类型为int16_t,content记录了值4396。

连锁更新

假如在压缩列表中,有多个连续的、长度介于250-253字节的节点,如下图所示:
在这里插入图片描述
此时,如果将一个长度大于等于254的新节点设置为压缩列表头节点,那么新节点将成为entry1的前置节点:
在这里插入图片描述
此时,entry1已经无法记录前置节点的长度,所以需要对entry1进行空间重新配,将previous_entry_length属性从原来的1字节扩展到5字节。但是,此时entry1节点总长度由于该更新也扩充到了254字节以上,以此类推,还需要对entry2、entey3…entryN进行更新,这种情况称为“连锁更新”。

除了添加节点可能出现连锁更新,删除节点也可能出现此情况,考虑以下情况:
在这里插入图片描述
entry1-entryN都是大小介于250-253字节的节点,big节点长度大于254字节,small节点长度小于254字节,将small节点删除后,仍会出现连锁更新。

连锁更新在最坏的情况下需要对列表执行N次空间重分配,每次空间重分配的最坏复杂度是O(N),所以连锁更新最坏复杂度是O(N**2)。

尽管复杂度很高,但是出现连锁更新的概率很低:

  • 首先,压缩列表中要有多个连续的长度介于250-153字节的节点
  • 其次,即使出现连锁更新,但只要被更新的节点数量不多,就不会对性能造成影响

对象

简介

在redis中,并没有直接使用简单动态字符串、字典、压缩列表、整数集合等数据结构,而是基于这些数据结构构建了一个对象系统,使用对象系统有以下几点好处:(1)可以针对不同的使用场景为对象设置不同的实现,从而优化对象在不同场景下的使用效率;(2)redis对象系统还实现了基于引用计数的内存回收机制以及对象共享机制,可以让多个键共享同一个对象来节省内存;(3)redis对象带有访问时间记录功能,在服务器启用了maxmemory功能时,空转时长较大的键会优先被服务器删除。

类型与编码

redis中每个对象都由一个redisObject结构表示:

typedef struct redisObject {
    // 类型
    unsigned type:4;
    // 编码
    unsigned encoding:4;
    // 对象最后一次被访问的时间
    unsigned lru:REDIS_LRU_BITS; /* lru time (relative to server.lruclock) */
    // 引用计数
    int refcount;
    // 指向实际值的指针
    void *ptr;
} robj;

type属性记录了对象的类型,这个属性值是以下常量中的一个:

常量类型 对象的名称
REDIS_STRING 字符串对象
REDIS_LIST 列表对象
REDIS_HASH 哈希对象
REDIS_SET 集合对象
REDIS_ZSET 有序集合对象

在redis中,总是键值对共存的情况,键总是一个字符串对象,值可以是任意对象。当我们对一个键执行TYPE命令时,命令的返回结果就是键对应的值对象类型,而不是键对象的类型。

ptr指针指向对象的底层实现数据结构,而这些数据结构由对象的encoding属性决定,encoding属性记录了对象所使用的编码,其值可以是以下列表中的一个:

编码常量 对应的数据结构
REDIS_ENCODING_RAW 简单动态字符串
REDIS_ENCODING_INT long类型的整数
REDIS_ENCODING_HT 字典
REDIS_ENCODING_LINKEDLIST 双端链表
REDIS_ENCODING_ZIPLIST 压缩列表
REDIS_ENCODING_INTSET 整数结合
REDIS_ENCODING_SKIPLIST 跳跃表
REDIS_ENCODING_EMBSTR embstr编码的简单动态字符串

每种类型都至少使用了两种不同的编码类型:

类型 编码 对象
REDIS_STRING REDIS_ENCODING_INT 使用整数值实现的字符串对象
REDIS_STRING REDIS_ENCODING_EMBSTR 使用embstr编码的简单动态字符串
REDIS_STRING REDIS_ENCODING_RAW 使用SDS实现的字符串
REDIS_LIST REDIS_ENCODING_ZIPLIST 使用压缩列表实现的列表
REDIS_LIST REDIS_ENCODING_LINKEDLIST 使用双端列表实现的列表
REDIS_HASH REDIS_ENCODING_ZIPLIST 使用压缩列表实现的哈希对象
REDIS_HASH REDIS_ENCODING_HT 使用字典实现的哈希对象
REDIS_SET REDIS_ENCODING_INTSET 使用整数集合实现的集合
REDIS_SET REDIS_ENCODING_HT 使用字典实现的集合
REDIS_ZSET REDIS_ENCODING_ZIPLIST 使用压缩列表实现的有序集合
REDIS_ZSET REDIS_ENCODING_SKIPLIST 使用跳跃表实现的有序集合

使用object encoding命令可以查看一个数据库值对象的编码。

字符串对象

字符串对象的编码可以是int、raw或者embstr。
如果一个字符串对象保存的是整数值,且该值可以用long类型表示,那么字符串对象会将整数值保存在字符串对象结构的ptr属性里,并将字符串对象的编码设置为int。

如果字符串保存的是一个字符串,且字符串的长度小于39字节,那么字符串使用embstr编码方式来保存这个字符串值。

embstr是专门用于保存短字符串的一种优化编码方式,但是embstr和raw编码一样,同样使用SDS来保存字符串,但不同的是,raw会调用两次内存分配函数分别创建redisObject和sdshdr结构,而embstr只会调用一次内存分配函数来分配一块连续的内存。

int编码和embstr编码的字符串对象在满足条件下,会被转换为raw编码的字符串对象。

对于int编码的字符串来说,如果我们执行一些命令,使得这个对象保存的不再是整数值,而是一个字符串,那编码将从int变为raw。

对于embstr编码的字符串来说,对字符串执行任何修改命令时,程序都会先将编码转换为raw格式,在执行修改。

列表对象

列表对象的编码可以是ziplist或者linkedlist,当

  1. 列表对象保存的所有字符串长度小于64字节
  2. 列表对象保存的元素数量小于512个
    时,采用ziplist编码,否则转换为linkedlist编码。

哈希对象

哈希对象的编码可以是ziplist或者hashtable,当

  1. 哈希对象保存的所有键值对的键和值的字符串长度都小于64字节
  2. 哈希对象保存的键值对数量小于512个
    时,采用ziplist,否则采用hashtable编码。

其中,采用ziplist时,键值对总是紧挨在一起的,先插入键,再紧接着插入值。

集合对象

可以是intset或者hashtable实现,intset编码的集合对象使用整数集合作为底层实现,当

  1. 集合对象保存的都是整数
  2. 集合对象保存的元素数量少于512个
    时,采用intset编码。

有序集合对象

有序集合可以采用ziiplist或者skiplist实现,当

  1. 有序集合保存的所有字符串长度小于64字节
  2. 有序集合保存的元素数量小于128个
    时,采用ziplist编码,其中键值对也是紧挨在一起,键在前,值在后,且ziplist中的集合元素按照分值从小到大排序

同时,为了兼顾有序和查找的复杂度,在有序对象中,还使用了一个dict字典保存了键到值的映射关系。

内存回收与对象共享

redis通过引用计数来实现了内存回收这一功能,创建新对象时,它的引用计数初始化为1,并通过以下几个函数来对引用计数进行更新:

函数 作用
incrRefCount 引用计数加1
decrRefCount 引用计数减1,当引用计数为0时,释放内存
resetRefCount 重置计数为0,但是不释放对象内存

引用计数除了用作内存回收以外,还带有对象共享的作用。
如果键A创建了一个包含整数值100的字符串对象作为值对象,同时键B也要创建一个包含整数值100的字符串对象作为值对象,那此时,为了节约内存,键A和键B可以共用一个值对象,只需要将键B的值指针指向键A的值对象,并将值对象的引用计数加1即可。

你可能感兴趣的:(redis)