欢迎投稿

今日深度:

Redis源码剖析和注释(二十三)--,redis源码剖析--

Redis源码剖析和注释(二十三)--,redis源码剖析--


Redis Sentinel实现(上)

1. Redis Sentinel 介绍和部署

请参考Redis Sentinel 介绍与部署

sentinel.c文件详细注释:Redis Sentinel详细注释

本文会分为两篇分别接受Redis Sentinel的实现,本篇主要将Redis哨兵的执行过程和执行的内容。

  • Redis Sentinel实现上
    • Redis Sentinel 介绍和部署
    • Redis Sentinel 的执行过程和初始化
      • 1 检查是否开启哨兵模式
      • 2 初始化哨兵的配置
      • 3 载入配置文件
        • 31 创建实例
        • 32 查找主节点
      • 4 开启 Sentinel
    • Redis Sentinel 的所有操作
      • 1 TILT 模式判断
      • 2 执行周期性任务
      • 3 执行脚本任务
        • 31 准备脚本
        • 32 执行脚本
        • 33 脚本清理工作
        • 34 杀死超时脚本
      • 4 脑裂
    • 哨兵的使命

标题4将会在Redis Sentinel实现(下)中详细剖析。

2. Redis Sentinel 的执行过程和初始化

Sentinel本质上是一个运行在特殊模式下的Redis服务器,无论如何,都是执行服务器的main来启动。主函数中关于Sentinel启动的代码如下:

int main(int argc, char **argv) {
    // 1. 检查开启哨兵模式的两种方式
    server.sentinel_mode = checkForSentinelMode(argc,argv);
    // 2. 如果已开启哨兵模式,初始化哨兵的配置
    if (server.sentinel_mode) {
        initSentinelConfig();
        initSentinel();
    }
    // 3. 载入配置文件
    loadServerConfig(configfile,options);
    // 开启哨兵模式,哨兵模式和集群模式只能开启一种
    if (!server.sentinel_mode) {
        // 在不是哨兵模式下,会载入AOF文件和RDB文件,打印内存警告,集群模式载入数据等等操作。
    } else { 
        sentinelIsRunning();
    }
}

以上过程可以分为四步:

  • 检查是否开启哨兵模式
  • 初始化哨兵的配置
  • 载入配置文件
  • 开启哨兵模式

2.1 检查是否开启哨兵模式

在Redis Sentinel 介绍与部署文章中,介绍了两种开启的方法:

  • redis-sentinel sentinel.conf
  • redis-server sentinel.conf --sentinel

主函数中调用了checkForSentinelMode()函数来判断是否开启哨兵模式。

int checkForSentinelMode(int argc, char **argv) {
    int j;

    if (strstr(argv[0],"redis-sentinel") != NULL) return 1;
    for (j = 1; j < argc; j++)
        if (!strcmp(argv[j],"--sentinel")) return 1;
    return 0;
}

如果开启了哨兵模式,就会将server.sentinel_mode设置为1

2.2 初始化哨兵的配置

在主函数中调用了两个函数initSentinelConfig()initSentinel(),前者用来初始化Sentinel节点的默认配置,后者用来初始化Sentinel节点的状态。sentinel.c文件详细注释:Redis Sentinel详细注释

sentinel.c文件中定义了一个全局变量sentinel,它是struct sentinelState类型的,用于保存当前Sentinel的状态。

  • initSentinelConfig(),初始化哨兵节点的默认端口为26379。
// 设置Sentinel的默认端口,覆盖服务器的默认属性
void initSentinelConfig(void) {
    server.port = REDIS_SENTINEL_PORT;
}
  • initSentinel(),初始化哨兵节点的状态
