Redis源码解析:24sentinel(五)TLIT模式、执行脚本

十一:TILT模式

         根据之前的介绍可知,哨兵的运行,非常依赖于系统时间,但是当系统时间被调整,或者哨兵中的流程因为某种原因(比如负载较高、IO发生阻塞、进程被信号停止等)而被阻塞时,哨兵的行为就会变得不可预知了。

         所谓TILT模式,就是一种特殊的保护模式。进入TILT模式后,哨兵只定期发送命令用于收集信息,而不采取实质性的动作,比如不会进行故障转移流程。

         当恢复正常30秒后,哨兵就是退出TILT模式。

 

         在哨兵的定时器函数sentinelTimer中,首先就是调用函数sentinelCheckTiltCondition判断哨兵当前是否需要进入TILT模式。该函数的代码如下:

void sentinelCheckTiltCondition(void) {
    mstime_t now = mstime();
    mstime_t delta = now - sentinel.previous_time;

    if (delta < 0 || delta > SENTINEL_TILT_TRIGGER) {
        sentinel.tilt = 1;
        sentinel.tilt_start_time = mstime();
        sentinelEvent(REDIS_WARNING,"+tilt",NULL,"#tilt mode entered");
    }
    sentinel.previous_time = mstime();
}

         正常情况下,本函数每隔100ms执行一次。每次执行都会更新sentinel.previous_time属性。如果某次调用本函数时,发现当前时间与sentinel.previous_time间的差值为负值,或者大于SENTINEL_TILT_TRIGGER(2000),则置sentinel.tilt为1,说明哨兵进入了TILT模式,并且置sentinel.tilt_start_time为当前时间。

 

         当进入TILT模式后,在收到其他实例的”INFO”命令回复后的回调函数sentinelRefreshInstanceInfo中,仅将收到的信息保存下来,而后续涉及到主从角色变化、故障转移流程等,都不再处理;而且当收到其他哨兵发来的,用于询问某主节点是否下线的"is-master-down-by-addr"命令时,一律回复“未下线”,因为处于TILT模式下的哨兵的判断,已经不可信了。

         在哨兵的“主函数”sentinelHandleRedisInstance中,在调用函数sentinelSendPeriodicCommands发送完周期性的命令之后,有下面的代码:

void sentinelHandleRedisInstance(sentinelRedisInstance *ri) {
    /* ========== MONITORING HALF ============ */
    /* Every kind of instance */
    sentinelReconnectInstance(ri);
    sentinelSendPeriodicCommands(ri);

    /* ============== ACTING HALF ============= */
    /* We don't proceed with the acting half if we are in TILT mode.
     * TILT happens when we find something odd with the time, like a
     * sudden change in the clock. */
    if (sentinel.tilt) {
        if (mstime()-sentinel.tilt_start_time < SENTINEL_TILT_PERIOD) return;
        sentinel.tilt = 0;
        sentinelEvent(REDIS_WARNING,"-tilt",NULL,"#tilt mode exited");
    }

    /* Every kind of instance */
    sentinelCheckSubjectivelyDown(ri);
    ...
}

         因此,在TILT模式下,仅仅发送命令收集信息,而不会进行故障转移流程相关的动作。并且,当哨兵处于TILT模式下连续超过SENTINEL_TILT_PERIOD(30秒)后,就会退出TILT模式。

 

十二:执行脚本

         哨兵支持在发生某种事件,或者是因发生了故障转移而主节点的地址发生变化时,能够执行相应的脚本,以便通知系统管理员事件的发生,或是通知客户端主节点的新地址信息。

        

         目前哨兵支持两种脚本。一种是当发生某种WARNING级别的事件(比如实例主观下线、客观下线等)时,调用脚本以便通过邮件、短信或者其他方式,将事件通知给系统管理员。脚本调用时,会传递两个参数,一是事件的类型,一是事件的描述信息。

         这种脚本可以通过配置文件中的” notification-script”选项配置:

