Redis底层数据结构一共有 6 种,分别是简单动态字符串、双向链表、压缩列表、哈希表、跳表和整数数组。它们和数据类型的对应关系如下图所示:

OBJECT ENCODING key 该命令是用来显示那五大数据类型的底层数据结构。
String数据结构类型
Redis使用了SDS作为默认的字符串表示,SDS是简单动态字符串(Simple Dynamic String)的缩写。SDS结构如下所示:
struct sdshdr {
int len; //buf已使用的长度
int free; //buf未使用的长度
char buf[]; //buf表示字节数组,用来存储字符串
};
-
Redis 为什么要重新定义SDS 去存储string呢?
1、c语言没有string类型, 只有char[],且char[]必须先分配空间长度;
2、获取char[]的长度,需要遍历数组,len(char[])时间复杂度O(n);
3、char[]预先分配了长度,数据增长后需要扩容;
4、c语言的char数组,用'\0'代表结束,意味着存储二进制数据不能包含'\0',图片音频等用二进制存储会有问题——这就是为什么Redis说自己实现的SDS是二进制安全的字符串。

通过SDS的结构可以看出,buf数组的长度=free+len+1(其中1表示字符串结尾的空字符);所以,一个SDS结构占据的空间为:free所占长度+len所占长度+ buf数组的长度=4+4+free+len+1=free+len+9。
4 字节的 len,可表示的字符串长度为
2^32-1,在实际应用中,存放于 Redis 中的字符串往往没有这么长,没有必要每个字符串都让 len 和 free 为 4 字节。
简单动态字符串SDS,字符串对象的编码可以是int,raw或者embstr。
- int 编码:保存的是可以用 long 类型表示的整数值。
- raw 编码:保存长度大于44字节的字符串(redis3.2版本之前是39字节,之后是44字节),可分配多次内存空间
- embstr 编码:保存长度小于44字节的字符串(redis3.2版本之前是39字节,之后是44字节)存储简短字符串,一次的内存分配;它是只读的,如果对内容进行修改,就会变成raw编码(即使没超过44字节);
- 关于redis3.2版本之前embstr转成raw的条件为什么长度是44?
首先这里要介绍下Redis的存储结构,其中所有对象都可以由redisObject结构来表示,可以参考我这篇博客:Redis第七讲 Redis存储模型详解
redisObject 占 16 个字节,当 buf 数组内的字符串长度是 39 时,sdshdr 的大小为 4+4+39+1=48,那一个字节是 ‘\0’,加起来刚好 64。embstr 最小为 16+4+4+1=25,而分配 32 字节的话 buf 只有 7 字节存储,长度太小了,所以实际会分配64字节,即 Redis 认为如果超过 64 字节就是大字符串。当字符数小于 39 时,都会分配 64 字节。
- embstr转成raw(embstr->raw)的条件
- embstr 被修改
- 长度超过44
- raw 原生SDS 字符长度 缩减到小于44,会逆向变成embstr编码吗?
不会;Redis底层编码,转变后 不可逆(不会回退)
从2.4版本开始,Redis 开始使用 jemalloc 内存分配器。这个比 glibc 的 malloc 要好不少,还省内存。在这里可以简单理解,jemalloc 会分配8,16,32,64等字节的内存。
无论是DictEntry对象,还是RedisObject、SDS对象,都需要内存分配器(如jemalloc)分配内存进行存储。当Redis存储数据时,会选择大小最合适的内存块进行存储。
SDS的结构优点?
- 存取二进制(二进制安全问题):SDS可以存储二进制数据,C字符串不可以。因为C中的字符串是以空字符串结尾,而对于二进制数据内容可能会包含空字符串,因此C字符串无法正确读取。而SDS以字符串长度len来作为字符串结束标识,因此没有这个问题。
- 内存重分配:使用C字符串时,若要修改字符串需要重新分配内存(先释放再申请),如果没有重新分配,字符串增大时容易造成内存溢出,字符串减小容易造成内存泄漏。而SDS由于可以记录len和free,因此解除了字符串长度和底层数组长度的耦合,可以进行优化。空间预分配策略(即分配内存时比实际需要的多)使得字符串长度增大时重新分配内存的概率大大减小;惰性空间释放策略使得字符串长度减小时重新分配内存的概率大大减小。
- 缓冲区溢出:由于上一个的内存重分配问题,造成了这里的缓冲区溢出问题。使用C字符串时,如果字符串长度增加时忘记重新分配内存,很容易造成内存溢出的问题。而SDS由于记录了长度,相应的API在可能造成缓冲区溢出时会自动重新分配内存,杜绝了缓冲区溢出。
因为 C 字符串不记录自身的长度,所以 strcat 会假定用户在执行这个函数时,已经为 dest 分配足够多的内存了,可以容纳 src 字符串中的所有内容,而一旦这个假设不成立,就会产生缓存区溢出。
在 C 语言中,字符串并不记录自身的长度信息,所以为了获取一个 C 字符串的长度,程序必须遍历整个字符串,对遇到的每个字符串进行计数,直到遇到代表字符串结尾的空字符串为止,整个时间复杂度为
O(N)。但是这种简单的操作不应该成为 Redis 性能的瓶颈,于是 Redis 定义了一个
len属性,专门用于存储字符串的长度,获取字符串的长度操作的时间复杂度为O(1),典型的空间换时间。
-
SDS对c原始char数组的改进
1、Redis实现的SDS支持扩容
2、包含长度len,获取长度复杂度O(1)
3、空间预分配
4、惰性空间释放
缓存区溢出问题
C 语言不记录自身长度带来的另外一个问题就是容易造成缓存区溢出。简单的来说,缓存区溢出通常指向缓存区写入了超过缓存区所能保存的最大数据量的数据。比如我们使用 char *strcat(char *dest, const char *src); 函数来将 src 字符串中的内容拼接到 dest 字符串的末尾。
因为 C 字符串不记录自身的长度,所以 strcat 会假定用户在执行这个函数时,已经为 dest 分配足够多的内存了,可以容纳 src 字符串中的所有内容,而一旦这个假设不成立,就会产生缓存区溢出。
所以在使用 strcat 拼接两个字符串时,一定要判断第一个字符串后面是否有足够的内存空间;如果不够了,就得手动扩容,这一系列判断 + 扩容操作需要开发人员自己去完成,步骤有些麻烦。
而 Redis SDS 提供的所有修改字符串的 API 中,都会判断修改之后是否会有内存溢出,SDS 会帮我们处理内存扩容,无需我们开发人员手动判断 + 扩容。
如何高效的进行字符串追加
我们前面了解到,C 字符串本身不记录字符串的长度,所以对于一个包含了 N 个字符的 C 字符串来说, 这个 C 字符串的底层实现总是一个 N+1 个字符长的数组(额外的一个字符空间用于保存空字符)。
因为 C 字符串的长度和底层数组的长度之间存在着这种关联性,所以每次增长或者缩短一个 C 字符串, 程序都总要对保存这个 C 字符串的数组进行一次内存重分配操作:
- 增长操作: 比如拼接操作(append),执行增长操作之前需要先进行内存重分配,以扩展底层数组的空间大小,如果忘了就会导致缓冲区溢出。
- 缩短操作: 比如截断操作(trim),执行缩短操作之后需要进行内存重分配,来释放掉字符串不再使用的那部分空间,如果忘了就会导致内存泄漏。
所以 Redis 为 SDS 设计了冗余空间,追加时只要内容不是太大,是可以不必重新分配内存的。
也就是 空间预分配 和 惰性删除。
从上面的几个问题我们可以看出,对于追求速度、效率的 Redis 来说,使用 char 来存储字符串并不是一个好的选择,因此 Redis 定义了一个新的数据结构 SDS。
频繁内存分配问题处理
每次增长或者缩短一个 C 字符串,程序都需要对保存这个 C 字符串的数组进行一次内存重新分配操作。因为内存重分配涉及复杂的算法,并且可能需要执行系统调用,所以它通常是一个比较耗时的操作。
为了避免 C 字符串的这种缺陷, SDS 通过未使用空间解除了字符串长度和底层数组长度之间的关联。通过未使用空间,SDS 实现了空间预分配和惰性空间释放两种优化策略。
空间预分配
空间预分配用于优化 SDS 的字符串增长操作:当 SDS 的 API 对一个 SDS 进行修改,并且需要对 SDS 进行空间扩展的时候,程序不仅会为 SDS 分配修改所必须要的空间,还会为 SDS 分配额外的未使用空间。
其中,额外分配的未使用空间数量由以下公式决定:
- 如果对 SDS 进行修改之后,SDS 的长度将小于 1MB,那么程序分配和
len属性同样大小的未使用空间,这时候free=len;- 如果对 SDS 进行修改之后,SDS 的长度将大于等于 1MB,那么程序会分配 1MB 的未使用空间,这时候free=1MB;
在扩展 SDS 空间之前,SDS API 会先检查未使用空间是否足够,如果足够的话,API 就会直接使用未使用空间,而无需执行内存重分配。
通过空间预分配策略,Redis 可以减少连续执行字符串增长操作所需的内存重分配次数。
Redis最大KV长度都是 512M,也就是K(字符串SDS)算的空间单位,也就是len和free是容量,而非长度.
惰性空间释放
惰性空间释放用于优化 SDS 的字符串缩短操作:当 SDS 的 API 需要缩短 SDS 保存的字符串时,程序并不立即使用内存重分配来回收缩短后多出来的字节,而是使用 free 属性将这些字节的数量记录下来,并等待将来使用。
通过惰性空间释放策略,SDS 避免了缩短字符串时所需的内存重分配操作,并为将来可能有的增长操作提供了优化。
SDS 数据结构
struct sdshdr {
unsigned int len; //buf 中已占字节数,len 的长度不包括 \0
unsigned int free; //buf 中剩余空闲字节数
char buf[]; //一个 char 类型的字符数组,用于存储实际字符串的内容,以 \0 结尾,可以使用 C 语言中现成的库函数
};
这种数据结构解决下以下问题:
- 使用
len来存储字符数组长度,因此获取字符串长度的时间复杂度为 O(1) (C 语言中获取字符串长度的时间复杂度为 O(N)。),同时读写字符串不依赖\0,保证了二进制安全。 - 使用
free来存储未使用的字节,来避免频繁的内存分配,减少耗时。 - 使用
buff来存储字符串内容。字符串仍然以\0作为结尾,是为了和 C 语言字符串保持一致,这样就可以复用 C 语言字符串相关函数,避免重新造轮子。 - 使用柔性数组 来存储
buf。SDS 对上层暴露的指针不是指向结构体 SDS 的指针,而是直接指向柔性数组 buf 的指针。上层可像读取 C 字符串一样读取 SDS 的内容,兼容 C 语言处理字符串的各种函数。
Redis 3.2 之前 SDS 的设计存在的问题及解决方案
上面的SDS数据结构是Redis3.2之前的版本,它能以 O(1) 的时间复杂度得到字符串的长度,并解决了二进制安全问题和缓存区溢出的问题。同时能够高效的对字符串进行追加操作,避免了频繁的扩容和缩容。
关于他的改进空间,可以根据他的数据结构来思考以下方面:
不同长度的字符串是否有必要占用相同大小的头部?一个 int 占 4 字节,在实际应用中,存放于 Redis 的字符串往往没有这么长,每个字符串都用 4 字节存储未免太浪费空间了。
我们考虑三种情况:短字符串,len 和 free 的长度为 1 字节就够了;长字符串,用 2 字节或 4 字节;更长的字符串,用 8 字节。
这样确实更省内存,但依然存在以下问题。
- 如何区分三种情况?
- 对于短字符串来说,头部还是太长了。以长度为 1 字节的字符串为例,len 和 free 本身就占了 2 字节,能不能进一步压缩呢?
对于问题 1,我们考虑增加一个字段 flags 来标识类型,用最小的 1 字节来存储,且把 flags 加在柔性数组 buf 之前,这样虽然多了 1 字节,但是通过偏移柔性数组的指针即能快速定位 flags,区分类型,也可以接受;对于问题 2,由于 len 已经是最小的 1 字节了,再压缩只能考虑用位来存储长度了。
结合两个问题,5 种类型(长度 1 字节、2 字节、4 字节、8 字节、小于 1 字节)的 SDS 至少需要 3 位来存储类型(2^3=8),1 个字节 8 位,剩余的 5 位存储长度,可以满足长度小于 32 的短字符串。
Redis 5 中 SDS 数据结构
在 Redis 5.0 中,我们用如下结构来存储长度小于 32 的短字符串。
struct __attribute__ ((__packed__)) sdshdr5 {
unsigned char flags; /* 低 3 位存储类型,高 5 位存储长度 */
char buf[];
};
在 Redis 3.2 版本之后,Redis 将 SDS 划分为 5 种类型:
sdshdr5:长度小于 1 字节
sdshdr8:长度 1 字节
sdshdr16:长度 2 字节
sdshdr32:长度 4 字节
sdshdr64:长度 8 字节
优化:根据字符串的长度,使用不同的数据结构进行存储
Redis 增加了一个 flags 字段来标识类型,用一个字节 (8 位) 来存储,在 sdshdr5 中:前 3 位表示字符串的类型;剩余 5 位,可以用来存储数组长度 len (5 bit) 。
而对于长度大于 31 的字符串,仅仅靠 flags 的后 5 位来存储长度明显是不够的,需要用另外的变量来存储。sdshdr8、sdshdr16、sdshdr32、sdshdr64 的数据结构定义如下:其中 len 表示已使用的长度,alloc 表示总长度,alloc - len 其实就是之前版本的 free 字段,buf 存储实际字符串内容,而 flags 的前 3 位依然存储类型,后 5 位则预留。
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len; /* 已使用长度,1字节 */
uint8_t alloc; /* 总长度,1字节 */
unsigned char flags; /* 前3位存储类型,后5位预留 */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr16 {
uint16_t len; /* 已使用长度,2字节 */
uint16_t alloc; /* 总长度,2字节 */
unsigned char flags; /* 前3位存储类型,后5位预留 */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
uint32_t len; /* 已使用长度,4字节 */
uint32_t alloc; /* 总长度,4字节 */
unsigned char flags; /* 前3位存储类型,后5位预留 */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {
uint64_t len; /* 已使用长度,8字节 */
uint64_t alloc; /* 总长度,8字节 */
unsigned char flags; /* 前3位存储类型,后5位预留 */
char buf[];
};
看到这里可以先总结一下结论:3.2版本之后 embstr 与 raw 编码的分界不再是 39,而是 44。
本身就是针对短字符串的 embstr 自然会使用最小的 sdshdr8,而 sdshdr8 与之前的 sdshdr 相比正好减少了5个字节。为什么呢,这里计算一下除去 buf 数组以外变量所占空间:
- sdsdr8 (len + alloc + flags) = 1+1+1 = 3
- sdshdr (len + free) = 4+4 = 8
所以 buf 数组能容纳的字符串长度增加了 5 个字节,变成了 44 !
具体的分析,大家可以参考这篇文档:要懂redis,首先得看懂sds(全网最细节的sds讲解)
整个Redis主体数据结构图如下:

Redis中的SDS(Simple Dynamic String)是用于存储字符串的数据结构,具备二进制安全、高效追加、空间预分配等特性。它通过记录长度len和空闲空间free避免了C字符串的一些问题,如内存溢出和频繁的内存重分配。Redis 3.2之前的SDS设计在内存使用上有所浪费,5.0版本引入了flags字段优化短字符串存储,提高了空间利用率。
1240

被折叠的 条评论
为什么被折叠?