// 执行Sentinel模式的初始化操作
void initSentinel(void) {
    unsigned int j;

    /* Remove usual Redis commands from the command table, then just add
     * the SENTINEL command. */
    // 将服务器的命令表清空
    dictEmpty(server.commands,NULL);
    // 只添加Sentinel模式的相关命令,Sentinel模式下一共11个命令
    for (j = 0; j < sizeof(sentinelcmds)/sizeof(sentinelcmds[0]); j++) {
        int retval;
        struct redisCommand *cmd = sentinelcmds+j;

        retval = dictAdd(server.commands, sdsnew(cmd->name), cmd);
        serverAssert(retval == DICT_OK);
    }

    /* Initialize various data structures. */
    // 初始化各种Sentinel状态的数据结构

    // 当前纪元,用于实现故障转移操作
    sentinel.current_epoch = 0;
    // 监控的主节点信息的字典
    sentinel.masters = dictCreate(&instancesDictType,NULL);
    // TILT模式
    sentinel.tilt = 0;
    sentinel.tilt_start_time = 0;
    // 最后执行时间处理程序的时间
    sentinel.previous_time = mstime();
    // 正在执行的脚本数量
    sentinel.running_scripts = 0;
    // 用户脚本的队列
    sentinel.scripts_queue = listCreate();
    // Sentinel通过流言协议接收关于主服务器的ip和port
    sentinel.announce_ip = NULL;
    sentinel.announce_port = 0;
    // 故障模拟
    sentinel.simfailure_flags = SENTINEL_SIMFAILURE_NONE;
    // Sentinel的ID置为0
    memset(sentinel.myid,0,sizeof(sentinel.myid));
}

在哨兵模式下,只有11条命令可以使用,因此要用哨兵模式的命令表来代替Redis原来的命令表。

之后就是初始化sentinel的成员变量。我们重点关注这几个成员:

  • dict *masters :当前哨兵节点监控的主节点字典。字典的键是主节点实例的名字,字典的值是一个指针,指向一个sentinelRedisInstance类型的结构。
  • int running_scripts: 当前正在执行的脚本的数量。
  • list *scripts_queue:保存要执行用户脚本的队列。

2.3 载入配置文件

在启动哨兵节点时,要指定一个.conf配置文件,配置文件可以将配置项分为两类。

Sentinel配置说明

  • sentinel monitor \ \ \ \
    • 例如:sentinel monitor mymaster 127.0.0.1 6379 2
    • 当前Sentinel节点监控 127.0.0.1:6379 这个主节点
    • 2 代表判断主节点失败至少需要2个Sentinel节点节点同意
    • mymaster 是主节点的别名
  • sentinel xxxxxx \ xxxxxx
    • 例如:sentinel down-after-milliseconds mymaster 30000
    • 每个Sentinel节点都要定期PING命令来判断Redis数据节点和其余Sentinel节点是否可达,如果超过30000毫秒且没有回复,则判定不可达。
    • 例如:sentinel parallel-syncs mymaster 1
    • 当Sentinel节点集合对主节点故障判定达成一致时,Sentinel领导者节点会做故障转移操作,选出新的主节点,原来的从节点会向新的主节点发起复制操作,限制每次向新的主节点发起复制操作的从节点个数为1。

配置文件以这样的格式告诉哨兵节点,监控的主节点是谁,有什么样的限制条件。如果想要监控多个主节点,只需按照此格式在配置文件中多写几份。