sentinel notification-script mymaster /var/redis/notify.sh

        

         另一种是当发生故障转移,导致主节点的地址信息发生了变化时,可以调用脚本通知连接Redis的客户端,使其能够感知到这种配置的变化,以及主节点的新地址信息。这种脚本的参数包括:<master-name> <role><state> <from-ip> <from-port> <to-ip> <to-port>

         参数<role>,根据故障转移流程是否是当前哨兵为领导节点完成的,要么是”leader”,要么是”observer”;参数<state>,目前只能是”start”;参数<from-ip>和<from-port>,是原来主节点的地址信息;参数<to-ip>和<to-port>,是新主节点的地址信息。         

         这种脚本可以通过配置文件中的” client-reconfig-script”选项配置:

sentinel client-reconfig-script mymaster /var/redis/reconfig.sh

 

         这两种脚本的调用规则和错误处理方式是:当脚本的退出码为1,或者因为收到某种信号导致脚本退出,则该脚本后续会被重试执行,最大的重试次数为10;当脚本的退出码为2(或者更高的值)时,脚本不会被重试执行;脚本的最长运行时间为60秒,运行时间超过该阈值的脚本会被KILL掉。

 

1:事件通知脚本

         在哨兵的代码中,每当有事件发生时,就会调用sentinelEvent函数。该函数主要做三件事:将事件信息记录日志;将事件信息发布到某个频道上,订阅该频道的客户端可以接收到这种事件信息;创建用以执行事件通知脚本的任务。

         sentinelEvent函数的代码如下:

void sentinelEvent(int level, char *type, sentinelRedisInstance *ri,
                   const char *fmt, ...) {
    va_list ap;
    char msg[REDIS_MAX_LOGMSG_LEN];
    robj *channel, *payload;

    /* Handle %@ */
    if (fmt[0] == '%' && fmt[1] == '@') {
        sentinelRedisInstance *master = (ri->flags & SRI_MASTER) ?
                                         NULL : ri->master;

        if (master) {
            snprintf(msg, sizeof(msg), "%s %s %s %d @ %s %s %d",
                sentinelRedisInstanceTypeStr(ri),
                ri->name, ri->addr->ip, ri->addr->port,
                master->name, master->addr->ip, master->addr->port);
        } else {
            snprintf(msg, sizeof(msg), "%s %s %s %d",
                sentinelRedisInstanceTypeStr(ri),
                ri->name, ri->addr->ip, ri->addr->port);
        }
        fmt += 2;
    } else {
        msg[0] = '\0';
    }

    /* Use vsprintf for the rest of the formatting if any. */
    if (fmt[0] != '\0') {
        va_start(ap, fmt);
        vsnprintf(msg+strlen(msg), sizeof(msg)-strlen(msg), fmt, ap);
        va_end(ap);
    }

    /* Log the message if the log level allows it to be logged. */
    if (level >= server.verbosity)
        redisLog(level,"%s %s",type,msg);

    /* Publish the message via Pub/Sub if it's not a debugging one. */
    if (level != REDIS_DEBUG) {
        channel = createStringObject(type,strlen(type));
        payload = createStringObject(msg,strlen(msg));
        pubsubPublishMessage(channel,payload);
        decrRefCount(channel);
        decrRefCount(payload);
    }

    /* Call the notification script if applicable. */
    if (level == REDIS_WARNING && ri != NULL) {
        sentinelRedisInstance *master = (ri->flags & SRI_MASTER) ?
                                         ri : ri->master;
        if (master->notification_script) {
            sentinelScheduleScriptExecution(master->notification_script,
                type,msg,NULL);
        }
    }
}

         参数level表示日志级别,还用于控制是否将事件发布到相应频道,以及是否创建任务;参数type表示事件名,比如"+monitor","+slave","+role-change"等,该参数还是发布频道的频道名;ri表示触发事件的实例;后面的参数表示事件的描述消息;

         该函数中,首先处理可变参数,组装事件消息msg;

         如果level大于等于server.verbosity,则将type和msg记录到日志中;

         如果level不是REDIS_DEBUG,则将msg发布到以type为名的频道中;

         如果level为REDIS_WARNING,并且ri不为NULL,则先根据ri找到相应的主节点master,如果该master配置了事件通知脚本的话,则调用函数sentinelScheduleScriptExecution创建任务节点,后续该任务会以type和msg为参数执行notification_script脚本;

 

