글을 작성하게 된 계기
레디스 클러스터의 리밸런싱(REBALANCING) 에서 글로벌 락이 필요하지 않은 것을 알게 되었고, 이를 정리하기 위해 글을 작성하게 되었습니다.
1. 레디스 클러스터의 리밸런싱과 락
레디스 클러스터 환경에서는 리밸런싱 작업을 수행할 때, 글로벌 락이 필요하지 않습니다. 이는 레디스가 해시 슬롯 기반으로 데이터를 분산 관리하고, 슬롯마다 소유자와 상태를 명확히 구분하기 때문입니다. 모든 데이터는 클러스터 내에 정의된 16,384개의 해시 슬롯 중 하나에 속하게 되며, 각 노드는 슬롯들의 일부를 책임 집니다. 리밸런싱이 시작되면, CLUSTER SETSLOT 명령을 사용해 특정 슬롯의 상태를 명시적으로 변경합니다.
글로벌 락 대신 상태 기반 분산 제어, 점진적이고 원자적인 키 이전, 클라이언트 리다이렉션 과 같은 메커니즘을 사용합니다.
상태 기반 분산 제어: MIGRATING과 IMPORTING이라는 슬롯 상태를 클러스터 전체에 공유해, 특정 슬롯이 이동 중임을 전파 합니다.점진적이고 원자적 키 이전: MIGRATE 명령을 통해 키를 하나씩 안전하고 원자적으로 이전합니다.클라이언트 리디렉션: MOVED와 ASK 응답을 통해 클라이언트가 리밸런싱 중에도 올바른 노드를 찾도록 해서 서비스 연속성을 확보합니다.
레디스 클러스터에서 슬롯 이동 시, 소스 노드는 MIGRATING, 목적지 노드는 IMPORTING 상태로 설정되어 클러스터 전체에 해당 슬롯이 이동 중임을 알립니다. 실제 키 이동은 MIGRATE 명령어를 통해 이루어지며, 소스 노드는 키를 직렬화(DUMP)한 뒤 네트워크로 전송하고, 목적지 노드가 이를 복원(RESTORE)한 후 소스 노드는 키를 삭제(DEL)하는 과정을 원자적으로 수행합니다.
즉, 레디스는 MIGRATE 명령이 실행될 때 특정 키 하나에 대해서만 접근 제어를 하며, 전체 노드나 클러스터에는 락을 걸지 않습니다. 이로 인해 리밸런싱 중에도 다른 슬롯의 데이터는 정상적으로 읽고 쓸 수 있어 전체 서비스에 영향이 없습니다. 클라이언트는 전환 중 -MOVED 또는 -ASK 응답을 받아 새로운 노드로 자동 안내되며, -MOVED는 슬롯 이동이 완료된 경우, -ASK는 이동 중 임시로 지정된 노드로의 요청을 의미합니다.
2. 리밸런싱 소스 코드 살펴보기
레디스 리밸런싱 과정에서 키를 다른 노드로 이동하고 조회하는 함수를 살펴보겠습니다. 여담이지만 소스 코드를 보는 동안, 락을 거는 부분이 분명히 등장할 것 같아서 기대했기 때문에 조금 아쉬웠습니다. 👀
migrateCommand: 클러스터에서 특정 키를 다른 노드로 이동시키는 명령을 처리합니다.getNodeByQuery: 클러스터 내에서 특정 키에 대한 요청을 처리할 노드를 결정합니다.
2-1. migrateCommand
migrateCommand 함수는 Redis 클러스터에서 특정 키들을 다른 노드로 전송할 때 사용됩니다. 내부에서는 키를 직렬화한 뒤, 대상 노드에 RESTORE 또는 클러스터 환경에서는 RESTORE-ASKING 명령을 전송해 데이터를 복원합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
void migrateCommand(client *c) {
......
/* Create RESTORE payload and generate the protocol to call the command. */
for (j = 0; j < num_keys; j++) {
......
if (server.cluster_enabled)
serverAssertWithInfo(c,NULL,rioWriteBulkString(&cmd,"RESTORE-ASKING",14));
else
serverAssertWithInfo(c,NULL,rioWriteBulkString(&cmd,"RESTORE",7));
serverAssertWithInfo(c,NULL,sdsEncodedObject(keyArray[j]));
serverAssertWithInfo(c,NULL,rioWriteBulkString(&cmd,keyArray[j]->ptr,sdslen(keyArray[j]->ptr)));
serverAssertWithInfo(c,NULL,rioWriteBulkLongLong(&cmd,ttl));
// 키 값을 DUMP 포맷으로 직렬화
createDumpPayload(&payload,kvArray[j],keyArray[j],dbid);
// 대상 노드에 전송할 명령어 작성
serverAssertWithInfo(c,NULL,rioWriteBulkString(&cmd, payload.io.buffer.ptr, sdslen(payload.io.buffer.ptr)));
sdsfree(payload.io.buffer.ptr);
if (replace)
serverAssertWithInfo(c,NULL,rioWriteBulkString(&cmd,"REPLACE",7));
}
......
{
sds buf = cmd.io.buffer.ptr;
size_t pos = 0, towrite;
int nwritten = 0;
while ((towrite = sdslen(buf)-pos) > 0) {
towrite = (towrite > (64*1024) ? (64*1024) : towrite);
// 소켓을 통해 직렬화된 명령어를 전송
nwritten = connSyncWrite(cs->conn,buf+pos,towrite,timeout);
if (nwritten != (signed)towrite) {
write_error = 1;
goto socket_err;
}
pos += nwritten;
}
}
......
for (j = 0; j < num_keys; j++) {
if (connSyncReadLine(cs->conn, buf2, sizeof(buf2), timeout) <= 0) {
socket_error = 1;
break;
}
if ((password && buf0[0] == '-') ||
(select && buf1[0] == '-') ||
buf2[0] == '-')
{
if (!error_from_target) {
cs->last_dbid = -1;
char *errbuf;
if (password && buf0[0] == '-') errbuf = buf0;
else if (select && buf1[0] == '-') errbuf = buf1;
else errbuf = buf2;
error_from_target = 1;
addReplyErrorFormat(c,"Target instance replied with error: %s",errbuf+1);
}
} else {
if (!copy) {
// COPY 옵션이 없으면 로컬 키 삭제
dbDelete(c->db,keyArray[j]);
// Redis keyspace 변경 알림
signalModifiedKey(c,c->db,keyArray[j]);
notifyKeyspaceEvent(NOTIFY_GENERIC,"del",keyArray[j],c->db->id);
server.dirty++;
newargv[del_idx++] = keyArray[j];
incrRefCount(keyArray[j]);
}
}
}
......
return;
}
먼저 클러스터 모드일 경우, 키를 다른 노드로 옮길 때 RESTORE-ASKING 명령을 사용합니다. 이는 대상 노드가 IMPORTING 상태인 슬롯에 대해 일시적으로 요청을 허용할 수 있도록 합니다. 클러스터 환경이 아닌 일반 인스턴스에서는 RESTORE 명령만 사용되며, 클러스터의 슬롯 상태를 고려하지 않습니다.
1
2
3
4
5
# 클러스터의 키 이동 과정에서 임시적이고 안전한 복원을 가능하게 합니다.
if (server.cluster_enabled)
serverAssertWithInfo(c,NULL,rioWriteBulkString(&cmd,"RESTORE-ASKING",14));
else
serverAssertWithInfo(c,NULL,rioWriteBulkString(&cmd,"RESTORE",7));
그 다음, 전송할 키의 값을 내부적으로 직렬화 합니다. 주어진 키와 값을 레디스의 DUMP 포맷으로 변환하며, 이는 RDB와 유사한 구조를 가집니다. 직렬화된 결과는 rioWriteBulkString을 통해 명령 버퍼에 추가되며, 이동할 레디스 인스턴스에 복원 명령을 전송할 준비를 합니다.
1
2
createDumpPayload(&payload, kvArray[j], keyArray[j], dbid);
serverAssertWithInfo(c,NULL, rioWriteBulkString(&cmd, payload.io.buffer.ptr, sdslen(payload.io.buffer.ptr)));
RDB(Redis Database Backup)_VERSION은 파일 포맷 버전을 의미합니다. 레디스는 데이터를 영속화할 때 RDB 형식의 바이너리 파일을 생성하는데, 이때 사용되는 파일 포맷은 버전에 따라 조금씩 다릅니다. 따라서, 레디스는 해당 데이터를 디코딩하기 위해 RDB 버전 번호를 파일에 함께 기록합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void createDumpPayload(rio *payload, robj *o, robj *key, int dbid) {
unsigned char buf[2];
uint64_t crc;
rioInitWithBuffer(payload,sdsempty());
serverAssert(rdbSaveObjectType(payload,o));
serverAssert(rdbSaveObject(payload,o,key,dbid));
/* Write the footer, this is how it looks like:
* ----------------+---------------------+---------------+
* ... RDB payload | 2 bytes RDB version | 8 bytes CRC64 |
* ----------------+---------------------+---------------+
* RDB version and CRC are both in little endian.
*/
buf[0] = RDB_VERSION & 0xff;
buf[1] = (RDB_VERSION >> 8) & 0xff;
payload->io.buffer.ptr = sdscatlen(payload->io.buffer.ptr,buf,2);
crc = crc64(0,(unsigned char*)payload->io.buffer.ptr,sdslen(payload->io.buffer.ptr));
memrev64ifbe(&crc);
payload->io.buffer.ptr = sdscatlen(payload->io.buffer.ptr,&crc,8);
}
이 과정에서 CRC64 체크섬을 사용하여 데이터의 무결성을 검증하는데, CRC 값을 8byte로 저장해 RESTORE 명령으로 읽을 때 데이터가 손상되지 않았는지 검증하는 것입니다.
1
crc = crc64(0,(unsigned char*)payload->io.buffer.ptr,sdslen(payload->io.buffer.ptr));
명령 버퍼가 준비되면 레디스는 connSyncWrite 함수를 사용해 대상 노드로 전송합니다. 최대 64KB 단위로 데이터를 잘라서 소켓을 통해 전송하며, 전송 도중 에러가 발생하면 즉시 처리가 중단되고 오류 상태가 반환됩니다.
1
2
3
4
5
while ((towrite = sdslen(buf)-pos) > 0) {
towrite = (towrite > (64*1024) ? (64*1024) : towrite);
nwritten = connSyncWrite(cs->conn, buf+pos, towrite, timeout);
...
}
마지막으로, MIGRATE 명령어에 COPY 옵션이 없는 경우, 원본 레디스 인스턴스에서 해당 키를 삭제합니다. 삭제 이후에는 내부 상태에 변경을 알리고, notifyKeyspaceEvent를 통해 keyspace 이벤트를 발생시킵니다. 또한 server.dirty++를 증가시켜 RDB 또는 AOF에 반영될 수 있도록 변경 사항을 표시합니다.
1
2
3
4
5
6
if (!copy) {
dbDelete(c->db, keyArray[j]);
signalModifiedKey(c, c->db, keyArray[j]);
notifyKeyspaceEvent(NOTIFY_GENERIC,"del", keyArray[j], c->db->id);
server.dirty++;
}
COPY 옵션은 MIGRATE 명령어에서 원본 레디스 인스턴스의 데이터를 삭제하지 않고 유지하기 위한 플래그입니다.
1
MIGRATE 127.0.0.1 6379 "" 0 5000 KEYS exKey COPY
2-2. getNodeByQuery
클러스터 모드에서 MIGRATE, GET, SET 등 특정 명령어가 어느 노드에서 처리되어야 하는지를 판단합니다. 주어진 키들로 해시 슬롯을 계산하고, 해당 슬롯을 소유한 노드 또는 클러스터 상태에 따라 현재 노드에서 처리할지, 다른 노드로 리디렉션할지, 또는 ASK/TRYAGAIN 등의 오류를 반환할지 결정합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
clusterNode *getNodeByQuery(client *c, struct redisCommand *cmd, robj **argv, int argc, int *hashslot, uint64_t cmd_flags, int *error_code) {
......
/* Return the hashslot by reference. */
if (hashslot) *hashslot = slot;
if ((migrating_slot || importing_slot) && cmd->proc == migrateCommand)
return myself;
if (migrating_slot && missing_keys) {
if (existing_keys) {
if (error_code) *error_code = CLUSTER_REDIR_UNSTABLE;
return NULL;
} else {
if (error_code) *error_code = CLUSTER_REDIR_ASK;
return getMigratingSlotDest(slot);
}
}
if (importing_slot && (c->flags & CLIENT_ASKING || cmd_flags & CMD_ASKING))
{
if (multiple_keys && missing_keys) {
if (error_code) *error_code = CLUSTER_REDIR_UNSTABLE;
return NULL;
} else {
return myself;
}
}
/* Handle the read-only client case reading from a slave: if this
* node is a slave and the request is about a hash slot our master
* is serving, we can reply without redirection. */
int is_write_command = (cmd_flags & CMD_WRITE) ||
(c->cmd->proc == execCommand && (c->mstate.cmd_flags & CMD_WRITE));
if (((c->flags & CLIENT_READONLY) || pubsubshard_included) &&
!is_write_command &&
clusterNodeIsSlave(myself) &&
clusterNodeGetSlaveof(myself) == n)
{
return myself;
}
/* Base case: just return the right node. However, if this node is not
* myself, set error_code to MOVED since we need to issue a redirection. */
if (n != myself && error_code) *error_code = CLUSTER_REDIR_MOVED;
return n;
}
요청된 키들로 해시 슬롯을 계산해 호출자에게 전달합니다.
1
if (hashslot) *hashslot = slot;
MIGRATE 명령어는 현재 노드가 해당 슬롯을 이동 중이거나 수신 중인 상태라면, 클러스터 규칙과 상관없이 현재 노드(myself)에서 처리합니다.
1
2
if ((migrating_slot || importing_slot) && cmd->proc == migrateCommand)
return myself;
레디스 클러스터에서 슬롯이 MIGRATING 상태일 경우, 해당 노드는 특정 슬롯의 키들을 다른 노드로 옮기고 있는 중입니다. 이때 클라이언트 요청에 포함된 키 중 일부는 이 노드에 있고 일부는 없다면, UNSTABLE 오류를 발생시켜 재시도를 유도합니다. 만약 해당 슬롯의 키가 하나도 없다면, ASK 리다이렉션을 통해 대상 노드로 요청을 보냅니다.
1
2
3
4
if (migrating_slot && missing_keys) {
if (existing_keys) *error_code = CLUSTER_REDIR_UNSTABLE;
else *error_code = CLUSTER_REDIR_ASK;
}
슬롯이 IMPORTING 상태일 경우, 해당 노드는 다른 노드로부터 슬롯을 받아오고 있는 중입니다. 클라이언트가 ASKING 플래그를 포함해 요청하면, 임시로 요청을 허용하며, 요청이 단일 키거나, 다중 키이더라도 모두 현재 노드에 존재하면 정상 처리합니다.
1
2
3
4
5
6
7
8
9
if (importing_slot && (c->flags & CLIENT_ASKING || cmd_flags & CMD_ASKING))
{
if (multiple_keys && missing_keys) {
if (error_code) *error_code = CLUSTER_REDIR_UNSTABLE;
return NULL;
} else {
return myself;
}
}
클라이언트가 READONLY 모드이며, 명령이 읽기 요청인 경우에는 현재 노드가 슬레이브 노드라 하더라도 직접 처리할 수 있습니다. 단, 이 경우 해당 슬롯의 마스터 노드가 여전히 그 슬롯을 소유하고 있어야 합니다.
1
2
3
if ((c->flags & CLIENT_READONLY) && !is_write_command && clusterNodeIsSlave(myself)) {
return myself;
}
마지막으로, 요청된 키의 슬롯을 담당하는 노드가 현재 노드가 아닌 경우, 레디스는 MOVED 리다이렉션 오류를 발생시켜, 클라이언트가 올바른 노드로 다시 요청을 보낼 수 있도록 합니다.
1
2
if (n != myself && error_code) *error_code = CLUSTER_REDIR_MOVED;
return n;
3. 정리
레디스 클러스터의 리밸런싱은 전체 시스템을 중단시키는 글로벌 락(Global Lock) 없이 수행됩니다. 대신 다음과 같은 정교한 메커니즘을 사용합니다.
- 상태 기반의 분산 제어: MIGRATING과 IMPORTING이라는 슬롯 상태를 클러스터 전체에 공유하여, 특정 슬롯이 이전 중임을 모두가 인지하게 합니다.
- 점진적이고 원자적인 키 이전: MIGRATE 명령을 통해 키를 하나씩 안전하고 원자적으로 이전합니다. Single-Threaded 모델 덕분에 이 명령이 실행되는 동안 데이터 일관성이 보장됩니다.
지능적인 클라이언트 리디렉션: MOVED와 ASK 응답을 통해 클라이언트가 리밸런싱 중에도 스스로 올바른 노드를 찾아가도록 유도하여 서비스 연속성을 확보합니다.
- Redis-Github: redis/src/cluster.c
- Redis-Github: redis/src/cluster.h
- Redis-IO: ASKING
- Redis-IO: MIGRATE
- Redis-IO: MOVED
- Redis-IO: Scale with Redis Cluster