GTLC全球技术领导力峰会·上海站,首批讲师正式上线! 了解详情
写点什么

Redis 是如何处理命令的(客户端)

2019 年 12 月 09 日

Redis 是如何处理命令的(客户端)

在使用 Redis 的过程中经常会好奇,在 Redis-Cli 中键入 SET KEY MSG 并回车之后,Redis 客户端和服务是如何对命令进行解析处理的,而在内部的实现过程是什么样的。


这两篇文章会分别介绍 Redis 客户端和服务端分别对命令是如何处理的,本篇文章介绍的是 Redis 客户端如何处理输入的命令、向服务发送命令以及取得服务端回复并输出到终端等过程。



文章中会将 Redis 服务看做一个输入为 Redis 命令,输出为命令执行结果的黑箱,对从命令到结果的过程不做任何解释,只会着眼于客户端的逻辑,也就是上图中的 1 和 4 两个过程。


从 main 函数开始

与其它的 C 语言框架/服务类似,Redis 的客户端 redis-cli 也是从 main 函数开始执行的,位于 redis-cli.c 文件的最后:


C


int main(int argc, char **argv) {    ...    if (argc == 0 && !config.eval) {        repl();    }    ...}
复制代码


在一般情况下,Redis 客户端都会进入 repl 模式,对输入进行解析;


Redis 中有好多模式,包括:Latency、Slave、Pipe、Stat、Scan、LRU test 等等模式,不过这些模式都不是这篇文章关注的重点,我们只会关注最常见的 repl 模式。


C


static void repl(void) {    char *line;    int argc;    sds *argv;
...
while((line = linenoise(context ? config.prompt : "not connected> ")) != NULL) { if (line[0] != '\0') { argv = cliSplitArgs(line,&argc);
if (argv == NULL) { printf("Invalid argument(s)\n"); continue; } if (strcasecmp(argv[0],"???") == 0) { ... } else { issueCommandRepeat(argc, argv, 1); } } } exit(0);}
复制代码


在上述代码中,我们省略了大量的实现细节,只保留整个 repl 中循环的主体部分,方便进行理解和分析,在 while 循环中的条件你可以看到 linenoise 方法的调用,通过其中的 promptnot connected> 可以判断出,这里向终端中输出了提示符,同时会调用 fgets 从标准输入中读取字符串:


C


127.0.0.1:6379>
复制代码


全局搜一下 config.prompt 不难发现这一行代码,也就是控制命令行提示的 prompt


C


anetFormatAddr(config.prompt, sizeof(config.prompt),config.hostip, config.hostport);
复制代码


接下来执行的 cliSplitArgs 函数会将 line 中的字符串分割成几个不同的参数,然后根据字符串 argv[0] 的不同执行的命令,在这里省略了很多原有的代码:


C


if (strcasecmp(argv[0],"quit") == 0 ||    strcasecmp(argv[0],"exit") == 0){    exit(0);} else if (argv[0][0] == ':') {    cliSetPreferences(argv,argc,1);    continue;} else if (strcasecmp(argv[0],"restart") == 0) {    ...} else if (argc == 3 && !strcasecmp(argv[0],"connect")) {    ...} else if (argc == 1 && !strcasecmp(argv[0],"clear")) {} else {    issueCommandRepeat(argc, argv, 1);}
复制代码


在遇到 quitexit 等跟客户端状态有关的命令时,就会直接执行相应的代码;否则就会将命令和参数 issueCommandRepeat 函数。


追踪一次命令的执行

Redis Commit: 790310d89460655305bd615bc442eeaf7f0f1b38

lldb: lldb-360.1.65

macOS 10.11.6


在继续分析 issueCommandRepeat 之前,我们先对 Redis 中的这部分代码进行调试追踪,在使用 make 编译了 Redis 源代码,启动 redis-server 之后;启动 lldb 对 Redis 客户端进行调试:


$ lldb src/redis-cli(lldb) target create "src/redis-cli"Current executable set to 'src/redis-cli' (x86_64).(lldb) b redis-cli.c:1290Breakpoint 1: where = redis-cli`repl + 228 at redis-cli.c:1290, address = 0x0000000100008cd4(lldb) process launchProcess 8063 launched: '~/redis/src/redis-cli' (x86_64)127.0.0.1:6379>
复制代码


redis-cli.c:1290 也就是下面这行代码的地方打断点之后:


C


-> 1290          if (line[0] != '\0') {
复制代码


执行 process launch 启动 redis-cli,然后输入 SET KEY MSG 回车以及 Ctrl-C:


在 lldb 中调试时,回车的输入经常会有问题,在这里输入 Ctrl-C 进入信号处理器,在通过 continue 命令进入断点:


C


127.0.0.1:6379> SET KEY MSG^C8063 stopped* thread #1: tid = 0xa95147, 0x00007fff90923362 libsystem_kernel.dylib`read + 10, stop reason = signal SIGSTOP    frame #0: 0x00007fff90923362 libsystem_kernel.dylib`read + 10libsystem_kernel.dylib`read:->  0x7fff90923362 <+10>: jae    0x7fff9092336c            ; <+20>    0x7fff90923364 <+12>: movq   %rax, %rdi    0x7fff90923367 <+15>: jmp    0x7fff9091c7f2            ; cerror    0x7fff9092336c <+20>: retq(lldb) cProcess 8063 resuming
Process 8063 stopped* thread #1: tid = 0xa95147, 0x0000000100008cd4 redis-cli`repl + 228 at redis-cli.c:1290, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1 frame #0: 0x0000000100008cd4 redis-cli`repl + 228 at redis-cli.c:1290 1287 1288 cliRefreshPrompt(); 1289 while((line = linenoise(context ? config.prompt : "not connected> ")) != NULL) {-> 1290 if (line[0] != '\0') { 1291 argv = cliSplitArgs(line,&argc); 1292 if (history) linenoiseHistoryAdd(line); 1293 if (historyfile) linenoiseHistorySave(historyfile);(lldb)
复制代码


输入两次 n 之后,打印 argvargc 的值:


C


(lldb) p argc(int) $1 = 3(lldb) p *argv(sds) $2 = 0x0000000100106cc3 "SET"(lldb) p *(argv+1)(sds) $3 = 0x0000000100106ce3 "KEY"(lldb) p *(argv+2)(sds) $4 = 0x0000000100106cf3 "MSG"(lldb) p line(char *) $5 = 0x0000000100303430 "SET KEY MSG\n"
复制代码


cliSplitArgs 方法成功将 line 中的字符串分隔成字符串参数,在多次执行 n 之后,进入 issueCommandRepeat 方法:


C


-> 1334                      issueCommandRepeat(argc-skipargs, argv+skipargs, repeat);
复制代码


对输入命令的处理

上一阶段执行 issueCommandRepeat 的函数调用栈中,会发现 Redis 并不会直接把所有的命令发送到服务端:


C


issueCommandRepeat    cliSendCommand        redisAppendCommandArgv            redisFormatCommandArgv            __redisAppendCommand
复制代码


而是会在 redisFormatCommandArgv 中对所有的命令进行格式化处理,将字符串转换为符合 RESP 协议的数据。


RESP 协议

Redis 客户端与 Redis 服务进行通讯时,会使用名为 RESP(REdis Serialization Protocol) 的协议,它的使用非常简单,并且可以序列化多种数据类型包括整数、字符串以及数组等。


对于 RESP 协议的详细介绍可以看官方文档中的 Redis Protocol specification,在这里对这个协议进行简单的介绍。


在将不同的数据类型序列化时,会使用第一个 byte 来表示当前数据的数据类型,以便在客户端或服务器在处理时能恢复原来的数据格式。



举一个简单的例子,字符串 OK 以及错误Error Message 等不同种类的信息的 RESP 表示如下:



在这篇文章中我们需要简单了解的就是 RESP “数据格式”的第一个字节用来表示数据类型,然后逻辑上属于不同部分的内容通过 CRLF(\r\n)分隔


数据格式的转换

redisFormatCommandArgv 方法中几乎没有需要删减的代码,所有的命令都会以字符串数组的形式发送到客户端:


C


int redisFormatCommandArgv(char **target, int argc, const char **argv, const size_t *argvlen) {    char *cmd = NULL;    int pos;    size_t len;    int totlen, j;
totlen = 1+intlen(argc)+2; for (j = 0; j < argc; j++) { len = argvlen ? argvlen[j] : strlen(argv[j]); totlen += bulklen(len); }
cmd = malloc(totlen+1); if (cmd == NULL) return -1;
pos = sprintf(cmd,"*%d\r\n",argc); for (j = 0; j < argc; j++) { len = argvlen ? argvlen[j] : strlen(argv[j]); pos += sprintf(cmd+pos,"$%zu\r\n",len); memcpy(cmd+pos,argv[j],len); pos += len; cmd[pos++] = '\r'; cmd[pos++] = '\n'; } assert(pos == totlen); cmd[pos] = '\0';
*target = cmd; return totlen;}
复制代码


SET KEY MSG 这一命令,经过这个方法的处理会变成:


C


*3\r\n$3\r\nSET\r\n$3\r\nKEY\r\n$3\r\nMSG\r\n
复制代码


你可以这么理解上面的结果:


C


*3\r\n    $3\r\nSET\r\n    $3\r\nKEY\r\n    $3\r\nMSG\r\n
复制代码


