Redis源码剖析和注释(二十三)--- Redis Sentinel实现(哨兵的执行过程和执行的内容)

来源:互联网 发布:在线剪辑音乐软件 编辑:程序博客网 时间:2024/06/05 21:09

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 a4dce0267abdb89f7422c9a42960e6cb6e4d565a12775: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实现(下)中单独剖析。

阅读全文
0 0
原创粉丝点击