Redis设计与实现——对象


Redis 的底层数据结构主要包括简单动态字符串(SDS)、双端链表、字典、跳跃表、整数集合、压缩列表。Redis 并没有直接使用这些数据结构来实现键值对数据库,而是基于这些数据结构创建了一个对象系统,这个系统包含字符串对象、列表对象、哈希对象、集合对象和有序集合对象这五种类型的对象,每一种对象类型都至少采用了两种编码,不同的编码使用的底层数据结构也不同。通过这五种不同类型的对象,Redis 可以在执行命令之前,根据对象的类型来判断一个对象是否可以执行给定的命令。使用对象的另一个好处是,我们可以针对不同的使用场景,为对象设置多种不同的数据结构实现,从而优化对象在不同场景下的使用效率

除此之外,Redis 的对象系统还实现了基于引用计数技术的内存回收机制:当程序不再使用某个对象的时候,这个对象所占用的内存就会被自动释放;另外,Redis 还通过引用计数技术实现了对象共享机制,这一机制可以在适当的条件下,通过让多个数据库键共享同一个对象来节约内存。

对象的类型与编码

Redis 使用对象来表示数据库中的键和值,每次当我们在 Redis 的数据库中新创建一个键值对时,我们至少会创建两个对象,一个对象用作键值对的键(键对象),另一个对象用作键值对的值(值对象),其中键总是一个字符串对象, 而值则可以是字符串对象、列表对象、哈希对象、集合对象或者有序集合对象的其中一种。

因此当我们对一个数据库键执行 TYPE 命令时,命令返回的结果为数据库键对应的值对象的类型,而不是键对象的类型。

Redis 中的每个对象都由一个 redisObject 结构表示,该结构中和保存数据有关的三个属性分别是 type 属性、 encoding 属性和 ptr 属性:

typedef struct redisObject {
    // 类型
    unsigned type:4;

    // 编码
    unsigned encoding:4;

    // 指向底层数据结构的指针
    void *ptr;
} robj;

type 属性记录了对象的类型,对于 Redis 来说,键对象总是字符串类型,值对象可以是任意支持的类型。因此,当我们说Redis键采用哪种对象类型的时候,指的是对应的值采用哪种对象类型。

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

ptr 指针指向了对象的底层数据结构,而这些数据结构由 encoding 属性决定。encoding 属性记录了对象所使用的编码, 也即是说这个对象使用了什么数据结构作为对象的底层实现,这个属性的值可以是表中列出的常量的其中一个。

编码常量编码对应的底层数据结构
REDIS_ENCODING_INTlong 类型的整数
REDIS_ENCODING_EMBSTRembstr 编码的简单动态字符串
REDIS_ENCODING_RAW简单动态字符串
REDIS_ENCODING_HT字典
REDIS_ENCODING_LINKEDLIST双端链表
REDIS_ENCODING_ZIPLIST压缩列表
REDIS_ENCODING_INTSET整数集合
REDIS_ENCODING_SKIPLIST跳跃表和字典

每种类型的对象都至少使用了两种不同的编码,下表列出了每种类型的对象可以使用的编码。

类型编码对象
REDIS_STRINGREDIS_ENCODING_INT使用整数值实现的字符串对象。
REDIS_STRINGREDIS_ENCODING_EMBSTR使用 embstr 编码的简单动态字符串实现的字符串对象。
REDIS_STRINGREDIS_ENCODING_RAW使用简单动态字符串实现的字符串对象。
REDIS_LISTREDIS_ENCODING_ZIPLIST使用压缩列表实现的列表对象。
REDIS_LISTREDIS_ENCODING_LINKEDLIST使用双端链表实现的列表对象。
REDIS_HASHREDIS_ENCODING_ZIPLIST使用压缩列表实现的哈希对象。
REDIS_HASHREDIS_ENCODING_HT使用字典实现的哈希对象。
REDIS_SETREDIS_ENCODING_INTSET使用整数集合实现的集合对象。
REDIS_SETREDIS_ENCODING_HT使用字典实现的集合对象。
REDIS_ZSETREDIS_ENCODING_ZIPLIST使用压缩列表实现的有序集合对象。
REDIS_ZSETREDIS_ENCODING_SKIPLIST使用跳跃表和字典实现的有序集合对象。