这是一个由三个字符串组成的数组,数组中的元素是 SETKEY 以及 MSG 三个字符串。


如果在这里打一个断点并输出 target 中的内容:



到这里就完成了对输入命令的格式化,在格式化之后还会将当前命令写入全局的 redisContextwrite 缓冲区 obuf 中,也就是在上面的缓冲区看到的第二个方法:


C


int __redisAppendCommand(redisContext *c, const char *cmd, size_t len) {    sds newbuf;
newbuf = sdscatlen(c->obuf,cmd,len); if (newbuf == NULL) { __redisSetError(c,REDIS_ERR_OOM,"Out of memory"); return REDIS_ERR; }
c->obuf = newbuf; return REDIS_OK;}
复制代码


redisContext

再继续介绍下一部分之前需要简单介绍一下 redisContext 结构体:


C


typedef struct redisContext {    int err;    char errstr[128];    int fd;    int flags;    char *obuf;    redisReader *reader;} redisContext;
复制代码


每一个 redisContext 的结构体都表示一个 Redis 客户端对服务的连接,而这个上下文会在每一个 redis-cli 中作为静态变量仅保存一个:


C


static redisContext *context;
复制代码


obuf 中包含了客户端未写到服务端的数据;而 reader 是用来处理 RESP 协议的结构体;fd 就是 Redis 服务对应的文件描述符;其他的内容就不多做解释了。


到这里,对命令的格式化处理就结束了,接下来就到了向服务端发送命令的过程了。


向服务器发送命令

与对输入命令的处理差不多,向服务器发送命令的方法也在 issueCommandRepeat 的调用栈中,而且藏得更深,如果不仔细阅读源代码其实很难发现:


C


issueCommandRepeat    cliSendCommand        cliReadReply            redisGetReply               redisBufferWrite
复制代码


Redis 在 redisGetReply 中完成对命令的发送:


C


int redisGetReply(redisContext *c, void **reply) {    int wdone = 0;    void *aux = NULL;
if (aux == NULL && c->flags & REDIS_BLOCK) { do { if (redisBufferWrite(c,&wdone) == REDIS_ERR) return REDIS_ERR; } while (!wdone);
... } while (aux == NULL); }
if (reply != NULL) *reply = aux; return REDIS_OK;}
复制代码


上面的代码向 redisBufferWrite 函数中传递了全局的静态变量 redisContext,其中的 obuf 中存储了没有向 Redis 服务发送的命令:


C


int redisBufferWrite(redisContext *c, int *done) {    int nwritten;
if (sdslen(c->obuf) > 0) { nwritten = write(c->fd,c->obuf,sdslen(c->obuf)); if (nwritten == -1) { if ((errno == EAGAIN && !(c->flags & REDIS_BLOCK)) || (errno == EINTR)) { } else { __redisSetError(c,REDIS_ERR_IO,NULL); return REDIS_ERR; } } else if (nwritten > 0) { if (nwritten == (signed)sdslen(c->obuf)) { sdsfree(c->obuf); c->obuf = sdsempty(); } else { sdsrange(c->obuf,nwritten,-1); } } } if (done != NULL) *done = (sdslen(c->obuf) == 0); return REDIS_OK;}
复制代码


代码的逻辑其实十分清晰,调用 write 向 Redis 服务代表的文件描述符发送写缓冲区 obuf 中的数据,然后根据返回值做出相应的处理,如果命令发送成功就会清空 obuf 并将 done 指针标记为真,然后返回,这样就完成了向服务器发送命令这一过程。



获取服务器回复

其实获取服务器回复和上文中的发送命令过程基本上差不多,调用栈也几乎完全一样:


C


issueCommandRepeat    cliSendCommand        cliReadReply            redisGetReply                redisBufferRead                redisGetReplyFromReader            cliFormatReplyRaw            fwrite
复制代码


同样地,在 redisGetReply 中获取服务器的响应:


C


int redisGetReply(redisContext *c, void **reply) {    int wdone = 0;    void *aux = NULL;
if (aux == NULL && c->flags & REDIS_BLOCK) { do { if (redisBufferWrite(c,&wdone) == REDIS_ERR) return REDIS_ERR; } while (!wdone);
do { if (redisBufferRead(c) == REDIS_ERR) return REDIS_ERR; if (redisGetReplyFromReader(c,&aux) == REDIS_ERR) return REDIS_ERR; } while (aux == NULL); }
if (reply != NULL) *reply = aux; return REDIS_OK;}
复制代码


redisBufferWrite 成功发送命令并返回之后,就会开始等待服务端的回复,总共分为两个部分,一是使用 redisBufferRead 从服务端读取原始格式的回复(符合 RESP 协议):


C


