Redis内部数据结构详解之简单动态字符串(SDS)

来源:互联网 发布:太原软件 编辑:程序博客网 时间:2024/05/02 01:10
         Redis没有直接使用C语言传统的字符串表示(以空间字符结尾的字符串数组,一下简称C字符串),而是自己构建了一种名为简单动态字符串的抽象类型,并将SDS 用作 Redis 的默认字符串表示。下面我们就来揭开 SDS 这层神秘的面纱吧。

一、SDS的定义:

    每个sds.h/sdshdr结构表示一个SDS值:
    struct sdshdr{
        //记录buf 数组中已使用字节的数量
        //等于 SDS 所保存字符创的数量
        int len ;
        //记录 buf 数组中未使用字节的数量
        int free ;
        //字节数组,用于保存字符创
        char buf[] ;
     };
如图:1-1
展示了一个SDS示例:
                                    图1-1

1.free 属性的值为0,表示这个SDS没有分配任何空间
2.len 属性的值为 5 ,表示这个SDS保存了一个五字节长的字符串
3.buf 属性是一个char 类型的数组,数组的前五个字节分别保存了 'R','e','d','i','s'五个字符,而最后一个字节则保存了空字符 '\0'。

二、SDS与C字符串的区别

    学过C语言都知道,C语言使用长度为N+1的字符数组来表示长度为N 的字符串,并且字符数组的最后一个元素总是空字符串 '\0'。
    例如,图2-1就展示了一个值为 "Redis"的字符串。
    C语言使用这种简单的字符串表示方式,并不能满足Redis 对字符串的安全性,效率以及功能方面的要求,那么接下来的内容将详细对比C字符串和 SDS 之间的区别,并说明SDS比C字符串更适用于 Redis 的原因。

    2.1常数复杂度获取字符串长度

         我们知道在C语言里面是字符串是不记录本身的长度的,如果我们硬要获取C语言里面字符串的长度,程序必须遍历整个字符串,对遇到每个字符串进行计数,知道遇到结尾空字符为止,整个操作的复杂度为O(N),详细过程如下图:
                            
                                                 图2-1 计算C字符创长度的过程


        和C语言不同的是,在SDS 中 len 属性就记录了SDS 本身的长度,所以获取一个 SDS 长度的复杂度仅为O(1)。如下图:
                                
                                                         图2-2   5字节长的SDS
        设置和跟新SDS 长度的工作是由 SDS 的 API 在执行时自动完成的,使用SDS 无需进行任何修改长度的工作。
        通过使用 SDS 而不是C字符串,Redis 将获取字符串长度所需的复杂度从O(N)降到了O(1) ,折确保了获取字符串长度的工作不会成为 Redis 的性能瓶颈。所以大家可以尽情的使用 Redis 的API 获取字符串的长度。

    2.2杜绝缓冲区溢出

            除了获取字符串长度的复杂度高之外,C字符串不记录自身长度带来的另一个问题是容易造成缓冲区溢出。举个例子,<string.h>/strcat 函数可以将 src 字符串中的内容拼接到 dest 字符串的末尾:
           char *strcat(char *dest , const char *scr);
        因为C字符串不记录自身的长度,所以 strcat 假定用户在执行这个函数时,已经为 dest 分配了足够多的内存,可以容纳 src 字符串中的所有内容,而一旦这个假定不成立时,就会造成缓冲区溢出。
        
        而与C字符串不同,SDS 的空间分配策略完全杜绝了发生缓冲区溢出的可能性:当SDS API 需要对SDS 进行修改时,API 会先检查 SDS 的空间是否满足修改所需的要求,如果不满足的话,API会自动将SDS 的空间扩展至执行修改所需的大小,然后才执行实际的修改操作,所以使用SDS 既不需要手动修改 SDS 的空间大小,也不会出现前面所说的缓冲区溢出问题。
        举个例子,SDS 的API里面也有一个用于执行拼接操作的 sdscat 函数,他可以将一个C字符串拼接到给定的 SDS 所保存的字符串的后面,但是在执行拼接操作之前,sdscat 会先检查给定 SDS 的空间是否足够,如果不足够的话,sdscat 就会先扩展 SDS 的空间,然后才执行拼接操作。
        例如:如果我们执行:
            sdscat( s , " Cluster " );                                                                                       
                                    
                                                        图2-3   sdscat执行之前的SDS
               其中SDS 的值 s 如图2-3所示,那么 sdscat 将在执行之前检查s 的长度是否足够,如果不足够话,sdscat 会先扩容,然后再执行拼接操作,拼接之后的 SDS 如下图:

                                                        图2-4  sdscat执行之后的SDS

        注意:上图所示的SDS ,sdscat 不仅对这个 SDS 进行了拼接操作,他还为 SDS 分配了 13 个字节的未使用空间,并且拼接之后的字符串也正好是 13 字节长,这种现象既不是 bug 也不是巧合,他和 SDS 的空间分配策略有关,我想大家也很想知道SDS 的空间分配策略到底是怎么一回事,那么接下来我们就来学习 SDS 的空间分配策略吧!

    2.3减少修改字符串时带来的内存重分配次数

        学过C语言的童鞋都知道,C语言使用长度为N+1的字符数组来表示长度为N 的字符串。因为C字符串的长度和底层数组的长度之间存在着这种联系,所以每次增长或则缩短一个C字符串,程序都总是要对保存这个C字符串的数据进行一次内存重分配操作:
       (1).如果程序执行之前是增长字符串操作,比如拼接操作,那么在执行这个操作之前,程序需要先通过内存重分配来扩展底层数组的空间大小,否则就会产生缓冲区溢出。
       (2).如果程序执行之前是缩短字符串操作,比如截取操作,那么在执行这个操作之前,程序需要通过内存重分配来释放字符创不在使用的那部分空间,否则就会产生内存泄漏。
        知道了以上两点,那么我们可以分析一下,如果在程序中,修改字符串的长度的情况不太常出现,那么每次修改都执行一次内存重分配是可以接收的,但是Redis 作为数据库,经常被用于速度要求严苛、数据被频繁修改的场合,如果我们采用C语言这种方式每做一次就进行一次内存重分配的话,结果大家可想而知。那么Redis还有存在的意义吗?
        为了避免C字符串这种缺陷,SDS通过未使用空间解除了字符串长度和底层数组长度之间的联系:在SDS 中,buf 数组的长度不一定就是字符数量加1,数组里面可以包含为使用的字节,而这些字节的数量就由 SDS 的 free 属性记录。
        通过未使用空间,SDS 实现了空间预分配和惰性空间释放两种优化策略。
        (1)空间预分配
                空间预分配用于优化SDS 的字符串增长操作:当SDS 的API 对一个 SDS 进行修改,并且需要对SDS 进行空间扩展的时候,程序不仅会为 SDS 分配修改所需要的空间,还为 SDS 分配额外的使用空间。
               分配未使用空间由以下两个公式决定:
                1.如果对SDS 进行修改之后,SDS 的长度(也就是 len 属性的值 ) 小于 1MB,那么程序分配和 len 属性同样大小的未使用空间,这时 SDS len 属性的值将和 free 属性值相同。例如,如果修改之后,SDS 的 len 将变成 21 字节,那么程序也会 分配 21 字节的未使用空间,那么 SDS 的数组 buf 的值也将变成 21+21+1 = 43字节(额外一个用来保存空字符)
                2.如果对SDS 进行修改之后,SDS 的长度(也就是 len 属性的值 ) 大于等于 1MB,那么程序会分配 1MB 的未使用空间。例如,如果修改之后,SDS 的 len 将变成 34M,那么程序也会 分配 1M 的未使用空间,那么 SDS 的数组 buf 的值也将变成 34M+1M+1byte。
          通过空间预分配策略,Redis 可以减少连续执行字符串增长操作所需的内存重分配次数。而且在扩展 SDS 之间之前,SDS API 会先检查未使用空间是否足够,如果足够的话,API就会直接使用未使用空间,而无需执行内存重分配。
        (2)惰性空间释放
                惰性空间释放用于优化 SDS 的字符串缩短操作:当SDS 的API 需要缩短 SDS 保存的字符串时,程序并不立即使用内存重分配来回收缩短后多出来的字节,而是使用 free 属性将这些字节的数量记录起来,并等待将来使用。
                见下图:
                
                                                         图2-5 
                空间释放由Redis 内部就帮我们处理好,但是如果我们需要立即释放内存资源我们也可以使用 SDS 提供相应的 API 真正的释放 SDS 的未使用空间,所以不用担心惰性空间释放策略会造成内存浪费。    

