redis在事件循环中做了什么(上)

1,083 阅读11分钟

上一篇启动流程在事件循环中结束了,现在来看看事件循环做了哪些事。

这是事件循环的函数签名

int aeProcessEvents(aeEventLoop *eventLoop, int flags)
  • flags会有几种标记,分别是:
  • AE_FILE_EVENTS 代表要处理准备好的socket事件
  • AE_TIME_EVENTS 代表要执行定时任务
  • AE_DONT_WAIT 代表不需要阻塞调用select
  • AE_CALL_BEFORE_SLEEP 执行beforeSleep钩子
  • AE_CALL_AFTER_SLEEP 执行afterSleep钩子

在调用aeProcessEvents时传入的标识是

  • AE_ALL_EVENTS | AE_CALL_BEFORE_SLEEP | AE_CALL_AFTER_SLEEP
  • ALL_EVENTS = AE_FILE_EVENTS + AE_TIME_EVENTS

下面来看看具体的逻辑

首先调用aeSearchNearestTimer(eventLoop) 查找即将要执行的定时任务。回顾redis启动流程,在initServer时,有这样一行代码

if (aeCreateTimeEvent(server.el, 1, serverCron, NULL, NULL) == AE_ERR) {

这里实际上就是将serverCron这个函数作为一个定时任务设置到了ae中,并且间隔时间是一毫秒。

回到aeSearchNearestTimer(eventLoop),这里将最近的定时任务返回,并计算它的触发时间,以此作为调用aeApiPoll的阻塞时长(相当于java.nio中的select函数)。

当aeApiPoll返回了一组准备好的事件时,根据事件类型触发不同的钩子,当redis刚启动时,只会注册一个tcpAcceptHandler,用于处理外部连接。对应

/* Create an event handler for accepting new connections in TCP and Unix
 * domain sockets.
 * 为每个socket设置读事件处理器  这样当接收到新的连接时 就会触发acceptTcpHandler 函数
 * */
for (j = 0; j < server.ipfd_count; j++) {
    if (aeCreateFileEvent(server.el, server.ipfd[j], AE_READABLE,
        // 当接收到client后 会自动创建conn 并基于conn创建client 然后在client相关的socket句柄上创建读写事件处理器
        acceptTcpHandler,NULL) == AE_ERR)
        {
            serverPanic(
                "Unrecoverable error creating server.ipfd file event.");
        }
}

有关acceptTcpHandler的代码就不具体展开了,下面简单说说它做了哪些事。

  1. 调用accept()内核函数,阻塞当前线程,完成3次握手。
  2. 根据client的socket句柄id/ip/port生成一个connection对象,这个对象包含了很多内核函数,可以从client读取数据,也可以写入数据,关闭连接等。此时conn->state为CONN_STATE_ACCEPTING。
  3. 检测此时连接数是否超过redis配置的上限,如果超过断开连接。通过调用内核级函数完成资源释放。
  4. 基于conn创建client对象,这与aof进行数据重做时创建的fakeClient不同,是能够将数据通过底层通道发送到对端的,而fakeClient无法发送数据。并且在创建client时如果发现存在conn会对conn进行一些tcp层的配置,比如非阻塞,关闭tcp延迟算法,开启tcp层心跳检测,还有将readQueryFromClient函数注册到conn上,在connSocketSetReadHandler中,在设置了read_handler(readQueryFromClient)后,会将ae_handler设置到事件循环上,这样就完成了从接入一个外部客户端到监听读取客户端事件准备情况的转换。ae_handler作为事件分发器,会根据本次事件类型不同转发给conn中不同的处理器(write_handler,read_handler)
  5. 执行connAccept,将conn->state修改成CONN_STATE_CONNECTED,并执行clientAcceptHandler,主要就是发起一个事件给module上注册的监听器.

起初redis在ae上只注册了连接事件,一旦与某个client建立连接后,又会在ae上追加ae_handler,假设这时client向redis发起了一个请求, 会触发ae_handler,在检测到本次准备好的事件是一个read事件后,就会使用read_handler去处理,也就是通过之前注册的readQueryFromClient。

这里简要描述下readQueryFromClient做了什么

  1. 判断针对该client的数据是否需要延后处理。如果开启了io线程,并且允许io线程从socket中读取数据。那么允许在某个时间点通过io线程批量读取数据并执行command,以提高效率。否则直接使用主线程读取数据+执command。 延迟处理会加入到一个pending_read队列中 对应的代码为
int postponeClientRead(client *c) {
    if (server.io_threads_active &&
        server.io_threads_do_reads &&
        !clientsArePaused() &&
        !ProcessingEventsWhileBlocked &&
        !(c->flags & (CLIENT_MASTER|CLIENT_SLAVE|CLIENT_PENDING_READ)))
    {
        c->flags |= CLIENT_PENDING_READ;
            listAddNodeHead(server.clients_pending_read,c);
        return 1;
    } else {
        return 0;
    }
}
  1. 按照协议类型解析数据,包含2种协议类型inline和multibulk,inline
  2. 将解析完的参数设置到client后,执行command。有关执行command的流程之后再讲,aof在数据恢复阶段也是通过模拟fakeClient执行记录的command完成数据重做的。 当socket写缓冲区有足够的空间时,是不需要注册writeHandler的,所以有关写事件的监听就不那么重要了,一般在执行command后会将数据通过client底层的conn对象返回给对端。

现在回到事件循环函数会发现,在执行aeApiPoll前后如果发现flags中包含了

  • AE_CALL_BEFORE_SLEEP
  • AE_CALL_AFTER_SLEEP 会执行beforeSleep/afterSleep,这2个函数放到下篇来讲。
if (eventLoop->beforesleep != NULL && flags & AE_CALL_BEFORE_SLEEP)
    eventLoop->beforesleep(eventLoop);

/* Call the multiplexing API, will return only on timeout or when
 * some event fires. */
numevents = aeApiPoll(eventLoop, tvp);

/* After sleep callback. */
if (eventLoop->aftersleep != NULL && flags & AE_CALL_AFTER_SLEEP)
    eventLoop->aftersleep(eventLoop);

最后会执行到时间的定时任务。

serverCron主要逻辑分为下面几步

1.更新系统缓存时间,如果频繁的调用时间函数可能会导致性能的浪费,在实时性要求没有这么高的情况下通过缓存时间来减少开销。 2.检测服务器此时是否设置了shutdown_asap标记,存在就直接退出redis进程。redis在启动过程中会安装信号处理器,当接收到合适的信号时就会设置这个标记,并在serverCron中关闭进程。 关闭redis的流程如下:

(1)检测此时是否处于数据恢复阶段或者是否以哨兵模式打开。 (在redis初始化的时候,哨兵模式会清理掉之前所有默认command,并设置哨兵相关的command,也就是哨兵节点不提供存取数据的功能,只是监控数据节点能否正常工作,它不需要数据恢复阶段,也不需要生成rdb/aof数据。)如果是的话清理掉SHUTDOWN_SAVE标记并追加一个SHUTDOWN_NOSAVE,代表在终止redis前不需要生成rdb快照。 检测这个时候是否有正在执行的rdb子进程,生成rdb的过程是由单独创建的子进程进行的,这时终止子进程,由主进程基于最新的数据生成快照。

(2)关闭module子进程。

(3)关闭aof子进程。对aof文件刷盘 在没有要求必须生成rdb数据或者必须禁止生成rdb数据(第一步就是为了判断这个),且设置了saveparam的情况下,通过判断是否满足saveparam条件,决定是否要生成rdb数据。(有关rdb/aof的数据生成时机以及逻辑之后再讲) 发出一个终止事件给module的监听器

(4)删除进程文件

(5)如果本节点作为master节点,将slaves对应client缓冲区的数据全部写出。正常情况下数据是会直接发送给slave的。存在2种情况会导致这个结果 [1]socket缓冲区没有足够空间,必须通过注册写事件+write_handler等待写事件准备完成 [2]针对redis中的主从功能,slave在启动时会从master上根据偏移量差值读取数据,做到数据同步,或者通过传输rdb数据进行数据同步。如果slave还没有同步完之前的数据,此时master无法发送新的数据给slave。也会被标记成pending_write。

(6)关闭所有socket

3.执行clientsCron,主要是在每个时间周期中做一些针对client的事件

(1)判断某个client是否长时间没有与本节点交互,如果是非

  • CLIENT_SLAVE
  • CLIENT_MASTER
  • CLIENT_BLOCKED
  • CLIENT_PUBSUB 也就是集群外的用户client,会直接断开连接,集群内部节点由集群模块负责尝试重试,所以不会在这里处理

(2)如果某个client由于某些key导致自身处于阻塞状态,并且在集群模式下。通过路由表判断key是否已经移动到了其他节点,如果是的话,解除阻塞状态。

(3)解除阻塞状态后,将client移动到server.unblocked_clients中,之后会在beforeSleep函数中继续处理

(4)检查这些client->querybuf是否过大,并进行缩容,避免内存的浪费。

(5)将当前时间点内与本server交互的所有client中,占用最大读写缓冲区(峰值)的记录下来

以上clientsCron的逻辑结束

4.执行databasesCron,周期性处理一些db相关的定时任务。

(1)判断是否开启了自动清理功能,并根据当前节点是否是master节点执行不同的逻辑

(2)假设当前节点是master,执行activeExpireCycle

[1]主要逻辑就是从每个db中抽取一部分的样本,检测是否过期。如果比率达到一定值,代表此时过期的key可能比较多,就会继续抽样处理,当处理时间达到限制值时,放弃检测。redis不会进行大范围的过期检测,因为它的定位是缓存框架,内部可能会有非常多的key。如果每轮都是检测全部数据,耗时大,并且效率不一定高。而如果将扫描集中在某个时间点。又会影响到用户访问这些数据(redis采用单线程执行命令),而如果采用的是多线程还需要对数据加锁,性能也会下降。所以抽样是一种比较好的策略,但是假设多次没有命中数据,大量过期数据残留在内存中,导致之后创建新对象内存不足会怎么办呢?这里就要配合redis的内存淘汰策略。

[2]当检测到需要被清理的key时,从db中删除redisObject,并且会在aof上记录一个delCommand,以及将删除命令存储到每个副本的写缓冲区中,此时还没有发送请求。

[3]如果有client监控了这个key,会被打上dirty_cas的标记,主要是在执行multi命令时使用。

(3)当前是slave节点,slave节点并没有从大范围的key中抽样检查少部分key,而是直接从一个slaveKeysWithExpire链表中抽样检查,该链表内的数据是由外部设置的(master)。实际上db本身采用的是惰性删除的策略,在需要通过key去获取对象的同时就会检测是否过期,并进行删除。这些抽样的逻辑主要是为了一些过期对象长时间未访问而避免内存的浪费。

(4)如果此时没有任何运行的子进程,尝试对db进行缩容,避免内存浪费。需要确保没有任何子进程是为了避免并发问题。dict内部使用2个数组实现,扩容时数据迁移并不是一次性完成,这里在databaseCron中分出来一部分时间进行数据迁移(1毫秒)

回到serverCron

5.检测此时是否有子进程在运行,没有子进程运行的情况下且设置了server.aof_rewrite_scheduled标记,代表需要执行rewrite任务。就开始执行rewrite任务。 原本要执行aof.rewrite任务时发现有其他子进程在执行就会设置这个标记。

6.判断此时是否有正在执行的子进程,如果有的话检测该子进程是否是用于同步slave节点数据的。是的话不需要处理。否则检测子进程是否完成,并根据进程任务类型进行一些后置处理

7.如果此时没有正在执行的子进程,检查是否满足saveparam参数,满足条件就开启后台进程执行rdb生成任务

8.如果此时不满足任何的rdb生成条件,就检测是否需要执行aof重做任务。需要aof此时的文件大小超过一定值。aof重做指的不是数据恢复,而是对aof中的command进行瘦身。

9.发现aof延迟刷盘标记,执行flushAppendOnlyFile

10.检测此时server.clients_paused状态是否可以结束.并尝试将此时flags中不包含block|slave标记的client转移到unblocked链表中。

11.执行replicationCron()

12.执行clusterCron() 集群和副本的相关逻辑放在之后讲

13.如果当前节点是哨兵节点,执行sentinelTimer() 有关哨兵的逻辑放在后面

14.在cluster模块中会生成一些临时性使用的cache_socket,检测这些socket是否长时间未使用,进行资源释放。

15.检查当前pending_writer任务是否很多,如果任务很少的情况下,暂停IO线程。IO线程就是在network模块中用于提升与client读写小效率的线程。在暂停IO线程前会尽量多的处理之前囤积的任务。

16.检测之前是否有囤积的rdb生成任务,并在此时执行

以上就是redis在ae(事件循环)中有关监听普通client的数据读取/写入/连接逻辑,以及主要的定时任务serverCron的执行逻辑。之后会讲一下在beforeSleep/afterSleep中做了哪些事。有关集群,哨兵,副本,rdb/aof的内容会在专门的文章中讲解。