既然配置文件都是如此,那么处理的函数也是如此处理,由于配置项很多,但是大体相似,所以我们列举处理示例的代码块:

    sentinelRedisInstance *ri;

    // SENTINEL monitor选项
    if (!strcasecmp(argv[0],"monitor") && argc == 5) {
        /* monitor <name> <host> <port> <quorum> */
        int quorum = atoi(argv[4]); //获取投票数
        // 投票数必须大于等于1
        if (quorum <= 0) return "Quorum must be 1 or greater.";
        // 创建一个主节点实例,并加入到Sentinel所监控的master字典中
        if (createSentinelRedisInstance(argv[1],SRI_MASTER,argv[2],
                                        atoi(argv[3]),quorum,NULL) == NULL)
        {
            switch(errno) {
            case EBUSY: return "Duplicated master name.";
            case ENOENT: return "Can't resolve master instance hostname.";
            case EINVAL: return "Invalid port number";
            }
        }

    // sentinel down-after-milliseconds选项
    } else if (!strcasecmp(argv[0],"down-after-milliseconds") && argc == 3) {
        /* down-after-milliseconds <name> <milliseconds> */
        // 获取根据name查找主节点实例
        ri = sentinelGetMasterByName(argv[1]);
        if (!ri) return "No such master with specified name.";
        // 设置主节点实例的主观下线的判断时间
        ri->down_after_period = atoi(argv[2]);
        if (ri->down_after_period <= 0)
            return "negative or zero time parameter.";
        // 根据ri主节点的down_after_period字段的值设置所有连接该主节点的从节点和Sentinel实例的主观下线的判断时间
        sentinelPropagateDownAfterPeriod(ri);

载入配置文件主要使用了两个函数createSentinelRedisInstance()sentinelGetMasterByName()。前者用来根据指定监控的主节点来创建实例,而后者则要根据名字找到对应的主节点实例来设置配置的参数。

2.3.1 创建实例

调用createSentinelRedisInstance()函数创建被该哨兵节点所监控的主节点实例,然后将新创建的主节点实例保存到sentinel.masters字典中,也就是初始化时创建的字典。该函数是一个通用的函数,根据参数flags不同创建不同类型的实例,并且将实例保存到不同的字典中:

  • SRI_MASTER:创建一个主节点实例,保存到当前哨兵节点监控的主节点字典中。
  • SRI_SLAVE:创建一个从节点实例,保存到主节点实例的从节点字典中。
  • SRI_SENTINE:创建一个哨兵节点实例,保存到其他监控该主节点实例的哨兵节点的字典中。

我们先列出函数的原型:

sentinelRedisInstance *createSentinelRedisInstance(char *name, int flags, char *hostname, int port, int quorum, sentinelRedisInstance *master)
  • 如果flags设置了SRI_MASTER,该实例被添加进sentinel.masters表中
  • 如果flags设置了SRI_SLAVE 或者 SRI_SENTINELmaster一定不为空并且该实例被添加到master->slavesmaster->sentinels
  • 如果该实例是从节点或者是哨兵节点,name参数被忽略,并且被自动设置为hostname:port

当根据flags能够获取实例的类型后,就会初始化一个sentinelRedisInstance类型的实例,添加到对应的字典中。

typedef struct sentinelRedisInstance {
    // 标识值,记录了当前Redis实例的类型和状态
    int flags;      /* See SRI_... defines */
    // 实例的名字
    // 主节点的名字由用户在配置文件中设置
    // 从节点以及Sentinel节点的名字由Sentinel自动设置,格式为:ip:port
    char *name;     /* Master name from the point of view of this sentinel. */
    // 实例运行的独一无二ID
    char *runid;    /* Run ID of this instance, or unique ID if is a Sentinel.*/
    // 配置纪元,用于实现故障转移
    uint64_t config_epoch;  /* Configuration epoch. */
    // 实例地址:ip和port
    sentinelAddr *addr; /* Master host. */
    // 实例的连接,有可能是被Sentinel共享的
    instanceLink *link; /* Link to the instance, may be shared for Sentinels. */
    // 最近一次通过 Pub/Sub 发送信息的时间
    mstime_t last_pub_time;   /* Last time we sent hello via Pub/Sub. */
    // 只有被Sentinel实例使用
    // 最近一次接收到从Sentinel发送来hello的时间
    mstime_t last_hello_time; 
    // 最近一次回复SENTINEL is-master-down的时间
    mstime_t last_master_down_reply_time; /* Time of last reply to
                                             SENTINEL is-master-down command. */
    // 实例被判断为主观下线的时间
    mstime_t s_down_since_time; /* Subjectively down since time. */
    // 实例被判断为客观下线的时间
    mstime_t o_down_since_time; /* Objectively down since time. */
    // 实例无响应多少毫秒之后被判断为主观下线
    // 由SENTINEL down-after-millisenconds配置设定
    mstime_t down_after_period; /* Consider it down after that period. */
    // 从实例获取INFO命令回复的时间
    mstime_t info_refresh;  /* Time at which we received INFO output from it. */

    // 实例的角色
    int role_reported;
    // 角色更新的时间
    mstime_t role_reported_time;
    // 最近一次从节点的主节点地址变更的时间
    mstime_t slave_conf_change_time; /* Last time slave master addr changed. */

    /* Master specific. */
    /*----------------------------------主节点特有的属性----------------------------------*/
    // 其他监控相同主节点的Sentinel
    dict *sentinels;    /* Other sentinels monitoring the same master. */
    // 如果当前实例是主节点,那么slaves保存着该主节点的所有从节点实例
    // 键是从节点命令,值是从节点服务器对应的sentinelRedisInstance
    dict *slaves;       /* Slaves for this master instance. */
    // 判定该主节点客观下线的投票数
    // 由SENTINEL monitor <master-name> <ip> <port> <quorum>配置
    unsigned int quorum;/* Number of sentinels that need to agree on failure. */
    // 在故障转移时,可以同时对新的主节点进行同步的从节点数量
    // 由sentinel parallel-syncs <master-name> <number>配置
    int parallel_syncs; /* How many slaves to reconfigure at same time. */
    // 连接主节点和从节点的认证密码
    char *auth_pass;    /* Password to use for AUTH against master & slaves. */

    /*----------------------------------从节点特有的属性----------------------------------*/
    // 从节点复制操作断开时间
    mstime_t master_link_down_time; /* Slave replication link down time. */
    // 按照INFO命令输出的从节点优先级
    int slave_priority; /* Slave priority according to its INFO output. */
    // 故障转移时,从节点发送SLAVEOF <new>命令的时间
    mstime_t slave_reconf_sent_time; /* Time at which we sent SLAVE OF <new> */
    // 如果当前实例是从节点,那么保存该从节点连接的主节点实例
    struct sentinelRedisInstance *master; /* Master instance if it's slave. */
    // INFO命令的回复中记录的主节点的IP
    char *slave_master_host;    /* Master host as reported by INFO */
    // INFO命令的回复中记录的主节点的port
    int slave_master_port;      /* Master port as reported by INFO */
    // INFO命令的回复中记录的主从服务器连接的状态
    int slave_master_link_status; /* Master link status as reported by INFO */
    // 从节点复制偏移量
    unsigned long long slave_repl_offset; /* Slave replication offset. */

    /*----------------------------------故障转移的属性----------------------------------*/
    // 如果这是一个主节点实例,那么leader保存的是执行故障转移的Sentinel的runid
    // 如果这是一个Sentinel实例,那么leader保存的是当前这个Sentinel实例选举出来的领头的runid
    char *leader; 
    // leader字段的纪元
    uint64_t leader_epoch; /* Epoch of the 'leader' field. */
    // 当前执行故障转移的纪元
    uint64_t failover_epoch; /* Epoch of the currently started failover. */
    // 故障转移操作的状态
    int failover_state; /* See SENTINEL_FAILOVER_STATE_* defines. */
    // 故障转移操作状态改变的时间
    mstime_t failover_state_change_time;
    // 最近一次故障转移尝试开始的时间
    mstime_t failover_start_time;   /* Last failover attempt start time. */
    // 更新故障转移状态的最大超时时间
    mstime_t failover_timeout;      /* Max time to refresh failover state. */
    // 记录故障转移延迟的时间
    mstime_t failover_delay_logged; 
    // 晋升为新主节点的从节点实例
    struct sentinelRedisInstance *promoted_slave; 
    // 通知admin的可执行脚本的地址,如果设置为空,则没有执行的脚本
    char *notification_script;
    // 通知配置的client的可执行脚本的地址,如果设置为空,则没有执行的脚本
    char *client_reconfig_script;
    // 缓存INFO命令的输出
    sds info; /* cached INFO output */
} sentinelRedisInstance;

该实例用来抽象描述一个节点,可以是主节点、从节点或者是哨兵节点。

2.3.2 查找主节点

在配置文件中分的那两个部分,第一部分是创建上面给出的结构实例,另一部分则是配置其中的一部分成员。因此,第一步要根据名字在哨兵节点的主节点字典中找到主节点实例。

sentinelRedisInstance *sentinelGetMasterByName(char *name) {
    sentinelRedisInstance *ri;
    sds sdsname = sdsnew(name);
    // 从Sentinel所监视的所有主节点中寻找名字为name的主节点,找到返回
    ri = dictFetchValue(sentinel.masters,sdsname);
    sdsfree(sdsname);
    return ri;
}

当找到并返回主节点实例后,就可以配置其变量了。例如:ri->down_after_period = atoi(argv[2])

2.4 开启 Sentinel

载入完配置文件,就会调用sentinelIsRunning()函数开启Sentinel。该函数主要干了这几个事:

  • 检查配置文件是否可写,因为要重写配置文件。
  • 为没有runid的哨兵节点分配 ID,并重写到配置文件中,并且打印到日志中。
  • 生成一个+monitor事件通知。

所以在启动一个哨兵节点时,查看日志会发现:

12775:X 28 May 15:14:34.953 # Sentinel ID is a4dce0267abdb89f7422c9a42960e6cb6e4
d565a
12775:X 28 May 15:14:34.953 # +monitor master mymaster 127.0.0.1 6379 quorum 2

至此,就正式启动了哨兵节点。我们用图片的方式来描述一下一个哨兵节点监控两个主节点的情况:

3. Redis Sentinel 的所有操作

Redis哨兵的操作,都是放在时间处理器中执行。服务器在初始化时会创建时间事件,并安装执行时间事件的处理函数serverCron(),在该函数调用sentinelTimer()函数(如下代码所示)来每100ms执行一次哨兵的定时中断,或者叫执行哨兵的任务。sentinel.c文件详细注释:Redis Sentinel详细注释

run_with_period(100) {
        if (server.sentinel_mode) sentinelTimer();
    }

sentinelTimer()函数就是Sentinel的主函数,他的执行过程非常清晰,我们直接给出代码:

void sentinelTimer(void) {
    // 先检查Sentinel是否需要进入TITL模式,更新最近一次执行Sentinel模式的周期函数的时间
    sentinelCheckTiltCondition();
    // 对Sentinel监控的所有主节点进行递归式的执行周期性操作
    sentinelHandleDictOfRedisInstances(sentinel.masters);
    // 运行在队列中等待的脚本
    sentinelRunPendingScripts();
    // 清理已成功执行的脚本,重试执行错误的脚本
    sentinelCollectTerminatedScripts();
    // 杀死执行超时的脚本,等到下个周期在sentinelCollectTerminatedScripts()函数中重试执行
    sentinelKillTimedoutScripts();

    /* We continuously change the frequency of the Redis "timer interrupt"
     * in order to desynchronize every Sentinel from every other.
     * This non-determinism avoids that Sentinels started at the same time
     * exactly continue to stay synchronized asking to be voted at the
     * same time again and again (resulting in nobody likely winning the
     * election because of split brain voting). */
    // 我们不断改变Redis定期任务的执行频率,以便使每个Sentinel节点都不同步,这种不确定性可以避免Sentinel在同一时间开始完全继续保持同步,当被要求进行投票时,一次又一次在同一时间进行投票,因为脑裂导致有可能没有胜选者
    server.hz = CONFIG_DEFAULT_HZ + rand() % CONFIG_DEFAULT_HZ;
}

我们可以将哨兵的任务按顺序分为四部分:

  • TILT 模式判断
  • 执行周期性任务。例如:定期发送PING、hello信息等等。
  • 执行脚本任务
  • 脑裂

接下来,依次分析

3.1 TILT 模式判断

TILT 模式是一种特殊的保护模式:当 Sentinel 发现系统有些不对劲时,Sentinel 就会进入 TILT 模式。

因为 Sentinel 的时间中断器默认每秒执行 10 次,所以我们预期时间中断器的两次执行之间的间隔为 100 毫秒左右。但是出现以下情况会出现异常:

  • Sentinel进程在某时被阻塞,有很多种原因,负载过大,IO任务密集,进程被信号停止等等。
  • 系统时钟发送明显变化

Sentinel 的做法是(如下sentinelCheckTiltCondition()函数所示),记录上一次时间中断器执行时的时间,并将它和这一次时间中断器执行的时间进行对比:

  • 如果两次调用时间之间的差距为负值,或者非常大(超过 2 秒钟),那么 Sentinel 进入 TILT 模式。
  • 如果 Sentinel 已经进入 TILT 模式,那么 Sentinel 延迟退出 TILT 模式的时间。
void sentinelCheckTiltCondition(void) {
    mstime_t now = mstime();
    // 最后一次执行Sentinel时间处理程序的时间过去了过久
    mstime_t delta = now - sentinel.previous_time;
    // 差为负数,或者大于2秒
    if (delta < 0 || delta > SENTINEL_TILT_TRIGGER) {
        // 设置Sentinel进入TILT状态
        sentinel.tilt = 1;
        // 设置进入TILT状态的开始时间
        sentinel.tilt_start_time = mstime();
        sentinelEvent(LL_WARNING,"+tilt",NULL,"#tilt mode entered");
    }
    // 设置最近一次执行Sentinel时间处理程序的时间
    sentinel.previous_time = mstime();
}

当 Sentinel 进入 TILT 模式时,它仍然会继续监视所有目标,但是:

  • 它不再执行任何操作,比如故障转移。
  • 当有实例向这个 Sentinel 发送 SENTINEL is-master-down-by-addr 命令时,Sentinel 返回负值:因为这个 Sentinel 所进行的下线判断已经不再准确。

如果 TILT 可以正常维持 30 秒钟,那么 Sentinel 退出 TILT 模式。

3.2 执行周期性任务

我们先来看看在执行周期性任务的函数sentinelHandleDictOfRedisInstances()

void sentinelHandleDictOfRedisInstances(dict *instances) {
    dictIterator *di;
    dictEntry *de;
    sentinelRedisInstance *switch_to_promoted = NULL;

    /* There are a number of things we need to perform against every master. */
    di = dictGetIterator(instances);
    // 遍历字典中所有的实例
    while((de = dictNext(di)) != NULL) {
        sentinelRedisInstance *ri = dictGetVal(de);
        // 对指定的ri实例执行周期性操作
        sentinelHandleRedisInstance(ri);
        // 如果ri实例是主节点
        if (ri->flags & SRI_MASTER) {
            // 递归的对主节点从属的从节点执行周期性操作
            sentinelHandleDictOfRedisInstances(ri->slaves);
            // 递归的对监控主节点的Sentinel节点执行周期性操作
            sentinelHandleDictOfRedisInstances(ri->sentinels);
            // 如果ri实例处于完成故障转移操作的状态,所有从节点已经完成对新主节点的同步
            if (ri->failover_state == SENTINEL_FAILOVER_STATE_UPDATE_CONFIG) {
                // 设置主从转换的标识
                switch_to_promoted = ri;
            }
        }
    }
    // 如果主从节点发生了转换
    if (switch_to_promoted)
        // 将原来的主节点从主节点表中删除,并用晋升的主节点替代
        // 意味着已经用新晋升的主节点代替旧的主节点,包括所有从节点和旧的主节点从属当前新的主节点
        sentinelFailoverSwitchToPromotedSlave(switch_to_promoted);
    dictReleaseIterator(di);
}

该函数可以分为两部分:

  • 递归的对当前哨兵所监控的所有主节点sentinel.masters,和所有主节点的所有从节点ri->slaves,和所有监控该主节点的其他所有哨兵节点ri->sentinels执行周期性操作。也就是sentinelHandleRedisInstance()函数。
  • 在执行操作的过程中,可能发生主从切换的情况,因此要给所有原来主节点的从节点(除了被选为当做晋升的从节点)发送slaveof命令去复制新的主节点(晋升为主节点的从节点)。对应sentinelFailoverSwitchToPromotedSlave()函数。

由于这里的操作过多,因此先跳过,单独在标题4进行剖析。

3.3 执行脚本任务

Sentinel的定时任务分为三步,也就是sentinelTimer()哨兵模式主函数中的三个函数:

  • sentinelRunPendingScripts():运行在队列中等待的脚本。
  • sentinelCollectTerminatedScripts():清理已成功执行的脚本,重试执行错误的脚本。
  • sentinelKillTimedoutScripts():杀死执行超时的脚本,等到下个周期在sentinelCollectTerminatedScripts()函数中重试执行。

3.3.1 准备脚本

我们先来说明脚本任务是如何加入到sentinel.scripts_queue中的。

首先在Sentinel中有两种脚本,分别是,都定义在sentinelRedisInstance结构中

  • 通知admin的脚本。char *notification_script
  • 重配置client的脚本。char *client_reconfig_script

在发生主从切换后,会调用sentinelCallClientReconfScript()函数,将重配置client的脚本放入脚本队列中。

在发生LL_WARNING级别的事件通知时,会调用sentinelEvent()函数,将通知admin的脚本放入脚本队列中。

然而这两个函数,都会调用最底层的sentinelScheduleScriptExecution()函数将脚本添加到脚本链表队列中。该函数源码如下:

#define SENTINEL_SCRIPT_MAX_ARGS 16
// 将给定参数和脚本放入用户脚本队列中
void sentinelScheduleScriptExecution(char *path, ...) {
    va_list ap;
    char *argv[SENTINEL_SCRIPT_MAX_ARGS+1];
    int argc = 1;
    sentinelScriptJob *sj;

    va_start(ap, path);
    // 将参数保存到argv中
    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;                                //执行脚本子进程的pid
    // 设置脚本的参数列表
    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. */
    // 如果队列长度大于256个,那么删除最旧的脚本,只保留255个
    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;
        }
        serverAssert(listLength(sentinel.scripts_queue) <=
                    SENTINEL_SCRIPT_MAX_QUEUE);
    }
}

