Redis 0.09x 源码阅读入口文件

Redis2505 字

0.09x 是目前能够下载到的最古老的版本,在代码中有标注,这是一个预览版,所有代码加起来在 1W 多行,其中包括了文档,和一些辅助脚本,第三方案例,真正的 C 语言实现的代码仅有 5200 多行,这其中其实已经包含了很多 Redis 的核心逻辑,阅读起来的障碍也会小很多。

Reids 7 的C 代码行数已经超过了15W行,阅读起来的难度可想而知。

2023-03-14T09:01:22.png

我们先来看一下 redis 的目录结构

2023-03-14T09:01:37.png

这个时候的目录非常简洁,工程目录下直接存放了所有的源代码,以及几个目录。

  • client-libraries 存放了各个语言的 client sdk,我们直接可以利用其中的 sdk 进行与 redis-server 的交互。
  • doc 存放了 redis 的所有文档。
  • utils 是 redis 的工具包,使用 ruby 进行编写的。

这个时候的 Makefile 异常简洁,没有后面那么复杂。一共只有 62 行,只要拥有一定的 C 语言基础相信直接就能看明白。

# Redis Makefile
# Copyright (C) 2009 Salvatore Sanfilippo <antirez at gmail dot com>
# This file is released under the BSD license, see the COPYING file

DEBUG?= -g
CFLAGS?= -std=c99 -pedantic -O2 -Wall -W -DSDS_ABORT_ON_OOM
CCOPT= $(CFLAGS)

OBJ = adlist.o ae.o anet.o dict.o redis.o sds.o zmalloc.o lzf_c.o lzf_d.o
BENCHOBJ = ae.o anet.o benchmark.o sds.o adlist.o zmalloc.o
CLIOBJ = anet.o sds.o adlist.o redis-cli.o zmalloc.o

PRGNAME = redis-server
BENCHPRGNAME = redis-benchmark
CLIPRGNAME = redis-cli

all: redis-server redis-benchmark redis-cli

# Deps (use make dep to generate this)
adlist.o: adlist.c adlist.h
ae.o: ae.c ae.h
anet.o: anet.c anet.h
benchmark.o: benchmark.c ae.h anet.h sds.h adlist.h
dict.o: dict.c dict.h
redis-cli.o: redis-cli.c anet.h sds.h adlist.h
redis.o: redis.c ae.h sds.h anet.h dict.h adlist.h zmalloc.c zmalloc.h
sds.o: sds.c sds.h
sha1.o: sha1.c sha1.h
zmalloc.o: zmalloc.c

redis-server: $(OBJ)
    $(CC) -o $(PRGNAME) $(CCOPT) $(DEBUG) $(OBJ)
    @echo ""
    @echo "Hint: To run the test-redis.tcl script is a good idea."
    @echo "Launch the redis server with ./redis-server, then in another"
    @echo "terminal window enter this directory and run 'make test'."
    @echo ""

redis-benchmark: $(BENCHOBJ)
    $(CC) -o $(BENCHPRGNAME) $(CCOPT) $(DEBUG) $(BENCHOBJ)

redis-cli: $(CLIOBJ)
    $(CC) -o $(CLIPRGNAME) $(CCOPT) $(DEBUG) $(CLIOBJ)

.c.o:
    $(CC) -c $(CCOPT) $(DEBUG) $(COMPILE_TIME) $<

clean:
    rm -rf $(PRGNAME) $(BENCHPRGNAME) $(CLIPRGNAME) *.o

dep:
    $(CC) -MM *.c

test:
    tclsh test-redis.tcl

bench:
    ./redis-benchmark

log:
    git log '--pretty=format:%ad %s' --date=short > Changelog

Redis 的入口