2:客户端重配置脚本

         当发生故障转移流程后,主节点的信息发生变化。哨兵感知到这种变化后,就会调用sentinelCallClientReconfScript函数,该函数会创建执行客户端重配置脚本的任务。

         sentinelCallClientReconfScript函数的代码如下:

void sentinelCallClientReconfScript(sentinelRedisInstance *master, int role, char *state, sentinelAddr *from, sentinelAddr *to) {
    char fromport[32], toport[32];

    if (master->client_reconfig_script == NULL) return;
    ll2string(fromport,sizeof(fromport),from->port);
    ll2string(toport,sizeof(toport),to->port);
    sentinelScheduleScriptExecution(master->client_reconfig_script,
        master->name,
        (role == SENTINEL_LEADER) ? "leader" : "observer",
        state, from->ip, fromport, to->ip, toport, NULL);
}

         该函数只在两个地方调用,一是当前哨兵为领导节点进行故障转移时,选中的从节点在其"INFO"命令回复信息中,表明其已升级为主节点时。这中情况下,参数role为SENTINEL_LEADER;一是当前哨兵收到其他哨兵发来的HELLO消息,发现其中的主节点信息与当前哨兵记录的主节点信息不一致时。这种情况下,参数role为SENTINEL_OBSERVER;

         本函数用于创建执行脚本master->client_reconfig_script的任务,如果master->client_reconfig_script属性为NULL,则说明未配置该脚本,因此直接返回;

         然后调用sentinelScheduleScriptExecution函数,根据脚本名,及其参数,创建任务节点。

 

3:创建新任务

         脚本都是由任务执行的,任务以节点的形式存放到列表sentinel.scripts_queue中。创建新任务的函数是sentinelScheduleScriptExecution,代码如下:

void sentinelScheduleScriptExecution(char *path, ...) {
    va_list ap;
    char *argv[SENTINEL_SCRIPT_MAX_ARGS+1];
    int argc = 1;
    sentinelScriptJob *sj;

    va_start(ap, path);
    while(argc < SENTINEL_SCRIPT_MAX_ARGS) {
        argv[argc] = va_arg(ap,char*);
        if (!argv[argc]) break;
        argv[argc] = sdsnew(argv[argc]); /* Copy the string. */
        argc++;
    }
    va_end(ap);
    argv[0] = sdsnew(path);

    sj = zmalloc(sizeof(*sj));
    sj->flags = SENTINEL_SCRIPT_NONE;
    sj->retry_num = 0;
    sj->argv = zmalloc(sizeof(char*)*(argc+1));
    sj->start_time = 0;
    sj->pid = 0;
    memcpy(sj->argv,argv,sizeof(char*)*(argc+1));

    listAddNodeTail(sentinel.scripts_queue,sj);

    /* Remove the oldest non running script if we already hit the limit. */
    if (listLength(sentinel.scripts_queue) > SENTINEL_SCRIPT_MAX_QUEUE) {
        listNode *ln;
        listIter li;

        listRewind(sentinel.scripts_queue,&li);
        while ((ln = listNext(&li)) != NULL) {
            sj = ln->value;

            if (sj->flags & SENTINEL_SCRIPT_RUNNING) continue;
            /* The first node is the oldest as we add on tail. */
            listDelNode(sentinel.scripts_queue,ln);
            sentinelReleaseScriptJob(sj);
            break;
        }
        redisAssert(listLength(sentinel.scripts_queue) <=
                    SENTINEL_SCRIPT_MAX_QUEUE);
    }
}

         参数path为任务要执行的脚本路径,之后的参数就是该脚本执行时的参数。

         首先将所有可变参数记录到数组argv中,然后将脚本路径记录到argv[0]中;

         然后创建任务结构sj,初始化该结构的属性,并将数组argv复制到sj->argv中;

         然后将sj追加到列表sentinel.scripts_queue的结尾;

         如果列表当前长度超过了SENTINEL_SCRIPT_MAX_QUEUE(256),则需要删除最早添加的任务。因此轮训列表,找到第一个当前未执行的任务,将其从列表中删除;

 