Redis使用了sentinelScriptJob结构来管理脚本的一些信息,正如上述代码初始化那一部分。

而且当前哨兵维护的哨兵队列最多只能保留最新的255个脚本,如果脚本过多就会从队列中删除对旧的脚本。

3.3.2 执行脚本

当要执行脚本放入了队列中,等到周期性函数sentinelTimer()时,就会执行。我们来执行脚本的函数sentinelRunPendingScripts()代码:

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();
        // 执行次数加1
        sj->retry_num++;
        // 创建子进程执行
        pid = fork();

        // fork()失败,报告错误
        if (pid == -1) {
            sentinelEvent(LL_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),表示发生了错误,不能重新执行
            _exit(2); /* Don't retry execution. */
        // 父进程,更新脚本的pid,和同时执行脚本的个数
        } else {
            sentinel.running_scripts++;
            sj->pid = pid;
            // 并且通知事件
            sentinelEvent(LL_DEBUG,"+script-child",NULL,"%ld",(long)pid);
        }
    }
}

因为Redis是单线程架构的,所以和持久化一样,执行脚本需要创建一个子进程。

  • 子进程:执行没有正在执行和已经到了执行时间的脚本任务。
  • 父进程:更新脚本的信息。例如:正在执行的个数和执行脚本的子进程的pid等等。

