Redis源碼剖析和註釋(二十三)--- Redis Sentinel實現(哨兵的執行過程和執行的內容)

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實現(下)中詳細剖析。

  1. 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_SENTINEL,master一定不爲空並且該實例被添加到master->slaves或master->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

至此,就正式啓動了哨兵節點。我們用圖片的方式來描述一下一個哨兵節點監控兩個主節點的情況:

  1. 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在同一時間開始完全繼續保持同步,當被要求進行投票時,一次又一次在同一時間進行投票,因爲腦裂導致有可能沒有勝選者。

  1. 哨兵的使命
    sentinel.c文件詳細註釋:Redis Sentinel詳細註釋

該部分在Redis Sentinel實現(下)中單獨剖析。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章