我们直接到 redis 的入口函数,其代码在 redis.c 中,直接拖拽滚动框到最底部即可,我对代码的每个阶段做了注释。启动一个 redis 服务其实 3 个阶段总计 8 个步骤。

  • 阶段一:基本初始化

    1. 初始化 server 配置,initServerConfig()。
    2. 判断是否有指定配置文件,如果有则重新加载配置文件,loadServerConfig()。
    3. 初始化服务,initServer()。
    4. 判断是否为后台执行,daemonize()。
  • 阶段二:检查 RDB

    1. 加载 rdb 文件,当进程退出后会加载以前保存的 rdb 文件,rdbLoad()。
  • 阶段三:事件驱动

    1. 创建 event-driven 其实就是解析客户端请,并且将 socket fd 放入到事件驱动当中去,aeCreateFileEvent()。
    2. 执行死循环,接收请求,其实这一步就是我们说 redis 是单线程的原因,aeMain()。
    3. acMain 判断是否停止,如果停止则删除事件,并退出程序,aeDeleteEventLoop()。
int main(int argc, char **argv) {
    //1. 初始化 server 段配置
    initServerConfig();
    //2. 判断是否有指定配置文件,如果有则重新加载配置文件
    if (argc == 2) {
        ResetServerSaveParams();
        loadServerConfig(argv[1]);
    } else if (argc > 2) {
        //如果参数过多则报错,显示正确的配置
        fprintf(stderr, "Usage: ./redis-server [/path/to/redis.conf]\n");
        exit(1);
    }
    //3. 初始化服务
    initServer();
    //4. 判断是否为后台执行
    if (server.daemonize) daemonize();
    redisLog(REDIS_NOTICE, "Server started, Redis version " REDIS_VERSION);
    //5. 加载 rdb 文件,当进程退出后会加载以前保存的 rdb 文件
    if (rdbLoad(server.dbfilename) == REDIS_OK)
        redisLog(REDIS_NOTICE, "DB loaded from disk");
    //6. event-driven 其实就是解析客户端请求
    if (aeCreateFileEvent(server.el, server.fd, AE_READABLE,
                          acceptHandler, NULL, NULL) == AE_ERR)
        oom("creating file event");
    redisLog(REDIS_NOTICE, "The server is now ready to accept connections on port %d", server.port);
    //7. 死循环执行
    aeMain(server.el);
    //8. 当接收到退出信号后,删除事件
    aeDeleteEventLoop(server.el);

    return 0;
}

阶段一:基本初始化

initServerConfig 初始化 Server 配置

initServerConfig 就是对 redis 进行默认值的初始化。

static void initServerConfig() {
    # 初始化 redis 默认 DB 数量,默认是 16 个
    server.dbnum = REDIS_DEFAULT_DBNUM;
        # 初始化 redis 默认端口 3306
    server.port = REDIS_SERVERPORT;
    # 日志级别,默认是 debug
    server.verbosity = REDIS_DEBUG;
    # 默认超时时间
    server.maxidletime = REDIS_MAXIDLETIME;
    # 存储策略
    server.saveparams = NULL;
    server.logfile = NULL; /* NULL = log on standard output */
    server.bindaddr = NULL;
        # 设置在向客户端应答时,是否把较小的包合并为一个包发送,默认为开启
    server.glueoutputbuf = 1;
    server.daemonize = 0;
    server.pidfile = "/var/run/redis.pid";
    server.dbfilename = "dump.rdb";
    # 是否要求通行证
    server.requirepass = NULL;
        # 对象共享池
    server.shareobjects = 0;
    # 重置保存策略
    ResetServerSaveParams();
    appendServerSaveParams(60 * 60, 1);  /* save after 1 hour and 1 change */
    appendServerSaveParams(300, 100);  /* save after 5 minutes and 100 changes */
    appendServerSaveParams(60, 10000); /* save after 1 minute and 10000 changes */
    /* 主从复制相关的配置 */
    server.isslave = 0;
    server.masterhost = NULL;
    server.masterport = 6379;
    server.master = NULL;
    server.replstate = REDIS_REPL_NONE;
}

