Redis字符串的底层实现:SDS

文章目录

        • 哪些值会被保存到SDS里?
        • SDS结构体
        • 一个疑问
        • 空间预分配
        • Redis没有直接使用C语言字符串的原因
          • 1. 效率低:获取字符串长度时间复杂度高
          • 2. 可能出现缓冲区溢出
          • 3. 二进制不安全
          • 4. 内存重分配次数太过频繁
        • C字符串和SDS对比
        • 参考资料

简介:字符串是Redis里最常用到的一种数据类型,Redis的key都是字符串类型的。在Redis中,默认字符串底层实现就是SDS(Simple Dynamic String),即简单动态字符串。


哪些值会被保存到SDS里?

当我们执行命令:

redis> SET name "zhangsan"

其中key的值name会被作为字符串保存在SDS中,因为value的值zhangsan在这里也是字符串,也会被保存在SDS中。

对于其他命令,如列表插入操作:

redis> RPUSH book "Java" "Golang" "C#" 

book和3个值JavaGolangC#都是以字符串形式保存的,所以底层实现也都是SDS。


SDS结构体

因为Redis是用C语言实现的,它的结构体为:

struct sdshdr {
    //当前存储字节数组buf已使用的字节数量,不包含终止符'\0'所占的1个长度数量
    int len;
    //buf数组未使用的字节数量
    int free;
    //字节数组,用于保存字符串内容
    char[] buf;
};

如下图1,字符串内容"Hello Redis"存储在12个长度的buf字符数组里,"Hello Redis"共11个字符(包括中间1个空格符,但不包括最后的终结符\0),所以len=11,由于没有空闲的空间了,所以free=0。

Redis字符串的底层实现:SDS_第1张图片

对于下图2,buf数组实际长度为12,存储内容为"Hello",存储字符串所占空间len=5,后面6个空白格代表剩余空闲空间,则free=6。

Redis字符串的底层实现:SDS_第2张图片

它们之间的关系可以表示为:free + len + 1 = buf数组分配到的实际长度,其中1代表额外存储的终结符’\0’。


一个疑问

在看SDS结构体定义的时候有个疑惑:len为什么不是long类型而是int类型呢?不怕int值不够用吗?

首先我们知道,Redis官方指出了字符串最大可容纳长度为512M,即229字节,个人认为Redis采用的C编译器int类型最大值为231-1,所以用int类型存储字符串长度对Redis来说是足够的。这里仅为个人见解,如有错误,请评论区指出~

Redis字符串的底层实现:SDS_第3张图片


空间预分配

​ 当SDS的字符串需要增长时,SDS API会先检查未使用空间是否够用,如果足够则直接使用未使用空间,如果当前len+free空间不足以容纳增长后的字符串,Redis需要为SDS分配到能容纳增长后字符串的空间len,除此之外,还会对未使用空间free变量进行额外分配,字符串增长操作是通过Redis自己实现的sdscat函数进行的。

若当前free+len不足以容纳增长后的字符串时,未使用空间的分配策略有如下2种:

  1. 若增长后字符串小于1M,则分配的free等于len

    (1) 如下图,“Hello"增长为11个字节的"Hello World”,由于当前(free + len = 5) < 1111 < 1M,所以len增长为11,free同样增长为11。

Redis字符串的底层实现:SDS_第4张图片

(2)下图同上,(free + len = 6) < 1111 < 1M,len增长为11,free同样增长为11

Redis字符串的底层实现:SDS_第5张图片

  1. 若增长后的字符串大于等于1M,则分配的free等于1M

    (1) 增长前len=1023,增长后大于了1M,free分配为1M,即使从1023增长到10M,free也同样是1M不变,而不是10M。

Redis字符串的底层实现:SDS_第6张图片

分配策略源码:

Redis字符串的底层实现:SDS_第7张图片

/*
 * 最大预分配长度
 */
#define SDS_MAX_PREALLOC (1024*1024)

Redis没有直接使用C语言字符串的原因
1. 效率低:获取字符串长度时间复杂度高

C语言:在获取字符串长度时需要通过strlen函数或者从首地址开始往后遍历,直到遇到’\0’,时间复杂度为O(n),其中n为字符串有效长度,当值越大时,读取的时间就越长,这对追求高性能的Redis来说无疑是不能接受的,更何况只是为了读取长度而已。

Redis:在读取字符串长度方面,Redis选择了自己维护一个len变量用以保存当前长度,所以读取时间复杂度只需要O(1),当长度改变时只需要同步更新len的值就行,空间换时间,这也是Redis快的一个原因。

2. 可能出现缓冲区溢出

C语言:假设有2个字符串s1:"Redis"和s2:"MongoDB",它们的内存地址是紧挨着的,当我们使用C函数strcat为s1拼接上" Cluster"时,在拼接前如果s1没有提前分配足够的空间,就会导致缓冲区溢出,数据将覆盖到s2的内存。如下图,此时s2是感知不到自身已经被改变了的,读取到的s2居然从MongoDB变成了Cluster

Redis字符串的底层实现:SDS_第8张图片

Redis:SDS的空间分配策略杜绝了缓冲区溢出的可能性,每次对字符串修改时都会先对空间进行检查,如果空间不足会提前扩展SDS的空间,保证了不会出现缓冲区溢出的问题。

3. 二进制不安全

C语言:C的字符串默认读到\0值就结束,而Redis中的字符串需要存储各种value值,中间是可能出现\0的,如图片、音频、视频文件等部分二进制内容为abcd\0ef,如果此时使用C字符串存储,读取到的将是abcd,那么就会出现读取到错误值的问题。

Reids:SDS不会对数据做任何的限制、过滤操作,因为SDS是用len属性判断是否结束,而不是通过结束符\0,所以数据写入时是什么样的,读出来就是什么样的,因此SDS可以保存任意格式的数据。

4. 内存重分配次数太过频繁

C语言:每次增长或缩减字符串时,都要对数组进行一次内存重分配。

Redis

​ (1) 在增长字符串时,SDS底层调用sdscat函数,会视情况按某种策略提前申请好剩余空间大小,以备下次增长时使用,不会每次都需要申请新的内存空间(空间预分配);

​ (2) 在缩减字符串时,采用惰性空间释放,SDS底层调用sdsrange或sdstrim等函数,只需将len减小n个长度,free增大n个长度,再改变终结符\0的位置即可。

sdsrange函数源码:

Redis字符串的底层实现:SDS_第9张图片

sdstrim函数源码:

Redis字符串的底层实现:SDS_第10张图片


C字符串和SDS对比

Redis字符串的底层实现:SDS_第11张图片

参考资料
  • 《Redis设计与实现》–黄健宏
  • sds源码:
    • https://github.com/huangz1990/redis-3.0-annotated/blob/unstable/src/sds.c
    • https://github.com/huangz1990/redis-3.0-annotated/blob/unstable/src/sds.h

你可能感兴趣的:(Redis,redis,数据结构,字符串,经验分享,程序人生)