数据结构论坛

首页 » 分类 » 常识 » Redis之数据存储
TUhjnbcbe - 2025/6/11 17:50:00

1简述

使用redis也有一段时间了,这次准备把redis相关的知识点好好汇总一下,大致分为几下几章:数据存储、网络模型、持久化、哨兵主从、集群、分布式消息/队列,本篇文章为redis系列的第一篇:数据存储

2数据类型

开始一门新的数据框架当然还是要从基本的东西开始。像刚开始学java从8大基本数据类型开始一样,接触redis当然也要从它的5大基本数据类型(String、hash、list、set、zset)开始,至于扩展的数据类型(bitmap、HyperLogLog、Geo、Stream、RedisTimeSeries)将在后续涉及的文章中再做介绍。redis快的原因最最主要的还是基于内存,下图是来自中关村的内存读取速度测试结果:

可以看到读取速度基本在恐怖的35GB/s+,最新一代m1max的mac宣称内存速度更是在GB/s。当然mac的内存和服务器的内存还有点区别,不在本章讨论范围。下图是我的固态硬盘的读取速度,用了好几年,速度基本在mb/s+:

如果是机械硬盘,速度更是只有一百多兆/s甚至几十兆/s,跟主流内存的速度至少差了两个量级。

2.1内部数据类型

私认为任何一门新技术的最好文档永远是官方文档,这里是redis的中文文档。当我们执行一个简单的字符串存储时例如:setsunmoon,redis是这么来存储数据的:

dicEntry

众所周知redis是一键值对数据库,而每一个键值对就存储在这个dicEntry结构体中。其中key就是这个键值对的“键”,是一个SDS(simpledynamicstring简单动态字符串),下面会有SDS的介绍,val里存着用redisObject包裹的“值”。next则指向下一个键值对,也就是下一个redis对象。

redisObject

type:用于记录“值”的数据类型,例如我们执行的setsunmoon,这里的”moon”的数据类型显然是字符串,所以type中所存储的值为String。

ptr:用于记录“值”,只不过这个值也是存储在一个SDS中。

SDS

redis是用c语言编写的,但是在redis中字符串定义方式却是将字符串存储在一个结构体里,这个结构体就叫做SDS(simpledynamicstring简单动态字符串)。redis的字符串之所以叫Strings而不是String可能也和这个有关系。SDS一共有三个属性(buf、len、free)。

buf:类型是字节数组,用于存储字符串,在c语言中,以’\0’表示字符串结尾。在本例中dicEntry中’”sun”,就存储在这里。

len:记录buf数组中的字节长度,且不会包含结尾的’\0’字符的长度

free:记录buf数组中空余的字节数据

SDS存储与简单的字符串相比有以下几个优点:

1、降低获取字符串长度的时间:因为SDS讲字符数据的长度信息存储在len里,获取字符串长度的时间复杂度从原来的O(n)降低到O(1),这样在大批量执行strlen(用于获取字符串长度)命令时,redis也可以快速响应。也可以说SDS是在用空间换时间。

2、避免缓冲区溢出:假设两个普通的C字符串在内存中的地址是连续的,S1为”sun”,S2为”moon”:

当我们直接对S1进行追加字符串操作时,比如新增一个”redis”(注意空格),则内存中的存储此时会变成这样:

显然原字符串S2的内容会被冲掉,这种情况就叫做缓冲区溢出,那么SDS是如何来避免缓冲区溢出的呢?首先SDS存储”sun”时是这样的:

在我们追加“redis”(注意空格)这个字符串时,显然长度5大于SDS的free长度,所以SDS在存储前会进行扩容操作,扩容后再拼接,此时的字符串是这样的:

扩容后的len和free是相等的,这也是sds的扩容策略之一。目的是为了降低内存重分配次数。

3、降低内存重分配次数:

对于一个sds,如果需要拼接N次,redis作者显然不会进行N次扩容,因为这样就会涉及N次内存分配,这无疑会影响性能。

实际上在对一个sds字符串进行拼接时,如果要拼接的字符串长度大于sds的free值,且拼接后的字符串长度小于1MB,那么扩容后的free值将恒等于buf的实际长度,即len值。如果拼接后的buff长度大于1MB,那么free值恒等于1MB也就是2^10()字节。上述的sds拼接后buf共占据了9+9+1(表字符串结尾)共19个字节。下次再进行字符串拼接时,如果要拼接的字符串长度小于free(9)则不会进行扩容,这就是sds的*空间预分配。那么问题来了,如果对sds进行字符串进行截掉操作呢?

还是这个sds,我们删除最后6个字节“redis”(注意空格),此时它将变为:

显然内存空间并没有被回收,空余的空间会记录在free里。你也不用担心一个巨大的free值出现,因为sds也提供了手动消除free的api,让我们在真正需要的时候进行操作。这就是sds的惰性空间释放

惰性空间释放和空间预分配本质上都是为了降低redis的内存分配次数,提高整体性能。

4、二进制安全:

我们知道普通的c字符串是以’\0’结尾的,那么图片、视频、音乐、压缩包等格式的文件的二进制编码难免会出现’\0’。c字符串在读取一个文件的二进制时,碰到’\0’就不会往下读了,所以说c串不是二进制安全的。sds本身就是以len的长度来判断字符串的结束,而不是’\0’,所以可以用来存储二进制值。

