前言
这段时间实验室工作较忙,很难静下心来写点东西。发现还是得把做的看的记录下来,抽时间总结一下。最近研究redis的源码,本文就先简要分析一下服务器与客户端的通信流程,这一块也算是redis的核心功能了。
接收新的客户端连接
一切的通信都要从接收客户端的连接开始。redis把读客户端数据、写数据到客户端以及接收客户端的连接三种事件以IO多路复用的形式,在event loop中用单进程、单线程来处理。IO多路复用可以在一次IO阻塞中监听多个文件描述符。接收客户端连接在redis中作为读事件来处理,每个redis服务器进程在初始化时候都会绑定一个端口作为客户端连接的端口,并在该端口上绑定一个事件处理函数。如果在该端口中监听到有客户端的连接请求,则会运行该事件处理函数。
该端口可以维护一个等待队列,来缓存没有处理的连接请求,超过队列长度的会被拒绝。当redis进程监听到有新的连接请求,则连续处理最多1000个连接。每获得一个连接,会为该连接分配一个新的端口作为socket连接,redis进程就得到了该连接的一个独有的文件描述符。然后会为该描述符创建一个redisClient的结构体。redis在配置文件中定义最多的客户端连接数,超过该数目的客户端会被拒绝,redisClient结构体内存被释放,向客户端的文件符发送拒绝信息。由于此时客户端不一定可写,所以该拒绝信息不保证能成功发送,这是尽最大努力的尝试。
创建redisClient主要是初始化各种状态,例如输入缓冲区,输出缓冲区的内存分配,参数列表的初始化等等。对于网络通信来说,这一步最重要的就是为新分配的文件描述符绑定一个读事件,也作为IO多路复用监听的事件之一。那么如果客户端有数据到达,该文件描述符就会被唤醒,执行读数据的事件处理函数。
读网络数据
如果有读事件到达,执行相应的事件处理函数。下面介绍一下redis通用的客户端读事件处理函数的逻辑。客户端向redis服务器发送的数据一般是redis命令。redis命令的格式是由严格的协议规定的,只有满足协议的命令才能得到正确解析,命令才会正确执行。而网络中数据到达的时间是不确定的,且传输层的输入缓冲区是有限的,命令参数可能非常长,例如可能客户端发了一个插入一条512MB的字符串数据到数据库中的命令,那么一次读是不一定能把命令解析完的,甚至不能把一个参数都读完整。
针对这种问题,redis的哲学是:尽可能地读所有数据,直到不能再读为止;尽可能解析命令,如果已经读出来的数据可以解析成一条完整的命令,那么执行该命令;如果不能构成完整的命令,那么尽可能地解析一个完整的参数,然后存到redisClient的参数数组中;如果不能解析一个完整的参数,那么尽可能获得将要解析的参数的信息,例如一共有多少参数,将要解析的参数要占多少内存,然后根据这些信息去优化输入缓冲区的空间来存放将要解析的参数,避免无谓的内存拷贝。
要满足上述要求,Redis必须为每个客户端设置一个输入缓冲区,缓存尚未能解析的命令;同时,已解析的参数可以在缓冲区中移除,加入每个客户端的参数数组中;客户端也要保存当前解析的命令的参数个数,以及当前正在解析的参数的长度。
Redis在执行读数据事件处理函数时,会保证输入缓冲区有16MB的空闲空间存放网络数据。如果有大量的数据作为参数到达服务器,Redis也不会瞬间让输入缓冲区空间暴涨,而是以16MB为单位地增加,避免短时间内消耗大量内存,使得服务器来不及释放空间。同时一次读太多数据也会导致服务器阻塞,由于Redis采用单进程单线程IO多路复用模型,应该避免IO阻塞。输入缓冲区最多1GB,超过则客户端会被马上释放。
每次接到读数据事件,都会更新客户端的最后互动时间,更新输入缓冲区的峰值,设置服务器的当前客户端为该客户端。在函数执行结束后,把当前客户端设为空。
Redis客户端发来的命令的格式是非常固定的。命令的开始会有一个整数代表参数个数,后面跟着每个参数的长度以及参数数据。参数个数,参数长度,参数数据都以\r\n划分。因此,Redis会检测字符串的\r\n,用来解析参数。在解析的过程中如果发现格式不满足协议要求,则马上停止解析,向客户端写错误信息到输出缓冲区,并设置客户端为输出缓冲区清空后就马上释放。
Redis对于非常长的参数(大于32KB),有特殊的优化方法。如果将要解析的参数的长度已经获取,那么在读数据到输入缓冲区时则不是16MB为单位读取了,而是只要该数据比16MB小,则只会读数据到刚好能解析该参数;如果比16MB大,则还是以16MB为单位读取,在读最后一小块时候还是能让输入缓冲区只存放该参数。为什么要让输入缓冲区只有该参数呢?这样就可以直接用输入缓冲区构造Redis对象加入参数数组内,而不用进行大量的内存复制,既耗时,又浪费内存。
当命令完全解析之后,Redis就会根据参数数组(第一个参数为命令名字)查找命令表,并用相应的参数执行相应的函数了。
写网络数据
不同于读事件,如果总是把客户端的文件描述符绑定写事件监听的话,那么会频繁地提示可写,因此只会在真的写数据到输出缓冲区之后,才绑定事件处理器,且在事件处理函数执行完后,要手动解除绑定。写数据一般发生在执行命令的时候,命令函数会把一些数据写到输出缓冲区中,然后要绑定写事件。
Redis的输出缓冲区有两个,一个是定长数组,是预分配的空间,一般是16KB。当该缓冲区没写满,且新数据能全部放进该区域时,优先写到该定长缓冲区,可以避免内存分配。一个是变长缓冲区,用列表实现,列表元素是Redis对象,且编码都是sds。之所以用Redis对象构成输出缓冲区,是可以充分利用对象的共享性。对象的共享性包括一些高频率使用的对象Redis把它们做成全局唯一的全局共享对象,还有一些数据库里保存的数据,在输出到网络时直接把数据库里的Redis对象引用计数加1即可,避免无谓的内存复制,既费时,又耗内存。
当列表末尾的Redis对象元素的sds长度不满16KB时,新写到缓冲区的数据优先拼接到列表的最后一个元素。这是因为每次写数据到网络都是以一个列表元素为单位写的,所以要控制好每次写的长度,避免过长产生阻塞,也避免过短,影响IO效率。而且每写完一个列表元素,就会把相应的内存释放掉,这个长度也是控制内存释放的速率。
在把数据推到变长缓冲区时,还会累加缓冲区的内存,当然这个内存只是估计值,因为有很大一部分可能都是共享对象。每次把数据写到变长缓冲区之后,都要判断一下当前输出缓冲区的大小是否超过限制,这个限制分为硬限制和软限制,超过硬限制或者在一段时间内仍然超过软限制,则会把客户端加入异步销毁列表,在服务器处理定时任务时候异步销毁掉客户端。
Redis写缓冲区也是尽力写,但有可能socket的输出缓冲区满了使得不一定把当前要写的数据都写完,而Redis的读写都是非阻塞的,就要记录当前写的数据的位置,下次接着写。
Redis同样会避免一个客户端写大量的数据阻塞主线程,因此一次事件处理一般只能写64KB的数据,然后把CPU时间让给别的事件。但如果此时Redis的内存占用超过上限,则希望把客户端的输出缓冲区尽快清空,以回收内存。
如果客户端设置了清空输出缓冲区马上释放,则释放客户端。
释放客户端
对于普通客户端,释放客户端主要是把客户端的输入缓冲区、输出缓冲区、参数数组以及把redisClient结构体本身所分配的内存全部归还给操作系统。对于本文所涉及的网络通信而言,在输入缓冲区或输出缓冲区超出限制的情况下,或是协议解析错误的情况下,会释放客户端,保证Redis服务器不会有大量的内存被客户端占据,影响正常业务。