Redis源码阅读(一)——从Redis内存耗尽问题开始

前言:

相信大家都或多或少关注了一些技术公众号。这些公众号在起步阶段,肯定都是会产出一些优秀的原创技术文章,要不然我们也不会去关注。随着时间推移,某些公众号逐渐做大,粉丝越来越多,开始能接到金主的广告。当然号主“恰饭”也是无可厚非的,毕竟有收入才会有持续创作的动力。但是某些号,在接广告的同时,反而原创也越来越少了,往往就是随便找篇文章转发。转发也就算了,关键这些号主们好像商量好了一样,转发的内容也一样,最多换个标题了事。
有没有可能这些号有了足够的粉丝,能进入广告金主爸爸的视线后,都被卖了,而且是卖给同一家运营公司了?
比如最近,有一篇文章就被各个公众号转载。文章标题大同小异,例如《X猫二面,Redis内存满了怎么办?》,《X东二面,Redis内存耗尽了怎么办?》,里面的内容一模一样,连排版都懒得排。em…
然后当我们去百度搜索“redis内存耗尽”时,也会搜出来一大堆标题大同小异,内容几乎一样的文章。貌似有一个人原创后,大家都在转载,转载,转载…

相关知识点总结

首先,对上面说的那篇被转载了无数次的文章里的知识点进行一个总结。
在这里插入图片描述

但笔者在总结的过程中,产生了很大问题,比如:
为什么config set maxmemory 0相当于不设置内存大小?
所谓的内存大小,是指数据库的内存大小,还是redis的运行内存大小?
内存大小设了1兆,此时已使用了1000kb,当我再set一个key-value,此时是25kb,能set进去吗?
等等…
这些问题貌似没什么实际意义,也不会有人在面试问这些,所以也没人去研究这些。不过笔者的好奇心出来就难以抑制,决定自己载份源码去调试调试,看看关于内存,redis做了哪些事

准备Redis调试环境

首先是准备redis的调试环境。有一本书叫《Redis设计与实现》,该书详细的介绍了redis源码的方方面面。书的作者黄健宏也对redis3.0的源代码进行了详细注释,并开源到github上。地址为: https://github.com/huangz1990/redis-3.0-annotated
由于redis是用c语言编译的,所以其他语言的开发者需要安装下载c语言开发工具,配置一些环境。具体的安装过程可参考这篇博文: https://www.cnblogs.com/grey-wolf/p/12637730.html 按照博文,操作一遍下来就可以开始debug redis了。
注意,接下来的源码都是基于Redis3.0这个版本的。

1 为什么config set maxmemory 0等同于不设置内存大小

阅读源码时,一开始我们可能不知道从何读起,这时可以从一个小的突破口去开始。此时我们想了解的是maxmemory相关的,那么就可以以它为关键字,全局搜索maxmemory,看看哪些地方出现了这个词。
在CLion中全局搜索后发现,貌似有个叫redis.h的头文件中,有关于maxmemory的定义。代码截图如下:
在这里插入图片描述
那么redis又是在何处设置这个值呢?
在redis.h头文件中继续搜索maxmemory,很快会发现这样一处代码:
在这里插入图片描述

#define REDIS_DEFUALT_MAXMEMORY 0

在C语言中,可以用 #define 定义一个标识符来表示一个常量。REDIS_DEFUALT_MAXMEMORY这个常量名,顾名思义就是maxmemory的默认值。ctrl+鼠标左键点击常量符合,CLion帮我们找到了两处使用这个常量的地方:
在这里插入图片描述
点进config.c文件,我们可以看到如下代码:

 /* 将当前的属性读入到文件中,步骤:
 (1).将当前server属性读入configstate
 (2).configstate属性变为字符串
 (3).将字符串写入文件 */
int rewriteConfig(char *path) {
	// 这里忽略不相干代码...
	rewriteConfigBytesOption(state,"maxmemory",server.maxmemory,REDIS_DEFAULT_MAXMEMORY);
	// ...
}

这个rewriteConfig()方法的作用笔者暂时没搞明白,一番百度后搜到如上方法注释中这么一个结果。反正理解起来此处像是get当前redis服务设置的maxmemory值,而是set,所以暂时忽略。

点进第二个文件:redis.c,我们会发现这样一段代码:

void initServerConfig() {
	// ...
	// 可以使用的最大内存
    server.maxmemory = REDIS_DEFAULT_MAXMEMORY;
    // 内存淘汰策略,也就是key的过期策略
    server.maxmemory_policy = REDIS_DEFAULT_MAXMEMORY_POLICY;
    server.maxmemory_samples = REDIS_DEFAULT_MAXMEMORY_SAMPLES;
    // ...
}

initServerConfig()这个方法名,顾名思义就是初始化redis服务配置,(好的代码就是如此,光看命名就可以大概是干什么的了)。
继续ctrl+鼠标点击,沿着这个方法往上找,我们会发现,在一个叫main()的方法中,调用了initServerConfig()
在这里插入图片描述
什么是main方法?main方法就是c程序的主函数,c程序总是从main函数开始执行的。
至此,我们可以轻而易举的得出这么一个流程:
redis启动——>调用main方法——>调用initServerConfig()方法初始化服务器——>给服务器的maxmemory赋初始值0
所以当我们没有在配置文件中指定maxmemory时,Redis默认使用的内存大小是0?
这就奇怪了,如果初始内存大小是0,redis还怎么提供服务啊,岂不是啥都干不了?
莫慌,来看下面的代码:

int processCommand(redisClient *c) {
	// ...
	/* Handle the maxmemory directive.
     *
     * First we try to free some memory if possible (if there are volatile
     * keys in the dataset). If there are not the only thing we can do
     * is returning an error. */
    // 如果设置了最大内存,那么检查内存是否超过限制,并做相应的操作
    if (server.maxmemory) {
        // 如果内存已超过限制,那么尝试通过删除过期键来释放内存
        int retval = freeMemoryIfNeeded();
        // 如果即将要执行的命令可能占用大量内存(REDIS_CMD_DENYOOM)
        // 并且前面的内存释放失败的话
        // 那么向客户端返回内存错误
        if ((c->cmd->flags & REDIS_CMD_DENYOOM) && retval == REDIS_ERR) {
            flagTransaction(c);
            addReply(c, shared.oomerr);
            return REDIS_OK;
        }
    }
    // ...
}

processCommand()方法是redis执行命令的核心方法,在里面有一个if (server.maxmemory)的判断,把默认值代入,这句代码就是:if(0)
在c语言中,if(0)会得到什么结果?肯定是false啊。也就是说,当不设置maxmemory值时,就永远都进不去if (server.maxmemory)中的逻辑,也就不会检查内存是否超过限制,并去做内存淘汰了。
所以0意味着不做限制,系统有多少内存,redis就能用多少。
当然如果我们在配置文件redis.conf中指定了内存大小,redis启动后就会使用我们配置的值作为maxmemory的限制。
那么redis又是何时读取配置文件,并加载指定的配置值呢?

2 Redis启动过程中如何读取配置文件

redis在windows环境中的启动方式是进入redis的根目录,然后cmd输入以下指令:

redis-server.exe redis.conf

在linux环境中指定配置文件启动的方式是输入命令:

redis-server	/路径/redis.conf

结合main函数,我们很容易就能理解启动命令:

