写点什么

Redis 数据类型及底层大剖析

  • 2023-05-03
    湖南
  • 本文字数:3540 字

    阅读完需:约 12 分钟

Redis 有丰富的数据类型,但最基本也是最常见的有下面五种:String、List、Set、Hset、Zset


数据类型整体来说会比较杂且枯燥一点,但是其在 Redis 中作用非常之大,为什么 Redis 能那么快呢,一方面来说因为其是内存型数据库,另外一方面也是因为其提供丰富的数据结构类型来加速性能。所以可以说这是学习 Redis 的第一关。

一、整体结构的了解

首先,我们先来对数据类型有个整体轮廓的了解。Redis 是一种 key-value 的存储结构,在 Redis 中,key 和 value 都被抽象为了对象,key 只能为 string 对象,而 value 则有多个,包括上文提到的五种基础数据类型以及 Redis 后续追加的四种数据类型。


在 Redis 这种对象的结构体如下:

  • type:表示哪种对象

  • encoding:表示底层的编码

  • lru:记录了对象的访问信息。用于内存淘汰的,内存淘汰有 8 种策略,一种是 lru,以及后面推出的 lfu 等等

  • refcount:引用计数,描述多少个指针指向该对象

  • ptr:内容指针,指向实际的内容

二、五种基本数据类型

1、String

String 对应的 value 可以是字符串,也可以是整形或者浮点型的数字,甚至是图片、视频、音频等二进制数据。value 能容纳最大的数据长度为 512M。而同时我们也要去了解这些基本类型的底层实现以及编码方式。


String 类型底层实现:int sds(simple dynamic string)


String 类型编码方式:intembstr raw


联系为:

int 很好理解,因为上文说了 String 可以存数字,所以存在这种编码方式与实现。


sds 的引入,主要是 c 语言的字符串过于简单导致有些方面存在缺陷,而 Redis 又是 C 语言实现的。所以 Redis 又在此基础之上封装出了一个结构 sds,正如那句计算机名言:没有什么问题不可以通过添加一层抽象来解决。


具体来说,C 中的字符串:

  • 每次计算字符串长度的复杂度需要 O(n)

  • 同时对字符串进行追加,需要重新分配内存

  • 非二进制安全


在 Redis 内部,字符串的追加以及返回长度是很常见的,而追求高性能的 Redis 肯定是不容许这些事情发生的,所以进行了结构的封装。sds 又分为 sds8,sds16,sds32,sds64,字段属性没有区别。结构如下:

首先,记录了 len 字段,可以 O(1)返回字符串的长度,同时 alloc 表示分配的内存,alloc-len 就是预留空间,预留的空间为 min(len,1M)。flags 代表 sds 的分类,上面说了 sds 分为了 4 种。同时不再以‘\0’作为结束标识,但内联数组最后会是'\0'结尾,二进制安全,拼接字符串前也会检查空间是否满足要求,不会导致缓冲区溢出的问题。


而对于三种编码方式,当存储的字符串是整数值,且大小在 long 范围,则采用 int 编码方式实现 int 数据结构,而当字符串长度比较小,采用 embstr 编码,当超过一定的长度 embstr 会变为 raw 编码。这个阈值在 3.2 版本之前是 39 字节,3.2 版本版本之后是 44 字节。


对于 embstr,它的 RedisObject 和 sds 是连续的,而 raw 下面这两个在内存是分开的。同时需要注意的是 embstr 被设置为了只读,任何写操作 embstr 都会变为 raw,理念就是修改过的字符串通常认为是易变的,计算机的局部性原理。而对于 embstr 来说,它的 RedisObject 是连续的,耦合性更强,当需要重新分配 embstr 的时候,这两个部分需要重新分配,而对于 raw 来说,它的 redisObject 不需要重新分配。


关于阈值的数值,有兴趣的可以继续往下看:


首先,Redis 使用了 jemalloc 作为内存分配器,jemalloc 以 64 字节为阈值区分大小字符串,所以 embstr 的阈值其实是受 64 字节的影响。超过 64 字节就超过一个内存单元,会用 raw 编码,反之认为是一个小字符串,用 embstr 编码。接着分析:我们也知道这两种编码方式由 RedisObject 和 sds 两部分组成,分别分析即可。


对于 RedisObject,其大小没有变过,大小为 4+4+24+32+64=128bits=16B

而对于 sds,内存大小为 1B+1B+1B+内联数组大小,同时内联大小最后有一个'\0'占一个字节。所以还剩下能用的大小就为:64-16-3-1=44

而对于之前为什么是 39 呢?少了 5 个字节,首先,下图是之前的 sds 结构图:

当时是通用的 sds 结构,没有细分为 8,16,32,64。3.2 之后的版本的 embstr 用的是 sds8,总容量 len 少了 3 个字节,容量也减少了 3 个字节,总共减少了 6 个字节,但是多了个 flag 字段占用 1 个字节,所以相比于没有拆分 sds 结构来说,embstr 的阈值会多 5 个字节。

2、list

List 列表是简单的字符串列表,按照插入顺序排序,可以从头部或尾部向 List 列表添加元素。列表能存的元素就非常之多了,每个列表支持超过 40 亿个元素。


同样的看看其底层实现与编码方式:

其底层实现是通过压缩列表或双向链表实现的, 当为小列表的时候采用压缩列表,判断小的判断有两个维度,一个是列表数量小(512 个),一个是列表元素占用字节少(64 字节)。当不满足 ziplist 编码的条件会转为 linkedlist 编码(linkedlist 底层是通过双向链表进行实现的),Redis 作为一种内存型数据库,很多时候我感觉是在有限的内存以及性能的追求寻找一个平衡,提供多种数据结构。


Redis 在 3.2 版本引进了一种新的数据结构 quicklist,为什么呢,ziplist 是为了在数据较少时节约内存,当数据稍多时插入数据会导致很多内存复制,而当 linkedlist 链表节点很多的话,又会占用很多内存,并不能很好的解决问题。quicklist 是这两者的结合体。单个 quicklist 节点存的是 ziplist,即多个数据,如下图:

  • 当数据较少的时候,quicklist 的节点只有一个,此时相当于就是一个 ziplist

  • 当数据很多的时候,则同时利用了 ziplist 和 linkedlist 的优势。


ziplist 本身存在一个连锁更新的问题,在 Redis7.0 之后,使用 listpack 的编码方式取代了 ziplist,但本质都是一种压缩的列表,可以统称为压缩列表,主要是用来解决连锁更新的问题。


连锁更新的问题大家可以去了解一下 ziplist 中 entry 它的结构,这个结构里面有个 prevlen 属性导致了连锁更新的问题,它用来表示上一个节点的数据长度,通过它可以定位到上一个数据,也因此压缩列表才可以从后向前遍历,如果前一个节点的长度小于 254 字节(255 是特殊字符,被 zlend 占用,表示 ziplist 的结束),这个属性占用一个字节,否则使用 5 个字节去保存长度值。连锁更新指的是后移操作不止发生了一次,而是发生了多次。


比如说当插入一个节点,导致后面的数据进行了一次后移操作,同时,因为它的插入,原本后面结点的 prevlen 属性本来只占用一个属性,而现在变成了五个字节,结点膨胀之后又要逐步迭代更新。实际业务中,很少碰到需要迭代更新超过 2 个结点的情况,但这种情况的存在会使性能不稳定。当然也是从结点的结构定义下手,有一个 element-tot-len 属性表示存储整个结点除它自身之外的长度。


规则如下:每个字节第一个 bit 标识是否结束,0 是结束,省下 7 个 bit 存储数据大小。那么,我们如何从当前结点找到上一个结点的首位置呢,该结点首部即上个结点的 element-tot-len 属性(该属性在结点结构尾部),假如为(00000010 10000101)占用大小的字节为 0000010 0000101 —--十进制—> 261 字节

3、set

Set 是一个无序且不重复的字符串集合


同样的看看其底层实现与编码方式:


其底层实现是通过哈希表或整数集合实现的,出于性能与内存的综合考虑,set 也支持两种编码方式,当元素都是整数且元素个数不超过 512 个,就用 INTSET 编码,否则用哈希表。哈希表能 O(1)时间进行快速查询,而 INTSET 编码只能进行二分查询。


4、HSet/Hash

HSet 是一个键值对集合,很适合拿来存储对象。比如任务信息对象,field 可以设置为任务类型,value 设置为任务配置参数。先来直观了解一下其结构,首先,它是一种 key-value 集合,但需要注意的是它的 value 形式是这样子的:value=[{field1,value1},...{fieldN,valueN}],比如说我们的购物车,永无 id 作为 key,商品 id 作为 field, 商品数量作为 value。


同样的 HSet 也有两种底层实现与编码方式:压缩列表和哈希表(压缩列表在较高的版本 7.0 已经完全是 listpack 了)

  • 当哈希类型元素个数小于 512 个,field 和 value 小于 64 字节,Redis 会使用压缩列表作为 Hash 类型的底层数据结构;

  • 如果哈希类型元素不满足上面条件,Redis 会使用哈希表作为 Hash 类型的 底层数据结构。

5、ZSet/SortedSet

ZSet 比 Set 多了一个排序属性 score,同样的 value 有两个值组成,一个是排序值,一个是元素值。当我们使用 ZADD 为集合添加元素,也是排序值在前,元素值在后。

同样的 ZSet 也有两种底层实现与编码方式:压缩列表跳表+哈希表

  • 如果有序集合的元素个数小于 128 个,并且每个元素值小于 64 字节时,Redis 会使用压缩列表作为 Zset 类型的底层数据结构;(是不包括分数值占用的字节的,score 入参的时候还是 double,还不知道转换成字符串会占用多少字节)

  • 如果有序集合的元素不满足上面的条件,Redis 会使用跳表+哈希表作为 ZSet 类型的底层数据结构;

三、关系总结图

下面是针对 Redis 对外提供的五种基本类型与底层实现的数据结构的关系,图来源于网络。最后 ZSet 对应的跳表最好再加个哈希表。使得可以 O(1)时间复杂度查询到成员的分数值。

作者:本生来滑稽

链接:https://juejin.cn/post/7228551764835156023

来源:稀土掘金

用户头像

还未添加个人签名 2021-07-28 加入

公众号:该用户快成仙了

评论

发布
暂无评论
Redis数据类型及底层大剖析_Java_做梦都在改BUG_InfoQ写作社区