通过encoding属性来设定对象所使用的编码,而不是为特定类型的对象关联一种固定的编码,是为了实现同一对象类型,支持不同的底层实现。这样就能根据不同场景,使用不同的底层数据结构,进而极大提升 Redis 的灵活性和效率。


字符串对象

字符串对象的编码可以是 intraw 或者 embstr

  • 如果一个字符串对象保存的是整数值,并且这个整数值可以用 long 类型来表示,那么字符串对象会将整数值保存在 ptr 属性里面(将 void* 转换成 long ),并将字符串对象的编码设置为 int

  • 如果字符串对象保存的是一个长度小于等于 39 字节的字符串,则编码类型为 embstr ,底层数据结构为 embstr 编码的 SDS 。

  • 如果字符串对象保存的是一个长度大于 39 字节的字符串,则编码类型为 raw ,底层数据结构为 SDS 。

注意 3.2 版本长度边界变为 45

embstr 编码是专门用于保存短字符串的一种优化编码方式,这种编码和 raw 编码一样,都使用 redisObject 结构和 sdshdr 结构来表示字符串对象,区别是 raw 编码会调用两次内存分配函数来分别创建 redisObject 结构和 sdshdr 结构,而 embstr 编码则只调用一次内存分配一块连续的空间,空间同时包含redisObject结构和sdshdr结构

embstr 编码的字符串对象在执行命令时,产生的效果和 raw 编码的字符串对象执行命令时产生的效果是相同的,但使用 embstr 编码的字符串对象来保存短字符串值有以下好处:

  1. embstr 编码将创建字符串对象所需的内存分配次数从 raw 编码的两次降低为一次。
  2. 释放 embstr 编码的字符串对象只需要调用一次内存释放函数, 而释放 raw 编码的字符串对象需要调用两次内存释放函数。
  3. 因为 embstr 编码的字符串对象的所有数据都保存在一块连续的内存里面, 所以这种编码的字符串对象比起 raw 编码的字符串对象能够更好地利用缓存带来的优势。
redis> SET story "Long, long, long ago there lived a king ..."
OK
redis> STRLEN story
(integer) 45
redis> OBJECT ENCODING story
"raw"

redis> SET msg "hello"
OK
redis> OBJECT ENCODING msg
"embstr"

redis> SET number 10086
OK
redis> OBJECT ENCODING number
"int"
编码转换

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

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

  • 对于embstr编码来说,只要我们修改了字符串的值,总会变成一个 raw 编码的字符串对象。

因为 Redis 没有为 embstr 编码的字符串对象编写任何相应的修改程序 (只有 int 编码和 raw 编码的字符串对象有这些程序),所以 embstr 编码的字符串对象实际上是只读的:当我们对 embstr 编码的字符串对象执行任何修改命令时,程序会先将对象的编码从 embstr 转换成 raw ,然后再执行修改命令。


列表对象

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

ziplist 编码的列表对象使用压缩列表作为底层实现, 每个压缩列表节点(entry)保存了一个列表元素。

另一方面, linkedlist 编码的列表对象使用双端链表作为底层实现, 每个双端链表节点(node)都保存了一个字符串对象, 而每个字符串对象都保存了一个列表元素。

注意, linkedlist 编码的列表对象在底层的双端链表结构中包含了多个字符串对象, 这种嵌套字符串对象的行为在哈希对象、集合对象和有序集合对象中都会出现, 字符串对象是 Redis 五种类型的对象中唯一一种会被其他四种类型对象嵌套的对象

编码转换

当列表对象保存的所有字符串元素的长度都小于 64 字节,且元素个数小于 512 个时,列表对象采用的是 ziplist 编码,否则使用 linkedlist 编码

可以通过配置文件修改默认值


哈希对象

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

ziplist 编码的哈希对象使用压缩列表作为底层实现, 每当有新的键值对要加入到哈希对象时, 程序会先将保存了键的压缩列表节点推入到压缩列表表尾, 然后再将保存了值的压缩列表节点推入到压缩列表表尾。

