Redis的字符串是如何实现的
本篇文章给大家分享《Redis的字符串是如何实现的》,覆盖了数据库的常见基础知识,其实一个语言的全部知识点一篇文章是不可能说完的,但希望通过这些问题,让读者对自己的掌握程度有一定的认识(B 数),从而弥补自己的不足,更好的掌握它。
在Redis这种内存数据库中,由于字符串被广泛的应用,在设计字符串时基于以下几点来设计:
1.支持丰富高效的字符串操作,比如追加、拷贝、比较等操作
2.能保存二进制数据
3.能尽可能的节省内存开销
可能会有人问了,既然C语言库提供了char*这样的字符数组来字符串操作。比如strcmp,strcat。感觉完全可以考虑直接使用C库提供的啊。C库字符串运用是很普遍,但是也不是没有问题的。它需要频繁的创建和检查空间,这在实际项目中其实很花时间的。所以,Redis设计了简单字符串(SDS,Simple Data )来表示字符串。同原来的C语言相比提升了字符串的操作效率,而且还支持二进制格式。下面我们就来介绍下Redis的字符串是如何实现的。
为什么不用char*
先来看看char*字符数组的结构,其实很简单就是开辟一块连续的内存空间来依次存放每一个字符,最后一个字符是"\0"表示字符串结束。C库中的字符串操作函数就是通过检查"\0"来判断字符串结束。比如strlen函数就是遍历字符数组中的每一个字符并计数,直到遇到"\0"结束计数,然后返回计数结果。下面我们通过一个代码来看看"\0"结束字符对字符串长度的影响。
这段代码的执行结果如下:
表示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[]; };
用个图来表示一下
代码中定义了一个别名
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 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 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#include using namespace std; typedef struct MyStruct { char a; int b; } MyStruct; int main() { cout 虽然char占用1个字节,int占用4个字节,但是打印出来是8,这样多出来的3个字节白白浪费掉了。现在我们运用__attribute__ ((packed))属性定义结构体,就可以实际占用多少字节,编译器就分配多少空间。我们把刚才代码修改一下加上这个属性。代码如下
#include#include using namespace std; typedef struct MyStruct { char a; int b; } __attribute__ ((__packed__))MyStruct; int main() { cout 运行这段代码,结果就变为5了,表示编译器用了紧凑型的内存分配。在开发过程中,为了节省内存开销就可以考虑把__attribute__ ((packed))这个属性运用起来。
理论要掌握,实操不能落!以上关于《Redis的字符串是如何实现的》的详细介绍,大家都掌握了吧!如果想要继续提升自己的能力,那么就来关注golang学习网公众号吧!

- 上一篇
- Redis中的bitmap详解

- 下一篇
- Redis事务处理的使用操作方法
-
- 斯文的羊
- 写的不错,一直没懂这个问题,但其实工作中常常有遇到...不过今天到这,看完之后很有帮助,总算是懂了,感谢大佬分享博文!
- 2023-02-03 12:58:51
-
- 漂亮的彩虹
- 细节满满,码住,感谢大佬的这篇文章内容,我会继续支持!
- 2023-01-28 13:46:57
-
- 沉默的老鼠
- 这篇技术贴真是及时雨啊,很详细,很棒,码起来,关注师傅了!希望师傅能多写数据库相关的文章。
- 2023-01-25 16:49:35
-
- 糊涂的飞鸟
- 这篇文章出现的刚刚好,细节满满,很棒,已加入收藏夹了,关注师傅了!希望师傅能多写数据库相关的文章。
- 2023-01-15 04:10:59
-
- 数据库 · Redis | 4小时前 | redis rdb aof redis-check-rdb redis-check-aof
- Redis数据文件损坏恢复攻略及详细步骤
- 500浏览 收藏
-
- 数据库 · Redis | 1天前 | redis redis-check-rdb 数据文件损坏 redis-check-aof 恢复步骤
- Redis数据文件损坏后的恢复攻略
- 465浏览 收藏
-
- 数据库 · Redis | 1天前 | redis Django 缓存 settings.py django-redis
- Django项目Redis缓存集成详解步骤
- 458浏览 收藏
-
- 数据库 · Redis | 2天前 |
- Redis启动时如何指定配置文件
- 225浏览 收藏
-
- 数据库 · Redis | 2天前 |
- Redis启动后无法访问?排查与解决攻略
- 498浏览 收藏
-
- 数据库 · Redis | 3天前 |
- Redis启动后访问异常?快速排查与解决
- 206浏览 收藏
-
- 数据库 · Redis | 3天前 |
- 检查Redis版本及升级指南
- 335浏览 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 542次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 508次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 497次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 484次学习
-
- 笔灵AI生成答辩PPT
- 探索笔灵AI生成答辩PPT的强大功能,快速制作高质量答辩PPT。精准内容提取、多样模板匹配、数据可视化、配套自述稿生成,让您的学术和职场展示更加专业与高效。
- 24次使用
-
- 知网AIGC检测服务系统
- 知网AIGC检测服务系统,专注于检测学术文本中的疑似AI生成内容。依托知网海量高质量文献资源,结合先进的“知识增强AIGC检测技术”,系统能够从语言模式和语义逻辑两方面精准识别AI生成内容,适用于学术研究、教育和企业领域,确保文本的真实性和原创性。
- 38次使用
-
- AIGC检测-Aibiye
- AIbiye官网推出的AIGC检测服务,专注于检测ChatGPT、Gemini、Claude等AIGC工具生成的文本,帮助用户确保论文的原创性和学术规范。支持txt和doc(x)格式,检测范围为论文正文,提供高准确性和便捷的用户体验。
- 37次使用
-
- 易笔AI论文
- 易笔AI论文平台提供自动写作、格式校对、查重检测等功能,支持多种学术领域的论文生成。价格优惠,界面友好,操作简便,适用于学术研究者、学生及论文辅导机构。
- 48次使用
-
- 笔启AI论文写作平台
- 笔启AI论文写作平台提供多类型论文生成服务,支持多语言写作,满足学术研究者、学生和职场人士的需求。平台采用AI 4.0版本,确保论文质量和原创性,并提供查重保障和隐私保护。
- 41次使用
-
- redis复制有可能碰到的问题汇总
- 2023-01-01 501浏览
-
- 使用lua+redis解决发多张券的并发问题
- 2023-01-27 501浏览
-
- Redis应用实例分享:社交媒体平台设计
- 2023-06-21 501浏览
-
- 使用Python和Redis构建日志分析系统:如何实时监控系统运行状况
- 2023-08-08 501浏览
-
- 如何利用Redis和Python实现消息队列功能
- 2023-08-16 501浏览