极客时间Redis源码学习笔记

Redis为什么快?

相信大家学Redis的时候,第一句话就是Redis是直接操作内存的,所以比操作磁盘IO的数据库要快。
但是只有这一点原因么?
答案肯定不是的,下面就是我在学习极客时间Redis源码时候的一些笔记和感想

Redis 是如何优化内存使用的呢?

实际上,Redis 是从三个方面来优化内存使用的,分别是内存分配、内存回收,以及数据替换

首先,在内存分配方面,Redis 支持使用不同的内存分配器,包括 glibc 库提供的默认分配器 tcmalloc、第三方库提供的 jemalloc。Redis 把对内存分配器的封装实现在了 zmalloc.h/zmalloc.c。

其次,在内存回收上Redis 支持设置过期 key,并针对过期 key 可以使用不同删除策略,这部分代码实现在 expire.c 文件中。同时,为了避免大量 key 删除回收内存,会对系统性能产生影响,Redis 在 lazyfree.c 中实现了异步删除的功能,所以这样,我们就可以使用后台 IO 线程来完成删除,以避免对 Redis 主线程的影响。

最后,针对数据替换,如果内存满了,Redis 还会按照一定规则清除不需要的数据,这也是 Redis 可以作为缓存使用的原因。Redis 实现的数据替换策略有很多种,包括 LRU、LFU 等经典算法。这部分的代码实现在了 evict.c 中。

内存分配?说的是数据结构么

C语言的字符串是涌char * 来实现的。但实际上,我们在使用 C 语言字符串时,经常需要手动检查和分配字符串空间,而这就会增加代码开发的工作量

Redis 设计了简单动态字符串(Simple Dynamic String,SDS)的结构,用来表示字符串。相比于 C 语言中的字符串实现,SDS 这种字符串的实现方式,会提升字符串的操作效率,并且可以用来保存二进制数据

Redis字符串的实现方式

为什么Redis不用char *

要想解答这个问题,我们需要先知道 char* 字符串数组的结构特点,还有 Redis 对字符串的需求是什么,所以下面我们就来具体分析一下。

### C语言char* 的结构设计

char * 字符数组的结构很简单,就是一块连续的内存空间,依次存放了字符串中的每一个字符。比如,下图显示的就是字符串“redis”的char*数组结构。


从图中可以看到,字符数组的最后一个字符是“\0”,这个字符的作用是什么呢?其实,C 语言在对字符串进行操作时,char 指针只是指向字符数组的起始位置,而字符数组的结尾位置就用“\0”表示*,意思是指字符串的结束。

这样一来,C 语言标准库中字符串的操作函数,就会通过检查字符数组中是否有“\0”,来判断字符串是否结束。比如,strlen 函数就是一种字符串操作函数,它可以返回一个字符串的长度。这个函数会遍历字符数组中的每一个字符,并进行计数,直到检查的字符为“\0”。此时,strlen 函数会停止计数,返回已经统计到的字符个数。下图显示了 strlen 函数的执行流程:

再通过一段代码,来看下“\0”结束字符对字符串长度的影响。这里我创建了两个字符串变量 a 和 b,分别给它们赋值为“red\0is”和“redis\0”。然后,我用 strlen 函数计算这两个字符串长度,如下所示:

  #include <stdio.h>#include <string.h>int main(){char *a = "red\0is";char *b = "redis\0";printf("%lu\n", strlen(a));printf("%lu\n", strlen(b));return 0;}
结果:3,5

表示 a 和 b 的长度分别是 3 个字符和 5 个字符。这是因为 a 中在“red”这 3 个字符后,就有了结束字符“\0”,而 b 中的结束字符是在“redis”5 个字符后。

也就是说,char* 字符串以“\0”表示字符串的结束,其实会给我们保存数据带来一定的负面影响。如果我们要保存的数据中,本身就有“\0”,那么数据在“\0”处就会被截断,而这就不符合 Redis 希望能保存任意二进制数据的需求了

操作函数复杂度

除了 char* 字符数组结构的设计问题以外,使用“\0”作为字符串的结束字符,虽然可以让字符串操作函数判断字符串的结束位置,但它也会带来另一方面的负面影响,也就是会导致操作函数的复杂度增加。

我还是以 strlen 函数为例,该函数需要遍历字符数组中的每一个字符,才能得到字符串长度,所以这个操作函数的复杂度是 O(N)。