4:执行任务

         在哨兵的定时器函数sentinelTimer中,会调用sentinelRunPendingScripts函数,依次执行列表sentinel.scripts_queue中的任务。该函数的代码如下:

void sentinelRunPendingScripts(void) {
    listNode *ln;
    listIter li;
    mstime_t now = mstime();

    /* Find jobs that are not running and run them, from the top to the
     * tail of the queue, so we run older jobs first. */
    listRewind(sentinel.scripts_queue,&li);
    while (sentinel.running_scripts < SENTINEL_SCRIPT_MAX_RUNNING &&
           (ln = listNext(&li)) != NULL)
    {
        sentinelScriptJob *sj = ln->value;
        pid_t pid;

        /* Skip if already running. */
        if (sj->flags & SENTINEL_SCRIPT_RUNNING) continue;

        /* Skip if it's a retry, but not enough time has elapsed. */
        if (sj->start_time && sj->start_time > now) continue;

        sj->flags |= SENTINEL_SCRIPT_RUNNING;
        sj->start_time = mstime();
        sj->retry_num++;
        pid = fork();

        if (pid == -1) {
            /* Parent (fork error).
             * We report fork errors as signal 99, in order to unify the
             * reporting with other kind of errors. */
            sentinelEvent(REDIS_WARNING,"-script-error",NULL,
                          "%s %d %d", sj->argv[0], 99, 0);
            sj->flags &= ~SENTINEL_SCRIPT_RUNNING;
            sj->pid = 0;
        } else if (pid == 0) {
            /* Child */
            execve(sj->argv[0],sj->argv,environ);
            /* If we are here an error occurred. */
            _exit(2); /* Don't retry execution. */
        } else {
            sentinel.running_scripts++;
            sj->pid = pid;
            sentinelEvent(REDIS_DEBUG,"+script-child",NULL,"%ld",(long)pid);
        }
    }
}

         sentinel.running_scripts表示当前正在运行的子进程数,也就是正在运行的任务数。如果该值小于SENTINEL_SCRIPT_MAX_RUNNING(16),则轮训列表sentinel.scripts_queue中的每个任务节点:

         如果该任务节点的标志位中设置了SENTINEL_SCRIPT_RUNNING,说明该任务正在运行,因此直接忽略该任务节点;

         创建任务节点时,其start_time属性置为0,当运行该任务时,就会将start_time置为当时时间。如果任务运行失败,且需要重试时,则将其置为下次运行该任务的时间。因此如果该属性不为0,且其值大于当前时间,说明该任务还不到运行的时候,因此直接忽略该任务节点;

         接下来就可以运行该任务节点了。首先将SENTINEL_SCRIPT_RUNNING标记增加到其标志位中;然后设置任务的start_time属性为当前时间;增加任务的retry_num值,该属性表示任务重试次数;

         然后就是调用fork创建子进程。创建子进程失败,则将SENTINEL_SCRIPT_RUNNING标记从任务标志位中清除,这样下次调用本函数时,会重新运行该任务;创建子任务成功,则在子进程中调用execve执行脚本;在父进程中,将子进程pid记录到任务的pid属性中,并增加sentinel.running_scripts的值。

 

5:收集任务执行状态

         在哨兵的定时器函数sentinelTimer中,会调用sentinelCollectTerminatedScripts函数,收集终止任务的结束状态,主要是判断任务是否需要重试执行。该函数的代码如下:

void sentinelCollectTerminatedScripts(void) {
    int statloc;
    pid_t pid;

    while ((pid = wait3(&statloc,WNOHANG,NULL)) > 0) {
        int exitcode = WEXITSTATUS(statloc);
        int bysignal = 0;
        listNode *ln;
        sentinelScriptJob *sj;

        if (WIFSIGNALED(statloc)) bysignal = WTERMSIG(statloc);
        sentinelEvent(REDIS_DEBUG,"-script-child",NULL,"%ld %d %d",
            (long)pid, exitcode, bysignal);

        ln = sentinelGetScriptListNodeByPid(pid);
        if (ln == NULL) {
            redisLog(REDIS_WARNING,"wait3() returned a pid (%ld) we can't find in our scripts execution queue!", (long)pid);
            continue;
        }
        sj = ln->value;

        /* If the script was terminated by a signal or returns an
         * exit code of "1" (that means: please retry), we reschedule it
         * if the max number of retries is not already reached. */
        if ((bysignal || exitcode == 1) &&
            sj->retry_num != SENTINEL_SCRIPT_MAX_RETRY)
        {
            sj->flags &= ~SENTINEL_SCRIPT_RUNNING;
            sj->pid = 0;
            sj->start_time = mstime() +
                             sentinelScriptRetryDelay(sj->retry_num);
        } else {
            /* Otherwise let's remove the script, but log the event if the
             * execution did not terminated in the best of the ways. */
            if (bysignal || exitcode != 0) {
                sentinelEvent(REDIS_WARNING,"-script-error",NULL,
                              "%s %d %d", sj->argv[0], bysignal, exitcode);
            }
            listDelNode(sentinel.scripts_queue,ln);
            sentinelReleaseScriptJob(sj);
            sentinel.running_scripts--;
        }
    }
}

         本函数就是以参数WNOHANG循环调用wait3,只要当前已经有终止子进程了,则wait3返回该子进程的pid,否则返回负值,直接退出循环。在循环中:

         首先取得子进程的退出状态;

         如果子进程是因为接收到信号后而终止的,则取得该信号值bysignal;

         然后调用函数sentinelGetScriptListNodeByPid,根据子进程的pid,找到任务列表sentinel.scripts_queue中对应的任务节点sj;

         如果子进程是由信号终止的,或者子进程的退出状态为"1",并且任务的重试次数不等于SENTINEL_SCRIPT_MAX_RETRY(10),则该任务可以重新执行。因此先将SENTINEL_SCRIPT_RUNNING标记从任务标志位中清除,然后置任务pid为0,然后调用函数sentinelScriptRetryDelay,得到该任务下一次执行的时间,记录到任务的start_time属性中;

         其他情况下,要么任务执行成功了,要么任务退出码不是1,则都需要将该任务节点从列表sentinel.scripts_queue中删除,并且减少sentinel.running_scripts的值;

 

         ps:这里感觉有BUG,当任务需要重试时,也需要减少sentinel.running_scripts的值;

 

6:杀死执行超时的任务

         在哨兵的定时器函数sentinelTimer中,会调用sentinelKillTimedoutScripts函数,杀死那些执行时间超过60秒的任务。该函数的代码如下:

void sentinelKillTimedoutScripts(void) {
    listNode *ln;
    listIter li;
    mstime_t now = mstime();

    listRewind(sentinel.scripts_queue,&li);
    while ((ln = listNext(&li)) != NULL) {
        sentinelScriptJob *sj = ln->value;

        if (sj->flags & SENTINEL_SCRIPT_RUNNING &&
            (now - sj->start_time) > SENTINEL_SCRIPT_MAX_RUNTIME)
        {
            sentinelEvent(REDIS_WARNING,"-script-timeout",NULL,"%s %ld",
                sj->argv[0], (long)sj->pid);
            kill(sj->pid,SIGKILL);
        }
    }
}

         该函数很简单,就是轮训列表sentinel.scripts_queue,针对其中的每个任务,如果该任务正在执行,并且执行时间已经超过了60秒,则调用kill,向该任务发送SIGKILL信号,杀死该子进程。

 

PS:

         关于哨兵,就暂时到这里了,呵呵…

         更多关于函数的注释,参考:

https://github.com/gqtc/redis-3.0.5/blob/master/redis-3.0.5/src/sentinel.c

posted @ 2016-06-05 10:47  gqtc  阅读(545)  评论(0编辑  收藏  举报