三、总结

Redis的简单动态字符串sds对比C语言的字符串char*,有以下特性:

1) 可以在O(1)的时间复杂度得到字符串的长度

2) 可以高效的执行append追加字符串操作

3) 二进制安全

sds通过判断当前字符串空余的长度与需要追加的字符串长度,如果空余长度大于等于需要追加的字符串长度,那么直接追加即可,这样就减少了重新分配内存操作;否则,先用sdsMakeRoomFor函数先对sds进行扩展,按照一定的机制来决定扩展的内存大小,然后再执行追加操作,扩展后多余的空间不释放,方便下次再次追加字符串,这样做的代价就是浪费了一些内存,但是在Redis字符串追加操作很频繁的情况下,这种机制能很高效的完成追加字符串的操作。

四、SDS API

函数名称

作用

复杂度

sdsnewlen

创建一个指定长度的sds,接受一个指定的C字符串作为初始化值

O(N)

sdsempty

创建一个只包含空字符串””的sds

O(N)

sdsnew

根据给定的C字符串,创建一个相应的sds

O(N)

sdsdup

复制给定的sds

O(N)

sdsfree

释放给定的sds

O(1)

sdsupdatelen

更新给定sds所对应的sdshdr的free与len值

O(1)

sdsclear

清除给定sds的buf,将buf初始化为””,同时修改对应sdshdr的free与len值

O(1)

sdsMakeRoomFor

对给定sds对应sdshdr的buf进行扩展

O(N)

sdsRemoveFreeSpace

在不改动sds的前提下,将buf的多余空间释放

O(N)

sdsAllocSize

计算给定的sds所占的内存大小

O(1)

sdsIncrLen

对给定sds的buf的右端进行扩展或缩小

O(1)

sdsgrowzero

将给定的sds扩展到指定的长度,空余的部分用\0进行填充

O(N)

sdscatlen

将一个C字符串追加到给定的sds对应sdshdr的buf

O(N)

sdscpylen

将一个C字符串复制到sds中,需要依据sds的总长度来判断是否需要扩展

O(N)

sdscatprintf

通过格式化输出形式,来追加到给定的sds

O(N)

sdstrim

对给定sds,删除前端/后端在给定的C字符串中的字符

O(N)

sdsrange

截取给定sds,[start,end]字符串

O(N)

sdscmp

比较两个sds的大小

O(N)

sdssplitlen

对给定的字符串s按照给定的sep分隔字符串来进行切割

O(N)




0 0