int redisBufferRead(redisContext *c) {    char buf[1024*16];    int nread;
nread = read(c->fd,buf,sizeof(buf)); if (nread == -1) { if ((errno == EAGAIN && !(c->flags & REDIS_BLOCK)) || (errno == EINTR)) { } else { __redisSetError(c,REDIS_ERR_IO,NULL); return REDIS_ERR; } } else if (nread == 0) { __redisSetError(c,REDIS_ERR_EOF,"Server closed the connection"); return REDIS_ERR; } else { if (redisReaderFeed(c->reader,buf,nread) != REDIS_OK) { __redisSetError(c,c->reader->err,c->reader->errstr); return REDIS_ERR; } } return REDIS_OK;}
复制代码


read 从文件描述符中成功读取数据并返回之后,我们可以打印 buf 中的内容:



刚刚向 buf 中写入的数据还需要经过 redisReaderFeed 方法的处理,截取正确的长度;然后存入 redisReader 中:


C


int redisReaderFeed(redisReader *r, const char *buf, size_t len) {    sds newbuf;
if (buf != NULL && len >= 1) { if (r->len == 0 && r->maxbuf != 0 && sdsavail(r->buf) > r->maxbuf) { sdsfree(r->buf); r->buf = sdsempty(); r->pos = 0; assert(r->buf != NULL); }
newbuf = sdscatlen(r->buf,buf,len); if (newbuf == NULL) { __redisReaderSetErrorOOM(r); return REDIS_ERR; }
r->buf = newbuf; r->len = sdslen(r->buf); }
return REDIS_OK;}
复制代码


最后的 redisGetReplyFromReader 方法会从 redisContext 中取出 reader,然后反序列化 RESP 对象,最后打印出来。



当我们从终端的输出中看到了 OK 以及这个命令的执行的时间时,SET KEY MSG 这一命令就已经处理完成了。


总结

处理命令的过程在客户端还是比较简单的:


  1. 在一个 while 循环中,输出提示符;

  2. 接收到输入命令时,对输入命令进行格式化处理;

  3. 通过 write 发送到 Redis 服务,并调用 read 阻塞当前进程直到服务端返回为止;

  4. 对服务端返回的数据反序列化;

  5. 将结果打印到终端。


用一个简单的图表示,大概是这样的:



References


本文转载自 Draveness 技术博客。


原文链接:https://draveness.me/redis-cli


2019 年 12 月 09 日 15:57187

评论

发布
暂无评论
发现更多内容

封装element-ui表格,我是这样做的

前端有的玩

Java Vue Element 封装

第八周学习总结

qihuajun

架构师课程第八周 作业

杉松壁

C++编译过程 宏 内联和静态变量

正向成长

第8周-作业2

seng man

8week

一叶知秋

LeetCode题解: 206. 反转链表,JavaScript,容易理解的递归解释,详细注释

Lee Chen

LeetCode 前端进阶训练营

程序的机器级表示-访问数据

引花眠

Jenkins 多分支项目过滤及 when 的高级用法

jerry.mei

DevOps 运维 自动化 jenkins CI/CD

AI与劳模的交点:拼多多农研大赛释放的产业能量

脑极体

第8周作业

小胖子

第8周-作业1

seng man

数据结构和算法-链表

jason

架构师训练营第八周课后题

Cloud.

从零开始写一个迷你版的Tomcat

简爱W

ARTS 06 - Jenkins 多分支项目过滤及 when 的高级用法

jerry.mei

学习 算法 ARTS 打卡计划 CI/CD ARTS活动

设计数据库

左洪斌

ARTS 打卡(2020.07.13-2020.07.19)

小王同学

周末在家加班开发代扣支付网关!

诸葛小猿

加班

MySQL 百万级数据量分页查询方法及其优化

xcbeyond

SQL优化 数据库优化

应用程序研发之网络-网络编程模型

superman

架构师训练营第八周课后总结

Cloud.

ARTS打卡 第9周

引花眠

ARTS 打卡计划

应用程序研发之网络 - Http

superman

计算机的时钟(二):Lamport逻辑时钟

ElvinYang

全栈新星 -- Dart

金刚狼

flutter dart 全栈 aqueduct

ARTS Week9

时之虫

ARTS 打卡计划

【架构师训练营 - 作业 -8】

小动物

安全系列之——手写JAVA加密、解密

诸葛小猿

对称加密 加密解密 非对称加密 rsa AES

应用程序研发之网络-分层模型

superman

JDK1.8新特性(六):Stream的终极操作,轻松解决集合分组、汇总等复杂操作

xcbeyond

stream 集合 新特性 JDK1.8 Collections

DNSPod与开源应用专场

DNSPod与开源应用专场

Redis 是如何处理命令的(客户端)-InfoQ