Redis Cluster 集羣伸縮原理源碼剖析
1. Redis 集羣伸縮教程
Redis
提供了靈活的節點擴容和收縮方案。在不影響集羣對外服務的情況下,可以爲集羣添加節點進行擴容也可以對下線節點進行縮容。
如何進行Redis Cluster
的伸縮,請參考 Redis Cluster 集羣擴容與收縮 本篇教程。本文詳細分別使用手動命令和redis-trib.rb
工具來執行Redis
集羣的擴容和收縮操作。
本篇文章根據Redis
源碼深入剖析集羣伸縮的原理。Redis Cluster文件詳細註釋
2. 集羣擴容原理剖析
集羣擴容的步驟如下:
- 準備新節點
- 加入集羣
- 遷移槽和數據
我們根據步驟一步一步分析。
2.1 準備新節點
本步驟就是準備新的節點和配置啓動文件。具體參考 Redis Cluster 載入配置文件、節點握手、分配槽源碼剖析 一文的載入配置一部分,該部分從Redis
服務器的main
函數開始分析,一直到服務器啓動成功。
2.2 加入集羣
將新節點加入集羣,就是發送CLUSTER MEET
命令,將準備的新節點加入到已搭建好的集羣,讓所有的集羣中的節點“認識”新的節點。
可以參考 Redis Cluster 載入配置文件、節點握手、分配槽源碼剖析 一文中的節點握手部分,該部分根據源碼分析了節點握手的三過程:發送MEET
消息,回覆PONG
消息,發送PING
消息,最後講解了Gossip
協議在Redis
中是如何使用的。
2.3 遷移槽和數據
集羣擴容的前兩步和搭建集羣很像,最後一步則是將集羣節點中的槽和數據遷移到新的節點中,而不是爲新的節點分配槽位。因此我們重點分析這一過程。Redis Cluster文件詳細註釋
將源節點的槽位和數據遷移到目標節點中,遷移單個槽步驟如下:
- 對目標節點發送
CLUSTER SETSLOT <slot> importing <source_name>
,在目標節點中將<slot>
設置爲導入狀態(importing)。 - 對源節點發送
CLUSTER SETSLOT <slot> migrating <target_name>
,在源節點中將<slot>
設置爲導出狀態(migrating)。 - 對源節點發送
CLUSTER GETKEYSINSLOT <slot> <count>
命令,獲取<count>
個屬於<slot>
的鍵,這些鍵要發送給目標節點。 - 對於第三步獲得的每個鍵,發送
MIGRATE host port "" dbid timeout [COPY | REPLACE] KEYS key1 key2 ... keyN
命令,將選中的鍵從源節點遷移到目標節點。
- 在
Redis 3.0.7
以後的版本支持了MIGRATE
命令的批量遷移操作。 - 如果不支持批量遷移,那麼會發送
MIGRATE host port key dbid timeout [COPY | REPLACE]
命令將一個鍵從源節點遷移到目標節點。 - 當一次無法遷移完成時,會循環執行第三步和第四步,直到
<count>
個鍵全部遷移完成。
- 在
- 向集羣中的任意節點發送
CLUSTER SETSLOT <slot> node <target_name>
,將<slot>
指派給目標節點。指派信息會通過消息發送到整個集羣中,然後最終所有的節點都會知道<slot>
已經指派給了目標節點。
我們就根據這些步驟逐步分析:
2.3.1 目標節點中,將槽設置爲導入狀態
客戶端連接上目標節點,併發送該命令給目標節點服務器,目標節點會調用clusterCommand()
函數來執行該命令,該函數是一個通用函數,能用於執行CLUSTER
開頭的所有命令。該函數會判斷SETSLOT
選項,但是SETSLOT
選項對應4種不同的函數分別是:
SETSLOT 10 MIGRATING <node ID> //設置10號槽處於MIGRATING狀態,遷移到<node ID>指定的節點
SETSLOT 10 IMPORTING <node ID> //設置10號槽處於IMPORTING狀態,將<node ID>指定的節點的槽導入到myself中
SETSLOT 10 STABLE //取消10號槽的MIGRATING/IMPORTING狀態
SETSLOT 10 NODE <node ID> //將10號槽綁定到NODE節點上
在SETSLOT
選項中先會判斷myself
節點是否爲主節點,如果是從節點則直接返回,然後獲取指定的槽號。
int slot;
clusterNode *n;
// 如果myself節點是從節點,回覆錯誤信息
if (nodeIsSlave(myself)) {
addReplyError(c,"Please use SETSLOT only with masters.");
return;
}
// 獲取槽號
if ((slot = getSlotOrReply(c,c->argv[2])) == -1) return;
本小節主要看CLUSTER SETSLOT <slot> importing <source_name>
命令,在目標節點中,將槽設置爲導入狀態,處理importing
狀態的代碼如下:
if (!strcasecmp(c->argv[3]->ptr,"importing") && c->argc == 5) {
// 如果該槽已經是myself節點負責,那麼不進行導入
if (server.cluster->slots[slot] == myself) {
addReplyErrorFormat(c,"I'm already the owner of hash slot %u",slot);
return;
}
// 獲取導入的目標節點
if ((n = clusterLookupNode(c->argv[4]->ptr)) == NULL) {
addReplyErrorFormat(c,"I don't know about node %s",(char*)c->argv[3]->ptr);
return;
}
// 爲該槽設置導入目標
server.cluster->importing_slots_from[slot] = n;
// 更新集羣狀態和保存配置
clusterDoBeforeSleep(CLUSTER_TODO_SAVE_CONFIG|CLUSTER_TODO_UPDATE_STATE);
addReply(c,shared.ok);
先判斷該槽位是否已經是目標節點所負責的,如果是則不需要進行導入,否則繼續調用clusterLookupNode()
函數,根據<source_name>
在當前集羣中查找源節點,然後將服務器集羣狀態的importing_slots_from
對應的槽和導入的源節點做映射。當前執行該命令的節點是目標節點,因此在目標節點視角中的集羣,該槽已經處於導入狀態。
如果執行成功,則在進入下個週期之前更新集羣狀態和保存配置。
最後返回客戶端一個OK
。
2.3.2 源節點中,將槽設置爲導出狀態
對源節點發送CLUSTER SETSLOT <slot> migrating <target_name>
,將槽設置爲導出狀態,對應的代碼如下:
if (!strcasecmp(c->argv[3]->ptr,"migrating") && c->argc == 5) {
// 如果該槽不是myself主節點負責,那麼就不能進行遷移
if (server.cluster->slots[slot] != myself) {
addReplyErrorFormat(c,"I'm not the owner of hash slot %u",slot);
return;
}
// 獲取遷移的目標節點
if ((n = clusterLookupNode(c->argv[4]->ptr)) == NULL) {
addReplyErrorFormat(c,"I don't know about node %s",(char*)c->argv[4]->ptr);
return;
}
// 爲該槽設置遷移的目標
server.cluster->migrating_slots_to[slot] = n;
// 更新集羣狀態和保存配置
clusterDoBeforeSleep(CLUSTER_TODO_SAVE_CONFIG|CLUSTER_TODO_UPDATE_STATE);
addReply(c,shared.ok);
在源節點視角的集羣中,執行該命令。先會對該槽位的所有權進行判斷,如果不屬於源節點,那麼就無權進行遷移。然後獲取要遷移到的目標節點,調用clusterLookupNode()
函數查找目標節點。最後將服務器集羣狀態中的migrating_slots_to
對應的槽和導出的目標節點做映射關係。
如果執行成功,則在進入下個週期之前更新集羣狀態和保存配置。
最後返回客戶端一個OK
。
2.3.3 獲取遷移槽中的鍵
這一步要獲取遷移槽中的所有鍵,這些鍵是要從源節點發送到目標節點。因此對應的選項是getkeysinslot
,代碼如下:
if (!strcasecmp(c->argv[1]->ptr,"getkeysinslot") && c->argc == 4) {
/* CLUSTER GETKEYSINSLOT <slot> <count> */
long long maxkeys, slot;
unsigned int numkeys, j;
robj **keys;
// 獲取槽號
if (getLongLongFromObjectOrReply(c,c->argv[2],&slot,NULL) != C_OK)
return;
// 獲取打印鍵的個數
if (getLongLongFromObjectOrReply(c,c->argv[3],&maxkeys,NULL)
!= C_OK)
return;
// 判斷槽號和個數是否非法
if (slot < 0 || slot >= CLUSTER_SLOTS || maxkeys < 0) {
addReplyError(c,"Invalid slot or number of keys");
return;
}
// 分配保存鍵的空間
keys = zmalloc(sizeof(robj*)*maxkeys);
// 將count個鍵保存到數組中
numkeys = getKeysInSlot(slot, keys, maxkeys);
// 添加回復鍵的個數
addReplyMultiBulkLen(c,numkeys);
// 添加回復每一個鍵
for (j = 0; j < numkeys; j++) addReplyBulk(c,keys[j]);
zfree(keys);
}
該函數會先根據傳入的參數,獲取到對應的槽號slot
和要打印槽中鍵的個數maxkey
。然後判斷指定的槽號和鍵的個數是否合法。
然後會爲回覆創建一個數組,將這些鍵保存到該數組中。
接下來會調用getKeysInSlot()
函數,最多獲取maxkey
個鍵。
最後就是回覆客戶端,先回復獲取鍵的個數,然後回覆每個獲取的鍵。
Redis
集羣中的數據庫,不僅在鍵值對字典中保存了當前鍵值對,還會在zskiplist *slots_to_keys
中保存槽和鍵之間的關係。
當執行一個寫數據庫的命令時,就會調用dbAdd()
函數將鍵加入到鍵值對字典中,而該函數會判斷是否當前運行在集羣模式下,如果運行在集羣模式下則會調用slotToKeyAdd()
函數將槽作爲分值將鍵作爲成員,添加到slots_to_keys
跳躍表中。而剛纔調用的getKeysInSlot()
函數則是遍歷這個跳躍表,最多返回<count>
個槽中的鍵。
假設我們有這麼幾個鍵,他在跳躍表中的樣子如下圖所示:
- 槽
6666
有一個鍵,鍵的名字是key:number
。 - 槽
6918
有三個鍵,鍵的名字是key:{test}:555
、key:{test}:666
、和key:{test}:777
。
當我們通過CLUSTER GETKEYSINSLOT <slot> <count>
命令獲取到<slot>
中的鍵時,下一步就可以進行遷移了。
2.3.4 遷移槽中的鍵
MIGRATE
命令的完整形式如下:
MIGRATE host port "" dbid timeout [COPY | REPLACE] KEYS key1 key2 ... keyN
MIGRATE host port key dbid timeout [COPY | REPLACE]
在Redis 3.0.7
之後支持了第一個批量遷移的版本。Redis Cluster文件詳細註釋
我們將MIGRATE
的執行過程分爲五個部分詳細解釋。
- 解析參數和判斷參數合法性
void migrateCommand(client *c) {
migrateCachedSocket *cs;
int copy, replace, j;
long timeout;
long dbid;
robj **ov = NULL; /* Objects to migrate. */
robj **kv = NULL; /* Key names. */
robj **newargv = NULL; /* Used to rewrite the command as DEL ... keys ... */
rio cmd, payload;
int may_retry = 1;
int write_error = 0;
int argv_rewritten = 0;
int first_key = 3; /* Argument index of the first key. */
int num_keys = 1; /* By default only migrate the 'key' argument. */
copy = 0;
replace = 0;
// 解析附加項
for (j = 6; j < c->argc; j++) {
// copy項:不刪除源節點上的key
if (!strcasecmp(c->argv[j]->ptr,"copy")) {
copy = 1;
// replace項:替換目標節點上已存在的key
} else if (!strcasecmp(c->argv[j]->ptr,"replace")) {
replace = 1;
// keys項:指定多個遷移的鍵
} else if (!strcasecmp(c->argv[j]->ptr,"keys")) {
// 第三個參數必須是空字符串""
if (sdslen(c->argv[3]->ptr) != 0) {
addReplyError(c,
"When using MIGRATE KEYS option, the key argument"
" must be set to the empty string");
return;
}
// 指定要遷移的鍵,第一個鍵的下標
first_key = j+1;
// 鍵的個數
num_keys = c->argc - j - 1;
break; /* All the remaining args are keys. */
} else {
addReply(c,shared.syntaxerr);
return;
}
}
// 參數有效性檢查
if (getLongFromObjectOrReply(c,c->argv[5],&timeout,NULL) != C_OK ||
getLongFromObjectOrReply(c,c->argv[4],&dbid,NULL) != C_OK)
{
return;
}
if (timeout <= 0) timeout = 1000;
// 檢查key是否存在,至少有一個key要遷移,否則如果所有的key都不存在,回覆一個"NOKEY"通知調用者,沒有要遷移的鍵
ov = zrealloc(ov,sizeof(robj*)*num_keys);
kv = zrealloc(kv,sizeof(robj*)*num_keys);
int oi = 0;
// 遍歷所有指定的鍵
for (j = 0; j < num_keys; j++) {
// 以讀操作取出key的值對象,保存在ov中
if ((ov[oi] = lookupKeyRead(c->db,c->argv[first_key+j])) != NULL) {
// 將存在的key保存到kv中
kv[oi] = c->argv[first_key+j];
// 計數存在的鍵的個數
oi++;
}
}
num_keys = oi;
// 沒有鍵存在,遷移失敗,返回"+NOKEY"
if (num_keys == 0) {
zfree(ov); zfree(kv);
addReplySds(c,sdsnew("+NOKEY\r\n"));
return;
}
..................
該部分主要根據函數附加的選項,
- 如果指定了
copy
,則表示不刪除源節點上的key,並且設置copy = 1
標識。 - 如果指定了
replace
,則表示替換目標節點上已存在的key,並且設置replace = 1
標識。 - 如果指定了
keys
,則表示批量遷移,那麼需要判斷第四個參數是否是空字符串(”“),如果不是則直接返回。如果命令語法正確,則需要更新第一個指定鍵在參數列表中的下標first_key = j+1
,和指定了鍵的個數num_keys = c->argc - j - 1
。
解析完成後,則會判斷參數的合法性,如果所有參數都合法,那麼會將鍵值對分別保存到ov
和kv
數組中。如果所有指定的鍵都不在當前節點中,那麼會回覆客戶端一個+NOKEY
錯誤。
然後這些就是遷移鍵的準備工作,接下來就要進行遷移鍵。
- 發送遷移數據
try_again:
write_error = 0;
// 返回一個包含連接目標實例的TCP套接字的migrateCachedSocket結構,有可能返回一個緩存套接字
cs = migrateGetSocket(c,c->argv[1],c->argv[2],timeout);
if (cs == NULL) {
zfree(ov); zfree(kv);
return; /* error sent to the client by migrateGetSocket() */
}
// 初始化緩衝區對象cmd,用來構建SELECT命令
rioInitWithBuffer(&cmd,sdsempty());
// 創建一個SELECT命令,如果上一次要還原到的數據庫ID和這次的不相同
int select = cs->last_dbid != dbid; /* Should we emit SELECT? */
// 則需要創建一個SELECT命令
if (select) {
serverAssertWithInfo(c,NULL,rioWriteBulkCount(&cmd,'*',2));
serverAssertWithInfo(c,NULL,rioWriteBulkString(&cmd,"SELECT",6));
serverAssertWithInfo(c,NULL,rioWriteBulkLongLong(&cmd,dbid));
}
// 將所有的鍵值對進行加工
for (j = 0; j < num_keys; j++) {
long long ttl = 0;
// 獲取當前key的過期時間
long long expireat = getExpire(c->db,kv[j]);
if (expireat != -1) {
// 計算key的生存時間
ttl = expireat-mstime();
if (ttl < 1) ttl = 1;
}
// 以"*<count>\r\n"格式爲寫如一個int整型的count
// 如果指定了replace,則count值爲5,否則爲4
// 寫回復的個數
serverAssertWithInfo(c,NULL,rioWriteBulkCount(&cmd,'*',replace ? 5 : 4));
// 如果運行在進羣模式下,寫回復一個"RESTORE-ASKING"
if (server.cluster_enabled)
serverAssertWithInfo(c,NULL,rioWriteBulkString(&cmd,"RESTORE-ASKING",14));
// 如果不是集羣模式下,則寫回復一個"RESTORE"
else
serverAssertWithInfo(c,NULL,rioWriteBulkString(&cmd,"RESTORE",7));
// 檢測鍵對象的編碼
serverAssertWithInfo(c,NULL,sdsEncodedObject(kv[j]));
// 寫回復一個鍵
serverAssertWithInfo(c,NULL,rioWriteBulkString(&cmd,kv[j]->ptr,sdslen(kv[j]->ptr)));
// 寫回復一個鍵的生存時間
serverAssertWithInfo(c,NULL,rioWriteBulkLongLong(&cmd,ttl));
// 將值對象序列化
createDumpPayload(&payload,ov[j]);
// 將序列化的值對象寫到回覆中
serverAssertWithInfo(c,NULL,
rioWriteBulkString(&cmd,payload.io.buffer.ptr,sdslen(payload.io.buffer.ptr)));
sdsfree(payload.io.buffer.ptr);
// 如果指定了replace,還要寫回復一個REPLACE選項
if (replace)
serverAssertWithInfo(c,NULL,rioWriteBulkString(&cmd,"REPLACE",7));
}
errno = 0;
{
sds buf = cmd.io.buffer.ptr;
size_t pos = 0, towrite;
int nwritten = 0;
// 將rio緩衝區的數據寫到TCP套接字中,同步寫,如果超過timeout時間,則返回錯誤
while ((towrite = sdslen(buf)-pos) > 0) {
// 一次寫64k大小的數據
towrite = (towrite > (64*1024) ? (64*1024) : towrite);
nwritten = syncWrite(cs->fd,buf+pos,towrite,timeout);
if (nwritten != (signed)towrite) {
write_error = 1;
goto socket_err;
}
// 記錄已寫的大小
pos += nwritten;
}
}
........................
要進行遷移鍵,必須在源節點和目標節點之間建立TCP
連接。爲了避免頻繁的創建釋放連接,因此在服務器中的server.migrate_cached_sockets
字典中緩存了最近的十個連接。該字典的鍵是host:ip
,字典的值是一個指針,指向migrateCachedSocket
結構。該結構定義如下:
// 最大的緩存數
#define MIGRATE_SOCKET_CACHE_ITEMS 64 /* max num of items in the cache. */
// 緩存連接的生存時間10s
#define MIGRATE_SOCKET_CACHE_TTL 10 /* close cached sockets after 10 sec. */
typedef struct migrateCachedSocket {
// TCP套接字
int fd;
// 上一次還原鍵的數據庫ID
long last_dbid;
// 上一次使用的時間
time_t last_use_time;
} migrateCachedSocket;
在調用一個migrateGetSocket()
函數之前首先會在migrate_cached_sockets
字典中尋找,如果沒找到則新創建一個連接,並且加入到緩存字典中,返回一個遷移連接。
然後又初始化一個緩衝區對象cmd
,用來做命令的緩存,首先得構建SELECT
命令,以防遷移到錯誤的數據庫。
接下來,就要遍歷每一個要發送的鍵,對於每一個鍵都要做以下操作,並且都是以Redis
通信協議的方式。
- 先獲取鍵的過期時間,然後計算出鍵的生存時間。
- 將一個整數值寫到緩衝區對象
cmd
中,該整數表示之後會解析多少項。如果指定了replace
,那麼整數值是5
,否則是4
。 - 如果當前在集羣模式下,那麼會將
RESTORE-ASKING
命令寫到緩衝區對象cmd
中,如果在普通的節點之間遷移,則寫RESTORE
命令。這兩個命令都是用來反序列化的。 - 檢測鍵名的對象編碼類型是否是字符串類型的編碼。
- 將當前遍歷的鍵,寫到緩衝區對象
cmd
中。 - 再將該鍵的生存時間,也寫到緩衝區對象
cmd
中。 - 調用
createDumpPayload()
函數將該鍵對應的值對象進行序列化,並且保存到另一個緩衝區對象payload
中,然後將payload
中的緩存寫到緩衝區對象cmd
中。
- 序列化格式如下:| 1 bytes type | obj | 2 bytes RDB version | 8 bytes CRC64 |
- 最後,如果指定了
replace
,則還需要多寫一個REPLACE
。
對於每一個鍵,經過以上操作,都會構建一個RESTORE
或RESTORE-ASKING
命令,這兩個命令都是用來進行反序列化序列的,這兩個命令唯一不同就是後者會指定一個CMD_ASKING
標識。在命令表中如下:
{"restore",restoreCommand,-4,"wm",0,NULL,1,1,1,0,0},
{"restore-asking",restoreCommand,-4,"wmk",0,NULL,1,1,1,0,0}, // 多指定了一個k
{"asking",askingCommand,1,"F",0,NULL,0,0,0,0,0},
// k:爲該命令執行一個隱式的 ASKING 命令,所以在集羣模式下,如果槽被標記爲'importing',那這個命令會被接收。
這兩個命令都調用restoreCommand
命令執行,因此參數上面的每一步操作都會對應這兩個命令的每一個參數,如下:
RESTORE key ttl serialized-value [REPLACE]
當構建好遷移的數據後,就要發送給目標節點,調用syncWrite()
函數同步將cmd
的緩存寫到連接的fd
中,設置超時時間爲timeout
,每次寫64K
大小。如果發生寫錯誤,會設置write_error = 1
並且跳轉到socket_err
的錯誤處理代碼。
- 目標節點執行反序列化命令並回復
當將構建好的反序列化命令發送給目標節點後,目標節點會執行後並回饋給源節點一些信息,源節點根據這些信息來信息判斷目標節點是否執行成功。
首先會執行SELECT
命令,進行切換數據庫,以防發生錯誤。
然後目標節點接收到源節點發送過來的RESTORE
或者restore-asking
命令後,會調用restoreCommand()
函數執行命令。該函數代碼如下:
void restoreCommand(client *c) {
long long ttl;
rio payload;
int j, type, replace = 0;
robj *obj;
// 解析REPLACE選項,如果指定了該選項,設置replace標識
for (j = 4; j < c->argc; j++) {
if (!strcasecmp(c->argv[j]->ptr,"replace")) {
replace = 1;
} else {
addReply(c,shared.syntaxerr);
return;
}
}
// 如果沒有指定替換標識,但是鍵存在,回覆一個錯誤
if (!replace && lookupKeyWrite(c->db,c->argv[1]) != NULL) {
addReply(c,shared.busykeyerr);
return;
}
// 獲取生存時間
if (getLongLongFromObjectOrReply(c,c->argv[2],&ttl,NULL) != C_OK) {
return;
} else if (ttl < 0) {
addReplyError(c,"Invalid TTL value, must be >= 0");
return;
}
// 驗證RDB版本和數據校驗和
if (verifyDumpPayload(c->argv[3]->ptr,sdslen(c->argv[3]->ptr)) == C_ERR)
{
addReplyError(c,"DUMP payload version or checksum are wrong");
return;
}
// 初始化緩衝區對象payload並設置緩衝區的地址,讀出了序列化數據到緩衝區中
rioInitWithBuffer(&payload,c->argv[3]->ptr);
// 類型錯誤
if (((type = rdbLoadObjectType(&payload)) == -1) ||
((obj = rdbLoadObject(type,&payload)) == NULL))
{
addReplyError(c,"Bad data format");
return;
}
// 如果指定了替代的標識,那麼刪除舊的鍵
if (replace) dbDelete(c->db,c->argv[1]);
// 添加鍵值對到數據庫中
dbAdd(c->db,c->argv[1],obj);
// 設置生存時間
if (ttl) setExpire(c->db,c->argv[1],mstime()+ttl);
signalModifiedKey(c->db,c->argv[1]);
addReply(c,shared.ok);
server.dirty++;
}
該函數首先會解析是否指定了REPLACE
選項,如果指定了,那麼設置replace = 1
,否則回覆一個-ERR
。
但是如果沒有指定REPLACE
選項,但是在數據庫中卻存在該鍵,那麼回覆-BUSYKEY
。
從參數中獲取生存時間ttl
,如果生存時間小於0
,則回覆一個錯誤。
然後,調用verifyDumpPayload()
函數從序列化的數據中驗證RDB
版本和校驗和,如果出錯則回覆一個錯誤。
接下來將序列化數據保存到緩衝區對象payload
中,然後根據序列化的格式,讀出鍵的值對象的編碼類型,然後根據編碼類型在讀出值對象。
如果指定了replace
標識,那麼會調用dbDelete()
將該將從數據庫中刪除。
如果獲取了生存時間ttl
,調用setExpire()
函數設置該鍵的生存時間。
最後執行成功,發送修改鍵的信號,更新髒鍵,並且回覆一個+OK
。
當命令執行完成後,目標節點將回復發送給源節點,源節點根據回覆來判斷是否執行成功。
- 讀取發送過去的命令回覆
// 讀取命令的回覆
char buf1[1024]; /* Select reply. */
char buf2[1024]; /* Restore reply. */
// 如果指定了select,讀取該命令的回覆
if (select && syncReadLine(cs->fd, buf1, sizeof(buf1), timeout) <= 0)
goto socket_err;
// 讀RESTORE的回覆
int error_from_target = 0;
int socket_error = 0;
int del_idx = 1; /* Index of the key argument for the replicated DEL op. */
// 沒有指定copy選項,分配一個新的參數列表空間
if (!copy) newargv = zmalloc(sizeof(robj*)*(num_keys+1));
// 讀取每一個鍵的回覆
for (j = 0; j < num_keys; j++) {
// 同步讀取每一個鍵的回覆,超時timeout
if (syncReadLine(cs->fd, buf2, sizeof(buf2), timeout) <= 0) {
socket_error = 1;
break;
}
// 如果指定了select,檢查select的回覆
if ((select && buf1[0] == '-') || buf2[0] == '-') {
// 如果select回覆錯誤,那麼last_dbid就是無效的了
if (!error_from_target) {
cs->last_dbid = -1;
addReplyErrorFormat(c,"Target instance replied with error: %s",
(select && buf1[0] == '-') ? buf1+1 : buf2+1);
error_from_target = 1;
}
} else {
// 沒有指定copy選項,要刪除源節點的鍵
if (!copy) {
// 刪除源節點的鍵
dbDelete(c->db,kv[j]);
// 發送信號
signalModifiedKey(c->db,kv[j]);
// 更新髒鍵個數
server.dirty++;
// 設置刪除鍵的列表
newargv[del_idx++] = kv[j];
incrRefCount(kv[j]);
}
}
}
........................
先調用syncReadLine()
讀一行回覆,也就是SELECT
命令的回覆,保存到buf1
中。
然後循環讀取每一個鍵執行的回覆,保存在buf2
中,如果buf1
或者buf2
的第一個字符是'-'
,表示目標節點執行RESTORE
命令發生了錯誤,那麼需要重置last_dbid
,因爲緩存的已經失效。
如果回覆的成功執行的信息。呢麼根據是否指定了copy
,如果沒有指定,表示要刪除源節點的鍵。那麼調用dbDelete()
函數將該鍵從源節點的數據庫中刪除,並且要將刪除的鍵保存到newargv
參數列表中。
- 錯誤處理
執行以上代碼時,可能會發生錯誤,有些錯誤無法挽回,直接返回,有些錯誤則可以執行重試操作。代碼如下:
// 套接字錯誤,第一個鍵就錯誤,可以進行重試
if (!error_from_target && socket_error && j == 0 && may_retry &&
errno != ETIMEDOUT)
{
goto socket_err; /* A retry is guaranteed because of tested conditions.*/
}
// 套接字錯誤,關閉遷移連接
if (socket_error) migrateCloseSocket(c->argv[1],c->argv[2]);
// 沒有指定copy選項
if (!copy) {
// 如果刪除了鍵
if (del_idx > 1) {
// 創建一個DEL命令,用來發送到AOF和從節點中
newargv[0] = createStringObject("DEL",3);
// 用指定的newargv參數列表替代client的參數列表
replaceClientCommandVector(c,del_idx,newargv);
argv_rewritten = 1;
} else {
zfree(newargv);
}
newargv = NULL; /* Make it safe to call zfree() on it in the future. */
}
// 執行到這裏,如果還沒有跳到socket_err,那麼關閉重試的標誌,跳轉到socket_err
if (!error_from_target && socket_error) {
may_retry = 0;
goto socket_err;
}
// 不是目標節點的回覆錯誤
if (!error_from_target) {
// 更新最近一次使用的數據庫ID
cs->last_dbid = dbid;
addReply(c,shared.ok);
} else {
/* On error we already sent it in the for loop above, and set
* the curretly selected socket to -1 to force SELECT the next time. */
}
// 釋放空間
sdsfree(cmd.io.buffer.ptr);
zfree(ov); zfree(kv); zfree(newargv);
return;
socket_err:
sdsfree(cmd.io.buffer.ptr);
// 如果沒有重寫client參數列表,關閉連接,因爲要保持一致性
if (!argv_rewritten) migrateCloseSocket(c->argv[1],c->argv[2]);
zfree(newargv);
newargv = NULL; /* This will get reallocated on retry. */
// 如果可以重試,跳轉到try_again
if (errno != ETIMEDOUT && may_retry) {
may_retry = 0;
goto try_again;
}
zfree(ov); zfree(kv);
addReplySds(c,
sdscatprintf(sdsempty(),
"-IOERR error or timeout %s to target instance\r\n",
write_error ? "writing" : "reading"));
return;
}
如果出現了套接字錯誤,第一個鍵就錯誤,可以進行重試,跳轉到socket_err
代碼。
如果出現了套接字錯誤,關閉遷移連接。
如果沒有指定copy
選項,並且進行了刪除鍵的操作,那麼要調用replaceClientCommandVector()
函數,將客戶端的參數列表替換爲DEL
命令,因爲執行了刪除鍵的操作,要傳播到從節點和AOF
文件中。並且設置重寫參數列表的標識argv_rewritten = 1
。如果以上條件都不滿足,則會關閉重試執行的標識,跳轉到socket_err
代碼。
如果不是目標節點的回覆的錯誤,則可以更新緩存的上一次使用的數據庫id
。
在socket_err
代碼部分,如果沒有重寫客戶端的參數列表,要關閉遷移連接,因爲要保持一致性。如果可以重試,那麼跳轉到try_again
發送遷移數據那一步,進行重新嘗試執行MIGRATE
命令。
如果都無法重試,最後就會回覆一個-IOERR
錯誤。
到此源節點執行MIGRATE
命令,就執行完成。也就是說槽中的數據已經遷移完成,下一步執行CLUSTER SETSLOT <slot> node <target_name>
命令將槽位在指派給目標節點,就大功告成。
2.3.5 遷移槽位
執行玩MIGRATE
命令將槽中的所有鍵遷移完成後,最後只要將槽位指派給目標節點就完成整個遷移操作。Redis Cluster文件詳細註釋
在任意節點執行CLUSTER SETSLOT <slot> NODE <target_name>
命令,最後都會將該信息通過消息發送給每一個集羣節點,但是redis-trib.rb
工具選擇給每一個主節點發送該命令,這樣可以阻止槽位和錯誤節點相關聯,可以更少的重定向就能找到正確的節點。處理該命令對應的代碼如下:
if (!strcasecmp(c->argv[3]->ptr,"node") && c->argc == 5) {
/* CLUSTER SETSLOT <SLOT> NODE <NODE ID> */
// 查找到目標節點
clusterNode *n = clusterLookupNode(c->argv[4]->ptr);
// 目標節點不存在,回覆錯誤信息
if (!n) {
addReplyErrorFormat(c,"Unknown node %s",(char*)c->argv[4]->ptr);
return;
}
// 如果這個槽已經由myself節點負責,但是目標節點不是myself節點
if (server.cluster->slots[slot] == myself && n != myself) {
// 保證該槽中沒有鍵,否則不能指定給其他節點
if (countKeysInSlot(slot) != 0) {
addReplyErrorFormat(c,
"Can't assign hashslot %d to a different node "
"while I still hold keys for this hash slot.", slot);
return;
}
}
// 該槽處於被遷移的狀態但是該槽中沒有鍵
if (countKeysInSlot(slot) == 0 && server.cluster->migrating_slots_to[slot])
// 取消遷移的狀態
server.cluster->migrating_slots_to[slot] = NULL;
// 如果該槽處於導入狀態,且目標節點是myself節點
if (n == myself &&
server.cluster->importing_slots_from[slot])
{
// 手動遷移該槽,將該節點的配置紀元設置爲一個新的紀元,以便集羣可以傳播新的版本。
// 注意,如果這導致與另一個獲得相同配置紀元的節點衝突,例如因爲取消槽的同時發生執行故障轉移的操作,則配置紀元衝突的解決將修復它,指定不同節點有一個不同的紀元。
if (clusterBumpConfigEpochWithoutConsensus() == C_OK) {
serverLog(LL_WARNING,"configEpoch updated after importing slot %d", slot);
}
// 取消槽的導入狀態
server.cluster->importing_slots_from[slot] = NULL;
}
clusterDelSlot(slot);
// 將slot槽指定給n節點
clusterAddSlot(n,slot);
} else {
addReplyError(c,"Invalid CLUSTER SETSLOT action or number of arguments");
return;
}
// 更新集羣狀態和保存配置
clusterDoBeforeSleep(CLUSTER_TODO_SAVE_CONFIG|CLUSTER_TODO_UPDATE_STATE);
addReply(c,shared.ok);
首先會根據<target_name>
參數在集羣中查找目標節點,如果目標節點不在集羣中,那麼回覆錯誤信息,直接返回。
如果是在源節點上執行該命令,那麼如果指定的<slot>
是源節點負責,並且源節點不等於目標節點,那麼需要判斷槽位中是否還有鍵沒有遷移,如果槽中還有鍵存在,則回覆錯誤信息,直接返回。
無論在什麼節點上執行該命令,如果判斷到指定的<slot>
已經空了,並且該槽處於導出狀態,都要取消導出的狀態。
如果是在目標節點上執行該命令,則且該槽處於導入狀態,那麼會調用clusterBumpConfigEpochWithoutConsensus()
函數來設置目標節點的紀元來解決不同節點的配置紀元configEpoch
可能發生衝突的情況。然後目標節點取消該槽位的導入狀態。
關於紀元的講解可以參考@gqtcgq的文章(http://blog.csdn.net/gqtcgq/article/details/51830428)
最後更新集羣中的槽位狀態,將指定的<slot>
槽位分配給目標節點。然後回覆客戶端一個OK
表示執行成功。
這樣,整個集羣擴容的過程就完成了。
3 集羣收縮的原理
集羣收縮的過程就是現將下線的節點中的所有槽和數據遷移到目標節點中,然後通過CLUSTER FORGET <NODE ID>
命令來讓集羣中所有的節點都知道下線的節點,並且忘記他。但是建議使用redis-trib.rb
工具的del-node
來收縮集羣,防止頻繁執行CLUSTER FORGET
命令。Redis Cluster文件詳細註釋
CLUSTER FORGET <NODE ID>
命令的原理就是讓下線的節點在某段時間內取消與所有集羣節點的交互行爲。具體代碼如下:
if (!strcasecmp(c->argv[1]->ptr,"forget") && c->argc == 3) {
// 根據<NODE ID>查找節點ilil
clusterNode *n = clusterLookupNode(c->argv[2]->ptr);
// 沒找到
if (!n) {
addReplyErrorFormat(c,"Unknown node %s", (char*)c->argv[2]->ptr);
return;
// 不能刪除myself
} else if (n == myself) {
addReplyError(c,"I tried hard but I can't forget myself...");
return;
// 如果myself是從節點,且myself節點的主節點是被刪除的目標鍵,回覆錯誤信息
} else if (nodeIsSlave(myself) && myself->slaveof == n) {
addReplyError(c,"Can't forget my master!");
return;
}
// 將n添加到黑名單中
clusterBlacklistAddNode(n);
// 從集羣中刪除該節點
clusterDelNode(n);
// 更新狀態和保存配置
clusterDoBeforeSleep(CLUSTER_TODO_UPDATE_STATE|
CLUSTER_TODO_SAVE_CONFIG);
addReply(c,shared.ok);
}
該函數先根據指定的下線節點的ID
在集羣中查找該節點,如果不能找到則回覆錯誤信息。
如果指點的節點是當前myself
節點,回覆錯誤信息,直接返回。
如果當前myself
節點是從節點,但是要忘記的節點是myself
節點的主節點,回覆錯誤信息,返回。
調用clusterBlacklistAddNode()
函數將要下線的節點加入黑名單中,這個黑名單是一個字典,保存要下線的節點名字。
然後將下線節點從集羣中刪除,並回復一個ok
給客戶端。
我們來關注一下下線節點黑名單。clusterBlacklistAddNode()
函數代碼如下:
void clusterBlacklistAddNode(clusterNode *node) {
dictEntry *de;
// 獲取node的ID
sds id = sdsnewlen(node->name,CLUSTER_NAMELEN);
// 先清理黑名單中過期的節點
clusterBlacklistCleanup();
// 然後將node添加到黑名單中
if (dictAdd(server.cluster->nodes_black_list,id,NULL) == DICT_OK) {
// 如果添加成功,創建一個id的複製品,以便能夠在最後free
id = sdsdup(id);
}
// 找到指定id的節點
de = dictFind(server.cluster->nodes_black_list,id);
// 爲其設置過期時間
dictSetUnsignedIntegerVal(de,time(NULL)+CLUSTER_BLACKLIST_TTL);
sdsfree(id);
}
黑名單中的節點生存時間只有60s
,每次加入節點都會先清理過期的節點,然後加入新的節點並且設置過期時間。加入黑名單中節點不會與其他節點進行消息交互,如果超過60s
,該節點就會從黑名單中清理,再次發送消息時,就會重新進行握手,最終重新上線。因此只有60s
的時間讓所有集羣節點忘記下線節點。