父进程更新完脚本的信息后就会继续执行下一个sentinelCollectTerminatedScripts()函数

3.3.3 脚本清理工作

  • 如果在子进程执行的脚本已经执行完成,则可以从脚本队列中将其删除。
  • 如果在子进程执行的脚本执行出错,但是可以在规定时间后重新执行,那么设置其执行的时间,下个周期重新执行。
  • 如果在子进程执行的脚本执行出错,但是无法在执行,那么也会脚本队里中将其删除。

函数sentinelCollectTerminatedScripts()源码如下:

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

    // 接受子进程退出码
    // WNOHANG:如果没有子进程退出,则立刻返回
    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(LL_DEBUG,"-script-child",NULL,"%ld %d %d",
            (long)pid, exitcode, bysignal);
        // 根据pid查找并返回正在运行的脚本节点
        ln = sentinelGetScriptListNodeByPid(pid);
        if (ln == NULL) {
            serverLog(LL_WARNING,"wait3() returned a pid (%ld) we can't find in our scripts execution queue!", (long)pid);
            continue;
        }
        sj = ln->value;

        // 如果退出码是1并且没到脚本最大的重试数量
        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 {
            // 发送脚本错误的事件通知
            if (bysignal || exitcode != 0) {
                sentinelEvent(LL_WARNING,"-script-error",NULL,
                              "%s %d %d", sj->argv[0], bysignal, exitcode);
            }
            // 从脚本队列中删除脚本
            listDelNode(sentinel.scripts_queue,ln);
            // 释放一个脚本任务结构和所有关联的数据
            sentinelReleaseScriptJob(sj);
            // 目前正在执行脚本的数量减1
            sentinel.running_scripts--;
        }
    }
}