hashtable 编码的哈希对象使用字典作为底层实现,字典是一种用于保存键值对的数据结构,Redis 的字典使用哈希表作为底层实现,一个哈希表里面可以有多个哈希表节点,每个哈希表节点保存的就是一个键值对。

编码转换

当哈希对象保存的所有键值对的键和值的字符串长度都小于64个字节,并且键值对数量小于512个时,使用ziplist编码,否则使用hashtable编码

可以通过配置文件修改默认值


集合对象

集合对象的编码可以是 intset 或者 hashtable

intset 编码的集合对象使用整数集合作为底层实现, 集合对象包含的所有元素都被保存在整数集合里面。

hashtable 编码的集合对象使用字典作为底层实现, 字典的每个键都是一个字符串对象, 每个字符串对象包含了一个集合元素, 而字典的值则全部被设置为 NULL

编码转换

当集合对象保存的所有元素都是整数值,并且元素数量不超过512个时,使用intset编码,否则使用hashtable编码


有序集合对象

有序集合的编码可以是 ziplist 或者 skiplist

ziplist 编码的有序集合对象使用压缩列表作为底层实现, 每个集合元素使用两个紧挨在一起的压缩列表节点来保存, 第一个节点保存元素的成员(member), 而第二个元素则保存元素的分值(score)。

skiplist 编码的有序集合对象使用 zset 结构作为底层实现, 一个 zset 结构同时包含一个字典和一个跳跃表

typedef struct zset {
    zskiplist *zsl;
    dict *dict;
} zset;

zset 结构中的 zsl 跳跃表按分值从小到大保存了所有有序集合元素, 每个跳跃表节点都保存了一个元素: 跳跃表节点的 object 属性保存了元素的成员, 而跳跃表节点的 score 属性则保存了元素的分值。 通过这个跳跃表, 程序可以对有序集合进行范围型操作, 比如 ZRANK 、 ZRANGE 等命令就是基于跳跃表 API 来实现的。

除此之外, zset 结构中的 dict 字典为有序集合创建了一个从成员到分值的映射, 字典中的每个键值对都保存了一个集合元素: 字典的键保存了元素的成员, 而字典的值则保存了元素的分值。 通过这个字典, 程序可以用 O(1) 复杂度查找给定成员的分值, ZSCORE 命令就是根据这一特性实现的, 而很多其他有序集合命令都在实现的内部用到了这一特性。

有序集合每个元素的成员都是一个字符串对象, 而每个元素的分值都是一个 double 类型的浮点数。 值得一提的是, 虽然 zset 结构同时使用跳跃表和字典来保存有序集合元素, 但这两种数据结构都会通过指针来共享相同元素的成员和分值, 所以同时使用跳跃表和字典来保存集合元素不会产生任何重复成员或者分值, 也不会因此而浪费额外的内存


为什么zset同时使用跳跃表和字典来实现?

在理论上来说, 有序集合可以单独使用字典或者跳跃表的其中一种数据结构来实现, 但无论单独使用字典还是跳跃表, 在性能上对比起同时使用字典和跳跃表都会有所降低。

  • 如果只使用字典来实现: 虽然能以 O(1) 复杂度查找成员的分值, 但因为字典以无序的方式来保存集合元素, 所以每次在执行范围型操作 —— 比如 ZRANK 、 ZRANGE 等命令时, 程序都需要对字典保存的所有元素进行排序, 完成这种排序需要至少 O(Nlog N) 时间复杂度, 以及额外的 O(N) 内存空间 (因为要创建一个数组来保存排序后的元素)。

  • 如果只使用跳跃表来实现:虽然跳跃表执行范围型操作有优势, 但因为没有了字典, 所以根据成员查找分值的操作复杂度将从 O(1) 上升为 O(log N)

总结:为了提升性能,让有序集合的查找和范围型操作都尽可能快地执行, Redis 选择了同时使用字典和跳跃表两种数据结构来实现有序集合

编码转换

当有序集合保存的所有元素成员长度都小于64字节,且元素数量小于128个时,使用ziplist编码,否则使用skiplist编码


参考资料

《Redis 设计与实现》——黄健宏

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值