/*
  argc:用来统计你运行程序时送给main函数的命令行参数的个数
 *argv[]: 字符串数组,用来存放指向你的字符串参数的指针数组,每一个元素指向一个参数
*/
int main(int argc, char **argv) {
	//...
	// 检查用户是否指定了配置文件,或者配置选项.例如使用命令:redis-server	/路径/redis.conf时,参数个数肯定>=2
    if (argc >= 2) {
        int j = 1; /* First option to parse in argv[] */
        sds options = sdsempty();
        char *configfile = NULL;

        /* Handle special options --help and --version */
        // 处理特殊选项 -h 、-v 和 --test-memory
        if (strcmp(argv[1], "-v") == 0 ||
            strcmp(argv[1], "--version") == 0)
            // 输出当前版本并退出
            version();
        if (strcmp(argv[1], "--help") == 0 ||
            strcmp(argv[1], "-h") == 0)
            // 输出帮助文档并退出
            usage();
        if (strcmp(argv[1], "--test-memory") == 0) {
        	// 使用memtest工具检测内存
            if (argc == 3) {
                memtest(atoi(argv[2]), 50);
                exit(0);
            } else {
            	// 未指定要检测的内存大小
                fprintf(stderr, "Please specify the amount of memory to test in megabytes.\n");
                fprintf(stderr, "Example: ./redis-server --test-memory 4096\n\n");
                exit(1);
            }
        }

        /* First argument is the config file name? */
        // 如果第一个参数(argv[1])不是以 "--" 开头
        // 那么它应该是一个配置文件
        if (argv[j][0] != '-' || argv[j][1] != '-')
            configfile = argv[j++];

        /* All the other options are parsed and conceptually appended to the
         * configuration file. For instance --port 6380 will generate the
         * string "port 6380\n" to be parsed after the actual file name
         * is parsed, if any. */
        // 对用户给定的其余选项进行分析,并将分析所得的字符串追加稍后载入的配置文件的内容之后
        // 比如 --port 6380 会被分析为 "port 6380\n"
        while (j != argc) {
            if (argv[j][0] == '-' && argv[j][1] == '-') {
                /* Option name */
                if (sdslen(options)) options = sdscat(options, "\n");
                options = sdscat(options, argv[j] + 2);
                options = sdscat(options, " ");
            } else {
                /* Option argument */
                options = sdscatrepr(options, argv[j], strlen(argv[j]));
                options = sdscat(options, " ");
            }
            j++;
        }
        if (configfile) server.configfile = getAbsolutePath(configfile);
        // 重置保存条件
        resetServerSaveParams();

        // 载入配置文件, options 是前面分析出的给定选项
        loadServerConfig(configfile, options);
        sdsfree(options);

        // 获取配置文件的绝对路径
        if (configfile) server.configfile = getAbsolutePath(configfile);
    } else {
    	// 若什么参数都没有,输出警告日志。
        redisLog(REDIS_WARNING,
                 "Warning: no config file specified, using the default config. In order to specify a config file use %s /path/to/%s.conf",
                 argv[0], server.sentinel_mode ? "sentinel" : "redis");
    }
	//...
}

阅读一遍源码,大概可以知道,redis的启动过程中,会判断命令的参数个数,若小于2,说明没指定任何参数,则直接使用默认配置启动。如有启动参数,则尝试从中找到配置文件的相对地址,并将其转成绝对路径,然后调用loadServerConfig()方法,加载配置文件。

void loadServerConfig(char *filename, char *options) {
	// 定义一个空字符串对象
	sds config = sdsempty();
	char buf[REDIS_CONFIGLINE_MAX+1];
    /* Load the file content */
    // 载入文件内容
    if (filename) {
        // ...
    }
    /* Append the additional options */
    // 追加 options 字符串到内容的末尾
    if (options) {
        config = sdscat(config,"\n");
        config = sdscat(config,options);
    }
    // 根据字符串内容,设置服务器配置
    loadServerConfigFromString(config);
    sdsfree(config);
}

而loadServerConfig方法会将配置文件的内容全部读取为一个字符串对象,并将启动命令中的其他option追加到config字符串中,最后将config字符串传给loadServerConfigFromString(),这个方法是真正处理配置的地方。

void loadServerConfigFromString(char *config) {
	// ...
	sds *lines;// 声明字符串数组引用
    // 用换行符将config字符串split成字符串数组
    lines = sdssplitlen(config,strlen(config),"\n",1,&totlines);

    for (i = 0; i < totlines; i++) {
    	// 遍历字符串数组,读取每个配置
    	// 逻辑基本如下图所示,就是不断的判断判断,命中了对应的配置,就将该配置值赋值给全局对象server.
    	// 该对象保存了当前redis服务的各种配置属性等等
    }
 }

没有什么复杂的设计模式,就是无尽的if else判断。
这里会将各个配置值赋给全局对象server,而从前面判断是否需要检查内存大小的逻辑,也是拿了server对象的maxmemory属性去判断。如此设置内存大小和判断内存大小就联系上了。
在这里插入图片描述
另外,在main函数最后,redis还对maxmemory值做了检测,若设置的值小于1mb,就会在启动后打印警告日志。