ResetServerSaveParams() 的操作这里很简单,其实就是将原有的内存空间清空。

static void ResetServerSaveParams() {
    zfree(server.saveparams);
    server.saveparams = NULL;
    server.saveparamslen = 0;
}

在这里,在 redis 0.091 版本中,redis 对内存分配使用的还是 zmalloc,这个我们放在后面去讲解,

server 是一个全局静态变量,数据类型是 redisServer。

loadServerConfig 重新加载配置文件

重新加载配置文件就很好理解了。

   if (argc == 2) {
        ResetServerSaveParams();
        loadServerConfig(argv[1]);
    } else if (argc > 2) {
        //如果参数过多则报错,显示正确的配置
        fprintf(stderr, "Usage: ./redis-server [/path/to/redis.conf]\n");
        exit(1);
    }

只是一段非常普通的 if 判断。

如果参数是两个,那么就说明有配置文件,执行加载逻辑

  1. 重新初始化 Server Save 参数
  2. loadServerConfig

loadServerConfig 由于代码太长了,而且大多数都是参数解析,在这里就不全都贴出来了,我们来看一些核心的逻辑。

//跳过了获取文件,判断文件是否有效的逻辑
while (fgets(buf, REDIS_CONFIGLINE_MAX + 1, fp) != NULL) {
        sds *argv;
        int argc, j;

        linenum++;
        line = sdsnew(buf);
        line = sdstrim(line, " \t\r\n");
/* Skip comments and blank lines*/
        if (line[0] == '#' || line[0] == '\0') {
            sdsfree(line);
            continue;
        }

        /* Split into arguments */
        argv = sdssplitlen(line, sdslen(line), " ", 1, &argc);
        sdstolower(argv[0]);

        /* Execute config directives */
        if (!strcmp(argv[0], "timeout") && argc == 2) {
            server.maxidletime = atoi(argv[1]);
            if (server.maxidletime < 1) {
                err = "Invalid timeout value";
                goto loaderr;
            }
                 } else if (xxxx) {
                        .....
                 } else .....
return;
    loaderr:
    fprintf(stderr, "\n*** FATAL CONFIG FILE ERROR ***\n");
    fprintf(stderr, "Reading the configuration file, at line %d\n", linenum);
    fprintf(stderr, ">>> '%s'\n", line);
    fprintf(stderr, "%s\n", err);
    exit(1);
}

在这段,出现了我们 redis 的第一个数据结构——sds,也就是我们在缓存中使用最多的字符串类型。

在 Redis0.091 中,SDS 的实现还是非常简单的,具体的 SDS 操作,我们在后续的部分进行讲解,我们先了解一下这个数据结构。头文件加实现代码一共才 275 行。

2023-03-14T09:02:12.png

Reids 利用 SDS 代替的字符串,这涉及到字符串的二进制安全问题。,C语言中,用“\0”表示字
符串的结束,如果字符串中本身就有“\0”字符,字符串就会被截断,即非二进制安全;若通过某种机制,保证读写字符串时不损害其内容,则是二进制安全。

在 C 语言中,字符串以 \0 结尾,这也就意味着字符串中如果出现了\0就会破坏数据的完整性,字符串也就会被截断。

为了解决这样的问题,redis 将 sds 设计成下面的样子。

typedef char *sds;

struct sdshdr {
    long len;
    long free;
    char buf[];
};
  • len 属性为5,表示这个SDS保存着一个5字节长的字符串,由于长度统计变量的存在,读取字符串不依赖”\0”终止符,保证了二进制安全。
  • free 属性为0,表示这个SDS没有分配任何未使用空间。
  • buf:内容存储在柔性数组 buf中,SDS 对上层暴露的指针不是指向结构体 SDS 的指针,而是直接只想荣幸数组的指针,上层就可以像读取 C 字符串一样读取 SDS 的内容,兼容 C 语言处理字符串的各种函数。

