时下的业界,相对于传统的关系型数据库,以key-value思想实现的NoSQL内存数据库非常流行,而提到内存数据库,很多读者第一反应就是Redis。确实,Redis以其高效的性能和优雅的实现成为众多内存数据库中的翘楚。
Redis的最新源码下载地址可以在Redis官网获得。我使用的是系统,使用wget命令将Redis源码文件下载下来:
进入生成的目录使用makefile进行编译:
编译成功后,会在src目录下生成多个可执行程序,其中redis-server和redis-cli是我们即将调试的程序。
进入src目录,使用GDB启动redis-server这个程序:
以上是redis-server启动成功后的画面。
我们再开一个session,再次进入Redis源码所在的src目录,然后使用GDB启动Redis客户端redis-cli:
当GDB中断在这个函数时,使用bt命令查看一下此时的调用堆栈:
通过这个堆栈,结合堆栈1处的代码:
将堆栈切换至10x0000000000426e25in_anetTcpServer(err=err@entry=0x745bb0server+560"",port=port@entry=6379,bindaddr=bindaddr@entry=0x0,af=af@entry=10,backlog=511)
:487
487if(anetListen(err,s,p-ai_addr,p-ai_addrlen,backlog)==ANET_ERR)s=ANET_ERR;
(gdb)infoargs
err=0x745bb0server+560""
port=6379
bindaddr=0x0
af=10
backlog=511
使用系统APIgetaddrinfo来解析得到当前主机的IP地址和端口信息。这里没有选择使用gethostbyname这个API是因为gethostbyname仅能用于解析ipv4相关的主机信息,而getaddrinfo既可以用于ipv4也可以用于ipv6,这个函数的签名如下:
这个函数的具体用法可以在Linuxman手册上查看。通常服务器端在调用getaddrinfo之前,将hints参数的ai_flags设置为AI_PASSIVE,用于bind;主机名nodename通常会设置为NULL,返回通配地址[::]。当然,客户端调用getaddrinfo时,hints参数的ai_flags一般不设置AI_PASSIVE,但是主机名node和服务名service(更愿意称之为端口)则应该不为空。
解析完协议信息后,利用得到的协议信息创建侦听socket,并开启该socket的reuseAddr选项。然后调用anetListen函数,在该函数中先bind后listen。至此,redis-server就可以在6379端口上接受客户端连接了。
同样的道理,要研究redis-server如何接受客户端连接,只要搜索socketAPIaccept函数即可。
经定位,我们最终在文件中找到anetGenericAccept函数:
我们用b命令在这个函数处加个断点,然后重新运行redis-server。一直到程序全部运行起来,GDB都没有触发该断点,这时新打开一个redis-cli,以模拟新客户端连接到redis-server上的行为。断点触发了,此时查看一下调用堆栈。
分析这个调用堆栈,梳理一下这个调用流程。在main函数的initServer函数中创建侦听socket、绑定地址然后开启侦听,接着调用aeMain函数启动一个循环不断地处理“事件”。
循环的退出条件是eventLoop→stop为1。事件处理的代码如下:
这段代码先通过flag参数检查是否有事件需要处理。如果有定时器事件(AE_TIME_EVENTS标志),则寻找最近要到期的定时器。
这段代码有详细的注释,也非常好理解。注释告诉我们,由于这里的定时器集合是无序的,所以需要遍历一下这个链表,算法复杂度是O(n)。同时,注释中也“暗示”了我们将来Redis在这块的优化方向,即把这个链表按到期时间从小到大排序,这样链表的头部就是我们要的最近时间点的定时器对象,算法复杂度是O(1)。或者使用Redis中的skiplist,算法复杂度是O(log(N))。
接着获取当前系统时间(aeGetTime(now_sec,now_ms);)将最早要到期的定时器时间减去当前系统时间获得一个间隔。这个时间间隔作为numevents=aeApiPoll(eventLoop,tvp);调用的参数,aeApiPoll()在Linux平台上使用epoll技术,Redis在这个IO复用技术上、在不同的操作系统平台上使用不同的系统函数,在Windows系统上使用select,在Mac系统上使用kqueue。这里重点看下Linux平台下的实现:
epoll_wait这个函数的签名如下:
最后一个参数timeout的设置非常有讲究,如果传入进来的tvp是NULL,根据上文的分析,说明没有定时器事件,则将等待时间设置为-1,这会让epoll_wait无限期地挂起来,直到有事件时才会被唤醒。挂起的好处就是不浪费CPU时间片。反之,将timeout设置成最近的定时器事件间隔,将epoll_wait的等待时间设置为最近的定时器事件来临的时间间隔,可以及时唤醒epoll_wait,这样程序流可以尽快处理这个到期的定时器事件(下文会介绍)。
对于epoll_wait这种系统调用,所有的fd(对于网络通信,也叫socket)信息包括侦听fd和普通客户端fd都记录在事件循环对象aeEventLoop的apidata字段中,当某个fd上有事件触发时,从apidata中找到该fd,并把事件类型(mask字段)一起记录到aeEventLoop的fired字段中去。我们先把这个流程介绍完,再介绍epoll_wait函数中使用的epfd是在何时何地创建的,侦听fd、客户端fd是如何挂载到epfd上去的。
在得到了有事件的fd以后,接下来就要处理这些事件了。在主循环aeProcessEvents中从aeEventLoop对象的fired数组中取出上一步记录的fd,然后根据事件类型(读事件和写事件)分别进行处理。
读事件字段rfileProc和写事件字段wfileProc都是函数指针,在程序早期设置好,这里直接调用就可以了。
我们通过搜索关键字epoll_create在ae_文件中找到EPFD的创建函数aeApiCreate。
使用GDB的b命令在这个函数上加个断点,然后使用run命令重新运行一下redis-server,触发断点,使用bt命令查看此时的调用堆栈。发现EPFD也是在上文介绍的initServer函数中创建的。
同样在initServer函数中,结合上文分析的侦听fd的创建过程,去掉无关代码,抽出这个函数的主脉络得到如下伪代码:
注意:这里所说的“主脉络”是指我们关心的网络通信的主脉络,不代表这个函数中其他代码就不是主要的。
如何验证这个断点处挂载到EPFD上的fd就是侦听fd呢?很简单,创建侦听fd时,用GDB记录下这个fd的值。例如,当我的电脑某次运行时,侦听fd的值是15。如下图(调试工具用的是CGDB):

