Redis 列表阻塞命令的实现

前言 

  在 Redis 的 列表(list) 命令中,有一些命令是阻塞模式的,比如:BRPOP,  BLPOP, BRPOPLPUSH, 这些命令都有可能造成客户端的阻塞。下面总结一下 Redis 实现阻塞和取消阻塞的过程。

阻塞过程

  当一个阻塞原语的处理目标为空键时, 执行该阻塞原语的客户端就会被阻塞。有以下步骤:

1:将客户端的状态设为“正在阻塞”, 并记录阻塞这个客户端的各个键,以及阻塞的最长时限(timeout) 等数据;

2:将客户端的信息记录到 server.db[i]->blocking_keys 中(其中 i 为客户端所使用的数据库号码);

3:继续维持客户端和服务器之间的网络连接,但不再向客户端传送任何信息,造成客户端阻塞;

note: step2 中 service.db[i]->blocking_keys 是一个字典,键是那些造成客户端阻塞的键, 值是一个链表,链表里保存了所有因这个键而被阻塞的客户端,如下图所示:

 

 

 阻塞的取消过程

  阻塞的取消有三种方法:

    【1】被动脱离:有其它客户端为造成阻塞的键推入了新元素;

    【2】主动脱离:到达执行阻塞原语时设定的最大阻塞时间(timeout);

    【3】强制脱离:客户端强制终止和服务端的连接,或者服务器停机;

 

被动脱离

    阻塞因 LPUSH, RPUSH, LINSERT 等添加命令而被取消,这三个添加新元素的命令,在底层都有一个 pushGenericCommand 的函数实现(在下方源码部分增加的 TODO 标志标识关键步骤):

void lpushCommand(redisClient *c) {
    pushGenericCommand(c,REDIS_HEAD);
}

void rpushCommand(redisClient *c) {
    pushGenericCommand(c,REDIS_TAIL);
}
调用 pushGenericCommand

  下面是 pushGenericCommand  函数的源码实现(在下方源码部分增加的 TODO 标志标识关键步骤):

/*-----------------------------------------------------------------------------
 * List Commands
 *----------------------------------------------------------------------------*/

void pushGenericCommand(redisClient *c, int where) {

    int j, waiting = 0, pushed = 0;

    // 取出列表对象
    robj *lobj = lookupKeyWrite(c->db,c->argv[1]);

    // 如果列表对象不存在,那么可能有客户端在等待这个键的出现
    int may_have_waiting_clients = (lobj == NULL);

    if (lobj && lobj->type != REDIS_LIST) {
        addReply(c,shared.wrongtypeerr);
        return;
    }

    // 将列表状态设置为就绪
    if (may_have_waiting_clients) signalListAsReady(c,c->argv[1]);  //TODO 1: 如果有 client 可能被阻塞,则新加 readyList 到 service.ready_Keys 的字典中的相应链表中

    // 遍历所有输入值,并将它们添加到列表中
    for (j = 2; j < c->argc; j++) {               //TODO 2: 此处把新 push 的值加入到对应 key 的列表中

        // 编码值
        c->argv[j] = tryObjectEncoding(c->argv[j]);

        // 如果列表对象不存在,那么创建一个,并关联到数据库
        if (!lobj) {
            lobj = createZiplistObject();
            dbAdd(c->db,c->argv[1],lobj);
        }

        // 将值推入到列表
        listTypePush(lobj,c->argv[j],where);

        pushed++;
    }

    // 返回添加的节点数量
    addReplyLongLong(c, waiting + (lobj ? listTypeLength(lobj) : 0));

    // 如果至少有一个元素被成功推入,那么执行以下代码
    if (pushed) {
        char *event = (where == REDIS_HEAD) ? "lpush" : "rpush";

        // 发送键修改信号
        signalModifiedKey(c->db,c->argv[1]);

        // 发送事件通知
        notifyKeyspaceEvent(REDIS_NOTIFY_LIST,event,c->argv[1],c->db->id);
    }

    server.dirty += pushed;
}
pushGenericCommand

pushGenericCommand 函数主要做了两件事情:

【1】向 readyList 添加到服务器;

【2】将新元素 value 添加到该 key 中

到此处为止,被该 key 阻塞的客户端还没有任何一个被解除阻塞状态,为了做到这一点,Redis 的主进程在执行完 pushGenericCommand 函数后,会继续调用 handleClientsBlockedOnLists  函数,该函数的源码如下(在下方源码部分增加的 TODO 标志标识关键步骤):

