Redis的字符串是如何实现的


Posted in Redis onOctober 24, 2021

前言

字符串在日常开发中应用得比较普遍,对于Redis来说,键值对中的键是字符串,值也是字符串。比如在Redis中写入一条客户信息记录姓名、性别、爱好等。

Redis的字符串是如何实现的

在Redis这种内存数据库中,由于字符串被广泛的应用,在设计字符串时基于以下几点来设计:
1.支持丰富高效的字符串操作,比如追加、拷贝、比较等操作
2.能保存二进制数据
3.能尽可能的节省内存开销

可能会有人问了,既然C语言库提供了char*这样的字符数组来字符串操作。比如strcmp,strcat。感觉完全可以考虑直接使用C库提供的啊。C库字符串运用是很普遍,但是也不是没有问题的。它需要频繁的创建和检查空间,这在实际项目中其实很花时间的。所以,Redis设计了简单字符串(SDS,Simple Data )来表示字符串。同原来的C语言相比提升了字符串的操作效率,而且还支持二进制格式。下面我们就来介绍下Redis的字符串是如何实现的。

为什么不用char*

先来看看char*字符数组的结构,其实很简单就是开辟一块连续的内存空间来依次存放每一个字符,最后一个字符是"\0"表示字符串结束。C库中的字符串操作函数就是通过检查"\0"来判断字符串结束。比如strlen函数就是遍历字符数组中的每一个字符并计数,直到遇到"\0"结束计数,然后返回计数结果。下面我们通过一个代码来看看"\0"结束字符对字符串长度的影响。

Redis的字符串是如何实现的

这段代码的执行结果如下:

Redis的字符串是如何实现的

表示a1的字符长度是2个字符。这是因为在he后面有了"\0",所以字符串以"\0"表示结束,这就会产生一个问题,如果字符串内部本身就有"\0",那么数据就会被"\0"截断,而这就不能保存任意二进制数据了。

传统设计操作复杂度高

除了上面提到的不能保存任意二进制数据以外,操作复杂度也挺大。比如C语言中用得比较普遍的strlen函数,它要遍历字符数组中的每一个字符才能得到字符串长度。所以,时间复杂度是O(n)。另外再说一个常用函数strcat,它同strlen函数一样先遍历字符串才能得到目标字符串的末尾,而且它把源字符串追加到目标字符串末尾的时,还得确认目标字符串是否具有足够的空间。所以在调用的时候,开发人员还要人为保证目标字符串有足够的可用空间,不然就需要动态地申请空间。这样不仅时间复杂度高,操作复杂度也高了。

SDS的设计

Redis在设计的时候还是尽量保证复用C标准的字符串操作函数的。Redis在保留了使用字符数组来保存实际数据基础上,专门设计了一种SDS数据结构。
首先,SDS结构里面包含了一个字符数组buf[],同时SDS结构里面还包含了三个元数据。分别是字符数组现有长度len,分配给字符数组的空间长度alloc以及SDS类型flags。其中len和alloc这两个元数据定义了不同类型的SDS。SDS定义代码如下所示:

typedef char *sds;

/* Note: sdshdr5 is never used, we just access the flags byte directly.
 * However is here to document the layout of type 5 SDS strings. */