2023-03-14T09:02:32.png

柔性数组只能放在结构体的尾部。柔性数组的地址和接头体是连续的,这样查找内存更快,因为不需要额外通过指针找到字符串为止,可以方便地通过柔性数组的首地址偏移得到结构体首地址,进而方便获取其余变量。

总结一下 SDS 和 Char 的区别

  1. 通过使用SDS字符串(len记录字符串长度),使得获取字符串长度的复杂度从O(N)变为O(1)
  2. 杜绝缓存区溢出,C字符串不记录自身长度,在拼接字符串时可能造成缓存区溢出
  3. 通过未使用空间free,减少修改字符串带来的内存重分配次数

其实这种设计在其他项目中也存在,这是 PHP3 中对 string 类型的视线。

2023-03-14T09:02:47.png

当校验没有问题后,就开始抓好难过解析配置,如果配置正确就继续扫描下一行,如果错误就会利用 goto 跳转到 loaderr。

可能在很多 C 语言的帖子中或者老师的课程中都不允许使用 goto 关键字,但是在实际的项目中这种用法有很多,并不是绝对不可以使用,因为在 C语言中并没有异常机制,像这种利用 goto 特性跳转到输出位置进行输出信息,处理异常会特别方便,代码逻辑也更加的清晰,在处理异常的同事也可以进行回收资源。

老师说的意思其实是不要多层跳转,因为多层跳转会让代码变得混乱。

initServer() 初始化 server

得到配置后,万事俱备只欠东风,我们开始对 server 进行初始化。

static void initServer() {
    int j;

    // 屏蔽信号,避免异常
    signal(SIGHUP, SIG_IGN);
    signal(SIGPIPE, SIG_IGN);

   
    server.clients = listCreate();
    server.slaves = listCreate();
    server.monitors = listCreate();
    server.objfreelist = listCreate();

    // 创建 SharedObjects 这里就是存储 redis 数据的地方;
        //  如果有熟悉Redis的朋友可能会知道 RedisObject,这个就是它的前身。
    createSharedObjects();

        //创建事件驱动
    server.el = aeCreateEventLoop();
    server.db = zmalloc(sizeof(redisDb) * server.dbnum);
    server.sharingpool = dictCreate(&setDictType, NULL);
    server.sharingpoolsize = 1024;

        // 如果分配内存失败也就意味着没有可用空间了,那就 OOM
    if (!server.db || !server.clients || !server.slaves || !server.monitors || !server.el || !server.objfreelist)
        oom("server initialization"); /* Fatal OOM */

    #创建 TCP 连接
    server.fd = anetTcpServer(server.neterr, server.port, server.bindaddr);
    if (server.fd == -1) {
        redisLog(REDIS_WARNING, "Opening TCP port: %s", server.neterr);
        exit(1);
    }

    for (j = 0; j < server.dbnum; j++) {
        server.db[j].dict = dictCreate(&hashDictType, NULL);
        server.db[j].expires = dictCreate(&setDictType, NULL);
        server.db[j].id = j;
    }

    server.cronloops = 0;
    server.bgsaveinprogress = 0;
    server.lastsave = time(NULL);
    server.dirty = 0;
    server.usedmemory = 0;
    server.stat_numcommands = 0;
    server.stat_numconnections = 0;
    server.stat_starttime = time(NULL);
    aeCreateTimeEvent(server.el, 1000, serverCron, NULL, NULL);
}
  • 屏蔽信号
  • 初始化 clients,slaves,monitors,objfreelist 四个链表。

    • clients 客户端
    • slaves 从库
    • monitors 监控器,某个 client 执行 monitors 的时候会变成一个监控器,服务端执行的所有命令都会发送给监控器。
    • objfreelist 空闲对象池
  • 初始化 dict 也就是 shareobjects 存储键值对的地方
  • 创建 tcp 连接
  • 创建数据库,其中会初始化 dict,过期时间 dict
  • 初始化 server 剩余的属性
  • 注册听时期