jemalloc

无论是上述的哪种结构对象(dicEntry、redisObject、SDS)在分配内存时,都需要内存分配器为它们分配内存,而jemalloc是redis的默认内存分配器。了解jemalloc需要先对内存碎片有个大概的理解。举个例子:

{%note::redis做删除值的时候,内存并没有直接被系统回收利用,这部分的内存就叫做内存碎片。%}对内存碎片有兴趣可以看看这里。jemalloc作为redis默认的内存分配器,在控制内存碎片这块做的相对较好。下图是jemalloc的内存分配策略:

例如一个对象占13个字节,jemalloc将会为它分配16哥字节的内存空间。

2.2对外数据类型

5种基本数据类型其实就是redis的对外的表现,但其实每种基本数据类型在redis至少对应两种内部编码,且不同的基本数据类型可能对应同一个内部编码,对应关系如下图所示:

可以看到hashtable和ziplist都被多种数据类型所使用,关于每种基本数据类型的指令可以在这里看到,我在这里主要介绍一下每种数据类型对应的内部编码。其中通过objectencoding可以查看对应的内部编码。

Strings

int:当value值是整数(包括负数)时,采用int编码。且它只占用8个字节,超过这个长度则转为embstr

embstr:用于存储小于等于39个字节的字符串,这个39其实是这么来的:Strings的存储其实是放在redisObject和sds中的,redisObject占用16字节的空间,sds占据9(len:4,free:4结尾:1)字节+buf(实际字符串长度)的空间,当buf恰好等于64-9-16=39时,根据jemalloc的内存分配策略,会正好分配64个字节的内存空间。

raw:那么raw就是用于存储大于39字节的字符串了。且由于embstr是只读的,对于embstr实现的字符串做编辑操作后,其内部实现都会采用raw(不管修改后是否大于39个字节)

hash

哈希不仅是redis的基本数据类型之一,也是redis本身作为键值对数据库所使用的数据结构。用人话说就是hash本身也是key-value结构,当存储一个hash键时,在redis中的存储大致是这样的:

ziplist(压缩表):当存储的值满足以下两个条件时,hash会采用压缩表作为内部实现。

{%note::1、存储的value元素数量小于redis.conf中hash-max-ziplist-entries的值(默认)

note::2、每一个元素(包括键和值)都下雨redis.conf中的hash-max-ziplist-value(默认64)个字节%}

虽然压缩表的增删改的时间复杂度都是O(n),但是因为它里面存储的数据量较小,所以时间也在可接受范围。

下图是{%spangreen::redis.cfg%}文件中关于这两个参数的设置:

hashtable(哈希表):增删改的时间复杂度为O(1),所以当hash里存储较大数据时,默认编码采取哈希表。

list(列表)

list是用链表实现的,这也意味着list的头尾操作的时间复杂度都是O(1),查找的复杂度为O(n),最大容量为2^32-1。在后面我们会用redis实现一个消息队列,用的也是列表。

linkedlist(双端链表):如下图所示,其中value用于存储值,prev指向前一个节点(实际为type为字符串的redisObject),next指向后一个节点。只使用listnode便可以简单的实现一个双向链表。

只使用listnode便可以简单的实现一个双向链表,但是使用list+listnode会降低获取链表长度、总数的复杂度,实现如下:

head:指向链表的表头

tail:指向链表的结尾

len:用于记录链表节点的数量

dup、free、match是节点操作函数,在实现多态链表中会有涉及。

当列表类型无法满足ziplist的条件时(存储的数量或者元素值64),Redis会使用linkedlist作为列表的内部实现。

set

集合(set)和列表都可以用于存储多字符串,且set是无序的。这意味着插入的复杂度为O(1),查找的复杂度为O(n),且set的元素不能重复。另外redis除了提供常规的curd外,还提供取集合的并集、差集、合集。

内部编码为intset(整数集合)和hashtable(哈希表)。

intset:当set中存储的数据都为整数且数量小于redis.conf中set-max-intset-entries(默认)时,采取inset编码。

hashtable:intset无法满足存储要求时将会转位哈希表存储。

zset

zset为有序集合(sortedset),因redis中关于sortedset的命令以z开头,所以又称zset.

zset的有序和list的有序有所不同,list中是依靠数组的索引下标实现的,zset是为每一个元素设置了score来实现有序的。

内部编码中的ziplist(压缩表)和hashtable(哈希表)上文有简述,这里主要介绍一下skiplist(跳跃表)。跳跃表除了用于实现zset外还是redis集群的内部数据结构

跳跃表是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的

-《redis的设计与实现》

跳跃表由skiplistNode和skiplist两个结构体组成,他们的构成如下:

关于跳跃表的实现需要画更多的图利于理解,也算是为自己新开了个坑。后面会用单独的一篇文章来描述跳表。

3总结

redis的数据存储及内部编码是redis入门的基础,在后续的文章将要分享的网络模型、持久化、哨兵集群、分布式消息/队列也离不开redis的数据存储。

1
查看完整版本: Redis之数据存储