Redis 命令执行过程 (下)
在上一篇文章中《Redis 命令执行过程(上)》中,我们首先了解 Redis 命令执行的整体流程,然后细致分析了从 Redis 启动到建立 socket 连接,再到读取 socket 数据到输入缓冲区,解析命令,执行命令等过程的原理和实现细节。接下来,我们来具体看一下 set 和 get 命令的实现细节和如何将命令结果通过输出缓冲区和 socket 发送给 Redis 客户端。
set 和 get 命令具体实现
前文讲到 processCommand 方法会从输入缓冲区中解析出对应的 redisCommand,然后调用 call 方法执行解析出来的 redisCommand的 proc 方法。不同命令的的 proc 方法是不同的,比如说名为 set 的 redisCommand 的 proc 是 setCommand 方法,而 get 的则是 getCommand 方法。通过这种形式,实际上实现在Java 中特别常见的多态策略。
setCommand 会判断set命令是否携带了nx、xx、ex或者px等可选参数,然后调用setGenericCommand命令。我们直接来看 setGenericCommand 方法。
setGenericCommand 方法的处理逻辑如下所示:
首先判断 set 的类型是 setnx 还是 setxx,如果是 nx 并且 key 已经存在则直接返回;如果是 xx 并且 key 不存在则直接返回。
调用 setKey 方法将键值添加到对应的 Redis 数据库中。
如果有过期时间,则调用 setExpire 将设置过期时间
进行键空间通知
返回对应的值给客户端。
具体 setKey 和 setExpire 的方法实现我们这里就不细讲,其实就是将键值添加到db的 dict 数据哈希表中,将键和过期时间添加到 expires 哈希表中,如下图所示。
接下来看 getCommand 的具体实现,同样的,它底层会调用 getGenericCommand 方法。
getGenericCommand 方法会调用 lookupKeyReadOrReply 来从 dict 数据哈希表中查找对应的 key值。如果找不到,则直接返回 C_OK;如果找到了,则根据值的类型,调用 addReply 或者 addReplyBulk 方法将值添加到输出缓冲区中。
lookupKeyReadWithFlags 会从 redisDb 中查找对应的键值对,它首先会调用 expireIfNeeded判断键是否过期并且需要删除,如果为过期,则调用 lookupKey 方法从 dict 哈希表中查找并返回。具体解释可以看代码中的详细注释
Redis 在调用查找键值系列方法前都会先调用 expireIfNeeded 来判断键是否过期,然后根据 Redis 是否配置了懒删除来进行同步删除或者异步删除。关于键删除的细节可以查看《详解 Redis 内存管理机制和实现》一文。
在判断键释放过期的逻辑中有两个特殊情况:
如果当前 Redis 是主从结构中的从实例,则只判断键是否过期,不直接对键进行删除,而是要等待主实例发送过来的删除命令后再进行删除。如果当前 Redis 是主实例,则调用 propagateExpire 来传播过期指令。
如果当前正在进行 Lua 脚本执行,因为其原子性和事务性,整个执行过期中时间都按照其开始执行的那一刻计算,也就是说lua执行时未过期的键,在它整个执行过程中也都不会过期。
lookupKey 方法则是通过 dictFind 方法从 redisDb 的 dict 哈希表中查找键值,如果能找到,则根据 redis 的 maxmemory_policy 策略来判断是更新 lru 的最近访问时间,还是调用 updateFU 方法更新其他指标,这些指标可以在后续内存不足时对键值进行回收。
将命令结果写入输出缓冲区
在所有的 redisCommand 执行的最后,一般都会调用 addReply 方法进行结果返回,我们的分析也来到了 Redis 命令执行的返回数据阶段。
addReply 方法做了两件事情:
prepareClientToWrite 判断是否需要返回数据,并且将当前 client 添加到等待写返回数据队列中。
调用 _addReplyToBuffer 和 _addReplyObjectToList 方法将返回值写入到输出缓冲区中,等待写入 socekt。
prepareClientToWrite 首先判断了当前 client是否需要返回数据:
Lua 脚本执行的 client 则需要返回值;
如果客户端发送来 REPLY OFF 或者 SKIP 命令,则不需要返回值;
如果是主从复制时的主实例 client,则不需要返回值;
当前是在 AOF loading 状态的假 client,则不需要返回值。
接着如果这个 client 还未处于延迟等待写入 (CLIENTPENDINGWRITE)的状态,则将其设置为该状态,并将其加入到 Redis 的等待写入返回值客户端队列中,也就是 clientspendingwrite队列。
Redis 将存储等待返回的响应数据的空间,也就是输出缓冲区分成两部分,一个固定大小的 buffer 和一个响应内容数据的链表。在链表为空并且 buffer 有足够空间时,则将响应添加到 buffer 中。如果 buffer 满了则创建一个节点追加到链表上。_addReplyToBuffer 和 _addReplyObjectToList 就是分别向这两个空间写数据的方法。
固定buffer和响应链表,整体上构成了一个队列。这么组织的好处是,既可以节省内存,不需一开始预先分配大块内存,并且可以避免频繁分配、回收内存。
上面就是响应内容写入输出缓冲区的过程,下面看一下将数据从输出缓冲区写入 socket 的过程。
prepareClientToWrite 函数,将客户端加入到了Redis 的等待写入返回值客户端队列中,也就是 clientspendingwrite 队列。请求处理的事件处理逻辑就结束了,等待 Redis 下一次事件循环处理时,将响应从输出缓冲区写入到 socket 中。
将命令返回值从输出缓冲区写入 socket
在 《Redis 事件机制详解》一文中我们知道,Redis 在两次事件循环之间会调用 beforeSleep 方法处理一些事情,而对 clientspendingwrite 列表的处理就在其中。
下面的 aeMain 方法就是 Redis 事件循环的主逻辑,可以看到每次循环时都会调用 beforesleep 方法。
beforeSleep 函数会调用 handleClientsWithPendingWrites 函数来处理 clientspendingwrite 列表。
handleClientsWithPendingWrites 方法会遍历 clientspendingwrite 列表,对于每个 client 都会先调用 writeToClient 方法来尝试将返回数据从输出缓存区写入到 socekt中,如果还未写完,则只能调用 aeCreateFileEvent 方法来注册一个写数据事件处理器 sendReplyToClient,等待 Redis 事件机制的再次调用。
这样的好处是对于返回数据较少的客户端,不需要麻烦的注册写数据事件,等待事件触发再写数据到 socket,而是在下一次事件循环周期就直接将数据写到 socket中,加快了数据返回的响应速度。
但是从这里也会发现,如果 clientspendingwrite 队列过长,则处理时间也会很久,阻塞正常的事件响应处理,导致 Redis 后续命令延时增加。
sendReplyToClient 方法其实也会调用 writeToClient 方法,该方法就是将输出缓冲区中的 buf 和 reply 列表中的数据都尽可能多的写入到对应的 socket中。
版权声明: 本文为 InfoQ 作者【程序员历小冰】的原创文章。
原文链接:【http://xie.infoq.cn/article/fec0e78754677d8022ce11482】。文章转载请联系作者。
评论