运营研发团队 李乐
前言
本文主要讲解服务器处理客户端命令请求的整个流程,包括服务器启动监听,接收命令请求并解析,执行命令请求,返回命令回复等,这也是本文的主题“命令处理的生命周期”。
Redis服务器作为典型的事件驱动程序,事件处理显得尤为重要,而Redis将事件分为两大类:文件事件与时间事件。文件事件即socket的可读可写事件,时间事件用于处理一些需要周期性执行的定时任务,本文将对这两种事件作详细介绍。基本知识
为了更好的理解服务器与客户端的交互,还需要学习一些基础知识,比如客户端信息的存储,Redis对外支持的命令集合,客户端与服务器socket读写事件的处理,Redis内部定时任务的执行等,本小节将对这些知识作简要介绍。
1.1 对象结构体robj简介
Redis是一个Key-Value数据库,key只能是字符串,value可能是字符串、哈希表、列表、集合和有序集合,这5种数据类型用结构体robj表示,我们称之为redis对象。结构体robj的type字段表示对象类型,5种对象类型在server.h文件定义:
#define OBJ_STRING 0#define OBJ_LIST 1#define OBJ_SET 2#define OBJ_ZSET 3#define OBJ_HASH 4
针对某一种类型的对象,redis在不同情况下可能采用不同的数据结构存储,结构体robj的的encoding字段表示当前对象底层存储采用的数据结构,即对象的编码,总共定义了10种encoding常量,如下表-1所示:
表-1 对象编码类型表encoding常量 | 数据结构 | 可存储对象类型 |
---|---|---|
OBJ_ENCODING_RAW | 简单动态字符串sds | 字符串 |
OBJ_ENCODING_INT | 整数 | 字符串 |
OBJ_ENCODING_HT | 字典dict | 集合、哈希表、有序集合 |
OBJ_ENCODING_ZIPMAP | 未使用 | |
OBJ_ENCODING_LINKEDLIST | 不再使用 | |
OBJ_ENCODING_ZIPLIST | 压缩列表ziplist | 哈希表、有序集合 |
BJ_ENCODING_INTSET | 整数集合intset | 集合 |
OBJ_ENCODING_SKIPLIST | 跳跃表skiplist | 有序集合 |
OBJ_ENCODING_EMBSTR | 简单动态字符串sds | 字符串 |
OBJ_ENCODING_QUICKLIST | 快速链表quicklist | 列表 |
对象的整个生命周期中,编码不是一成不变的,比如集合对象。当集合中所有元素都可以用整数表示时,底层数据结构采用整数集合;执行SADD命令往集合添加元素时,redis总会校验待添加元素是否可以解析为整数,如果解析失败,则会将集合存储结构转换为字典。
if (subject->encoding == OBJ_ENCODING_INTSET) { if (isSdsRepresentableAsLongLong(value,&llval) == C_OK) { subject->ptr = intsetAdd(subject->ptr,llval,&success); } else { //编码转换 setTypeConvert(subject,OBJ_ENCODING_HT); }}
对象在不同情况下可能采用不同的数据结构存储,那对象可能同时采用多种数据结构存储吗?根据上面的表格,有序集合可能采用压缩列表、跳跃表和字典存储。使用字典存储时,根据成员查找分值的时间复杂度为O(1),而对于ZRANGE与ZRANK等命令,需要排序才能实现,时间复杂度至少为O(NlogN);使用跳跃表存储时,ZRANGE与ZRANK等命令的时间复杂度为O(logN),而根据成员查找分值的时间复杂度同样是O(logN)。字典与跳跃表各有优势,因此Redis会同时采用字典与跳跃表存储有序集合。这里有读者可能会有疑问,同时采用两种数据结构存储不浪费空间吗?数据都是通过指针引用的,两种存储方式只需要额外存储一些指针即可,空间消耗是可以接受的。有序集合存储结构定义如下:
typedef struct zset { dict *dict; zskiplist *zsl;} zset;
观察表-1,注意到编码OBJ_ENCODING_RAW和OBJ_ENCODING_EMBSTR都表示的是简单动态字符串,那么这两种编码有什么区别吗?在回答此问题之前需要先了解结构体robj的定义:
#define LRU_BITS 24 typedef struct redisObject { unsigned type:4; unsigned encoding:4; unsigned lru:LRU_BITS; //缓存淘汰使用 int refcount; //引用计数 void *ptr;} robj;
下面详细分析结构体各字段含义:
ptr是void*类型的指针,指向实际存储的某一种数据结构,但是当robj存储的是数据可以用long类型表示时,数据直接存储在ptr字段。可以看出,为了创建一个字符串对象,必须分配两次内存,robj与sds存储空间;两次内存分配效率低下,且数据分离存储降低了计算机高速缓存的效率。因此提出OBJ_ENCODING_EMBSTR编码的字符串,当字符串内容比较短时,只分配一次内存,robj与sds连续存储,以此提升内存分配效率与数据访问效率。OBJ_ENCODING_EMBSTR编码的字符串内存结构如下图-1所示:
图-1 EMBSTR编码字符串对象内存结构
refcount存储当前对象的引用次数,用于实现对象的共享。共享对象时,refcount加1;删除对象时,refcount减1,当refcount值为0时释放对象空间。删除对象的代码如下:void decrRefCount(robj *o) { if (o->refcount == 1) { switch(o->type) { //根据对象类型,释放其指向数据结构空间 case OBJ_STRING: freeStringObject(o); break; case OBJ_LIST: freeListObject(o); break; case OBJ_SET: freeSetObject(o); break; ………… } zfree(o); //释放对象空间 } else { //引用计数减1 if (o->refcount != OBJ_SHARED_REFCOUNT) o->refcount--; }}
lru字段占24比特,用于实现缓存淘汰策略,可以在配置文件中使用maxmemory-policy指令配置已用内存达到最大内存限制时的缓存淘汰策略。lru根据用户配置缓存淘汰策略存储不同数据,常用的策略就是LRU与LFU,LRU的核心思想是,如果数据最近被访问过,那么将来被访问的几率也更高,此时lru字段存储的是对象访问时间;LFU的核心思想是,如果数据过去被访问多次,那么将来被访问的频率也更高,此时lru字段存储的是上次访问时间与访问次数。假如使用GET命令访问数据时,会执行下面代码更新对象的lru字段:
if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) { updateLFU(val);} else { val->lru = LRU_CLOCK();}
LRU_CLOCK函数用于获取当前时间,注意此时间不是实时获取的,redis1秒为周期执行系统调用获取精确时间,缓存在全局变量server.lruclock,LRU_CLOCK函数获取的只是缓存在此变量中的时间。
updateLFU函数用于更新对象的上次访问时间与访问次数,函数实现如下:void updateLFU(robj *val) { unsigned long counter = LFUDecrAndReturn(val); counter = LFULogIncr(counter); val->lru = (LFUGetTimeInMinutes()<<8) | counter;}
可以发现lru的低8比特存储的是对象的访问次数,高16比特存储的是对象的上次访问时间,以分钟为单位;需要特别注意的是函数LFUDecrAndReturn,其返回计数值counter,对象的访问次数在此值上累加。为什么不直接累加呢?假设每次只是简单的对访问次数累加,那么越老的数据一般情况下访问次数越大,即使该对象可能很长时间已经没有访问。因此访问次数应该有一个随时间衰减的过程,函数LFUDecrAndReturn实现了此衰减功能。
1.2 客户端结构体client简介
Redis是典型的客户端服务器结构,客户端通过socket与服务端建立网络连接并发送命令请求,服务端处理命令请求并回复。Redis使用结构体client存储客户端连接的所有信息,包括但不限于客户端的名称、客户端连接的套接字描述符、客户端当前选择的数据库ID、客户端的输入缓冲区与输出缓冲区等。结构体client字段较多,此处只介绍命令处理主流程所需的关键字段。
typedef struct client { uint64_t id; int fd; redisDb *db; robj *name; time_t lastinteraction sds querybuf; int argc; robj **argv; struct redisCommand *cmd; list *reply; unsigned long long reply_bytes; size_t sentlen; char buf[PROTO_REPLY_CHUNK_BYTES]; int bufpos; } client;
各字段含义如下:
- 1) id:客户端唯一ID,通过全局对象server的next_client_id字段实现;
- 2) fd:客户端socket的文件描述符;
- 3) db:客户端使用select命令选择的数据库对象,其结构体定义如下:
typedef struct redisDb { int id; long long avg_ttl; dict *dict; dict *expires; dict *blocking_keys; dict *ready_keys; dict *watched_keys; } redisDb;
其中id为数据库序号,默认情况下Redis有16个数据库,id序号为0~15;dict存储数据库所有键值对;expires存储键的过期时间;avg_ttl存储数据库对象的平均TTL,用于统计;
使用命令BLPOP阻塞获取列表元素时,如果链表为空,会阻塞客户端,同时将此列表键记录在blocking_keys;当使用命令PUSH向列表添加元素时,会从字典blocking_keys中查找该列表键,如果找到说明有客户端正阻塞等待获取此列表键,于是将此列表键记录到字典ready_keys,以便后续响应正在阻塞的客户端;Redis支持事务,命令用于MULTI开启事务,命令EXEC用于执行事务;但是开启事务到执行事务期间,如何保证关心的数据不会被修改呢?Redis采用乐观锁实现。开启事务的同时可以使用WATCH key命令监控关心的数据键,而watched_keys字典存储的就是被WATCH命令监控的所有数据键,其中key-value分别为数据键与客户端对象。当Redis服务器接收到写命令时,会从字典watched_keys中查找该数据键,如果找到说明有客户端正在监控此数据键,于是会标记客户端对象为dirty;待Redis服务器收到客户端EXEC命令时,如果客户端带有dirty标记,则会拒绝执行事务。- 4) name:客户端名称,可以使用命令CLIENT SETNAME设置;
- 5) lastinteraction:客户端上次与服务器交互的时间,以此实现客户端的超时处理;
- 6) querybuf:输入缓冲区,recv函数接收的客户端命令请求会暂时缓存在此缓冲区;
- 7) argc:输入缓冲区的命令请求是按照Redis协议格式编码字符串,需要解析出命令请求的所有参数,参数个数存储在argc字段,参数内容被解析为robj对象,存储在argv数组;
- 8) cmd:待执行的客户端命令;解析命令请求后,会根据命令名称查找该命令对应的命令对象,存储在客户端cmd字段,可以看到其类型为struct redisCommand;
- 9) reply:输出链表,链表节点的类型是robj,存储待返回给客户端的命令回复数据;reply_bytes表示已返回给客户端的字节数;
- 10) sentlen:当输出数据缓存在reply字段时,表示已返回给客户端的对象数目;当输出数据缓存在buf字段时,表示已返回给客户端的字节数目;看到这里读者可能会有疑问,为什么同时需要reply和buf的存在呢?其实二者只是用于返回不同的数据类型而已,详情参见3.3节;
- 11) buf:输出缓冲区,存储待返回给客户端的命令回复数据,bufpos表示输出缓冲区中数据的最大字节位置,显然sentlen~bufpos区间的数据都是需要返回给客户端的。
1.3 服务端结构体redisServer简介
结构体redisServer存储Redis服务器的所有信息,包括但不限于数据库、配置参数、命令表、监听端口与地址、客户端列表、若干统计信息、RDB与AOF持久化相关信息、主从复制相关信息、集群相关信息等。结构体redisServer的段非常多,这里只对部分字段做简要说明,以便读者对于服务端有个粗略了解,至于其他字段在讲解各知识点时会做说明。
struct redisServer { char *configfile; int hz; int dbnum; redisDb *db; dict *commands; aeEventLoop *el; int port; char *bindaddr[CONFIG_BINDADDR_MAX]; int bindaddr_count; int ipfd[CONFIG_BINDADDR_MAX]; int ipfd_count; list *clients; int maxidletime; }
各字段含义如下:
- 1) configfile:配置文件绝对路径;
- 2) hz:serverCron函数的执行频率,默认为10,可通过参数hz配置,最小值1最大值500。Redis服务器有很多任务需要定时执行,比如说定时清除过期键,定时处理超时客户端链接等,直接使用系统定时器开销较大,函数serverCron就用于执行这些定时任务,详情参见1.4.2节。当serverCron函数的执行频率确定时,通过函数的执行次数就可以判断是否需要执行某个定时任务,宏定义run_with_period就实现了此功能,其中server.cronloops字段就表示serverCron函数已经执行的次数;
#define run_with_period(_ms_) if ((_ms_ <= 1000/server.hz) || !(server.cronloops%((_ms_)/(1000/server.hz)))
当然由于hz是用户配置的,其并不能代表真实的serverCron函数执行频率。
- 3) dbnum:数据库的数目,可通过参数databases配置,默认16;
- 4) db:数据库数组,数组的每个元素都是redisDb类型;
- 5) commands:命令字典,Redis支持的所有命令都存储在这个字典中,key为命令名称,vaue为struct redisCommand对象;
- 6) el:Redis是典型的事件驱动程序,el即代表着Redis的事件循环;
- 7) port:服务器监听端口号,可通过参数port配置,默认端口号6379;
- 8) bindaddr:绑定的所有IP地址,可以通过参数bind配置多个,例如bind 192.168.1.100 10.0.0.1,bindaddr_count为用户配置的IP地址数目;CONFIG_BINDADDR_MAX常量为16,即绑定16个IP地址;Redis默认会绑定到当前机器所有可用的Ip地址;
- 9) ipfd:针对bindaddr字段的所有IP地址创建的socket文件描述符,ipfd_count为创建的socket文件描述符数目;
- 10) clients:当前连接到Redis服务器的所有客户端;
- 11) maxidletime:最大空闲时间,可通过参数timeout配置,结合client对象的lastinteraction字段,当客户端超过maxidletime没有与服务器交互时,会认为客户端超时并释放该客户端连接;
1.4 命令结构体redisCommand简介
Redis支持的所有命令初始都存储在全局变量redisCommandTable,类型为struct redisCommand[ ],定义及初始化如下:
struct redisCommand redisCommandTable[] = { {"get",getCommand,2,"rF",0,NULL,1,1,1,0,0}, {"set",setCommand,-3,"wm",0,NULL,1,1,1,0,0}, …………}
结构体redisCommand相对简单,主要定义了命令的名称、命令处理函数以及命令标志等:
struct redisCommand { char *name; redisCommandProc *proc; int arity; char *sflags; int flags; long long microseconds, calls;};
各字段含义如下:
- 1) name:命令名称;
- 2) proc:命令处理函数;
- 3) arity:命令参数数目,用于校验命令请求格式是否正确;当arity小于0时,表示命令参数数目大于等于arity;当arity大于0时,表示命令参数数目必须为arity;注意命令请求中命令的名称本身也是一个参数,如GET命令的参数数目为2,命令请求格式为“GET key”;
- 4) sflags:命令标志,例如标识命令时读命令还是写命令,详情参见表-2;注意到sflags的类型为字符串,此处只是为了良好的可读性;
- 5) flags:命令的二进制标志,服务器启动时解析sflags字段生成;
- 6) calls:从服务器启动至今命令执行的次数,用于统计;
- 7) microseconds:从服务器启动至今命令总的执行时间,microseconds/calls即可计算出该命令的平均处理时间,用于统计;
表-2 命令标志类型
字符标识 | 二进制标识 | 含义 | 相关命令 |
---|---|---|---|
w | CMD_WRITE | 写命令 | set、del、incr、lpush |
r | CMD_READONLY | 读命令 | get、exists、llen |
m | CMD_DENYOOM | 内存不足时,拒绝执行此类命令 | set、append、lpush |
a | CMD_ADMIN | 管理命令 | save、shutdown、slaveof |
p | CMD_PUBSUB | 发布订阅相关命令 | subscribe、unsubscribe |
s | CMD_NOSCRIPT | 命令不可以在lua脚本使用 | auth、save、brpop |
R | CMD_RANDOM | 随机命令,即使命令请求参数完全相同,返回结果也可能不容 | srandmember、scan、time |
S | CMD_SORT_FOR_SCRIPT | 当在lua脚本使用此类命令时,需要对输出结果做排序 | sinter、sunion、sdiff |
l | CMD_LOADING | 服务器启动载入过程中,只能执行此类命令 | select、auth、info |
t | CMD_STALE | 当从服务器与主服务器断开链接,且从服务器配置slave-serve-stale-data no时,从服务器只能执行此类命令 | auth、shutdown、info |
M | CMD_SKIP_MONITOR | 此类命令不会传播给监视器 | exec |
k | CMD_ASKING | restore-asking | |
F | CMD_FAST | 命令执行时间超过阈值时,会记录延迟事件,此标志用于区分延迟事件类型,F表示fast-command | get、setnx、strlen、exists |
当服务器接收到一条命令请求时,需要从命令表中查找命令,而redisCommandTable命令表是一个数组,意味着查询命令的时间复杂度为O(N),效率低下。因此Redis在服务器初始化时,会将redisCommandTable转换为一个字典存储在redisServer对象的commands字段,key为命令名称,value为命令redisCommand对象。populateCommandTable函数实现了命令表从数组到字典的转化,同时解析sflags生成flags:
void populateCommandTable(void) { int numcommands = sizeof(redisCommandTable)/sizeof(structredisCommand); for (j = 0; j < numcommands; j++) { struct redisCommand *c = redisCommandTable+j; char *f = c->sflags; while(*f != '\0') { switch(*f) { case 'w': c->flags |= CMD_WRITE; break; case 'r': c->flags |= CMD_READONLY; break; } f++; } retval1 = dictAdd(server.commands, sdsnew(c->name), c); }}
同时对于经常使用的命令,Redis甚至会在服务器初始化的时候将命令缓存在redisServer对象,这样使用的时候就不需要每次都从commands字典中查找了:
struct redisServer { struct redisCommand *delCommand,*multiCommand,*lpushCommand, *lpopCommand,*rpopCommand, *sremCommand, *execCommand, *expireCommand,*pexpireCommand;}
1.5 事件处理
Redis服务器是典型的事件驱动程序,而事件又分为文件事件(socket的可读可写事件)与时间事件(定时任务)两大类。无论是文件事件还是时间事件都封装在结构体aeEventLoop:
typedef struct aeEventLoop { int stop; aeFileEvent *events; aeFiredEvent *fired; aeTimeEvent *timeEventHead; aeBeforeSleepProc *beforesleep; aeBeforeSleepProc *aftersleep;} aeEventLoop;
stop标识事件循环是否结束;events为文件事件数组,存储已经注册的文件事件;fired存储被触发的文件事件;Redis有多个定时任务,因此理论上应该有多个时间事件节点,多个时间事件形成链表,timeEventHead即为时间事件链表头结点;Redis服务器需要阻塞等待文件事件的发生,进程阻塞之前会调用beforesleep函数,进程因为某种原因被唤醒之后会调用aftersleep函数。
事件驱动程序通常存在while/for循环,循环等待事件发生并处理,Redis也不例外,其事件循环如下:while (!eventLoop->stop) { if (eventLoop->beforesleep != NULL) eventLoop->beforesleep(eventLoop); aeProcessEvents(eventLoop, AE_ALL_EVENTS|AE_CALL_AFTER_SLEEP);}
函数aeProcessEvents为事件处理主函数,其第二个参数是一个标志位,AE_ALL_EVENTS表示函数需要处理文件事件与时间事件,AE_CALL_AFTER_SLEEP表示阻塞等待文件事件之后需要执行aftersleep函数。
1.5.1 文件事件
Redis客户端通过TCP socket与服务端交互,文件事件指的就是socket的可读可写事件。 socket读写操作有阻塞与非阻塞之分,采用阻塞模式时,一个进程只能处理一条网络连接的读写事件,为了同时处理多条网络连接,通常会采用多线程或者多进程,效率低下;非阻塞模式下,可以使用目前比较成熟的IO多路复用模型select/epoll/kqueue等,视不同操作系统而定。
这里只对epoll作简要介绍。epoll是linux内核为处理大量并发网络连接而提出的解决方案,能显著提升系统CPU利用率。epoll使用非常简单,总共只有三个API,epoll_create函数创建一个epoll专用的文件描述符,用于后续epoll相关API调用;epoll_ctl函数向epoll注册、修改或删除需要监控的事件;epoll_wait函数会阻塞进程,直到监控的某个网络连接有事件发生。int epoll_create(int size)
输入参数size通知内核程序期望注册的网络连接数目,内核以此判断初始分配空间大小;注意在linux2.6.8版本以后,内核动态分配空间,此参数会被忽略。返回参数为epoll专用的文件描述符,不再使用时应该及时关闭此文件描述符。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)
函数执行成功时返回0,否则返回-1,错误码设置在变量errno;输入参数含义如下:
- 1) epfd:函数epoll_create返回的epoll文件描述符;
- 2) op:需要进行的操作,EPOLL_CTL_ADD表示注册事件,EPOLL_CTL_MOD表示修改网络连接事件,EPOLL_CTL_DEL表示删除事件;
- 3) fd:网络连接的socket文件描述符;
- 4) event:需要监控的事件或者已触发的事件,结构体epoll_event定义如下:
struct epoll_event { __uint32_t events; epoll_data_t data; };typedef union epoll_data { void *ptr; int fd; __uint32_t u32; __uint64_t u64;} epoll_data_t;
其中events表示需要监控的事件类型或已触发的事件类型,比较常用的是EPOLLIN文件描述符可读事件,EPOLLOUT文件描述符可写事件;data保存与文件描述符关联的数据。
int epoll_wait(int epfd,struct epoll_event * events,int maxevents,int timeout)
函数执行成功时返回0,否则返回-1,错误码设置在变量errno;输入参数含义如下:
- 1) epfd:函数epoll_create返回的epoll文件描述符;
- 2) epoll_event:作为输出参数使用,用于回传已触发的事件数组;
- 3) maxevents:每次能处理的最大事件数目;
- 4) timeout:epoll_wait函数阻塞超时时间,如果超过timeout时间还没有事件发生,函数不再阻塞直接返回;当timeout等于0时函数立即返回,timeout等于-1时函数会一直阻塞直到有事件发生。
Redis并没有直接使用epoll提供的的API,而是同时支持四种IO多路复用模型,并将每种模型的API进一步统一封装,由文件ae_evport.c、ae_epoll.c、ae_kqueue.c和ae_select.c实现。
static int aeApiCreate(aeEventLoop *eventLoop);static int aeApiAddEvent(aeEventLoop *eventLoop, int fd, int mask);static void aeApiDelEvent(aeEventLoop *eventLoop, int fd, int delmask)static int aeApiPoll(aeEventLoop *eventLoop, struct timeval *tvp);
以epoll为例,aeApiCreate函数是对epoll_create的封装;aeApiAddEvent函数用于添加事件,是对epoll_ctl的封装;aeApiDelEvent函数用于删除事件,是对epoll_ctl的封装;aeApiPoll是对epoll_wait的封装。
四个函数输入参数含义如下:- 1) eventLoop:事件循环,与文件事件相关最主要有三个字段,apidata指向IO多路复用模型对象,注意四种IO多路复用模型对象的类型不同,因此此字段是void*类型;events存储需要监控的事件数组,以socket文件描述符作为数组索引存取元素;fired存储已出发的事件数组。
以epoll模型为例,apidata字段指向的IO多路复用模型对象定义如下:
typedef struct aeApiState { int epfd; struct epoll_event *events;} aeApiState;
其中epfd函数epoll_create返回的epoll文件描述符,events存储epoll_wait函数返回时已触发的事件数组。
- 2) fd:操作的socket文件描述符;
- 3) mask或delmask:添加或者删除的事件类型,AE_NONE表示没有任何事件;AE_READABLE表示可读事件;AE_WRITABLE表示可写事件;
- 4) tvp:阻塞等待文件事件的超时时间;
这里只对等待事件函数aeApiPoll实现作简要介绍:
static int aeApiPoll(aeEventLoop *eventLoop, struct timeval *tvp) { aeApiState *state = eventLoop->apidata; //阻塞等待事件的发生 retval = epoll_wait(state->epfd,state->events,eventLoop->setsize, tvp ? (tvp->tv_sec*1000 + tvp->tv_usec/1000) : -1); if (retval > 0) { int j; numevents = retval; for (j = 0; j < numevents; j++) { int mask = 0; struct epoll_event *e = state->events+j; //转换事件类型为Redis定义的 if (e->events & EPOLLIN) mask |= AE_READABLE; if (e->events & EPOLLOUT) mask |= AE_WRITABLE; //记录已发生事件到fired数组 eventLoop->fired[j].fd = e->data.fd; eventLoop->fired[j].mask = mask; } } return numevents;}
函数首先需要通过eventLoop->apidata字段获取到epoll模型对应的aeApiState结构体对象,才能调用epoll_wait函数等待事件的发生;而epoll_wait函数将已触发的事件存储到aeApiState对象的events字段,Redis再次遍历所有已触发事件,将其封装在eventLoop->fired数组,数组元素类型为结构体aeFiredEvent,只有两个字段,fd表示发生事件的socket文件描述符,mask表示发生的事件类型,如AE_READABLE可读事件和AE_WRITABLE可写事件。
上面简单介绍了epoll的使用,以及Redis对epoll等IO多路复用模型的封装,下面我们回到本小节的主题,文件事件。结构体aeEventLoop有一个关键字段events,类型为aeFileEvent数组,存储所有需要监控的文件事件。文件事件结构体定义如下:typedef struct aeFileEvent { int mask; aeFileProc *rfileProc; aeFileProc *wfileProc; void *clientData;} aeFileEvent;
其中mask存储监控的文件事件类型,如AE_READABLE可读事件和AE_WRITABLE可写事件;rfileProc为函数指针,指向读事件处理函数;wfileProc同样为函数指针,指向写事件处理函数;clientData指向对应的客户端对象。
调用aeApiAddEvent函数添加事件之前之前,首先需要调用aeCreateFileEvent函数创建对应的文件事件,并存储在aeEventLoop结构体的events字段,aeCreateFileEvent函数简单实现如下:int aeCreateFileEvent(aeEventLoop *eventLoop, int fd, int mask, aeFileProc *proc, void *clientData){ aeFileEvent *fe = &eventLoop->events[fd]; if (aeApiAddEvent(eventLoop, fd, mask) == -1) return AE_ERR; fe->mask |= mask; if (mask & AE_READABLE) fe->rfileProc = proc; if (mask & AE_WRITABLE) fe->wfileProc = proc; fe->clientData = clientData; return AE_OK;}
Redis服务器启动时需要创建socket并监听,等待客户端连接;客户端与服务器建立socket连接之后,服务器会等待客户端的命令请求;服务器处理完成客户端的命令请求之后,命令回复会暂时缓存在client结构体的buf缓冲区,待客户端文件描述符的可写事件发生时,才会真正往客户端发送命令回复。这些都需要创建对应的文件事件:
aeCreateFileEvent(server.el, server.ipfd[j], AE_READABLE, acceptTcpHandler,NULL);aeCreateFileEvent(server.el,fd,AE_READABLE, readQueryFromClient, c);aeCreateFileEvent(server.el, c->fd, ae_flags, sendReplyToClient, c);
可以发现接收客户端连接的处理函数为acceptTcpHandler,此时还没有创建对应的客户端对象,因此函数aeCreateFileEvent第四个参数为NULL;接收客户端命令请求的处理函数为readQueryFromClient;向发送命令回复的处理函数为sendReplyToClient。
最后思考一个问题, aeApiPoll函数的第二个参数是时间结构体timeval,存储调用epoll_wait时传入的超时时间,那么这个函数怎么计算出来的呢?我们之前提过,Redis除了要处理各种文件事件外,还需要处理很多定时任务(时间事件),那么当Redis由于执行epoll_wait而阻塞时,恰巧定时任务到期而需要处理怎么办?要回答这个问题需要分析下Redis事件循环的执行函数aeProcessEvents,函数在调用aeApiPoll之前会遍历Redis的时间事件链表,查找最早会发生的时间事件,以此作为aeApiPoll需要传入的超时时间。
int aeProcessEvents(aeEventLoop *eventLoop, int flags){ shortest = aeSearchNearestTimer(eventLoop); long long ms = shortest->when_sec - now_sec)*1000 + shortest->when_ms - now_ms; //阻塞等待文件事件发生 numevents = aeApiPoll(eventLoop, tvp); for (j = 0; j < numevents; j++) { aeFileEvent *fe = &eventLoop->events[eventLoop->fired[j].fd]; //处理文件事件,即根据类型执行rfileProc或wfileProc } //处理时间事件 processed += processTimeEvents(eventLoop);}
1.5.2 时间事件
1.5.1节介绍了Redis文件事件,已经知道事件循环执行函数aeProcessEvents的主要逻辑:1)查找最早会发生的时间事件,计算超时时间;2)阻塞等待文件事件的产生;3)处理文件事件;4)处理时间事件。时间事件的执行函数为processTimeEvents。
Redis服务器内部有很多定时任务需要执行,比如说定时清除超时客户端连接,定时删除过期键等,定时任务被封装为时间事件结构体aeTimeEvent存储,多个时间事件形成链表,存储在aeEventLoop结构体的timeEventHead字段,其指向链表首节点。时间事件aeTimeEvent定义如下:typedef struct aeTimeEvent { long long id; long when_sec; long when_ms; aeTimeProc *timeProc; aeEventFinalizerProc *finalizerProc; void *clientData; struct aeTimeEvent *next;} aeTimeEvent;
各字段含义如下:
- 1) id:时间事件唯一ID,通过字段eventLoop->timeEventNextId实现;
- 2) when_sec与when_ms:时间事件触发的秒数与毫秒数;
- 3) timeProc:函数指针,指向时间事件处理函数;
- 4) finalizerProc:函数指针,删除时间事件节点之前会调用此函数;
- 5) clientData:指向对应的客户端对象;
- 6) next:指向下一个时间事件节点。
时间事件执行函数processTimeEvents的处理逻辑比较简单,只是遍历时间事件链表,判断当前时间事件是否已经到期,如果到期则执行时间事件处理函数timeProc:
static int processTimeEvents(aeEventLoop *eventLoop) { te = eventLoop->timeEventHead; while(te) { aeGetTime(&now_sec, &now_ms); if (now_sec > te->when_sec || (now_sec == te->when_sec && now_ms >= te->when_ms)) { //处理时间事件 retval = te->timeProc(eventLoop, id, te->clientData); //重新设置时间事件到期时间 if (retval != AE_NOMORE) { aeAddMillisecondsToNow(retval, &te->when_sec,&te->when_ms); } } te = te->next; }}
注意时间事件处理函数timeProc返回值retval,其表示此时间事件下次应该被触发的时间,单位毫秒,且是一个相对时间,即从当前时间算起,retval毫秒后此时间事件会被触发。
其实Redis只有一个时间事件节点,看到这里读者可能会有疑惑,服务器内部不是有很多定时任务吗,为什么只有一个时间事件呢?回答此问题之前我们需要先分析这个唯一的时间事件节点。Redis创建时间事件节点的函数为aeCreateTimeEvent,内部实现非常简单,只是创建时间事件节点并添加到时间事件链表。aeCreateTimeEvent函数定义如下:long long aeCreateTimeEvent(aeEventLoop *eventLoop, long long milliseconds, aeTimeProc *proc, void *clientData, aeEventFinalizerProc *finalizerProc);
其中输入参数eventLoop指向事件循环结构体;milliseconds表示此时间事件触发时间,单位毫秒,注意这是一个相对时间,即从当前时间算起,milliseconds毫秒后此时间事件会被触发;proc指向时间事件的处理函数;clientData指向对应的结构体对象;finalizerProc同样是函数指针,删除时间事件节点之前会调用此函数。
读者可以在代码目录全局搜索aeCreateTimeEvent,会发现确实只创建了一个时间事件节点:aeCreateTimeEvent(server.el, 1, serverCron, NULL, NULL);
该时间事件在1毫秒后会被触发,处理函数为serverCron,参数clientData与finalizerProc都为NULL。而函数serverCron实现了Redis服务器所有定时任务的周期执行。
int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) { run_with_period(100) { //100毫秒周期执行 } run_with_period(5000) { //5000毫秒周期执行 } //清除超时客户端链接 clientsCron(); //处理数据库 databasesCron(); server.cronloops++; return 1000/server.hz;}
变量server.cronloops用于记录serverCron函数的执行次数,变量server.hz表示serverCron函数的执行频率,用户可配置,最小为1最大为500,默认为10。假设server.hz取默认值10,函数返回1000/server.hz会更新当前时间事件的触发时间为100毫秒后,即serverCron的执行周期为100毫秒。run_with_period宏定义实现了定时任务按照指定时间周期执行,其会被替换为一个if条件判断,条件为真才会执行定时任务,定义如下:
#define run_with_period(_ms_) if ((_ms_ <= 1000/server.hz) || !(server.cronloops%((_ms_)/(1000/server.hz))))
另外我们可以看到serverCron函数会无条件执行某些定时任务,比如清除超时客户端连接,以及处理数据库(清除数据库过期键等)。需要特别注意一点,serverCron函数的执行时间不能过长,否则会导致服务器不能及时响应客户端的命令请求。以过期键删除为例,分析下Redis是如何保证serverCron函数的执行时间。过期键删除由函数activeExpireCycle实现,由函数databasesCron调用,其函数是实现如下:
#define ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC 25void activeExpireCycle(int type) { timelimit = 1000000*ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC/server.hz/100; timelimit_exit = 0; for (j = 0; j < dbs_per_call && timelimit_exit == 0; j++) { do { //查找过期键并删除 if ((iteration & 0xf) == 0) { elapsed = ustime()-start; if (elapsed > timelimit) { timelimit_exit = 1; break; } } }while (expired > ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP/4) }}
函数activeExpireCycle最多遍历dbs_per_call个数据库,并记录每个数据库删除的过期键数目;当删除过期键数目大于门限时,认为此数据库过期键较多,需要再次处理。考虑到极端情况,当数据库键数目非常多且基本都过期时,do-while循环会一直执行下去。因此我们添加timelimit时间限制,每执行16次do-while循环,检测函数activeExpireCycle执行时间是否超过timelimit,如果超过则强制结束循环。
初看timelimit的计算方式可能会比较疑惑,其计算结果使得函数activeExpireCycle的总执行时间占CPU时间的25%。仍然假设server.hz取默认值10,即每秒钟函数activeExpireCycle执行10次,那么每秒钟函数activeExpireCycle的总执行时间为100000025/100,每次函数activeExpireCycle的执行时间为100000025/100/10,单位微妙。2 sever启动过程
上一节我们讲述了客户端,服务端,事件处理等基础知识,下面开始学习Redis服务器的启动过程,这里主要分为server初始化,监听端口以及等待命令三个小节。
2.1 server初始化
服务器初始化主流程可以简要分为7个步骤:1)初始化配置,包括用户可配置的参数,以及命令表的初始化;2)加载并解析配置文件;3)初始化服务端内部变量,其中就包括数据库;4)创建事件循环eventLoop;5)创建socket并启动监听;6)创建文件事件与时间事件;7)开启事件循环。下面详细介绍步骤1~4,至于步骤5~7将会在2.2小节介绍。
图-2 server初始化流程
步骤1)初始化配置,由函数initServerConfig实现,其实就是给配置参数赋初始值:void initServerConfig(void) { //serverCron函数执行频率,默认10 server.hz = CONFIG_DEFAULT_HZ; //监听端口,默认6379 server.port = CONFIG_DEFAULT_SERVER_PORT; //最大客户端数目,默认10000 server.maxclients = CONFIG_DEFAULT_MAX_CLIENTS; //客户端超时时间,默认0,即永不超时 server.maxidletime = CONFIG_DEFAULT_CLIENT_TIMEOUT; //数据库数目,默认16 server.dbnum = CONFIG_DEFAULT_DBNUM; //初始化命令表,1.4小节已经讲过,这里不再详述 populateCommandTable(); …………}
步骤2)加载并解析配置文件,入口函数为loadServerConfig,函数声明如下:
void loadServerConfig(char *filename, char *options)
输入参数filename表示配置文件全路径名称,options表示命令行输入的配置参数,例如我们通常以以下命令启动Redis服务器:
/home/user/redis/redis-server /home/user/redis/redis.conf -p 4000
使用GDB启动redis-server,打印函数 loadServerConfig输入参数如下:
(gdb) p filename$1 = 0x778880 "/home/user/redis/redis.conf"(gdb) p options$2 = 0x7ffff1a21d33 "\"-p\" \"4000\" "
Redis的配置文件语法相对简单,每一行是一条配置,格式如“配置 参数1 [参数2] [……]”,加载配置文件只需要一行一行将文件内容读取到内存中即可,GDB打印加载到内存中的配置如下:
(gdb) p config"bind 127.0.0.1\n\nprotected-mode yes\n\nport 6379\ntcp-backlog 511\n\ntcp-keepalive 300\n\n………"
加载完成后会调用loadServerConfigFromString函数解析配置,输入参数config即配置字符串,实现如下:
void loadServerConfigFromString(char *config) { //分割配置字符串多行,totlines记录行数 lines = sdssplitlen(config,strlen(config),"\n",1,&totlines); for (i = 0; i < totlines; i++) { //跳过注释行与空行 if (lines[i][0] == '#' || lines[i][0] == '\0') continue; argv = sdssplitargs(lines[i],&argc); //解析配置参数 //赋值 if (!strcasecmp(argv[0],"timeout") && argc == 2) { server.maxidletime = atoi(argv[1]); }else if (!strcasecmp(argv[0],"port") && argc == 2) { server.port = atoi(argv[1]); } //其他配置 }}
函数首先将输入配置字符串以“n”为分隔符划分为多行,totlines记录总行数,lines数组存储分割后的配置,数组元素类型为字符串SDS;for循环遍历所有配置行,解析配置参数,并根据参数内容设置结构体server各字段。注意Redis配置文件中行开始“#”字符标识本行内容为注释,解析时需要跳过。
步骤3)初始化服务器内部变量,比如客户端链表,数据库,全局变量共享对象等;入口函数为initServer,函数逻辑相对简单,这里只做简要说明;void initServer(void) { server.clients = listCreate(); //初始化客户端链表 //创建数据库字典 server.db = zmalloc(sizeof(redisDb)*server.dbnum); for (j = 0; j < server.dbnum; j++) { server.db[j].dict = dictCreate(&dbDictType,NULL); ………… }}
注意数据库字典的dictType指向的是结构体dbDictType,其中定义了数据库字典键的哈希函数,键比较函数,以及键与值的析构函数,定义如下:
dictType dbDictType = { dictSdsHash, NULL, NULL, dictSdsKeyCompare, dictSdsDestructor, dictObjectDestructor};
数据库的键都是SDS类型,键哈希函数为dictSdsHash,,键比较函数为dictSdsKeyCompare,键析构函数为dictSdsDestructor;数据库的值是robj对象,值析构函数为dictObjectDestructor;键和值的内容赋值函数都为NULL。
1.1节提到对象robj的refcount字段存储当前对象的引用次数,意味着对象是可以共享的。要注意的是,只有当对象robj存储的是0~10000以内的整数,对象robj才会被共享,且这些共享整数对象的引用计数初始化为INT_MAX,保证不会被释放。执行命令时Redis会返回一些字符串回复,这些字符串对象同样在服务器初始化时创建,且永远不会尝试释放这类对象。所有共享对象都存储在全局结构体变量shared。void createSharedObjects(void) { //创建命令回复字符串对象 shared.ok = createObject(OBJ_STRING,sdsnew("+OK\r\n")); shared.err = createObject(OBJ_STRING,sdsnew("-ERR\r\n")); //创建0~10000整数对象 for (j = 0; j < OBJ_SHARED_INTEGERS; j++) { shared.integers[j] = makeObjectShared(createObject(OBJ_STRING,(void*)(long)j)); shared.integers[j]->encoding = OBJ_ENCODING_INT; }}
步骤4)创建事件循环eventLoop,即分配结构体所需内存,并初始化结构体各字段;epoll就是在此时创建的:
aeEventLoop *aeCreateEventLoop(int setsize) { if ((eventLoop = zmalloc(sizeof(*eventLoop))) == NULL) goto err; eventLoop->events = zmalloc(sizeof(aeFileEvent)*setsize); eventLoop->fired = zmalloc(sizeof(aeFiredEvent)*setsize); if (aeApiCreate(eventLoop) == -1) goto err;}
输入参数setsize理论上等于用户配置的虽大客户端数目即可,但是为了确保安全,这里设置setsize等于最大客户端数目加128。函数aeApiCreate内部调用epoll_create创建epoll,并初始化结构体eventLoop的字段apidata。
2.2 启动监听
上节介绍了服务器初始化的前面4个步骤,初始化配置;加载并解析配置文件;初始化服务端内部遍历,包括数据库,全局共享变量等;创建时间循环eventLoop。完成这些操作之后,Redis将创建socket并启动监听,同时创建对应的文件事件与时间事件并开始事件循环。下面将详细介绍步骤5~7。
步骤5)创建socket并启动监听;用户可通过指令port配置socket绑定端口号,指令bind配置socket绑定IP地址;注意指令bind可配置多个IP地址,中间用空格隔开;创建socket时只需要循环所有IP地址即可。int listenToPort(int port, int *fds, int *count) { for (j = 0; j < server.bindaddr_count || j == 0; j++) { //创建socket并启动监听,文件描述符存储在fds数组作为返回参数 fds[*count] = anetTcpServer(server.neterr,port,server.bindaddr[j], server.tcp_backlog); //设置socket非阻塞 anetNonBlock(NULL,fds[*count]); (*count)++; }}
输入参数port表示用户配置的端口号,server结构体的bindaddr_count字段存储用户配置的IP地址数目,bindaddr字段存储用户配置的所有IP地址。函数anetTcpServer实现了socket的创建,绑定,以及监听流程,这里不做过多详述。参数fds与count可用作输出参数,fds数组存储创建的所有socket文件描述符,count存储socket数目。
注意到所有创建的socket都会设置为非阻塞模式,原因在于Redis使用了IO多路复用模式,其要求socket读写必须是非阻塞的,函数anetNonBlock通过系统调用fcntl设置socket非阻塞模式。步骤6)创建文件事件与时间事件;步骤5中已经完成了socket的创建与监听,1.5.1节提到socket的读写事件被抽象为文件事件,因为对于监听的socket还需要创建对应的文件事件。for (j = 0; j < server.ipfd_count; j++) { if (aeCreateFileEvent(server.el, server.ipfd[j], AE_READABLE, acceptTcpHandler,NULL) == AE_ERR){ }}
server结构体的ipfd_count字段存储创建的监听socket数目,ipfd数组存储创建的所有监听socket文件描述符,需要循环所有的监听socket,为其创建对应的文件事件。可以看到监听事件的处理函数为acceptTcpHandler,实现了socket连接请求的accept,以及客户端对象的创建。
1.5.2小节提到定时任务被抽象为时间事件,且Redis只创建了一个时间事件,在服务端初始化时创建。此时间事件的处理函数为serverCron,初次创建时1毫秒后备触发。if (aeCreateTimeEvent(server.el, 1, serverCron, NULL, NULL) == AE_ERR) { exit(1);}
步骤7)开启事件循环;
前面6个步骤已经完成了服务端的初始化工作,并在指定IP地址、端口监听客户端连接,同时创建了文件事件与时间事件;此时只需要开启事件循环等待事件发生即可。void aeMain(aeEventLoop *eventLoop) { eventLoop->stop = 0; //开始事件循环 while (!eventLoop->stop) { if (eventLoop->beforesleep != NULL) eventLoop->beforesleep(eventLoop); //事件处理主函数 aeProcessEvents(eventLoop, AE_ALL_EVENTS|AE_CALL_AFTER_SLEEP); }}
事件处理主函数aeProcessEvents已经详细介绍过,这里需要重点关注函数beforesleep,其在每次事件循环开始,即Redis阻塞等待文件事件之前执行。函数beforesleep会执行一些不是很费时的操作,集群相关操作,过期键删除操作(这里可称为快速过期键删除),向客户端返回命令回复等。这里简要介绍下快速过期键删除操作。
void beforeSleep(struct aeEventLoop *eventLoop) { if (server.active_expire_enabled && server.masterhost == NULL) activeExpireCycle(ACTIVE_EXPIRE_CYCLE_FAST);}
Redis过期键删除有两种策略:1)访问数据库键时,校验该键是否过期,如果过期则删除;2)周期性删除过期键,beforeSleep函数与serverCron函数都会执行。server结构体的active_expire_enabled字段表示是否开启周期性删除过期键策略,用户可通过set-active-expire指令配置;masterhost字段存储当前Redis服务器的master服务器的域名,如果为NULL说明当前服务器不是某个Redis服务器的slaver。注意到这里依然是调用函数activeExpireCycle执行过期键删除,只是参数传递的是ACTIVE_EXPIRE_CYCLE_FAST,表示快速过期键删除。
回顾下1.5.2节讲述函数activeExpireCycle的实现,函数计算出timelimit,即函数最大执行时间,循环删除过期键时会校验函数执行时间是否超过此限制,超过则结束循环。显然快速过期键删除时只需要缩短timelimit即可,计算策略如下:void activeExpireCycle(int type) { static int timelimit_exit = 0; static long long last_fast_cycle = 0 if (type == ACTIVE_EXPIRE_CYCLE_FAST) { //上次activeExpireCycle函数是否已经执行完毕 if (!timelimit_exit) return; //当前时间距离上次执行快速过期键删除是否已经超过2000微妙 if (start < last_fast_cycle + 1000*2) return; last_fast_cycle = start; } //快速过期键删除时,函数执行时间不超过1000微妙 if (type == ACTIVE_EXPIRE_CYCLE_FAST) timelimit = 1000; }
执行快速过期键删除有很多限制,当函数activeExpireCycle正在执行时直接返回;当上次执行快速过期键删除的时间距离当前时间小于2000微妙时直接返回。思考下为什么可以通过变量timelimit_exit判断函数activeExpireCycle是否正在执行呢?注意到变量timelimit_exit声明为static,即函数执行完毕不会释放变量空间。那么可以在函数activeExpireCycle入口赋值timelimit_exit为0,返回之前赋值timelimit_exit为1,由此便可通过变量timelimit_exit判断函数activeExpireCycle是否正在执行。变量last_fast_cycle声明为static也是同样的原因。同时可以看到当执行快速过期键删除时,设置函数activeExpireCycle的最大执行时间为1000微妙。
函数aeProcessEvents为事件处理主函数,首先查找最近发生的时间事件,调用epoll_wait阻塞等待文件事件的发生并设置超时事件;待epoll_wait返回时,处理触发的文件事件;最后处理时间事件。步骤6中已经创建了文件事件,为监听socket的读事件,事件处理函数为acceptTcpHandler,即当客户端发起socket连接请求时,服务端会执行函数acceptTcpHandler处理。acceptTcpHandler函数主要做了两件事:1)accept客户端的连接请求;2)创建客户端对象;3)创建文件事件。步骤2与步骤3由函数createClient实现,输入参数fd为accept客户端连接请求后生成的socket文件描述符。client *createClient(int fd) { client *c = zmalloc(sizeof(client)); //设置socket为非阻塞模式 anetNonBlock(NULL,fd); //设置TCP_NODELAY anetEnableTcpNoDelay(NULL,fd); //如果服务端配置了tcpkeepalive,则设置SO_KEEPALIVE if (server.tcpkeepalive) anetKeepAlive(NULL,fd,server.tcpkeepalive); if (aeCreateFileEvent(server.el,fd,AE_READABLE, readQueryFromClient, c) == AE_ERR){ }}
为了使用IO多路复用模式,此处同样需要设置socket为非阻塞模式。
TCP是基于字节流的可靠传输层协议,为了提升网络利用率,一般默认都会开启Nagle。当应用层调用write函数发送数据时,TCP并不一定会立刻将数据发送出去,根据Nagle算法,还必须满足一定条件才行。Nagle是这样规定的:如果数据包长度大于一定门限时,则立即发送;如果数据包中含有FIN(表示断开TCP链接)字段,则立即发送;如果当前设置了TCP_NODELAY选项,则立即发送;如果所有条件都不满足,默认需要等待200毫秒超时后才会发送。Redis服务器向客户端返回命令回复时,希望TCP能立即将该回复发送给客户端,因此需要设置TCP_NODELAY。思考下如果不设置会怎么样呢?从客户端分析,命令请求的响应时间会大大加长。TCP是可靠的传输层协议,每次都需要经历三次握手与四次挥手,为了提升效率,可以设置SO_KEEPALIVE,即TCP长连接,这样TCP传输层会定时发送心跳包确认该连接的可靠性。应用层也不再需要频繁的创建于释放TCP连接了。server结构体的tcpkeepalive字段表示是否启用TCP长连接,用户可通过参数tcp-keepalive配置。接收到客户端连接请求之后,服务器需要创建文件事件等待客户端的命令请求,可以看到文件事件的处理函数为readQueryFromClient,当服务器接收到客户端的命令请求时,会执行此此函数。3 命令处理过程
上一节分析了服务器的启动过程,包括配置文件的解析,创建socket并启动监听,创建文件事件与时间事件并开启事件循环。服务器启动完成后,只需要等待客户端连接并发送命令请求即可。本小节主要介绍命令的处理过程,可以分为三个阶段,解析命令请求,命令调用和返回结果给客户端。
3.1 命令解析
TCP是一种基于字节流的传输层通信协议,因此接收到的TCP数据不一定是一个完整的数据包,其有可能是多个数据包的组合,也有可能是某一个数据包的部分,这种现象被称为半包与粘包。如图-3所示。
图-3 TCP半包与粘包
客户端应用层分别发送三个数据包,data3、data2和data1,但是TCP传输层在真正发送数据时,将data3数据包分割为data3_1与data3_2,并且将data1与data2数据合并,此时服务器接收到的数据包就不是一个完整的数据包。为了区分一个完整的数据包,通常有如下三种方法:1)数据包长度固定;2)通过特定的分隔符区分,比如HTTP协议就是通过换行符区分的;3)通过在数据包头部设置长度长度字段区分数据包长度,比如FastCGI协议。Redis采用自定义协议格式实现不同命令请求的区分,例如当用户在redis-cli客户端键入下面命令:SET redis-key value1 vlaue2 value3
客户端会将该命令请求转换为以下协议格式,然后发送给服务器:
*5\r\n$3\r\n$9redis-key\r\n$6value1\r\n$6vlaue2\r\n$6value3\r\n
其中,换行符rn用于区分命令请求的若干参数,“*5”表示该命令请求有5个参数,“$3”、“$9”和“$6”等表示该参数字符串长度,多个请求参数之间用“rn”分隔开
需要注意的是,Redis还支持在telnet会话输入命令的方式,只是此时没有了请求协议中的“*”来声明参数的数量,因此必须使用空格来分割各个参数,服务器在接收到数据之后,会将空格作为参数分隔符解析命令请求。这种方式的命令请求称为内联命令。Redis服务器接收到的命令请求首先存储在客户端对象的querybuf输入缓冲区,然后解析命令请求各个参数,并存储在客户端对象的argv(参数对象数组)和argc(参数数目)字段。参考2.2小节可以知道解析客户端命令请求的入口函数为readQueryFromClient,会读取socket数据存储到客户端对象的输入缓冲区,并调用函数processInputBuffer解析命令请求。processInputBuffer函数主要逻辑如图-4所示。
图-4 命令解析流程图
下面简要分析通过redis-cli客户端发送的命令请求的解析过程。假设客户端命令请求为“SET redis-key value1”,在函数processMultibulkBuffer添加断点,GDB打印客户端输入缓冲区内容如下:(gdb) p c->querybuf$3 = (sds) 0x7ffff1b45505 "*3\r\n$3\r\nSET\r\n$9\r\nredis-key\r\n$6\r\nvalue1\r\n"
解析该命令请求可以分为2个步骤,1)解析命令请求参数数目;2)循环解析每个请求参数。下面详细分析每个步骤的源码实现
步骤1)解析命令请求参数数目;querybuf指向命令请求首地址,命令请求参数数目的协议格式为“3rn”,即首字符必须是“”,并且可以使用字符“r”定位到行尾位置;解析后的参数数目暂存在客户端对象的multibulklen字段,表示等待解析的参数数目,变量pos记录已解析命令请求的长度。//定位到行尾newline = strchr(c->querybuf,'\r');//解析命令请求参数数目,并存储在客户端对象的multibulklen字段serverAssertWithInfo(c,NULL,c->querybuf[0] == '*');string2ll(c->querybuf+1,newline-(c->querybuf+1),&ll);c->multibulklen = ll;//记录已解析位置偏移量pos = (newline-c->querybuf)+2;//分配请求参数存储空间c->argv = zmalloc(sizeof(robj*)*c->multibulklen);
GDB打印主要变量内容如下:
(gdb) p c->multibulklen$9 = 3(gdb) p pos$10 = 4
步骤2)循环解析每个请求参数:
命令请求各参数的协议格式为“$3\r\nSET\r\n”,即首字符必须是“$”。解析当前参数之前需要解析出参数的字符串长度,可以使用字符“r”定位到行尾位置;注意到解析参数长度时,字符串开始位置为querybuf+pos+1;字符串参数长度暂存在客户端对象的bulklen字段,同时更新已解析字符串长度pos。//定位到行尾newline = strchr(c->querybuf+pos,'\r');//解析当前参数字符串长度,字符串首字符偏移量为posif (c->querybuf[pos] != '$') { return C_ERR;}ok = string2ll(c->querybuf+pos+1,newline-(c->querybuf+pos+1),&ll);pos += newline-(c->querybuf+pos)+2;c->bulklen = ll;
GDB打印主要变量内容如下:
(gdb) p c->querybuf+pos$13 = 0x7ffff1b4550d "SET\r\n$9\r\nredis-key\r\n$6\r\nvalue1\r\n"(gdb) p c->bulklen$15 = 3(gdb) p pos$16 = 8
解析出参数字符串长度之后,可直接读取该长度的参数内容,并创建字符串对象;同时需要更新待解析参数multibulklen。
//解析参数c->argv[c->argc++] = createStringObject(c->querybuf+pos,c->bulklen);pos += c->bulklen+2;//待解析参数数目减一c->multibulklen--;
当multibulklen值更新尾0时,说明参数解析完成,结束循环。读者可以思考下,待解析参数数目,当前参数长度为什么都需要暂存在客户端结构体,使用函数局部变量行不行?肯定是不行的,原因就在于上面提到的TCP半包与粘包现象,服务器可能只接收到部分命令请求,例如“3rn$3\r\nSET\r\n$9rnredis”。当函数processMultibulkBuffer执行完毕时,同样只会解析部分命令请求“3rn$3\r\nSET\r\n$9rn”,此时就需要记录该命令请求待解析的参数数目,以及待解析参数的长度;而剩余待解析的参数“redis”会继续缓存在客户端的输入缓冲区。
3.2 命令调用
参考图-4,解析完成命令请求之后,会调用函数processCommand处理该命令请求,而处理命令请求之前还有很多校验逻辑,比如说客户端是否已经完成认证,命令请求参数是否合法等。下面简要列出若干校验规则。
校验1)如果是quit命令直接返回并关闭客户端;if (!strcasecmp(c->argv[0]->ptr,"quit")) { addReply(c,shared.ok); c->flags |= CLIENT_CLOSE_AFTER_REPLY; return C_ERR;}
校验2)执行函数lookupCommand查找命令后,如果命令不存在返回错误;
c->cmd = c->lastcmd = lookupCommand(c->argv[0]->ptr);if (!c->cmd) { addReplyErrorFormat(c,"unknown command '%s'",(char*)c->argv[0]->ptr); return C_OK;}
校验3)如果命令参数数目不合法,返回错误。命令结构体的arity用于校验参数数目是否合法,当arity小于0时,表示命令参数数目大于等于arity;当arity大于0时,表示命令参数数目必须为arity;注意命令请求中命令的名称本身也是一个参数。
if ((c->cmd->arity > 0 && c->cmd->arity != c->argc) || (c->argc < -c->cmd->arity)) { addReplyErrorFormat(c,"wrong number of arguments for '%s' command", c->cmd->name); return C_OK;}
校验4)如果使用指令“requirepass password”设置了密码,且客户端没未认证通过,只能执行auth命令,auth命令格式为“AUTH password”。
if (server.requirepass && !c->authenticated && c->cmd->proc != authCommand){ addReply(c,shared.noautherr); return C_OK;}
校验5)如果使用指令“maxmemory <bytes>”设置了最大内存限制,且当前内存使用量超过了该配置门限,服务器会拒绝执行带有“m”(CMD_DENYOOM)标识的命令,如SET命令、APPEND命令和LPUSH命令等。命令标识参见1.4小节。
if (server.maxmemory) { int retval = freeMemoryIfNeeded(); if ((c->cmd->flags & CMD_DENYOOM) && retval == C_ERR) { addReply(c, shared.oomerr); return C_OK; }}
校验6)除了上面的5种校验,还有很多校验规则,比如集群相关校验,持久化相关校验,主从复制相关校验,发布订阅相关校验,以及事务操作等。这些校验规则会在相关章节会作详细介绍。
当所有校验规则都通过后,才会调用命令处理函数执行命令,代码如下:start = ustime();c->cmd->proc(c);duration = ustime()-start;//更新统计信息:当前命令执行时间与调用次数c->lastcmd->microseconds += duration;c->lastcmd->calls++;//记录慢查询日志slowlogPushEntryIfNeeded(c,c->argv,c->argc,duration);
执行命令完成后,如果有必要,还需要更新统计信息,记录慢查询日志,AOF持久化该命令请求,传播命令请求给所有的从服务器等。持久化与主从复制会在相关章节会作详细介绍,这里主要介绍慢查询日志的实现方式。
void slowlogPushEntryIfNeeded(client *c, robj **argv, int argc, long long duration) { //执行时间超过门限,记录该命令 if (duration >= server.slowlog_log_slower_than) listAddNodeHead(server.slowlog, slowlogCreateEntry(c,argv,argc,duration)); //慢查询日志最多记录条数为slowlog_max_len,超过需删除 while (listLength(server.slowlog) > server.slowlog_max_len) listDelNode(server.slowlog,listLast(server.slowlog));}
可以使用指令“slowlog-log-slower-than 10000”配置执行时间超过多少毫秒才会记录慢查询日志,指令“slowlog-max-len 128”配置慢查询日志最大数目,超过会删除最早的日志记录。可以看到慢查询日志记录在服务端结构体的slowlog字段,即存储速度非常快,并不会影响命令执行效率。用户可通过“SLOWLOG subcommand [argument]”命令查看服务器记录的慢查询日志。
3.3 返回结果
Redis服务器返回结果类型不同,协议格式不同,而客户端可以根据返回结果的第一个字符判断返回类型。Redis的返回结果可以分为5类:
- 1)状态回复,第一个字符是“+”;例如,SET命令执行完毕会向客户端返回“+OKrn”。
addReply(c, ok_reply ? ok_reply : shared.ok);
变量ok_reply通常为NULL,则返回的是共享变量shared.ok,在服务器启动时就完成了共享变量的初始化。
shared.ok = createObject(OBJ_STRING,sdsnew("+OK\r\n"));
- 2)错误回复,第一个字符是“-”;例如,当客户端请求命令不存在时,会向客户端返回“-ERR unknown command 'testcmd'”。
addReplyErrorFormat(c,"unknown command '%s'",(char*)c->argv[0]->ptr);
而函数addReplyErrorFormat内部实现会拼装错误回复字符串。
addReplyString(c,"-ERR ",5);addReplyString(c,s,len);addReplyString(c,"\r\n",2);
- 3)整数回复,第一个字符是“:”;例如,INCR命令执行完毕向客户端返回“:100rn”。
addReply(c,shared.colon);addReply(c,new);addReply(c,shared.crlf);
其中共享变量shared.colon与shared.crlf同样都是在服务器启动时就完成了初始化。
shared.colon = createObject(OBJ_STRING,sdsnew(":"));shared.crlf = createObject(OBJ_STRING,sdsnew("\r\n"));
- 4)批量回复,第一个字符是“$”;例如,GET命令查找键向客户端返回结果“$5rnhellorn”,其中$5表示返回字符串长度。
//计算返回对象obj长度,并拼接为字符串“$5\r\n”addReplyBulkLen(c,obj);addReply(c,obj);addReply(c,shared.crlf);
- 5)多条批量回复,第一个字符是“”;例如,LRANGE命令可能会返回多个多个值,格式为“3rn$6\r\nvalue1\r\n$6rnvalue2rn$6\r\nvalue3\r\n”,与命令请求协议格式相同,“*3”表示返回值数目,“$6”表示当前返回值字符串长度,多个返回值用“rn”分隔开。
//拼接返回值数目“*3\r\n”addReplyMultiBulkLen(c,rangelen);//循环输出所有返回值while(rangelen--) { //拼接当前返回值长度“$6\r\n” addReplyLongLongWithPrefix(c,len,'$'); addReplyString(c,p,len); addReply(c,shared.crlf);}
可以看到5种类型的返回结果都是调用类似于addReply函数返回的,那么是这些方法将返回结果发送给客户端的吗?其实不是。回顾1.2小节讲述的客户端结构体client,其中有两个关键字段reply和buf,分别表示输出链表与输出缓冲区,而函数addReply会直接或者间接的调用以下两个函数将返回结果暂时缓存在reply或者buf字段。
//添加字符串都输出缓冲区int _addReplyToBuffer(client *c, const char *s, size_t len) //添加各种类型的对象到输出链表void _addReplyObjectToList(client *c, robj *o)void _addReplySdsToList(client *c, sds s)void _addReplyStringToList(client *c, const char *s, size_t len)
需要特别注意的是,reply和buf字段不可能同时缓存待返回给客户端的数据。从客户端结构体的sentlen字段就能看出,当输出数据缓存在reply字段时,sentlen表示已返回给客户端的对象数目;当输出数据缓存在buf字段时,sentlen表示已返回给客户端的字节数目。那么当reply和buf字段同时缓存有输出数据呢?只有sentlen字段显然是不够的。从_addReplyToBuffer函数实现同样可以看出该结论。
int _addReplyToBuffer(client *c, const char *s, size_t len) { if (listLength(c->reply) > 0) return C_ERR;}
调用函数_addReplyToBuffer缓存数据到输出缓冲区时,如果检测到reply字段有待返回给客户端的数据,函数返回错误。而通常缓存数据时都会先尝试缓存到buf输出缓冲区,如果失败会再次尝试缓存到reply输出链表。
if (_addReplyToBuffer(c,obj->ptr,sdslen(obj->ptr)) != C_OK) _addReplyObjectToList(c,obj);
而函数addReply在将待返回给客户端的数据暂时缓存在输出缓冲区或者输出链表的同时,会将当前客户端添加到服务端结构体的clients_pending_write链表,以便后续能快速查找出哪些客户端有数据需要发送。
listAddNodeHead(server.clients_pending_write,c);
看到这里读者可能会有疑问,函数addReply只是将待返回给客户端的数据暂时缓存在输出缓冲区或者输出链表,那么什么时候将这些数据发送给客户端呢?读者是否还记得在介绍开启事件循环时,提到函数beforesleep在每次事件循环阻塞等待文件事件之前执行,主要执行一些不是很费时的操作,比如过期键删除操作,向客户端返回命令回复等。
函数beforesleep会遍历clients_pending_write链表中每一个客户端节点,并发送输出缓冲区或者输出链表中的数据。//遍历clients_pending_write链表listRewind(server.clients_pending_write,&li);while((ln = listNext(&li))) { client *c = listNodeValue(ln); listDelNode(server.clients_pending_write,ln); //向客户端发送数据 if (writeToClient(c->fd,c,0) == C_ERR) continue;}
看到这里我想大部分读者可能都会认为返回结果已经发送给客户端,命令请求也已经处理完成了。其实不然,读者可以思考这么一个问题,当返回结果数据量非常大时,是无法一次性将所有数据都发送给客户端的,即函数writeToClient执行之后,客户端输出缓冲区或者输出链表中可能还有部分数据未发送给客户端。这时候怎么办呢?很简单,只需要添加文件事件,监听当前客户端socket文件描述符的可写事件即可。
if (aeCreateFileEvent(server.el, c->fd, AE_WRITABLE, sendReplyToClient, c) == AE_ERR){}
可以看到该文件事件的事件处理函数为sendReplyToClient,即当客户端可写时,函数sendReplyToClient会发送剩余部分的数据给客户端。
至此,命令请求才算是真正处理完成了。4 本文小结
为了更好的理解服务器与客户端的交互,本文首先介绍了一些基础结构体,如对象结构体robj,客户端结构体client,服务端结构体redisServer以及命令结构体redisCommand。
Redis服务器是典型的事件驱动程序,将事件处理分为两大类:文件事件与时间事件。文件事件即socket的可读可写事件,时间事件即需要周期性执行的一些定时任务。Redis采用比较成熟的IO多路复用模型(select/epoll等)处理文件事件,并对这些IO多路复用模型做了简单封装。Redis服务器只维护了一个时间事件节点,该时间事件处理函数为serverCron,执行了所有需要周期性执行的一些定时任务。事件是理解Redis的基石,希望读者能认真学习。最后本文介绍了服务器处理客户端命令请求的整个流程,包括服务器启动监听,接收命令请求并解析,执行命令请求,返回命令回复等。