baiyan
引入
首先看一张我们非常熟悉的redis命令执行图:
那么思考这样一个问题,当我们连接了redis服务端之后,然后输入并执行某条redis命令:如set key1 value1。这条命令究竟是如何被发送到redis服务端的,redis服务端又是如何解析,并作出相应处理,并返回执行成功的呢?
客户端到服务端的命令传输(请求)
redis在TCP协议基础之上,封装了自己的一套协议规范,方便服务端与客户端去接收与解析数据,划清命令参数之间的边界,方便最终对以TCP字节流传输的数据进行处理。下面我们使用tcpdump来捕获redis-cli发送命令时的数据包:
tcpdump port 6379 -i lo -X
这时,我们在客户端中输入set key1 value1命令。在tcpdump中捕获的数据包如下:
第一个是客户端发送命令到redis服务端时的数据包,而第二个是redis服务端响应给客户端的数据包。我们首先只看第一个数据包,它从客户端43856端口发送到redis服务端的6379端口。首先前20个字节是IP头部,后32字节是TCP头部(由于TCP头部后面存在可选项)。
我们主要关注从“2a33”开始的数据信息,从这里开始就是redis具体的数据格式了。从右边对数据的一个ASCII码翻译也可以看到set、key1、value1的字样,中间还有一些用.表示的字符,那么这里,我们根据抓包结果分析一下redis数据传输的协议格式。
- 2a33:0x2a是字符"*"的ASCII码值,0x33是"3"的ASCII码值(十进制值是51)
- 0d0a:0d是"r"的ASCII码值,0a是"n"的ASCII码值
- 7365:是"s"和"e"的ASCII码值
- 740d:是"t"和"r"的ASCII码值
- 0a24:是"n"和"$"的ASCII码值
- 340d:是"4"和"r"的ASCII码值
- 0a6b:是"n"和"k"的ASCII码值
- 6579:是"e"和"y"的ASCII码值
- 310d:是"1"和"r"的ASCII码值
- 0a24:是"n"和"$"的ASCII码值
- 360d:是”6"和"r"的ASCII码值
- 0a76:是"n"和"v"的ASCII码值
- 616c:是"a"和"l"的ASCII码值
- 7565:是"u"和"e"的ASCII码值
- 310d:是"1"和"r"的ASCII码值
- 0a: 是"n"的ASCII码值
看到这里,我们是否能够发现以下规律:
- redis以"*"作为标志,表示命令的开始。在*后面紧跟的数字代表参数的个数(set key1 value1有3个参数所以为3)
- redis以"$"作为命令参数的开始,后面紧跟的数字代表参数的长度(如key1的长度为4所以为$4)
- redis以"rn"作为参数之间的分隔符,方便解析TCP字节流数据时定位边界位置
综合来看,客户端向服务端发送的redis数据包格式如下:
*3 \r\n set \r\n $4 \r\n key1 \r\n $6 \r\n value1 \r\n
相比FastCGI协议,redis仅仅使用几个分隔符和特殊字符,就完成了对命令的传输语法及数据格式的规范化,同时服务端通过其中定义好的分隔符,也能够方便高效地从字节流数据中解析并读取出正确的数据。这种通信协议简单高效,能够满足redis对高性能的要求。
服务端对命令的处理
既然命令已经通过redis数据传输协议安全地送达到了服务端,那么,服务端就要开始对传输过来的字节流数据进行处理啦。由于我们在协议中清晰地定义了每个参数的边界(\r\n),所以,redis服务端解析起来也非常轻松。
第一步:回调函数的使用
redis是典型事件驱动程序。为了提高单进程的redis的性能,redis采用IO多路复用技术来处理客户端的命令请求。redis会在创建客户端实例的时,指定服务端接收到客户端命令请求的事件时,所要执行的事件处理函数:
client *createClient(int fd) {
client *c = zmalloc(sizeof(client));
if (fd != -1) {
anetNonBlock(NULL,fd); //设置非阻塞
anetEnableTcpNoDelay(NULL,fd); //设置不采用Nagle算法,避免半包与粘包现象
if (server.tcpkeepalive)
anetKeepAlive(NULL,fd,server.tcpkeepalive); //设置keep-alive
//注意这里创建了一个文件事件。当客户端读事件就绪的时候,回调readQueryFromClient()函数
if (aeCreateFileEvent(server.el,fd,AE_READABLE,readQueryFromClient, c) == AE_ERR) {
close(fd);
zfree(c);
return NULL;
}
}
...
}
为了暂存客户端请求到服务端的字节流数据,redis封装了一个接收缓冲区,来缓存从套接字中读取的数据。后续的命令处理流程从缓冲区中读取命令数据并处理即可。缓冲区的好处在于不用一直维持读写套接字。在后续的流程中,我们只需要从缓冲区中读取数据,而不是仍从套接字中读取。这样就可以提前释放套接字,节省资源。缓冲区的建立与使用就是在之前讲过的客户端回调函数readQueryFromClient()中完成的:
void readQueryFromClient(aeEventLoop *el, int fd, void *privdata, int mask) {
...
qblen = sdslen(c->querybuf); //获取缓冲区长度
if (c->querybuf_peak < qblen) c->querybuf_peak = qblen;
c->querybuf = sdsMakeRoomFor(c->querybuf, readlen); //创建一个sds结构作为缓冲区
nread = read(fd, c->querybuf+qblen, readlen); //从套接字中读取数据到缓冲区中暂存
...
//真正地处理命令
processInputBufferAndReplicate(c);
}
第二步:分发器的使用
这段代码创建并往缓冲区中写入了字节流数据,然后调用processInputBufferAndReplicate()去真正地处理命令。processInputBufferAndReplicate()函数中只是简单的调用了==processInputBuffer()函数。由于我们之前的缓冲区中已经有了客户端发给服务端的字节流数据,所以我们需要在这一层进行数据初步的筛选与处理:
void processInputBuffer(client *c) {
// 如果缓冲区还没有处理完,继续循环处理
while(c->qb_pos < sdslen(c->querybuf)) {
...
// 对字节流数据进行定制化分发处理
if (c->reqtype == PROTO_REQ_INLINE) { //如果是INLINE类型的请求
if (processInlineBuffer(c) != C_OK) break; //调用processInlineBuffer解析缓冲区数据
} else if (c->reqtype == PROTO_REQ_MULTIBULK) {//如果是MULTIBULK类型的请求
if (processMultibulkBuffer(c) != C_OK) break; //调用processMultibulkBuffer解析缓冲区数据
} else {
serverPanic("Unknown request type");
}
// 开始处理具体的命令
if (c->argc == 0) { //命令参数为0个,非法
resetClient(c);
} else { //命令参数不为0,合法
// 调用processCommand()真正处理命令
if (processCommand(c) == C_OK) { //
...
}
}
}
}
看到这里,读者可能会有些疑惑。什么是INLINE、什么是MULTIBULK?在redis中,有两种请求命令类型:
- INLINE类型:简单字符串格式,如ping命令
- MULTIBULK类型:字符串数组格式。如set、get等等大部分命令都是这种类型
这个函数其实就是一个分发器。由于底层的字节流数据是无规则的,所以我们需要根据客户端的reqtype字段,去区分请求字节流数据属于那种请求类型,进而分发到对应的函数中进行处理。由于我们经常执行的命令都是MULTIBULK类型,我们也以MULTIBULK类型为例。对于set、get这种MULTIBULK请求类型,会被分发到processMultibulkBuffer()函数中进行处理。
第三步:检查接收缓冲区的数据完整性
在开启TCP的Nagle算法时,TCP会将多个redis命令请求的数据包合并或者拆分发送。这样就会出现在一个数据包中命令不完整、或者一个数据包中包含多个命令的情况。为了解决这个问题,processMultibulkBuffer()函数保证,当只有在缓冲区中包含一个完整请求时,这个函数才会成功解析完字节流中的命令参数,并返回成功状态码。否则,会break出外部的while循环,等待下一次事件循环再从套接字中读取剩余的数据,再进行对命令的解析。这样就保证了redis协议中的数据的完整性,也保证了实际命令参数的完整性。
int processMultibulkBuffer(client *c) {
while(c->multibulklen) {
...
/* 读取命令参数字节流 */
if (sdslen(c->querybuf)-c->qb_pos < (size_t)(c->bulklen+2)) { //如果$后面代表参数长度的数字与实际命令长度不匹配(+2的位置是\r\n),说明数据不完整,直接跳出循环,等待下一次读取剩余数据
break;
} else { //命令完整,进行一些执行命令之前的初始化工作
if (c->qb_pos == 0 && c->bulklen >= PROTO_MBULK_BIG_ARG && sdslen(c->querybuf) == (size_t)(c->bulklen+2)) {
c->argv[c->argc++] = createObject(OBJ_STRING,c->querybuf);
sdsIncrLen(c->querybuf,-2);
c->querybuf = sdsnewlen(SDS_NOINIT,c->bulklen+2);
sdsclear(c->querybuf);
} else {
c->argv[c->argc++] =
createStringObject(c->querybuf+c->qb_pos,c->bulklen);
c->qb_pos += c->bulklen+2;
}
c->bulklen = -1;
c->multibulklen--; //处理下一个命令参数
}
}
}
第四步:真正地处理命令
我们回到外层。当我们成功执行processMultibulkBuffer()函数之后,说明当前命令已经完整,可以对命令进行处理了。我们想一下,加入要我们去设计根据不同的命令,调用不同的处理函数,从而完成不同的功能,我们应该怎么做呢?想了想,我们可以简单写出以下代码:
if (command == "get") {
doGetCommand(); //get命令处理函数
} else if (command == "set") {
doSetCommand(); //set命令处理函数
} else {
printf("非法命令")
}
以上代码非常简单,只是根据我们得到的不同命令请求,分发到不同的命令处理函数中进行定制化处理。那么redis其实也是同样的道理,那究竟redis是怎么做的呢:
int processCommand(client *c) {
//如果是退出命令直接返回
if (!strcasecmp(c->argv[0]->ptr,"quit")) {
addReply(c,shared.ok);
c->flags |= CLIENT_CLOSE_AFTER_REPLY;
return C_ERR;
}
//去字典里查找命令,并把要执行的命令处理函数赋值到c结构体中的cmd字段
c->cmd = c->lastcmd = lookupCommand(c->argv[0]->ptr);
// 返回值校验
if (!c->cmd) { //没有找到该命令
flagTransaction(c);
sds args = sdsempty();
int i;
for (i=1; i < c->argc && sdslen(args) < 128; i++)
args = sdscatprintf(args, "`%.*s`, ", 128-(int)sdslen(args), (char*)c->argv[i]->ptr);
addReplyErrorFormat(c,"unknown command `%s`, with args beginning with: %s",
(char*)c->argv[0]->ptr, args);
sdsfree(args);
return C_OK;
} else if ((c->cmd->arity > 0 && c->cmd->arity != c->argc) || //命令参数不匹配
(c->argc < -c->cmd->arity)) {
flagTransaction(c);
addReplyErrorFormat(c,"wrong number of arguments for '%s' command",
c->cmd->name);
return C_OK;
}
// 真正执行命令
if (c->flags & CLIENT_MULTI &&
c->cmd->proc != execCommand && c->cmd->proc != discardCommand &&
c->cmd->proc != multiCommand && c->cmd->proc != watchCommand)
{
queueMultiCommand(c);
addReply(c,shared.queued);
} else { //真正执行命令
call(c,CMD_CALL_FULL); //核心函数
c->woff = server.master_repl_offset;
if (listLength(server.ready_keys))
handleClientsBlockedOnKeys();
}
return C_OK;
}
在这个函数中,最重要的就是lookupCommand()函数和call()函数的调用了。在redis中,所有命令都存储在一个字典中,这个字典长这样:
struct redisCommand redisCommandTable[] = {
{"module",moduleCommand,-2,"as",0,NULL,0,0,0,0,0},
{"get",getCommand,2,"rF",0,NULL,1,1,1,0,0},
{"set",setCommand,-3,"wm",0,NULL,1,1,1,0,0},
{"setnx",setnxCommand,3,"wmF",0,NULL,1,1,1,0,0},
{"setex",setexCommand,4,"wm",0,NULL,1,1,1,0,0},
{"psetex",psetexCommand,4,"wm",0,NULL,1,1,1,0,0},
{"append",appendCommand,3,"wm",0,NULL,1,1,1,0,0},
{"strlen",strlenCommand,2,"rF",0,NULL,1,1,1,0,0},
{"del",delCommand,-2,"w",0,NULL,1,-1,1,0,0},
{"unlink",unlinkCommand,-2,"wF",0,NULL,1,-1,1,0,0},
{"exists",existsCommand,-2,"rF",0,NULL,1,-1,1,0,0},
{"setbit",setbitCommand,4,"wm",0,NULL,1,1,1,0,0},
{"getbit",getbitCommand,3,"rF",0,NULL,1,1,1,0,0},
{"bitfield",bitfieldCommand,-2,"wm",0,NULL,1,1,1,0,0},
{"setrange",setrangeCommand,4,"wm",0,NULL,1,1,1,0,0},
{"getrange",getrangeCommand,4,"r",0,NULL,1,1,1,0,0},
{"substr",getrangeCommand,4,"r",0,NULL,1,1,1,0,0},
{"incr",incrCommand,2,"wmF",0,NULL,1,1,1,0,0},
{"decr",decrCommand,2,"wmF",0,NULL,1,1,1,0,0},
{"mget",mgetCommand,-2,"rF",0,NULL,1,-1,1,0,0},
{"rpush",rpushCommand,-3,"wmF",0,NULL,1,1,1,0,0},
{"lpush",lpushCommand,-3,"wmF",0,NULL,1,1,1,0,0},
{"rpushx",rpushxCommand,-3,"wmF",0,NULL,1,1,1,0,0},
{"lpushx",lpushxCommand,-3,"wmF",0,NULL,1,1,1,0,0},
{"linsert",linsertCommand,5,"wm",0,NULL,1,1,1,0,0},
{"rpop",rpopCommand,2,"wF",0,NULL,1,1,1,0,0},
{"lpop",lpopCommand,2,"wF",0,NULL,1,1,1,0,0},
{"brpop",brpopCommand,-3,"ws",0,NULL,1,-2,1,0,0},
{"brpoplpush",brpoplpushCommand,4,"wms",0,NULL,1,2,1,0,0},
{"blpop",blpopCommand,-3,"ws",0,NULL,1,-2,1,0,0},
{"llen",llenCommand,2,"rF",0,NULL,1,1,1,0,0},
{"lindex",lindexCommand,3,"r",0,NULL,1,1,1,0,0},
{"lset",lsetCommand,4,"wm",0,NULL,1,1,1,0,0},
{"lrange",lrangeCommand,4,"r",0,NULL,1,1,1,0,0},
{"ltrim",ltrimCommand,4,"w",0,NULL,1,1,1,0,0},
{"lrem",lremCommand,4,"w",0,NULL,1,1,1,0,0},
...
};
我们可以看到,这个字典是所有命令的集合,我们调用lookupCommand就是从这里获取命令及命令的相关信息的。它是一个结构体数组,包含所有命令名称、命令处理函数、参数个数、以及种种标记。其实这里就相当于一个配置信息的维护,以及命令道处理函数名称的映射关系,从而很好的解决了我们一开始使用if-else来分发命令处理函数的难以维护、可扩展性差的问题。
在我们成功在字典中找到一个命令的处理函数之后,我们只需要去调用相应的命令处理函数就好啦。上面最后的call()函数中就对相应的命令处理函数进行了调用,并返回调用结果给客户端。比如,setCommand()就是set命令的实际处理函数:
void setCommand(client *c) {
int j;
robj *expire = NULL;
int unit = UNIT_SECONDS;
int flags = OBJ_SET_NO_FLAGS;
for (j = 3; j < c->argc; j++) {
char *a = c->argv[j]->ptr;
robj *next = (j == c->argc-1) ? NULL : c->argv[j+1];
if ((a[0] == 'n' || a[0] == 'N') &&
(a[1] == 'x' || a[1] == 'X') && a[2] == '\0' &&
!(flags & OBJ_SET_XX))
{
flags |= OBJ_SET_NX;
} else if ((a[0] == 'x' || a[0] == 'X') &&
(a[1] == 'x' || a[1] == 'X') && a[2] == '\0' &&
!(flags & OBJ_SET_NX))
{
flags |= OBJ_SET_XX;
} else if ((a[0] == 'e' || a[0] == 'E') &&
(a[1] == 'x' || a[1] == 'X') && a[2] == '\0' &&
!(flags & OBJ_SET_PX) && next)
{
flags |= OBJ_SET_EX;
unit = UNIT_SECONDS;
expire = next;
j++;
} else if ((a[0] == 'p' || a[0] == 'P') &&
(a[1] == 'x' || a[1] == 'X') && a[2] == '\0' &&
!(flags & OBJ_SET_EX) && next)
{
flags |= OBJ_SET_PX;
unit = UNIT_MILLISECONDS;
expire = next;
j++;
} else {
addReply(c,shared.syntaxerr);
return;
}
}
c->argv[2] = tryObjectEncoding(c->argv[2]);
setGenericCommand(c,flags,c->argv[1],c->argv[2],expire,unit,NULL,NULL);
}
这个函数首先对NX、EX参数进行了判断及处理,最终调用了setGenericCommand(),来执行set命令的通用逻辑部分:
void setGenericCommand(client *c, int flags, robj *key, robj *val, robj *expire, int unit, robj *ok_reply, robj *abort_reply) {
long long milliseconds = 0; /* initialized to avoid any harmness warning */
if (expire) {
if (getLongLongFromObjectOrReply(c, expire, &milliseconds, NULL) != C_OK)
return;
if (milliseconds <= 0) {
addReplyErrorFormat(c,"invalid expire time in %s",c->cmd->name);
return;
}
if (unit == UNIT_SECONDS) milliseconds *= 1000;
}
if ((flags & OBJ_SET_NX && lookupKeyWrite(c->db,key) != NULL) ||
(flags & OBJ_SET_XX && lookupKeyWrite(c->db,key) == NULL))
{
addReply(c, abort_reply ? abort_reply : shared.nullbulk);
return;
}
setKey(c->db,key,val);
server.dirty++;
if (expire) setExpire(c,c->db,key,mstime()+milliseconds);
notifyKeyspaceEvent(NOTIFY_STRING,"set",key,c->db->id);
if (expire) notifyKeyspaceEvent(NOTIFY_GENERIC,
"expire",key,c->db->id);
addReply(c, ok_reply ? ok_reply : shared.ok);
}
最终会调用addReply()通用返回函数,应该是要把执行结果返回给客户端了。我们看看该函数里面做了些什么:
void addReply(client *c, robj *obj) {
if (prepareClientToWrite(c) != C_OK) return;
if (sdsEncodedObject(obj)) {
if (_addReplyToBuffer(c,obj->ptr,sdslen(obj->ptr)) != C_OK)
_addReplyStringToList(c,obj->ptr,sdslen(obj->ptr));
} else if (obj->encoding == OBJ_ENCODING_INT) {
char buf[32];
size_t len = ll2string(buf,sizeof(buf),(long)obj->ptr);
if (_addReplyToBuffer(c,buf,len) != C_OK)
_addReplyStringToList(c,buf,len);
} else {
serverPanic("Wrong obj->encoding in addReply()");
}
}
我们仔细阅读这段代码,好像并没有找到执行结果是什么时候返回给客户端的。在这个函数中,只是将返回结果添加到了输出缓冲区中,一个命令就执行完了。那么究竟是什么时候返回的呢?是否还记得在介绍开启事件循环时,提到函数beforesleep()会在每次事件循环阻塞等待文件事件之前执行,主要执行一些不是很费时的操作,比如过期键删除操作,向客户端返回命令回复等。这样,就可以减节省返回执行结果时的网络通信开销,将同一个客户端上的多个命令的多次返回,对多个命令做一个缓存,最终一次性统一返回,减少了返回的次数,提高了性能。
客户端到服务端的命令传输(响应)
执行完set key1 value1命令之后,我们得到了一个"OK"的返回,代表命令执行成功。其实我们仔细观察上面返回的第二个数据包,其实底层是一个"+OK"的返回值。那么为什么要有一个+号呢?因为除了我们上面讲过的set命令,还有get命令、lpush命令等等,他们的返回值都是不一样的。get会返回数据集合、lpush会返回一个整数,代表列表的长度等等。一个字符串的表示是远远不能满足需要的。所以在redis通信协议中,一共定义了五种返回值结构。客户端通过每种返回结构的第一个字符,来判断是哪种类型的返回值:
- 状态回复:第一个字符是“+”;例如,SET命令执行完毕会向客户端返回“+OK\r\n”。
- 错误回复:第一个字符是“-”;例如,当客户端请求命令不存在时,会向客户端返回“-ERR unknown command 'testcmd'”。
- 整数回复:第一个字符是“:”;例如,INCR命令执行完毕向客户端返回“:100\r\n”。
- 批量回复:第一个字符是“$”;例如,GET命令查找键向客户端返回结果“$5\r\nhello\r\n”,其中$5表示返回字符串长度。
- 多条批量回复:第一个字符是“”;例如,LRANGE命令可能会返回多个多个值,格式为“3\r\n$6\r\nvalue1\r\n$6rnvalue2rn$6\r\nvalue3\r\n”,与命令请求协议格式相同,“\*3”表示返回值数目,“$6”表示当前返回值字符串长度,多个返回值用“\r\n”分隔开。
我们执行set命令就是第一种类型,即状态回复。客户端通过+号,就能够知道这是状态回复,从而就知道该如何读取后面的字节流内容了。
总结
至此,我们就走完了一个redis命令的完整生命周期,同时也了解了redis通信协议的格式与规范。接下来,我将会深入每一个命令的实现,大家加油。
参考资料
- 【Redis源码分析】Redis命令处理生命周期