/* This function should be called by Redis every time a single command,
 * a MULTI/EXEC block, or a Lua script, terminated its execution after
 * being called by a client.
 *
 * 这个函数会在 Redis 每次执行完单个命令、事务块或 Lua 脚本之后调用。  //TODO 0:  NOTICE
 *
 * All the keys with at least one client blocked that received at least
 * one new element via some PUSH operation are accumulated into
 * the server.ready_keys list. This function will run the list and will
 * serve clients accordingly. Note that the function will iterate again and
 * again as a result of serving BRPOPLPUSH we can have new blocking clients
 * to serve because of the PUSH side of BRPOPLPUSH. 
 *
 * 对所有被阻塞在某个客户端的 key 来说,只要这个 key 被执行了某种 PUSH 操作
 * 那么这个 key 就会被放到 serve.ready_keys 去。
 * 
 * 这个函数会遍历整个 serve.ready_keys 链表,
 * 并将里面的 key 的元素弹出给被阻塞客户端,
 * 从而解除客户端的阻塞状态。
 *
 * 函数会一次又一次地进行迭代,
 * 因此它在执行 BRPOPLPUSH 命令的情况下也可以正常获取到正确的新被阻塞客户端。
 */
void handleClientsBlockedOnLists(void) {

    // 遍历整个 ready_keys 链表
    while(listLength(server.ready_keys) != 0) {
        list *l;

        /* Point server.ready_keys to a fresh list and save the current one
         * locally. This way as we run the old list we are free to call
         * signalListAsReady() that may push new elements in server.ready_keys
         * when handling clients blocked into BRPOPLPUSH. */
        // 备份旧的 ready_keys ,再给服务器端赋值一个新的
        l = server.ready_keys;
        server.ready_keys = listCreate();

        while(listLength(l) != 0) {                 //TODO 1: 不断取出 server.ready_keys 的所有元素(可能对应多个不同的阻塞 Key)

            // 取出 ready_keys 中的首个链表节点
            listNode *ln = listFirst(l);

            // 指向 readyList 结构
            readyList *rl = ln->value;

            /* First of all remove this key from db->ready_keys so that
             * we can safely call signalListAsReady() against this key. */
            // 从 ready_keys 中移除就绪的 key
            dictDelete(rl->db->ready_keys,rl->key);

            /* If the key exists and it's a list, serve blocked clients
             * with data. */
            // 获取键对象,这个对象应该是非空的,并且是列表
            robj *o = lookupKeyWrite(rl->db,rl->key);
            if (o != NULL && o->type == REDIS_LIST) {
                dictEntry *de;

                /* We serve clients in the same order they blocked for
                 * this key, from the first blocked to the last. */
                // 取出所有被这个 key 阻塞的客户端
                de = dictFind(rl->db->blocking_keys,rl->key);
                if (de) {
                    list *clients = dictGetVal(de);
                    int numclients = listLength(clients);

                    while(numclients--) {         //TODO 2: 不断取出因为等待该 key 被阻塞的客户端
                        // 取出客户端
                        listNode *clientnode = listFirst(clients);
                        redisClient *receiver = clientnode->value;

                        // 设置弹出的目标对象(只在 BRPOPLPUSH 时使用)
                        robj *dstkey = receiver->bpop.target;

                        // 从列表中弹出元素
                        // 弹出的位置取决于是执行 BLPOP 还是 BRPOP 或者 BRPOPLPUSH
                        int where = (receiver->lastcmd &&
                                     receiver->lastcmd->proc == blpopCommand) ?
                                    REDIS_HEAD : REDIS_TAIL;
                        robj *value = listTypePop(o,where);    //TODO 3: 从该 key 已添加的元素中 pop 出第一个元素,并用于阻塞客户端的返回值

                        // 还有元素可弹出(非 NULL)
                        if (value) {
                            /* Protect receiver->bpop.target, that will be
                             * freed by the next unblockClient()
                             * call. */
                            if (dstkey) incrRefCount(dstkey);

                            // 取消客户端的阻塞状态
                            unblockClient(receiver);        //TODO 4: 从 service.blocking_keys 中移除对应阻塞的客户端

                            // 将值 value 推入到造成客户度 receiver 阻塞的 key 上
                            if (serveClientBlockedOnList(receiver,
                                rl->key,dstkey,rl->db,value,
                                where) == REDIS_ERR)
                            {
                                /* If we failed serving the client we need
                                 * to also undo the POP operation. */
                                    listTypePush(o,value,where);
                            }

                            if (dstkey) decrRefCount(dstkey);
                            decrRefCount(value);
                        } else {
                            // 如果执行到这里,表示还有至少一个客户端被键所阻塞
                            // 这些客户端要等待对键的下次 PUSH
                            break;
                        }
                    }
                }
                
                // 如果列表元素已经为空,那么从数据库中将它删除
                if (listTypeLength(o) == 0) dbDelete(rl->db,rl->key);
                /* We don't call signalModifiedKey() as it was already called
                 * when an element was pushed on the list. */
            }

            /* Free this item. */
            decrRefCount(rl->key);
            zfree(rl);
            listDelNode(l,ln);
        }
        listRelease(l); /* We have the new list on place at this point. */
    }
}
handleClientsBlockedOnLists

