Redis Sentinel實現(上)
- 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實現(下)中詳細剖析。
- 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
至此,就正式啓動了哨兵節點。我們用圖片的方式來描述一下一個哨兵節點監控兩個主節點的情況:
- 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在同一時間開始完全繼續保持同步,當被要求進行投票時,一次又一次在同一時間進行投票,因爲腦裂導致有可能沒有勝選者。
- 哨兵的使命
sentinel.c文件詳細註釋:Redis Sentinel詳細註釋
該部分在Redis Sentinel實現(下)中單獨剖析。