struct __attribute__ ((__packed__)) sdshdr5 {
    unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len; /* used */
    uint8_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr16 {
    uint16_t len; /* used */
    uint16_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
    uint32_t len; /* used */
    uint32_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {
    uint64_t len; /* used */
    uint64_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};

用个图来表示一下

Redis的字符串是如何实现的

代码中定义了一个别名

typedef char *sds;

所以SDS本质还是字符数组,只是在字符数组基础上增加了额外的元数据,Redis在使用字符数组时直接使用sds这个别名。

SDS的高效操作

创建sds

Redis调用sdsnewlen函数创建sds。我们以sedsnewlen举例,代码如下:

hisds sdsnewlen(const void *init, size_t initlen) {
   //指向SDS结构的指针
    void *sh;
    //sds类型变量,就是char*的别名
    sds s;
    //根据大小获取SDS的类型
    char type = hi_sdsReqType(initlen);
    /* Empty strings are usually created in order to append. Use type 8
     * since type 5 is not good at this. */
    if (type == SDS_TYPE_5 && initlen == 0) type = SDS_TYPE_8;
    int hdrlen = sdsHdrSize(type);
    unsigned char *fp; /* flags pointer. */

     //为新创建的sds结构分配内存
    sh = s_malloc(hdrlen+initlen+1);
    if (sh == NULL) return NULL;
    if (!init)
        memset(sh, 0, hdrlen+initlen+1);
    
    //指向SDS结构体中的buf数组,sh指向SDS结构的起始位置,hdrlen表示元数据的长度    
    s = (char*)sh+hdrlen;
    fp = ((unsigned char*)s)-1;
    //根据类型初始化len,alloc
    switch(type) {
        case SDS_TYPE_5: {
            *fp = type | (initlen << HI_SDS_TYPE_BITS);
            break;
        }
        case SDS_TYPE_8: {
            SDS_HDR_VAR(8,s);
            sh->len = initlen;
            sh->alloc = initlen;
            *fp = type;
            break;
        }
        case SDS_TYPE_16: {
            SDS_HDR_VAR(16,s);
            sh->len = initlen;
            sh->alloc = initlen;
            *fp = type;
            break;
        }
        case SDS_TYPE_32: {
            SDS_HDR_VAR(32,s);
            sh->len = initlen;
            sh->alloc = initlen;
            *fp = type;
            break;
        }
        case SDS_TYPE_64: {
            SDS_HDR_VAR(64,s);
            sh->len = initlen;
            sh->alloc = initlen;
            *fp = type;
            break;
        }
    }
    if (initlen && init)
    //将字符串拷贝给sds变量s
        memcpy(s, init, initlen);
     //字符串变量末尾添加"\0"表示字符串结束  
    s[initlen] = '\0';
    return s;
}

该函数主要执行过程如下:
1.根据初始化长度获取SDS类型。如果初始化长度initlen为0,一般被认为是要执行append操作,设置SDS类型为SDS_TYPE_8
2.为新创建的SDS结构分配内存,内存空间为元数据长度+buf长度+字符串最后的结束符"\0"。
3.根据SDS类型去初始化元数据len和alloc
4.将字符串拷贝给sds

字符数组拼接

由于sds结构中记录了占用的空间和被分配的空间,所以它比传统C语言的字符串效率更高。下面我们通过Redis的字符串追加函数sdscatlen来看一看。代码如下:

sds sdscatlen(sds s, const void *t, size_t len) {
//获取目标字符串的长度 
 size_t curlen = sdslen(s);
//根据追加长度和目标字符串长度判断是否需要增加新的空间
  s = sdsMakeRoomFor(s,len);
  if (s == NULL) return NULL;
  //将源字符串t中len长度的数据拷贝到目标字符串尾部
  memcpy(s+curlen, t, len);
  //设置目标字符串的最新长度
  sdssetlen(s, curlen+len);
  //拷贝完成后,在字符串结尾加上"\0"
  s[curlen+len] = '\0';
  return s;
}

这个函数有三个参数分别是目标字符串s,源字符串t和要追加的长度len。这个代码执行过程如下:
1.首先获取目标字符串的长度,然后调用sdsMakeRoomFor函数判断是否要给目标字符串添加新的空间,这样就可以保证目标字符串有足够的空间追加字符串
2.在保证了有足够空间可以追加字符串后,将源字符串中指定长度len的数据追加到目标字符串
3.设置目标字符串的最新长度

长度获取

代码中,函数sdslen记录了字符数组的使用长度,不用同C库一样遍历字符串了,这样可以大大降低了操作使用字符串的开销。该函数的代码如下所示:

static inline size_t sdslen(const sds s) {
    unsigned char flags = s[-1];
    switch(flags&SDS_TYPE_MASK) {
        case SDS_TYPE_5:
            return SDS_TYPE_5_LEN(flags);
        case SDS_TYPE_8:
            return SDS_HDR(8,s)->len;
        case SDS_TYPE_16:
            return SDS_HDR(16,s)->len;
        case SDS_TYPE_32:
            return SDS_HDR(32,s)->len;
        case SDS_TYPE_64:
            return SDS_HDR(64,s)->len;
    }
    return 0;
}

这样时间复杂度直接降到了O(1)。这个函数有一个骚操作,通过s[-1]获取到flags,然后调用SDS_HDR宏函数。我们来看下这个宏函数定义

#define SDS_HDR(T,s) ((struct sdshdr##T *)((s)-(sizeof(struct sdshdr##T))))

其中##用来将两个token连接为一个token,所以加上参数将在预编译阶段将被替换如下

SDS_HDR(8,s);
((struct sdshdr8 *)((s)-(sizeof(struct sdshdr8))))

字符数组地址减去结构体的大小,就能获取到结构体的首地址,然后直接访问len属性。

预分配内存空间

此外,在代码中还使用了sdsMakeRoomFor函数,它在拼接字符串之前会检查是否需要扩容,如果需要扩容则会预分配空间。这一设计的好处就是避免了开发中忘记给目标字符串扩容而导致操作失败。比如strcpy(char* dst, const char* dst),如果src长度大于了dst的长度,又没有做检查就会遭成内存溢出。代码如下所示:

sds sdsMakeRoomFor(sds s, size_t addlen) {
    void *sh, *newsh;
    //获取SDS目前可用的空间
    size_t avail = sdsavail(s);
    size_t len, newlen;
    char type, oldtype = s[-1] & SDS_TYPE_MASK;
    int hdrlen;
    size_t usable;

    //空余空间足够,无需扩展
    if (avail >= addlen) return s;

    len = sdslen(s);
    sh = (char*)s-sdsHdrSize(oldtype);
    newlen = (len+addlen);
    assert(newlen > len);   /* Catch size_t overflow */
    //如果新的字符数组长度小于SDS_MAX_PREALLOC
    //分配2倍所需长度
    if (newlen < SDS_MAX_PREALLOC)
        newlen *= 2;
    //否则分配新长度加上SDS_MAX_PREALLOC的长度
    else
        newlen += SDS_MAX_PREALLOC;
   
   //重新获取SDS类型
    type = sdsReqType(newlen);

    /* Don't use type 5: the user is appending to the string and type 5 is
     * not able to remember empty space, so sdsMakeRoomFor() must be called
     * at every appending operation. */
    if (type == SDS_TYPE_5) type = SDS_TYPE_8;

    hdrlen = sdsHdrSize(type);
    assert(hdrlen + newlen + 1 > len);  /* Catch size_t overflow */
    if (oldtype==type) {
        newsh = s_realloc_usable(sh, hdrlen+newlen+1, &usable);
        if (newsh == NULL) return NULL;
        s = (char*)newsh+hdrlen;
    } else {
        / /如果头部大小发生变化只需要将字符数组向前移,不使用realloc
        newsh = s_malloc_usable(hdrlen+newlen+1, &usable);
        if (newsh == NULL) return NULL;
        memcpy((char*)newsh+hdrlen, s, len+1);
        s_free(sh);
        s = (char*)newsh+hdrlen;
        s[-1] = type;
        sdssetlen(s, len);
    }
    usable = usable-hdrlen-1;
    if (usable > sdsTypeMaxSize(type))
        usable = sdsTypeMaxSize(type);
     //更新SDS容量  
    sdssetalloc(s, usable);
    return s;
}

其中SDS_MAX_PREALLOC的长度为1024*1024

#define SDS_MAX_PREALLOC (1024*1024)

节省内存的设计

前面讲SDS结构的时候提到过它有一个元数据flag,表示字符串类型。SDS一共有5中类型,它们分别是sdshdr5,sdshdr8,sdshdr16,sdshdr32和sdshdr64。这五种的主要区别是它们字符数组的现有长度len和分配空间alloc的不同。
那么我们就以sdshdr16为例,它的定义如下

struct __attribute__ ((__packed__)) sdshdr16 {
    uint16_t len; /* used */
    uint16_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};

我们可以看到现有长度len和已分配空间alloc都是uint16_t类型,uint16_t是16位无符号整型,会占用2个字节。当字符串类型是sdshdr16的时候它包含的字符数组长度最大为2^16-1字节。而对于其它三种sdshdr8,sdshdr32和sdshdr64,以此类推它们的类型就分别是uin8_t,uin32_t和uint64_t,len和alloc这两个元数据占用的空间分别是1字节、4字节和8字节。
实际上,设计不同的结构头的目的是为了灵活保存不同大小的字符串,从而有效地节省内存空间。在保存不同大小的字符串时,结构头占用的内存空间也不一样,这样一来保存小字符串时,占用的空间也会比较小。
除了设计不同类型的结构头以外,Redis还使用编译优化来节省内存空间。比如上面sdshdr16的代码中就有__attribute__ ((packed)),它的目的是告诉编译器采用紧凑的方式分配内存,默认情况下编译器会按照16字节的对齐方式给变量分配内存。也就是说一个变量没有到16个字节,编译器也会给它分配16个字节。
我们来举个例子

#include <string.h>
#include <iostream>

using namespace std;

typedef struct MyStruct
{
 char  a;
 int b;   
} MyStruct;


int
main()
{
    cout << sizeof(MyStruct) << endl;
    
    return 0;
}

虽然char占用1个字节,int占用4个字节,但是打印出来是8,这样多出来的3个字节白白浪费掉了。现在我们运用__attribute__ ((packed))属性定义结构体,就可以实际占用多少字节,编译器就分配多少空间。我们把刚才代码修改一下加上这个属性。代码如下

#include <string.h>
#include <iostream>

using namespace std;

typedef struct MyStruct
{
 char  a;
 int b;   
} __attribute__ ((__packed__))MyStruct;


int
main()
{
    cout << sizeof(MyStruct) << endl;
    
    return 0;
}

运行这段代码,结果就变为5了,表示编译器用了紧凑型的内存分配。在开发过程中,为了节省内存开销就可以考虑把__attribute__ ((packed))这个属性运用起来。

到此这篇关于Redis的字符串是如何实现的的文章就介绍到这了,更多相关Redis字符串实现内容请搜索三水点靠木以前的文章或继续浏览下面的相关文章希望大家以后多多支持三水点靠木!

Redis 相关文章推荐
redis连接被拒绝的解决方案
Apr 12 Redis
在K8s上部署Redis集群的方法步骤
Apr 27 Redis
Redis IP地址的绑定的实现
May 08 Redis
关于redisson缓存序列化几枚大坑说明
Aug 04 Redis
Redis字典实现、Hash键冲突及渐进式rehash详解
Sep 04 Redis
浅谈Redis的keys命令到底有多慢
Oct 05 Redis
详解Redis在SpringBoot工程中的综合应用
Oct 16 Redis
一文搞懂Redis中String数据类型
Apr 03 Redis
 Redis 串行生成顺序编码的方法实现
Apr 03 Redis
Redis实现一个账号只能登录一个设备
Apr 19 Redis
Redis基本数据类型哈希Hash常用操作命令
Jun 01 Redis
Redis实现短信验证码登录的示例代码
Jun 14 Redis
SpringBoot集成Redis的思路详解
详解redis在微服务领域的贡献
详解Redis在SpringBoot工程中的综合应用
Oct 16 #Redis
Redis三种集群模式详解
浅谈Redis的keys命令到底有多慢
基于Redis结合SpringBoot的秒杀案例详解
Jedis操作Redis实现模拟验证码发送功能
Sep 25 #Redis
You might like
php实现aes加密类分享
2014/02/16 PHP
php实现概率性随机抽奖代码
2016/01/02 PHP
动态加载外部javascript文件的函数代码分享
2011/07/28 Javascript
js猜数字小游戏的简单实现代码
2013/07/02 Javascript
php读取sqlite数据库入门实例代码
2014/06/25 Javascript
javascript删除元素节点removeChild()用法实例
2015/05/26 Javascript
浅谈jquery中delegate()与live()
2015/06/22 Javascript
javascript实现继承的简单实例
2015/07/26 Javascript
JavaScript实现的多个图片广告交替显示效果代码
2015/09/04 Javascript
jQuery图片左右滚动代码 有左右按钮实例
2016/06/20 Javascript
AngularJS基础 ng-include 指令简单示例
2016/08/01 Javascript
JavaScript实现Java中Map容器的方法
2016/10/09 Javascript
Javascript 制作图形验证码实例详解
2016/12/22 Javascript
JS如何实现网站中PC端和手机端自动识别并跳转对应的代码
2020/01/08 Javascript
JS实现可视化音频效果的实例代码
2020/01/16 Javascript
Vue实现Layui的集成方法步骤
2020/04/10 Javascript
VUE中setTimeout和setInterval自动销毁案例
2020/09/07 Javascript
[03:12]完美世界DOTA2联赛PWL DAY6集锦
2020/11/05 DOTA
python基础教程之面向对象的一些概念
2014/08/29 Python
python实现定时同步本机与北京时间的方法
2015/03/24 Python
python使用matplotlib画饼状图
2018/09/25 Python
Python正则表达式和re库知识点总结
2019/02/11 Python
详解Python3之数据指纹MD5校验与对比
2019/06/11 Python
python SocketServer源码深入解读
2019/09/17 Python
wxPython实现整点报时
2019/11/18 Python
opencv中图像叠加/图像融合/按位操作的实现
2020/04/01 Python
Python中and和or如何使用
2020/05/28 Python
详解python with 上下文管理器
2020/09/02 Python
python 无损批量压缩图片(支持保留图片信息)的示例
2020/09/22 Python
python matplotlib工具栏源码探析二之添加、删除内置工具项的案例
2021/02/25 Python
Bose美国官网:购买Bose耳机和音箱
2019/03/10 全球购物
社区学雷锋活动策划方案
2014/01/30 职场文书
《奇妙的国际互联网》 教学反思
2014/02/25 职场文书
不拖欠农民工工资承诺书
2014/03/31 职场文书
2015毕业生简历自我评价
2015/03/02 职场文书
《风筝》教学反思
2016/02/23 职场文书