handleClientsBlockedOnLists  函数主要执行如下操作:

【1】如果 service.ready_keys 不为空,那么弹出该链表的表头元素,并取出其中的 readyList 值;

【2】根据 readyList 值中保存的 key 和 db, 在 service.blocking_keys 中查找所有因为该 key 而被阻塞的客户端(以链表形式保存);

【3】如果 Key 不为空,那么从 Key 的列表中弹出一个元素,并获取客户端链表的第一个客户端,然后将被弹出元素作为被阻塞的客户端的返回值;

【4】根据 readyList 结构的属性,删除 service.blocking_keys 中相应的客户端数据,取消客户端的阻塞状态;

【5】继续执行步骤 【3】和 【4】,知道 key 没有元素可弹出,或者因为 key 而阻塞的客户端都取消阻塞为止;

【6】继续执行步骤 【1】,直到 ready_keys 字典所有链表里的所有 readyList 结构都被处理完为止;

 

主动脱离

    阻塞因超过最大等待时间而被取消。当客户端被阻塞时,所有造成它阻塞的键,以及阻塞的最长时限都会被记录在客户端里面,并且盖客户端的状态会被设置为”正在阻塞“。每次 Redis 服务器常规操作函数(redis.c/serverCron) 执行时,程序都会检查所有连接到服务器的客户端,查看哪些处于”正在阻塞“状态的客户端时限是否已经过期,如果是的话,就给客户端返回一个空白回复,然后撤销对客户端的阻塞。下面是相关源码:

void clientsCron(void) {
    /* Make sure to process at least 1/(server.hz*10) of clients per call.
     *
     * 这个函数每次执行都会处理至少 1/server.hz*10 个客户端。
     *
     * Since this function is called server.hz times per second we are sure that
     * in the worst case we process all the clients in 10 seconds.
     *
     * 因为这个函数每秒钟会调用 server.hz 次,
     * 所以在最坏情况下,服务器需要使用 10 秒钟来遍历所有客户端。
     *
     * In normal conditions (a reasonable number of clients) we process
     * all the clients in a shorter time. 
     *
     * 在一般情况下,遍历所有客户端所需的时间会比实际中短很多。
     */

    // 客户端数量
    int numclients = listLength(server.clients);

    // 要处理的客户端数量
    int iterations = numclients / (server.hz * 10);

    // 至少要处理 50 个客户端
    if (iterations < 50)
        iterations = (numclients < 50) ? numclients : 50;

    while (listLength(server.clients) && iterations--) {
        redisClient *c;
        listNode *head;

        /* Rotate the list, take the current head, process.
         * This way if the client must be removed from the list it's the
         * first element and we don't incur into O(N) computation. */
        // 翻转列表,然后取出表头元素,这样一来上一个被处理的客户端会被放到表头
        // 另外,如果程序要删除当前客户端,那么只要删除表头元素就可以了
        listRotate(server.clients);
        head = listFirst(server.clients);
        c = listNodeValue(head);
        /* The following functions do different service checks on the client.
         * The protocol is that they return non-zero if the client was
         * terminated. */
        // 检查客户端,并在客户端超时时关闭它
        if (clientsCronHandleTimeout(c)) continue;
        // 根据情况,缩小客户端查询缓冲区的大小
        if (clientsCronResizeQueryBuffer(c)) continue;
    }
}
clientsCron

 

阻塞的取消策略

    当程序添加一个新的被阻塞客户端到 server.blocking_keys 字典的链表中时,他将客户端放在链表的最后,而当 handleClientsBlockedOnLists  取消客户端的阻塞时候,它从链表的最前面开始取消阻塞;这个链表形成了一个 FIFO 队列,最先被阻塞的客户端总是最先脱离阻塞状态,Redis 文档称这种模式为先阻塞先服务(FBFS, first-block-first-server)。

 

参考内容:

  [1]:The Design and Implementation of Redis  黄健宏

 

posted on 2020-05-29 00:30  爱笑的张飞  阅读(2979)  评论(0编辑  收藏  举报

导航