3.3.4 杀死超时脚本

Sentinel规定一个脚本最多执行60s,如果执行超时,则会杀死正在执行的脚本。

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;
        // 如果当前脚本正在执行且执行,且脚本执行的时间超过60s
        if (sj->flags & SENTINEL_SCRIPT_RUNNING &&
            (now - sj->start_time) > SENTINEL_SCRIPT_MAX_RUNTIME)
        {   // 发送脚本超时的事件
            sentinelEvent(LL_WARNING,"-script-timeout",NULL,"%s %ld",
                sj->argv[0], (long)sj->pid);
            // 杀死执行脚本的子进程
            kill(sj->pid,SIGKILL);
        }
    }
}

3.4 脑裂

Redis的官方Sentinel文档中给出了一种关于脑裂的场景。

+----+         +----+
| M1 |---------| R1 |
| S1 |         | S2 |
+----+         +----+

Configuration: quorum = 1
// M1是主节点
// R1是从节点
// S1、S2是哨兵节点

在此种情况中,如果主节点M1出现故障,那么R1将被晋升为主节点,因为两个Sentinel节点可以就配置的quorum = 1达成一致,并且会执行故障转移操作。如下图所示:

+----+           +------+
| M1 |----//-----| [M1] |
| S1 |           | S2   |
+----+           +------+