/* Warning the user about suspicious maxmemory setting. */
    // 检查不正常的 maxmemory 配置
    if (server.maxmemory > 0 && server.maxmemory < 1024 * 1024) {
        redisLog(REDIS_WARNING,
                 "WARNING: You specified a maxmemory value that is less than 1MB (current value is %llu bytes). 
                 Are you sure this is what you really want?",server.maxmemory);
    }

看到这里,我们基本了解了redis启动过程中如何加载配置文件的。
前面示例中,指定maxmemory时的单位都是mb,例如maxmemory 100mb
那么如果我用kb可不可以,用Gb可不可以,不带参数可不可以?

3.内存大小单位

在给server.maxmemory赋值的时候,我们可以看到,还调用了一个memtoll方法,对配置值进行了转换,来看一下memtoll()方法:

long long memtoll(const char *p, int *err) {
    const char *u;
    char buf[128];
    long mul; /* unit multiplier */
    long long val;
    unsigned int digits;

    if (err) *err = 0;
    /* Search the first non digit character. */
    u = p;
    if (*u == '-') u++;
    while(*u && isdigit(*u)) u++;
    if (*u == '\0' || !strcasecmp(u,"b")) {// 调用strcasecmp不区分大小比较
        mul = 1;
    } else if (!strcasecmp(u,"k")) {
        mul = 1000;// 不带尾巴B或b的
    } else if (!strcasecmp(u,"kb")) {
        mul = 1024;// 带尾巴B或b的
    } else if (!strcasecmp(u,"m")) {
        mul = 1000*1000; // 不带尾巴B或b的
    } else if (!strcasecmp(u,"mb")) {
        mul = 1024*1024; // 带尾巴B或b的
    } else if (!strcasecmp(u,"g")) {
        mul = 1000L*1000*1000;// 不带尾巴B或b的
    } else if (!strcasecmp(u,"gb")) {
        mul = 1024L*1024*1024;// 不带尾巴B或b的
    } else {
    	// 如果是其他,则默认倍数为1.即最终的内存大小为 数字*1(b)
        if (err) *err = 1;
        mul = 1;
    }
    digits = u-p;
    if (digits >= sizeof(buf)) {
        if (err) *err = 1;
        return LLONG_MAX;
    }
    memcpy(buf,p,digits);
    buf[digits] = '\0';
    val = strtoll(buf,NULL,10);
    return val*mul;
}

从上面的代码我们可以得出,以下写法都是支持的:

maxmemory 1048576
maxmemory 1048576B
maxmemory 1000KB
maxmemory 100MB
maxmemory 1GB
maxmemory 1000K
maxmemory 100M
maxmemory 1G

但是,redis目前是不支持TB为单位的内存设置的。例如如果配置文件中写的是 maxmemory 1TB,最终会当成1b来返回。如果有土豪公司的redis服务器内存是TB级别的,只能辛苦一下转换成GB了。

4.为什么32位计算机Redis最大内存只有3GB

加载完配置文件后,redis就会根据全局配置对象server来初始化redis服务。
初始化redis服务的核心方法是initServer();在该方法中,做了一系列准备工作,例如初始化数据库。本文先忽略其他事情,着重关注maxmemory。
在initServer()方法中,可以看到如下一段代码:

	/* 32 bit instances are limited to 4GB of address space, so if there is
     * no explicit limit in the user provided configuration we set a limit
     * at 3 GB using maxmemory with 'noeviction' policy'. This avoids
     * useless crashes of the Redis instance for out of memory. */
    // 对于 32 位实例来说,默认将最大可用内存限制在 3 GB
    if (server.arch_bits == 32 && server.maxmemory == 0) {
        redisLog(REDIS_WARNING,
                 "Warning: 32 bit instance detected but no memory limit set. Setting 3 GB maxmemory limit with 'noeviction' policy now.");
        server.maxmemory = 3072LL * (1024 * 1024); /* 3 GB */
        server.maxmemory_policy = REDIS_MAXMEMORY_NO_EVICTION;
    }

server.arch_bits是什么东西呢?搜索后可以发现,在initServerConfig()方法中,也就是初始化默认配置的方法中,redis进行了一系列计算并对他赋值:

// 设置服务器的运行架构
    server.arch_bits = (sizeof(long) == 8) ? 64 : 32;

我们知道,在C语言中,一般在64位机器中,long占8个字节,在32位机器中,long占4个字节。上面那句代码,就是用来判断当前计算机是32位还是64位。但是,C语言中long占据的字节数与编译器的数据模型有关。若在64位机器上将redis实例编译为32位,此处代码获取到的值也是32位。所以上面的代码就是通过取long的字节大小来判断当前redis实例是32位还是64位。
而当前redis实例是32位时并且未给redis设置内存大小时,redis启动时会默认将其值设为3GB。
为什么呢?
首先,当redis实例被编译成32位时,redis默认视为在32位计算机种运行。
在计算机中,为了提高各个组件直接传输数据的效率,工程师们设计了各种总线,而其中有一个叫地址总线的,它主要用来指出数据总线上的源数据或目的数据在主存单元的地址或者I/O设备的地址。总之就是传输数据所在地址。而32位地址中,地址总线的宽度(也就是一次能传输的数据的长度)为32位,2的32次方=4G.也就是说,32位计算机的地址总线,最多支持4GB的寻址。再多,就找不到了。所以32位计算机最多只支持4GB大小的内存。除掉系统启动必须要使用的内存,所以redis给了3GB的内存限制。
但是此处有个问题,当没有给redis指定内存大小时,才会有3GB的默认限制。若是有人使用了32位redis实例,并且加了限制,且这个值大于4GB,那岂不是会出问题?例如这篇博文:redis服务器出现oom错误
笔者去github上翻了一下最新的redis源码,发现此处逻辑还是这样,笔者决定提个pr上去,通不通过再说。

5.如果值是负数会发生什么?

假如有人在配置maxmemory时,手残加了个负号,会发生什么?
如图,此时笔者指定-100kb的内存大小来启动redis:
在这里插入图片描述
启动后,客户端连上服务器,并获取当前内存大小:
在这里插入图片描述
可以看到,此时redis的内存大小是-1024kb,明显是不合逻辑的,内存大小是负数,那岂不是存不了任何东西?
结合上文启动过程中对maxmemory的读取和配置,我们在关键的地方打上断点,以debug模式再启动一遍。随后发现,在memtoll方法返回前,负号还是正常在的:
在这里插入图片描述
按照截图中的运算结果,memtoll方法的返回值是-102400。也就是redis的maxmemory值应为-102400b。
继续往下一步,回到loadServerConfigFromString方法后,发现,此时值变正数了。为什么?
注意看server.maxmemory的数据类型:unsigned long long.也就是说这是一个不带负号的数值。
二进制在计算机中以补码形式存在,正数的补码就是它本身,负数的补码是对应正数的原码取反加1得到.
-102400对应正数为102400
原码为:000…00011001000000000000
取反后:111…11100110111111111111
+1后:111…11100111000000000000
unsigned为无符号数,最高位的1会被作为数值的一部分,转为十进制后,刚好得到18446744073709449216。
2的64次方值为18446744073709551616,而此时maxmemory的值为18446744073709449216。两者做差运算,刚好为102400.
在这里插入图片描述
总结来说,maxmemory这个变量是个无符号整数,无论你给的是负值还是正值,对于redis来说,它都是正值。

6.Redis可以设置的最大内存值是多少?

那么,根据unsigned long long maxmemory这个定义,我们很容易就能算出可以为redis设置的最大内存限制。
2的64次方 B = 2的64次方 / 1024 KB = 2的54次方 KB = 2的54次方 /1024 MB = 2的44次方GB = 2的44次方 / 1024 TB = 2的34次方 TB = 2的24次方PB = 2的14次方EB = 2的4次方ZB
也就是16ZB,大概100亿TB,1万亿GB吧。
看来有生之年是见不到maxmemory不够用的时候了。
所以当有人手误在设置maxmemory给了个负值的时候,最终设置的值其实是无限接近这个最大值,也就相当于没对redis的内存大小做限制。

总结

最后,来看一下redis启动过程中关于maxmemory配置的处理逻辑图。
点击下方链接查看泳道图:
链接:https://gitmind.cn/app/flowchart/9a31285836
密码:8849

本文的内容,基本都是关于配置文件中指定内存大小时,redis相关的处理。另外我们知道,redis在运行过程中,也可以通过命令:config set maxmemory 1025GB 来改变redis的内存大小。那么当客户端发出这样一条命令请求时,redis又会做什么处理呢?redis又是如何区分普通的set,get命令和config set ,config get这些命令呢?未完待续。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值