然后在运行程序至绑定fd的地方,确认一下绑定到EPFD上的fd值:

acceptTcpHandler函数定义如下(位于文件中):
anetTcpAccept函数中调用的就是我们上面说的anetGenericAccept函数了。
至此,这段流程总算连起来了,在acceptTcpHandler上加个断点,然后重新运行一下redis-server,再开个redis-cli去连接redis-server。看看是否能触发该断点,如果能触发该断点,说明我们的分析是正确的。
经验证,确实触发了该断点。

在acceptTcpHandler中成功接受新连接后,产生客户端fd,然后调用acceptCommonHandler函数,在该函数中调用createClient函数,在createClient函数中先将客户端fd设置成非阻塞的,然后将该fd关联到EPFD上去,同时记录到整个程序的aeEventLoop对象上。
客户端fd触发可读事件后,回调函数是readQueryFromClient。该函数实现如下(位于文件中):
给这个函数加个断点,然后重新运行下redis-server,再启动一个客户端,然后尝试给服务器发送一个命令“sethelloworld”。但是在我们实际调试的时候会发现。只要redis-cli一连接成功,GDB就触发该断点,此时并没有发送我们预想的命令。我们单步调试readQueryFromClient函数,将收到的数据打印出来,得到如下字符串:
c→querybuf是什么呢?这里c的类型是client结构体,它是上文中连接接收成功后产生的新客户端fd绑定回调函数时产生的、并传递给readQueryFromClient函数的参数。我们可以在中找到它的定义:
client实际上是存储每个客户端连接信息的对象,其fd字段就是当前连接的fd,querybuf字段就是当前连接的接收缓冲区,也就是说每个新客户端连接都会产生这样一个对象。从fd上收取数据后就存储在这个querybuf字段中。
我们贴一下完整的createClient函数的代码:
接着上一课的内容继续分析。
redis-cli给redis-server发送的第一条数据是*1\r\n$7\r\nCOMMAND\r\n。我们来看下对于这条数据如何处理,单步调试一下readQueryFromClient调用read函数收取完数据,接着继续处理c→querybuf的代码即可。经实际跟踪调试,调用的是processInputBuffer函数,位于文件中:
命令解析完成以后,从processMultibulkBuffer函数返回,在processCommand函数中处理刚才记录在client对象argv字段中的命令。
在processCommand函数中处理命令,流程大致如下:
(1)先判断是不是quit命令,如果是,则往发送缓冲区中添加一条应答命令(应答redis客户端),并给当前client对象设置CLIENT_CLOSE_AFTER_REPLY标志,这个标志见名知意,即应答完毕后关闭连接。
(2)如果不是quit命令,则使用lookupCommand函数从全局命令字典表中查找相应的命令,如果出错,则向发送缓冲区中添加出错应答。出错不是指程序逻辑出错,有可能是客户端发送的非法命令。如果找到相应的命令,则执行命令后添加应答。
全局字典表是前面介绍的server全局变量(类型是redisServer)的一个字段commands。
至于这个全局字典表在哪里初始化以及相关的数据结构类型,由于与本课程主题无关,这里就不分析了。
下面重点探究如何将应答命令(包括出错的应答)添加到发送缓冲区去。我们以添加一个“ok”命令为例:
addReply函数中有两个关键的地方,一个是prepareClientToWrite函数调用,另外一个是_addReplyToBuffer函数调用。先来看prepareClientToWrite,这个函数中有这样一段代码:
这段代码先判断发送缓冲区中是否还有未发送的应答命令——通过判断client对象的bufpos字段(int型)和reply字段(这是一个链表)的长度是否大于0。
如果当前client对象不是处于CLIENT_PENDING_WRITE状态,且在发送缓冲区没有剩余数据,则给该client对象设置CLIENT_PENDING_WRITE标志,并将当前client对象添加到全局server对象的名叫clients_ping_write链表中去。这个链表中存的是所有有数据要发送的client对象,注意和上面说的reply链表区分开来。
关于CLIENT_PENDING_WRITE标志,redis解释是:
翻译成中文就是:一个有数据需要发送,但是还没有注册可写事件的client对象。
下面讨论_addReplyToBuffer函数,位于文件中。
在这个函数中再次确保了client对象的reply链表长度不能大于0(if判断,如果不满足条件,则退出该函数)。reply链表存储的是待发送的应答命令。应答命令被存储在client对象的buf字段中,其长度被记录在bufpos字段中。buf字段是一个固定大小的字节数组:
PROTO_REPLY_CHUNK_BYTES在redis中的定义是16*1024,也就是说应答命令数据包最长是16k。
回到我们上面提的命令:*1\r\n$7\r\nCOMMAND\r\n,通过lookupCommand解析之后得到command命令,在GDB中显示如下:
版权声明:本站所有作品(图文、音视频)均由用户自行上传分享,仅供网友学习交流,不声明或保证其内容的正确性,如发现本站有涉嫌抄袭侵权/违法违规的内容。请举报,一经查实,本站将立刻删除。