如果执行了故障转移之后,就会完全以对称的方式创建了两个主节点。客户端可能会不明确的写入数据到两个主节点,这就可能造成很多严重的后果,例如:争抢服务器的资源,争抢应用服务,数据损坏等等。

因此,最好不要进行这样的部署。

在哨兵模式的主函数sentinelTimer(),为了防止这样的部署造成的一些后果,所以每次执行后都会更改服务器的周期任务执行频率,如下所述:

server.hz = CONFIG_DEFAULT_HZ + rand() % CONFIG_DEFAULT_HZ;

不断改变Redis定期任务的执行频率,以便使每个Sentinel节点都不同步,这种不确定性可以避免Sentinel在同一时间开始完全继续保持同步,当被要求进行投票时,一次又一次在同一时间进行投票,因为脑裂导致有可能没有胜选者。

4. 哨兵的使命

sentinel.c文件详细注释:Redis Sentinel详细注释

该部分在Redis Sentinel实现(下)中单独剖析。

www.htsjk.Com true http://www.htsjk.com/redis/28982.html NewsArticle Redis源码剖析和注释(二十三)--,redis源码剖析-- Redis Sentinel实现(上) 1. Redis Sentinel 介绍和部署 请参考Redis Sentinel 介绍与部署 sentinel.c 文件详细注释:Redis Sentinel详细注释 本文会分为...
相关文章
    暂无相关文章
评论暂时关闭