listCreate() 引出了,我们的第二个数据结构——双向链表。

2023-03-14T09:14:02.png

其代码量加载一起一共是 214 行,核心主要是下面两个结构体,list、ListNode 构成了 redis 中的双向链表。

typedef struct list {
    listNode *head;
    listNode *tail;
    void *(*dup)(void *ptr);
    void (*free)(void *ptr);
    int (*match)(void *ptr, void *key);
    unsigned int len;
} list;

typedef struct listNode {
    struct listNode *prev;
    struct listNode *next;
    void *value;
} listNode;

在链表中保存着 head 和 tail,也就是链表中的头和尾。每个 Node 节点都保存着前一个和后一个的节点指针。

通过单个节点我们不仅能够访问到下一个节点,还能访问到上一个节点。使用双链表而非单链表的目的是想增加结构的灵活性,因为只有经常对数据进行增加或者删除才会用到链表这种数据结构。无论是增加节点抑或删除节点,双链表都要比单链表更加简单高效。但提高灵活性的代价是需要更多的空间,但这个代价小到可以被我们忽略。

daemonize 守护进程

static void daemonize(void) {
    int fd;
    FILE *fp;

    if (fork() != 0) exit(0); /* parent exits */
    setsid(); /* create a new session */

    /* Every output goes to /dev/null. If Redis is daemonized but
     * the 'logfile' is set to 'stdout' in the configuration file
     * it will not log at all. */
    if ((fd = open("/dev/null", O_RDWR, 0)) != -1) {
        dup2(fd, STDIN_FILENO);
        dup2(fd, STDOUT_FILENO);
        dup2(fd, STDERR_FILENO);
        if (fd > STDERR_FILENO) close(fd);
    }
    /* Try to write the pid file */
    fp = fopen(server.pidfile, "w");
    if (fp) {
        fprintf(fp, "%d\n", getpid());
        fclose(fp);
    }

这是一个标准的守护进程代码。

  1. fork 子进程,父进程退出
  2. 设置会话 id
  3. 转移标准输入,标准输出,标准错误
  4. 将 pid 放到文件中

阶段二:rdbLoad 加载 rdb 文件

函数loadDb 的实现完全是根据写入磁盘数据库文件的rdbSave函数来实现的, 后续我们在定时事件中再看rdbSave实现,在此略过分析loadDb的实现。

阶段三:事件驱动

事件驱动框架是 Redis Server 运行的核心,该框架爱一单启动后就会一直执循环执行,每次循环都会处理一批出发的网络读写事件。

其实进入事件框架开始执行并不复杂,main 函数直接调用时间框架爱的主题函数 aeMain 后就进入了时间处理循环了。

//7. 死循环执行
aeMain(server.el);

而他的源代码实现更简单。

void aeMain(aeEventLoop *eventLoop)
{
    eventLoop->stop = 0;
    while (!eventLoop->stop)
        aeProcessEvents(eventLoop, AE_ALL_EVENTS);
}

其实就是一个死循环,如果当前的事件驱动的 stop 属性等于 1 了就说明服务终止,那么就退出循环,整个业务逻辑就结束了。

//8. 当接收到退出信号后,删除事件
aeDeleteEventLoop(server.el);

在 0.091 这个版本中还没有引入 epoll 的支持,底层使用的还是 select。

2023-03-14T09:11:15.png

所以在这个版本中,如果没有做内核优化,那么 redis 的并发 fd 最多也就是 1024 个,远没有后面那么高的并发量。

到目前为止,我们算是知道了 redis0.091 的入口,接下来,我们会进入到具体的实现细节当中,下一章,我们来看 redis server 是如何处理用户请求的。

maksim
Maksim(一笑,吡罗),PHPer,Goper
OωO
开启隐私评论,您的评论仅作者和评论双方可见