我们来看另一个常用的操作函数:字符串追加函数 strcat。strcat 函数是将一个源字符串 src 追加到一个目标字符串的末尾。该函数的代码如下所示:

  char *strcat(char *dest, const char *src) {//将目标字符串复制给tmp变量char *tmp = dest;//用一个while循环遍历目标字符串,直到遇到“\0”跳出循环,指向目标字符串的末尾while(*dest)dest++;//将源字符串中的每个字符逐一赋值到目标字符串中,直到遇到结束字符while((*dest++ = *src++) != '\0' )return tmp;}

从代码中可以看到,strcat 函数和 strlen 函数类似,复杂度都很高,也都需要先通过遍历字符串才能得到目标字符串的末尾然后对于 strcat 函数来说,还要再遍历源字符串才能完成追加另外,它在把源字符串追加到目标字符串末尾时,还需要确认目标字符串具有足够的可用空间,否则就无法追加

所以,这就要求开发人员在调用 strcat 时,要保证目标字符串有足够的空间,不然就需要开发人员动态分配空间,从而增加了编程的复杂度。而操作函数的复杂度一旦增加,就会影响字符串的操作效率,这就不符合 Redis 对字符串高效操作的需求了。

SDS

因为 Redis 是使用 C 语言开发的,所以为了保证能尽量复用 C 标准库中的字符串操作函数,Redis 保留了使用字符数组来保存实际的数据。但是,和 C 语言仅用字符数组不同,Redis 还专门设计了 SDS(即简单动态字符串)的数据结构。下面我们一起来看看。

SDS 结构设计

首先,SDS 结构里包含了一个字符数组 buf[],用来保存实际数据。同时,SDS 结构里还包含了三个元数据,分别是字符数组现有长度 len、分配给字符数组的空间长度 alloc,以及 SDS 类型 flags。其中,Redis 给 len 和 alloc 这两个元数据定义了多种数据类型,进而可以用来表示不同类型的 SDS,稍后我会给你具体介绍。下图显示了 SDS 的结构,你可以先看下。

另外,如果你在 Redis 源码中查找过 SDS 的定义,那你可能会看到,Redis 使用 typedef 给 char* 类型定义了一个别名,这个别名就是 sds,如下所示:

typedef char *sds;

其实,这是因为 SDS 本质还是字符数组,只是在字符数组基础上增加了额外的元数据。在 Redis 中需要用到字符数组时,就直接使用 sds 这个别名。

同时,在创建新的字符串时,Redis 会调用 SDS 创建函数 sdsnewlen。sdsnewlen 函数会新建 sds 类型变量(也就是 char* 类型变量),并新建 SDS 结构体,把 SDS 结构体中的数组 buf[] 赋给 sds 类型变量。最后,sdsnewlen 函数会把要创建的字符串拷贝给 sds 变量。下面的代码就显示了 sdsnewlen 函数的这个操作逻辑,你可以看下。

sds sdsnewlen(const void *init, size_t initlen) {void *sh;  //指向SDS结构体的指针sds s;     //sds类型变量,即char*字符数组  真正的字符数组...sh = s_malloc(hdrlen+initlen+1);   //新建SDS结构,并分配内存空间...s = (char*)sh+hdrlen;              //sds类型变量指向SDS结构体中的buf数组,sh指向SDS结构体起始位置,hdrlen是SDS结构体中元数据的长度...if (initlen && init)memcpy(s, init, initlen);    //将要传入的字符串拷贝给sds变量ss[initlen] = '\0';               //变量s末尾增加\0,表示字符串结束return s;

了解了 SDS 结构的定义后,我们再来看看,相比传统 C 语言字符串,SDS 操作效率的改进之处。

SDS 操作效率

因为 SDS 结构中记录了字符数组已占用的空间和被分配的空间,这就比传统 C 语言实现的字符串能带来更高的操作效率。

我还是以字符串追加操作为例。Redis 中实现字符串追加的函数是 sds.c 文件中的 sdscatlen 函数。这个函数的参数一共有三个,分别是目标字符串 s、源字符串 t 和要追加的长度 len,源码如下所示:

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

通过分析这个函数的源码,我们可以看到 sdscatlen 的实现较为简单,其执行过程分为三步:

  • 首先,获取目标字符串的当前长度,并调用 sdsMakeRoomFor 函数,根据当前长度和要追加的长度,判断是否要给目标字符串新增空间。这一步主要是保证,目标字符串有足够的空间接收追加的字符串。
  • 其次,在保证了目标字符串的空间足够后,将源字符串中指定长度 len 的数据追加到目标字符串。
  • 最后,设置目标字符串的最新长度。


所以,到这里你就能发现,和 C 语言中的字符串操作相比,SDS 通过记录字符数组的使用长度和分配空间大小,避免了对字符串的遍历操作,降低了操作开销,进一步就可以帮助诸多字符串操作更加高效地完成,比如创建、追加、复制、比较等,这一设计思想非常值得我们学习。

此外,SDS 把目标字符串的空间检查和扩容封装在了 sdsMakeRoomFor 函数中,并且在涉及字符串空间变化的操作中,如追加、复制等,会直接调用该函数。(抽象封装思想)

这一设计实现,就避免了开发人员因忘记给目标字符串扩容,而导致操作失败的情况。比如,我们使用函数 strcpy (char *dest, const char *src) 时,如果 src 的长度大于 dest 的长度,代码中我们也没有做检查的话,就会造成内存溢出。所以这种封装操作的设计思想,同样值得我们学习。

那么,除了使用元数据记录字符串数组长度和封装操作的设计思想,SDS 还有什么优秀的设计与实现值得我们学习呢?这就和我刚才给你介绍的 Redis 对内存节省的需求相关了。

紧凑型字符串结构的编程技巧

前面我提到,SDS 结构中有一个元数据 flags,表示的是 SDS 类型。事实上,SDS 一共设计了 5 种类型,分别是 sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64。这 5 种类型的主要区别就在于,它们数据结构中的字符数组现有长度 len 和分配空间长度 alloc,这两个元数据的数据类型不同

因为 sdshdr5 这一类型 Redis 已经不再使用了。以 sdshdr8 为例,它的定义如下所示:

struct __attribute__ ((__packed__)) sdshdr8 {uint8_t len; /* 字符数组现有长度*/uint8_t alloc; /* 字符数组的已分配空间,不包括结构体和\0结束字符*/unsigned char flags; /* SDS类型*/char buf[]; /*字符数组*/
};

我们可以看到,现有长度 len 和已分配空间 alloc 的数据类型都是 uint8_tuint8_t 是 8 位无符号整型,会占用 1 字节的内存空间。当字符串类型是 sdshdr8 时,它能表示的字符数组长度(包括数组最后一位\0)不会超过 256 字节(2 的 8 次方等于 256)。

而对于 sdshdr16、sdshdr32、sdshdr64 三种类型来说,它们的 len 和 alloc 数据类型分别是 uint16_t、uint32_t、uint64_t,即它们能表示的字符数组长度,分别不超过 2 的 16 次方、32 次方和 64 次方。这两个元数据各自占用的内存空间在 sdshdr16、sdshdr32、sdshdr64 类型中,则分别是 2 字节、4 字节和 8 字节。

实际上,SDS 之所以设计不同的结构头(即不同类型),是为了能灵活保存不同大小的字符串,从而有效节省内存空间。因为在保存不同大小的字符串时,结构头占用的内存空间也不一样,这样一来,在保存小字符串时,结构头占用空间也比较少。

否则,假设 SDS 都设计一样大小的结构头,比如都使用 uint64_t 类型表示 len 和 alloc,那么假设要保存的字符串是 10 个字节,而此时结构头中 len 和 alloc 本身就占用了 16 个字节了,比保存的数据都多了。所以这样的设计对内存并不友好,也不满足 Redis 节省内存的需求。

好了,除了设计不同类型的结构头,Redis 在编程上还使用了专门的编译优化来节省内存空间。在刚才介绍的 sdshdr8 结构定义中,我们可以看到,在 struct 和 sdshdr8 之间使用了__attribute__ ((packed)),如下所示:

struct __attribute__ ((__packed__)) sdshdr8

其实这里,attribute ((packed))的作用就是告诉编译器,在编译 sdshdr8 结构时,不要使用字节对齐的方式,而是采用紧凑的方式分配内存。这是因为在默认情况下,编译器会按照 8 字节对齐的方式,给变量分配内存。也就是说,即使一个变量的大小不到 8 个字节,编译器也会给它分配 8 个字节。

为了方便你理解,我给你举个例子。假设我定义了一个结构体 s1,它有两个成员变量,类型分别是 char 和 int,如下所示:

#include <stdio.h>
int main() {struct s1 {char a;int b;} ts1;printf("%lu\n", sizeof(ts1));return 0;
}

虽然 char 类型占用 1 个字节,int 类型占用 4 个字节,但是如果你运行这段代码,就会发现打印出来的结果是 8。这就是因为在默认情况下,编译器会给 s1 结构体分配 8 个字节的空间,而这样其中就有 3 个字节被浪费掉了。

为了节省内存,Redis 在这方面的设计上可以说是精打细算的。所以,Redis 采用了__attribute__ ((packed))属性定义结构体,这样一来,结构体实际占用多少内存空间,编译器就分配多少空间。

比如,我用__attribute__ ((packed))属性定义结构体 s2,同样包含 char 和 int 两个类型的成员变量,代码如下所示:

#include <stdio.h>
int main() {struct __attribute__((packed)) s2{char a;int b;} ts2;printf("%lu\n", sizeof(ts2));return 0;
}

当你运行这段代码时,你可以看到,打印的结果是 5,表示编译器用了紧凑型内存分配,s2 结构体只占用 5 个字节的空间。

好了,总而言之,如果你在开发程序时,希望能节省数据结构的内存开销,就可以把__attribute__ ((packed))这个编程方法用起来。

讨论问题

SDS 字符串在 Redis 内部模块实现中也被广泛使用,你能在 Redis server 和客户端的实现中,找到使用 SDS 字符串的地方么?

参考答案

1、Redis 中所有 key 的类型就是 SDS(详见 db.c 的 dbAdd 函数)

2、Redis Server 在读取 Client 发来的请求时,会先读到一个缓冲区中,这个缓冲区也是 SDS(详见 server.h 中 struct client 的 querybuf 字段)

3、写操作追加到 AOF 时,也会先写到 AOF 缓冲区,这个缓冲区也是 SDS (详见 server.h 中 struct client 的 aof_buf 字段)

4、server.h 文件中的 redisObject 对象,key 和 value 都是对象,key (键对象)都是 SDS 简单动态字符串对象
5、 cluter.c 的 clusterGenNodesDescription 函数中。这个函数代表以 csv 格式记录当前节点已知所有节点的信息。
6、 client.h 的 clusterLink 结构体中。clusterLink 包含了与其他节点进行通讯所需的全部信息,用 SDS 来存储输出缓冲区和输入缓冲区。
7、server.h 的 client 结构体中。缓冲区 querybuf、pending_querybuf 用的 sds 数据结构。
8、networking.c 中的 catClientInfoString 函数。获取客户端的各项信息,将它们储存到 sds 值 s 里面,并返回。
9、 sentinel.c 中的 sentinelGetMasterByName 函数。根据名字查找主服务器,而参数名字会先转化为 SDS 后再去找主服务器。
10、 server.h 中的结构体 redisServer,aof_buf 缓存区用的 是 sds。
11、slowlog.h 中的结构体 slowlogEntry,用来记录慢查询日志,其他 client 的名字和 ip 地址用的是 sds。

实习成长之路:Redis为什么快?为什么Redis同样也是String字符串,但是要比Java性能好?SDS数据结构是什么?什么是紧凑型编程技巧?相关推荐

  1. 顶级程序员的成长之路1

    本文关注的问题是程序员的水平究竟应该按照什么样的不同层级而逐渐提高?或者说,在学习编程的过程中,每一个阶段究竟应当设定什么样的目标才比较合理?本文的内容主要借鉴了周伟明先生的专栏文章<程序员的十 ...

  2. 想要精通算法和SQL的成长之路 - 判断子序列问题

    想要精通算法和SQL的成长之路 - 判断子序列问题 前言 一. 判断子序列 1.1 动态规划做法 1.2 双指针 二. 不同的子序列 前言 想要精通算法和SQL的成长之路 - 系列导航 一. 判断子序 ...

  3. redis成长之路——(一)

    为什么使用redis Redis适合所有数据in-momory的场景,虽然Redis也提供持久化功能,但实际更多的是一个disk-backed的功能,跟传统意义上的持久化有比较大的差别,那么可能大家就 ...

  4. 刘知远、赵鑫、施柏鑫:AI青年科研人员成长之路

    整理 | 刘冰一 在6月2日举办的智源大会青源学术年会举办的一个圆桌"青年科研人员成长之路与经验分享"上,清华大学计算机系副教授刘知远.中国人民大学高瓴人工智能学院长聘副教授赵鑫. ...

  5. 一个大神的Android成长之路

    这篇文章是我的一个朋友写的,总结了这些年的技术成长之路,我觉得对于很多技术人都有借鉴的作用,技术是相通的,不要整天想一口气吃成一个胖子,不积跬步无以至千里,既然选择了技术这条路,就不畏艰辛,苦中有甜, ...

  6. 我的前端成长之路:中医药大学毕业的业务女前端修炼之路

    简介: 前端工程师的修炼没有捷径,踏踏实实的通过一个个项目的实践来升级打怪实现进阶:本文仅分享自己11年的前端生涯,探讨一直在业务中的技术人的成长之路,也复盘再认识下自己,每个节点我遇到的问题和我的选 ...

  7. 如何成为一名架构师,架构师成长之路(转)

    转自http://blog.csdn.net/fei33423/article/details/61934514 如何成为一名架构师,架构师成长之路 原创 2017年03月13日 22:50:34 3 ...

  8. 如何成为一名架构师,架构师成长之路

    技术人人都是xx 父文章 人人都是面试_个人渣记录仅为自己搜索用的博客-CSDN博客 我的成长之路_个人渣记录仅为自己搜索用的博客-CSDN博客 相关文章 架构师好书推荐_个人渣记录仅为自己搜索用的博 ...

  9. 张俊红 python_我的朋友张俊红成长之路

    张俊红同学是个好同志,是我的一位好朋友,与俊红在现实中素未谋面过(微信视频过).我认识俊红两年了,2016年的时候,在他大四实习的时候,我就认识他了.那时俊红正忙着转型做数据分析师,他偶尔也会在公众号 ...

  10. 高通Camera 软件工程师的成长之路

    本文以个人的工作学习经历来描述一 Camera 软件工程师的披荆斩棘之路,时间从大学开始,以至任职于高通 Camera 软件工程师结束,杂以本人学习过程当中的一系列学习笔记和博客文章,绝对是干货满满, ...

最新文章

  1. Python多线程学习(下)
  2. Spring Boot + Dataway :接口不用写,配配就出来?
  3. c++ max 的头文件_学用C/C++编写小游戏程序(2.2 打字练习游戏)
  4. 排序学习(LTR)经典算法:RankNet、LambdaRank和LambdaMart
  5. 触发键盘_雷蛇这款光轴机械键盘开箱评测,光速触发,颜值爆表
  6. java –cp ./:_成为Java流大师–第3部分:终端操作
  7. OpenShift 4 - DevSecOps Workshop (7) - 为Pipeline增加向Nexus制品库推送任务
  8. jQuery基础总结!!!
  9. Faster R-CNN源码中RPN的解析(自用)
  10. iOS 动画十四:Replicating Animations
  11. 大工19春计算机文化基础在线测试3,大工19春《计算机文化基础》在线测试3.doc...
  12. 《Using OpenRefine》翻译~10
  13. android手机向电脑传输文件,手机怎么用数据线连接电脑传输文件
  14. Restarting data prefetching from start repeated many times one by one. why?
  15. ~scanf的意思、作用
  16. 为什么打印还要另存为_为什么打印时会出现另存为保存文件
  17. python绘制相频特性曲线_数据分析之Matplotlib和机器学习基础
  18. pycharm快速注释快捷键
  19. char* 和 char[]区别
  20. python运维工程师前景及待遇_做运维工程师有前途吗?

热门文章

  1. 华为荣耀8x云相册不见了_京东手机最新销量排行:荣耀、小米卖的最好
  2. flink globalwindow_《从0到1学习Flink》—— 介绍Flink中的Stream Windows
  3. formdata 嵌套_解决form嵌套
  4. Java编程:迪杰斯特拉算法(已知固定起点最短路径问题)
  5. 谷歌浏览器:解决Chrome浏览器添加扩展程序报错无法从该网站添加应用、扩展程序和用户脚本
  6. MYSQL中TRUNCATE和DELETE的区别
  7. Java Swing的进化
  8. dedecms读取多个类别信息
  9. 计算Pearson 相关系数的三种方式
  10. Single-Shot Calibration:基于全景基础设施的多相机和多激光雷达之间的外参标定(ICRA2021)...