Redis에는 다양한 자료형과 기능이 있습니다.
이번 글에서는 모든 기능을 다 설명하긴 보단 유스케이스를 정의한 뒤 자주 쓰이는 자료형과 기능에 대해 설명하겠습니다.
많은 자료형과 기능을 다루다보니, 글이 길어지게 되었습니다... 찾아서 보는 방법 추천합니다..
String 자료형
String 자료형이지만, 이진 안전 문자열이기 때문에 이미지나 실행파일 등 문자열 이외의 데이터도 저장할 수 있습니다.
이진 안전이란?
데이터를 처리할 때 어떤 특정 형식(텍스트, 숫자)에 국한되지 않고, 모든 유형의 데이터를 안전하게 처리할 수 있다는 것을 의미합니다.
- 실전 레디스
유스케이스
String 자료형은 주로 캐시, 방문자 수, 키 - 값 저장소로 사용됩니다.
그중, 캐시를 예시로 들어 설명하겠습니다.
캐시란 자주 사용하는 데이터나 값을 미리 복사해 놓는 임시 장소를 가리킵니다.
Redis Key naming convention
Yes, colon sign is a convention when naming keys. In this tutorial on redis website is stated:
Try to stick with a schema. For instance "object-type:id:field" can be a nice idea, like in "user:1000:password".
I like to use dots for multi-words fields, like in "comment:1234:reply.to".
Redis에서는 키 이름을 지을 때 주로 콜론(:)을 사용합니다. Redis의 공식 웹사이트에서는 'object-type:id' 형식을 권장합니다.
예를 들어, ID가 1000인 멤버의 필드 값을 저장하려면 "Member:1000"과 같이 키 이름을 지을 수 있습니다.
위의 형식과 같이 저장을 한 뒤, 조회를 하면 저장한 값들이 정상적으로 조회가 됩니다.
이때 Redis를 이용해 캐시를 적용하려면 다양한 캐시 전략에 대해 알아야 하는데 이 글은 Redis의 자료형과 기능에 대한 글이어서 생략하겠습니다. 자세한 전략을 알고 싶으신 분은 밑의 링크를 참고하세요.
https://medium.com/@mmoshikoo/cache-strategies-996e91c80303
명령어
1. SET / O(1)
SET 명령은 키에 값을 저장하는 명령어 입니다.
EX 옵션과 같이 시간을 지정하는 옵션을 지정하면, TTL도 설정할 수 있습니다.
void setCommand(client *c) {
robj *expire = NULL;
int unit = UNIT_SECONDS;
int flags = OBJ_NO_FLAGS;
if (parseExtendedStringArgumentsOrReply(c,&flags,&unit,&expire,COMMAND_SET) != C_OK) {
return;
}
c->argv[2] = tryObjectEncoding(c->argv[2]);
setGenericCommand(c,flags,c->argv[1],c->argv[2],expire,unit,NULL,NULL);
}
코드를 보시면 parseExtendedStringArgumentsOrReply 함수를 통해 SET 명령어의 추가 옵션 (EX, XX, NX)등을 파싱 합니다.
그런 뒤, setGenericCommand를 통해 SET 명령을 실행시킵니다.
SET의 추가 옵션
EX -- 지정된 만료 시간을 초 단위(양의 정수)로 설정합니다.
PX -- 지정된 만료 시간을 밀리초 단위(양의 정수)로 설정합니다.
EXAT -- 지정된 유닉스 시간(초 단위)으로 키의 만료 시간을 설정합니다.
PXAT -- 지정된 유닉스 시간(밀리초 단위)으로 만료 시간을 설정합니다.
NX -- 키가 존재하지 않을 때만 키를 설정합니다.
XX -- 키가 이미 존재할 때만 키를 설정합니다.
KEEPTTL -- 키와 연관된 TTL을 유지합니다.
GET -- 키에 저장된 기존 문자열을 반환하거나, 키가 존재하지 않으면 nil을 반환합니다.
2. MSET / O(N)
MSET 명령은 여러 개의 키 값을 한 번에 저장하는 명령어입니다.
키가 이미 존재하는 경우 덮어씌우며, SET을 이용해 여러 개의 명령어를 저장하는 것에 비해 효율적입니다. (네트워크 왕복시간을 줄이기 때문)
void msetGenericCommand(client *c, int nx) {
int j;
if ((c->argc % 2) == 0) {
addReplyErrorArity(c);
return;
}
/* Handle the NX flag. The MSETNX semantic is to return zero and don't
* set anything if at least one key already exists. */
if (nx) {
for (j = 1; j < c->argc; j += 2) {
if (lookupKeyWrite(c->db,c->argv[j]) != NULL) {
addReply(c, shared.czero);
return;
}
}
}
int setkey_flags = nx ? SETKEY_DOESNT_EXIST : 0;
for (j = 1; j < c->argc; j += 2) {
c->argv[j+1] = tryObjectEncoding(c->argv[j+1]);
setKey(c, c->db, c->argv[j], c->argv[j + 1], setkey_flags);
notifyKeyspaceEvent(NOTIFY_STRING,"set",c->argv[j],c->db->id);
/* In MSETNX, It could be that we're overriding the same key, we can't be sure it doesn't exist. */
if (nx)
setkey_flags = SETKEY_ADD_OR_UPDATE;
}
server.dirty += (c->argc-1)/2;
addReply(c, nx ? shared.cone : shared.ok);
}
if 조건문을 통해 올바르지 않은 형태의 명령일 때 에러를 반환합니다.
인자의 개수가 짝수일 때 에러를 반환하는데 이는, MSET은 명령어는 키-값 쌍을 동시에 설정하는 명령어이기 때문에 인자의 개수가 무조건 홀수개여야 합니다. ex) MSET KEY VALUE KEY1 VALUE1
그리고 nx flag가 true인 경우 -> MSETNX 명령어이므로 각 키가 존재하는지 확인을 하고 키가 저장소에 하나라도 저장되어 있으면 아무것도 설정하지 않고 0을 반환합니다. 그런 뒤 for 루프를 통해 키-값 쌍을 설정합니다.
3. GET / O(1)
키 값을 가져오는 명령어입니다. 키가 존재하지 않는 경우 nil을 반환합니다.
void getCommand(client *c) {
getGenericCommand(c);
}
int getGenericCommand(client *c) {
robj *o;
if ((o = lookupKeyReadOrReply(c,c->argv[1],shared.null[c->resp])) == NULL)
return C_OK;
if (checkType(c,o,OBJ_STRING)) {
return C_ERR;
}
addReplyBulk(c,o);
return C_OK;
}
키를 조회한 뒤, 키가 존재하지 않으면 C_OK를 반환하면서 종료합니다.
그 다음, 타입을 조회합니다. 이때 타입이 String이 아니라면 C_ERR를 반환하여 함수는 종료됩니다.
마지막으로 addREplyBulk를 통해 사용자에게 응답하고 C_OK를 통해 종료합니다.
4. MGET / O(N)
여러 개의 키를 지정하여 한번에 값을 가져옵니다.
void mgetCommand(client *c) {
int j;
addReplyArrayLen(c,c->argc-1);
for (j = 1; j < c->argc; j++) {
robj *o = lookupKeyRead(c->db,c->argv[j]);
if (o == NULL) {
addReplyNull(c);
} else {
if (o->type != OBJ_STRING) {
addReplyNull(c);
} else {
addReplyBulk(c,o);
}
}
}
}
for 루프를 통해 각 키에 대해 반복하며, lookupKeyRead 함수를 통해 키에 대한 값을 조회합니다.
이후, 조회된 타입이 문자열인 경우 addReplyBulk를 통해 사용자에게 반환합니다.
사용자는 이 과정을 통해 요청한 모든 키에 대한 값을 배열 형태로 받게 됩니다.
그 외의 String 명령어들
5. APPEND / O(1)
키가 존재하는 경우, 키값 끝에 인수 내용을 추가합니다. 키가 존재하지 않는 경우에는 새로운 string 키를 만듭니다.
6. STRLEN / O(1)
키값의 문자열 길이를 반환합니다.
7. SETRANGE / O(1)
Key, offset, value를 통해 해당 key의 value를 offset의 위치부터 작성합니다.
다음과 같이 사용할 수 있으며, 이때 offset은 0부터 시작합니다.
8. GETRANGE / O(1)
키 값을 기준으로 범위를 지정하여 데이터를 반환합니다.
이때 문자열은 0부터 시작합니다.
9. GETEX / O(1)
키 값을 가져오며, 옵션에서 TTL을 설정할 수 있습니다.
10. GETDEL / O(1)
키 값을 가져오고 동시에 그 키를 삭제합니다.
Queue, Stack의 pop과 같은 기능이라고 생각하시면 됩니다.
11. MSETNX / O(N)
여러 개의 키와 값의 쌍을 지정하여 한번에 키에 값을 저장할 수 있습니다.
이때 하나라도 키가 이미 존재하는 경우에는 모든 저장에 실패합니다.
트랜잭션의 성질 중 하나인, 원자성을 보장하신다고 생각하시면 쉽습니다.
String형에서 값이 숫자인 경우만 사용할 수 있는 명령어
1. INCR / O(1)
키 값을 1만큼 증가시킵니다. 값이 정수가 아닌 경우 오류를 반환합니다.
2. INCRBY / O(1)
키 값을 지정한 값 만큼 증가시킵니다. 값이 정수가 아닌 경우 오류를 반환합니다.
3. INCRBYFLOAT / O(1)
지정한 숫자(정수/유리수)만큼 증가시킵니다.
지정한 키가 존재하지 않는 경우에는 0을 기준으로 삼아 지정한 숫자를 리턴합니다.
4. DECR / O(1)
키 값을 1만큼 감소시킵니다. 지정한 키가 존재하지 않는 경우엔 0을 기준 삼아 값을 저장합니다.
5. DECRBY / O(1)
키 값을 지정한 값 만큼 감소시킵니다. 지정한 키가 존재하지 않는 경우엔 0을 기준삼아 저장합니다.
0을 기준삼아 저장한다는 것은?
키의 값을 0으로 설정한 다음 진행시키는 것
예를 들어 DECR B라고 했을 때 (B라는 키가 존재하지 않는 경우) B의 값은 0에서 1이 감소한 -1이 됩니다.
List 자료형
List형은 리스트 좌우 끝부분에 요소를 추가 및 삭제하거나 부분적으로 요소를 가져오는 등의 동작을 할 수 있습니다.
하지만 중간 부분으로 접근하는 것이 느리며, 특히 데이터가 큰 경우에는 많이 느려지기 때문에 주의가 필요합니다.
유스케이스
트위터등 게시 사이트 타임라인을 표시할 때 레디스를 활용합니다.
- 실전 레디스
실전 레디스에서도 나와있듯이 타임라인, 최근 게시물을 표시할 때 Redis의 리스트 자료형은 매우 유용하게 사용될 수 있습니다.
Twitter에서는 사용자가 트윗을 작성하면 해당 트윗을 로드 밸런서를 통해 데이터베이스에 저장하고, 동시에 Redis에 캐싱하는 팬 아웃 캐싱 방식을 사용합니다.
이 과정에서 Redis의 리스트 자료형을 사용하여 캐싱을 하게 됩니다. 이는 사용자의 홈 타임라인에서 팔로우하는 사람들의 트윗을 시간 순서대로 효율적으로 보여줄 수 있는 중요한 장점을 제공합니다.
명령어
1. LPOP / 단일 요소 O(1) / 여러 요소 O(N)
LPOP은 키로 지정한 리스트에서 가장 앞에 있는 요소를 삭제하고 그 값을 반환합니다.
Redis OSS 6.2부터는 count 옵션이 생겨 삭제할 수 있는 요소의 개수를 정할 수 있습니다.
void lpopCommand(client *c) {
popGenericCommand(c,LIST_HEAD);
}
코드를 보면 LIST_HEAD, 가장 앞에 있는 요소를 전달 인자로 보내는 모습을 볼 수 있습니다.
2. RPOP / 단일 요소 O(1) / 여러 요소 O(N)
RPOP은 키로 지정한 리스트의 끝 부분 요소를 삭제하고 그 값을 반환합니다.
Redis OSS 6.2부터는 count 옵션이 생겨 삭제할 수 있는 요소의 개수를 정할 수 있습니다.
/* RPOP <key> [count] */
void rpopCommand(client *c) {
popGenericCommand(c,LIST_TAIL);
}
코드를 보면 LIST_TAIL, 가장 뒤에 있는 요소를 전달 인자로 보내는 모습을 볼 수 있습니다.
popGenericCommand
pop할 때 공통적으로 사용되는 popGenericCommand에 대해 알아보겠습니다.
int hascount = (c->argc == 3);
long count = 0;
robj *value;
if (c->argc > 3) {
addReplyErrorArity(c);
return;
} else if (hascount) {
/* Parse the optional count argument. */
if (getPositiveLongFromObjectOrReply(c,c->argv[2],&count,NULL) != C_OK)
return;
}
robj *o = lookupKeyWriteOrReply(c, c->argv[1], hascount ? shared.nullarray[c->resp]: shared.null[c->resp]);
if (o == NULL || checkType(c, o, OBJ_LIST))
return;
lookupKeyWriteOrReply 함수를 통해 키의 객체를 찾습니다. 만약에 없다면 null을 반환합니다.
그 후, 반환된 객체가 리스트 타입인지 검사합니다.
if (!count) {
value = listTypePop(o, where);
serverAssert(value != NULL);
addReplyBulk(c, value);
decrRefCount(value);
listElementsRemoved(c, c->argv[1], where, o, 1, 1, NULL);
}
count가 아닌 경우 -> 단일 요소 삭제
리스트에서 요소를 pop하여 반환합니다.
반환된 값이 null인지 확인하고 null이 아니면 클라이언트에게 응답합니다.
그 후, 반환된 값의 *참조 카운트를 감소시킵니다. (밑의 링크 보고 보충하기)
마지막으로 제거된 요소의 수와 관련된 메타데이터를 업데이트합니다.
참조 카운트란?
객체는 생성될 때 초기 참조 카운트가 1로 설정됩니다.
이는 객체가 메모리에 생성됐음을 나타내며, 해당 객체를 참조하는 모든 곳에서 이 카운트를 증가시킵니다.
객체를 더 이상사용하지 않을 때는 참조를 해제하고 이에 따라 카운트를 감소시킵니다.
else {
long llen = listTypeLength(o);
long rangelen = (count > llen) ? llen : count;
long rangestart = (where == LIST_HEAD) ? 0 : -rangelen;
long rangeend = (where == LIST_HEAD) ? rangelen - 1 : -1;
int reverse = (where == LIST_HEAD) ? 0 : 1;
addListRangeReply(c, o, rangestart, rangeend, reverse);
listTypeDelRange(o, rangestart, rangelen);
listElementsRemoved(c, c->argv[1], where, o, rangelen, 1, NULL);
}
Count인 경우 -> 범위 요소 삭제
클라이언트에게 범위 내의 요소를 응답한 뒤, 리스트에서 제거합니다.
마지막으로 제거된 요소들의 수와 관련된 메타데이터를 업데이트합니다.
전체 코드
/* Implements the generic list pop operation for LPOP/RPOP.
* The where argument specifies which end of the list is operated on. An
* optional count may be provided as the third argument of the client's
* command. */
void popGenericCommand(client *c, int where) {
int hascount = (c->argc == 3);
long count = 0;
robj *value;
if (c->argc > 3) {
addReplyErrorArity(c);
return;
} else if (hascount) {
/* Parse the optional count argument. */
if (getPositiveLongFromObjectOrReply(c,c->argv[2],&count,NULL) != C_OK)
return;
}
robj *o = lookupKeyWriteOrReply(c, c->argv[1], hascount ? shared.nullarray[c->resp]: shared.null[c->resp]);
if (o == NULL || checkType(c, o, OBJ_LIST))
return;
if (hascount && !count) {
/* Fast exit path. */
addReply(c,shared.emptyarray);
return;
}
if (!count) {
/* Pop a single element. This is POP's original behavior that replies
* with a bulk string. */
value = listTypePop(o,where);
serverAssert(value != NULL);
addReplyBulk(c,value);
decrRefCount(value);
listElementsRemoved(c,c->argv[1],where,o,1,1,NULL);
} else {
/* Pop a range of elements. An addition to the original POP command,
* which replies with a multi-bulk. */
long llen = listTypeLength(o);
long rangelen = (count > llen) ? llen : count;
long rangestart = (where == LIST_HEAD) ? 0 : -rangelen;
long rangeend = (where == LIST_HEAD) ? rangelen - 1 : -1;
int reverse = (where == LIST_HEAD) ? 0 : 1;
addListRangeReply(c,o,rangestart,rangeend,reverse);
listTypeDelRange(o,rangestart,rangelen);
listElementsRemoved(c,c->argv[1],where,o,rangelen,1,NULL);
}
}
3. LPUSH / 단일 인자 O(1) / 여러 인자 O(N)
LPUSH는 키로 지정한 리스트의 앞 부분에 지정한 값을 모두 삽입합니다.
/* LPUSH <key> <element> [<element> ...] */
void lpushCommand(client *c) {
pushGenericCommand(c,LIST_HEAD,0);
}
코드를 보면 LIST_HEAD, 가장 앞 부분에 값을 넣는 것을 볼 수 있습니다.
4. RPUSH / 단일 인자 O(1) / 여러 인자 O(N)
RPUSH는 키로 지정한 리스트의 끝부분에 지정한 값을 모두 삽입합니다.
/* RPUSH <key> <element> [<element> ...] */
void rpushCommand(client *c) {
pushGenericCommand(c,LIST_TAIL,0);
}
코드를 보면 LIST_TAIL, 맨 뒷부분에 값을 넣는 것을 볼 수 있습니다.
이때 LPUSH, RPUSH 모두 0이라는 값을 보내는 것을 볼 수 있는데 이는 xx 옵션에 대한 값입니다.
xx 옵션이란 키가 존재할 때만 넣게 하는 옵션으로 LPUSH, RPUSH에서는 0(false)를 보냄으로써 키가 존재하지 않을 때도 값을 넣게 하고 있습니다.
pushGenericCommand
if (checkType(c,lobj,OBJ_LIST)) return;
if (!lobj) {
if (xx) {
addReply(c, shared.czero);
return;
}
키의 타입이 리스트인지 확인합니다.
만약 리스트가 아니라면 함수는 종료됩니다.
저장소에 키가 존재하지 않는 경우 xx가 참이면 클라이언트에게 0을 반환하고 함수를 종료합니다.
lobj = createListListpackObject();
dbAdd(c->db,c->argv[1],lobj);
키가 존재하지 않고 xx가 거짓인 경우에는 리스트 객체를 생성한 뒤, db에 추가합니다.
listTypeTryConversionAppend(lobj,c->argv,2,c->argc-1,NULL,NULL);
for (j = 2; j < c->argc; j++) {
listTypePush(lobj,c->argv[j],where);
server.dirty++;
}
where에 따라 LIST_HEAD, LIST_TAIL에 요소를 추가합니다.
추가를 한 뒤, server.dirty 카운트를 증가시켜 DB에 변경사항을 기록합니다.
addReplyLongLong(c, listTypeLength(lobj));
char *event = (where == LIST_HEAD) ? "lpush" : "rpush";
signalModifiedKey(c,c->db,c->argv[1]);
notifyKeyspaceEvent(NOTIFY_LIST,event,c->argv[1],c->db->id);
리스트의 길이를 클라이언트에게 응답으로 보냅니다.
추가된 위치에 따라 event 문자열을 설정하고, 키가 수정되었음을 신호로 보내며, 키 공간 이벤트를 알립니다.
전체 코드
/* Implements LPUSH/RPUSH/LPUSHX/RPUSHX.
* 'xx': push if key exists. */
void pushGenericCommand(client *c, int where, int xx) {
int j;
robj *lobj = lookupKeyWrite(c->db, c->argv[1]);
if (checkType(c,lobj,OBJ_LIST)) return;
if (!lobj) {
if (xx) {
addReply(c, shared.czero);
return;
}
lobj = createListListpackObject();
dbAdd(c->db,c->argv[1],lobj);
}
listTypeTryConversionAppend(lobj,c->argv,2,c->argc-1,NULL,NULL);
for (j = 2; j < c->argc; j++) {
listTypePush(lobj,c->argv[j],where);
server.dirty++;
}
addReplyLongLong(c, listTypeLength(lobj));
char *event = (where == LIST_HEAD) ? "lpush" : "rpush";
signalModifiedKey(c,c->db,c->argv[1]);
notifyKeyspaceEvent(NOTIFY_LIST,event,c->argv[1],c->db->id);
}
그 외의 list 명령어들
5. LMPOP / O(N + M)
Redis OSS 7.0부터 사용할 수 있습니다.
키로 지정한 리스트의 처음 혹은 마지막부터 여러 요소를 삭제하고, 그 값을 반환합니다.
6. BLMPOP / O(N + M)
Redis OSS 7.0부터 사용할 수 있습니다.
키로 지정한 리스트의 처음 혹은 마지막부터 여러 요소를 삭제하고, 그 값을 반환합니다.
리스트에 요소가 있으면 LMPOP과 똑같이 작동하며, 리스트에 요소가 없으면 처리를 블록하고, 순서 집합에 요소가 추가될때까지 처리를 대기합니다. (timeout 까지 기다립니다)
7. LINDEX / O(N)
키로 지정한 리스트에 지정한 인덱스 위치에 있는 요소를 반환합니다.
8. LINSERT / O(N)
키로 지정한 리스트에 지정한 요소의 바로 앞 혹은 뒤에 같은 요소를 삽입합니다.
지정한 요소를 찾을 수 없는 경우에는 -1을 반환하며, 동작이 수행되지 않습니다.
9. LLEN / O(1)
키로 지정한 리스트의 길이를 반환합니다.
10. LRANGE / O(S + N)
키로 지정한 리스트의 인덱스 범위를 지정하여 데이터를 반환합니다. (인덱스는 0부터 시작합니다)
11. LREM / O(N + M)
키로 지정한 리스트에서 특정 요소를 지정한 수만큼 삭제합니다.
지정한 숫자가 양수라면 시작부분부터 끝부분으로 이동하고, 음수면 끝부분에서 시작부분으로 이동하면서 삭제합니다.
12. LSET / O(N)
키로 지정한 리스트에서 지정한 인덱스에 있는 값을 사용자가 지정한 값으로 수 있도록 갱신합니다.
13. LTRIM / 제거되는 요소의 개수가 1개면 O(1) / 여러개면 O(N)
키로 지정한 리스트를 특정 인덱스 범위에 포함된 요소로만 이뤄진 리스트로 갱신합니다.
인덱스는 0부터 시작합니다.
14. LPOS / O(N) / MAXLEN 옵션을 통해 탐색할 길이를 제공하면 상수 시간으로 실행 가능
키로 지정한 리스트에서 탐색 대상 요소의 인덱스를 반환합니다.
옵션이 주어지지 않으면 탐색 대상 요소를 처음 발견한 위치의 인덱스를 반환합니다.
Hash 자료형
Hash 자료형은 순서 없이 필드와 값이 여러 쌍으로 매핑된 구조입니다.
유스케이스
관계형 데이터베이스의 행과 유사하며, 여러 개의 필드 값쌍을 한 번에 저장하고 검색하는데 유용합니다.
위의 경우와 같이 객체를 표현할 때 여러 개의 필드를 한 번에 저장, 조회할 수 있어 장점을 가지는 자료형입니다.
명령어
1. HSET / 추가된 각 필드, 값 쌍에 대해 O(1)이므로 개수에 따라 O(1) - O(N)
키로 지정한 해시에 필드와 값의 쌍을 지정하여 필드에 값을 저장합니다.
필드가 이미 존재하는 경우에는 덮어씁니다.
void hsetCommand(client *c) {
int i, created = 0;
robj *o;
if ((c->argc % 2) == 1) {
addReplyErrorArity(c);
return;
}
if ((o = hashTypeLookupWriteOrCreate(c,c->argv[1])) == NULL) return;
hashTypeTryConversion(c->db,o,c->argv,2,c->argc-1);
for (i = 2; i < c->argc; i += 2)
created += !hashTypeSet(c->db, o,c->argv[i]->ptr,c->argv[i+1]->ptr,HASH_SET_COPY);
/* HMSET (deprecated) and HSET return value is different. */
char *cmdname = c->argv[0]->ptr;
if (cmdname[1] == 's' || cmdname[1] == 'S') {
/* HSET */
addReplyLongLong(c, created);
} else {
/* HMSET */
addReply(c, shared.ok);
}
signalModifiedKey(c,c->db,c->argv[1]);
notifyKeyspaceEvent(NOTIFY_HASH,"hset",c->argv[1],c->db->id);
server.dirty += (c->argc - 2)/2;
}
인수 개수가 홀수 인 경우 (필드 - 값 쌍이므로 짝수여야 한다) 에러를 반환합니다.
for 루프를 통해 인수 목록에서 필드와 값을 추출하여 해시에 설정합니다.
새로운 필드가 생성될 때마다 created 값을 증가합니다.
그 후, 두 번째 글자가 s, S인지를 확인해서 (HSET과 HMSET 구분) 그에 따라 다른 응답을 반환합니다.
HSET은 생성된 필드의 수 반환.
HMSET은 OK 반환 (Deprecated여서 다음 업데이트에서 코드가 변경되지 않을까 합니다)
마지막으로 server.dirty 값을 변경하여 변경 사항을 기록합니다.
2. HGET / O(1)
키로 지정한 해시에서 지정한 필드에 저장된 값을 반환합니다.
void hgetCommand(client *c) {
robj *o;
if ((o = lookupKeyReadOrReply(c,c->argv[1],shared.null[c->resp])) == NULL ||
checkType(c,o,OBJ_HASH)) return;
addHashFieldToReply(c, o, c->argv[2]->ptr);
}
키를 찾아본 뒤, 키가 존재하지 않거나 값의 타입이 해쉬가 아니면 함수를 종료 시킵니다.
addHashFieldToReply 함수를 통해 객체에서 해당 필드의 값을 클라이언트에게 응답으로 보냅니다.
3. HDEL / O(N)
키로 지정한 해시에서 지정한 필드를 삭제합니다.
이때 해시의 필드를 삭제한 후, 필드가 남아있지 않다면 key를 삭제합니다.
void hdelCommand(client *c) {
robj *o;
int j, deleted = 0, keyremoved = 0;
if ((o = lookupKeyWriteOrReply(c,c->argv[1],shared.czero)) == NULL ||
checkType(c,o,OBJ_HASH)) return;
for (j = 2; j < c->argc; j++) {
if (hashTypeDelete(o,c->argv[j]->ptr,1)) {
deleted++;
if (hashTypeLength(o, 0) == 0) {
dbDelete(c->db,c->argv[1]);
keyremoved = 1;
break;
}
}
}
if (deleted) {
signalModifiedKey(c,c->db,c->argv[1]);
notifyKeyspaceEvent(NOTIFY_HASH,"hdel",c->argv[1],c->db->id);
if (keyremoved)
notifyKeyspaceEvent(NOTIFY_GENERIC,"del",c->argv[1],
c->db->id);
server.dirty += deleted;
}
addReplyLongLong(c,deleted);
}
주어진 키에 해당하는 객체를 찾아본 뒤, 키가 존재하지 않거나, 값의 타입이 해쉬가 아니면 함수를 종료 시킵니다.
그 후, 인수 목록에서 필드를 추출하여 해시에서 삭제를 시도합니다.
이때 삭제에 성공하면 deleted 변수의 값을 증가시킵니다. (0 -> 1) {false -> true}
삭제에 성공한 경우 "hdel" 이벤트를 발생시킵니다.
이때 남아있는 필드의 수에 따라 "del"이벤트가 발생할 수 있습니다.
또한 server.dirty 값을 변경 시켜 db에 변경을 기록합니다.
이때 해시의 필드를 삭제한 후, 필드의 수가 0이면 db에서 해당 해시 키를 삭제합니다.
그런 뒤, keyremoved 플래그를 설정합니다.
keyremoved 플래그가 설정되면 함수를 호출해 "del" 이벤트를 발생시킵니다.
그 외 hash 명령어들
4. HEXISTS / O(1)
키로 지정한 해시에서 지정한 필드가 존재하면 1, 존재하지 않으면 0을 반환합니다.
5. HGETALL / O(N)
키로 지정한 해시에 포함된 모든 필드와 값 쌍을 반환합니다.
6. HKEYS / O(N)
키로 지정한 해시에 포함된 모든 필드 목록을 반환합니다.
7. HLEN / O(1)
키로 지정한 해시에 포함된 필드 수를 반환합니다.
8. HVALS / O(N)
키로 지정한 해시의 필드에 연결된 모든 값을 반환합니다.
9. HSCAN / 한 번 실행될 때마다 O(1)
키로 지정한 해시의 필드 집합을 반복처리 하여 필드 이름과 저장된 값의 쌍 목록을 반환합니다.
이때 필드가 너무 많은 경우 반복해서 실행합니다. 이때의 시간 복잡도는 O(N) 입니다.
10. HSETNX / O(1)
지정된 필드가 아직 존재하지 않는 경우에만 저장합니다.
만약 존재하는 경우 이 작업은 동작을 수행하지 않습니다.
11. HRANDFIELD / O(N)
키로 지정한 해시의 필드를 무작위로 반환합니다.
count 옵션을 사용해 반환하는 필드 수를 지정할 수 있습니다.
SET 자료형
SET 자료형은 문자열의 집합입니다.
쉽게 설명하자면 SET 자료형에 저장되는 각각의 값은 string 자료형입니다.
또한, SET 자료형의 특성 상 중복된 값을 저장할 수 없다는 장점을 가지고 있습니다.
유스케이스
여러 값을 순서와 중복 없이 저장되기 때문에 주로 멤버십, 고유 사용자 수, 친구 관계등에서 사용될 수 있습니다.
그 중, 멤버십을 예시로 들어 설명하겠습니다.
user1과 user2가 유튜브 프리미엄을 결제하여, 이에 따라 `youtube_premium`이라는 Redis 키에 저장되었습니다.
서비스 중에는 유튜브 프리미엄 회원만 제공하는 서비스가 있습니다. 이 때 Redis의 `youtube_premium` 키의 값들을 검사하여 확인할 수 있습니다.
예를 들어, user1은 `youtube_premium`에 저장되어 있으므로 1 (true)이 반환되고, user3은 포함되어 있지 않으므로 0 (false)이 반환됩니다.
이렇듯 순서 없이 중복된 값을 저장할 수 없는 유스케이스에서 SET은 유용하게 쓰일 수 있습니다.
명령어
1. SADD / 추가된 요소에 대해 O(1)이므로 개수에 따라 O(1) - O(N)
키로 지정한 집합에 하나 이상의 멤버를 추가합니다.
void saddCommand(client *c) {
robj *set;
int j, added = 0;
set = lookupKeyWrite(c->db,c->argv[1]);
if (checkType(c,set,OBJ_SET)) return;
# 주어진 키에 해당하는 값이 없을 때
if (set == NULL) {
set = setTypeCreate(c->argv[2]->ptr, c->argc - 2);
dbAdd(c->db,c->argv[1],set);
} else {
setTypeMaybeConvert(set, c->argc - 2);
}
for (j = 2; j < c->argc; j++) {
if (setTypeAdd(set,c->argv[j]->ptr)) added++;
}
if (added) {
signalModifiedKey(c,c->db,c->argv[1]);
notifyKeyspaceEvent(NOTIFY_SET,"sadd",c->argv[1],c->db->id);
}
server.dirty += added;
addReplyLongLong(c,added);
}
키에 해당하는 값이 집합이 아니면 함수를 종료합니다.
만약 주어진 키에 해당하는 값이 없다면 새로운 집합을 생성합니다. 그런 뒤, 요소를 추가합니다.
집합이 존재한다면 요소를 추가합니다.
이후, server.dirty의 값을 변경해 데이터베이스에 변경사항을 기록하고 클라이언트에게 추가된 요소의 수를 응답으로 보냅니다.
2. SREM / 추가된 요소에 대해 O(1)이므로 개수에 따라 O(1) - O(N)
키로 지정한 집합에 지정한 하나 이상의 멤버를 삭제합니다.
void sremCommand(client *c) {
robj *set;
int j, deleted = 0, keyremoved = 0;
if ((set = lookupKeyWriteOrReply(c,c->argv[1],shared.czero)) == NULL ||
checkType(c,set,OBJ_SET)) return;
for (j = 2; j < c->argc; j++) {
if (setTypeRemove(set,c->argv[j]->ptr)) {
deleted++;
if (setTypeSize(set) == 0) {
dbDelete(c->db,c->argv[1]);
keyremoved = 1;
break;
}
}
}
if (deleted) {
signalModifiedKey(c,c->db,c->argv[1]);
notifyKeyspaceEvent(NOTIFY_SET,"srem",c->argv[1],c->db->id);
if (keyremoved)
notifyKeyspaceEvent(NOTIFY_GENERIC,"del",c->argv[1],
c->db->id);
server.dirty += deleted;
}
addReplyLongLong(c,deleted);
}
주어진 키에 대한 값이 없거나 집합이 아니라면 함수를 종료합니다.
그 후, 멤버를 삭제하며 제거에 성공하면 deleted 변수의 값을 증가시킵니다.
만약 집합이 비어있는 경우엔 저장소에서 KEY를 삭제합니다. 키를 삭제한 경우에는 break를 통해 반복문을 종료합니다.
deleted의 값이 1 (하나 이상의 요소가 삭제 되었을 때)이면 집합에서 요소가 제거되었다고 알림을 보내고 KEY도 삭제된 경우엔 케도 삭제되었다고 알림을 보냅니다.
마지막으로 server.dirty 값을 변경하여 변경 사항을 기록합니다.
알림을 보낸다고 하는 게 무슨 소리야?
알림에는 상당히 많은 옵션이 있지만, 모두 Redis에 있는 PubSub 메커니즘을 사용해 전달합니다.
Stackoverflow에 notifyKeyspaceEvent와 pub/sub과 관련이 있나요? 라고 올린 글에 달린 답변입니다.
우선 pub/sub 이란 특정한 주제에 대하여 해당 주제를 구독한 모두에게 메시지를 발행하는 방법입니다.
쉽게 설명하자면 인스타그램의 공지채널이 pub/sub의 좋은 예시입니다.
인스타그램의 공지채널에 참여한 수신자들에게 발행인은 메시지를 보낼 수 있습니다.
또한 채널에 참여한 수신자들은 발행인이 메시지를 보내면 알림을 받습니다.
이제 인스타그램 공지채널이라는 개념을 srem에 적용시켜보겠습니다.
Redis에서는 모든 이벤트에 대해 단일 키를 모니터링 할 수 있습니다.
또한 모든 키에 대해 단일 이벤트를 모니터링 할 수 있습니다.
SREM을 통해 변화가 생기면 해당 키를 구독하고 있던 구독자들은 알림을 수신합니다.
또한 SREM이라는 이벤트를 구독하고 있던 구독자들도 알림을 수신합니다.
Stackoverflow Q&A
3. SISMEMBER / O(1)
키로 지정한 집합에 지정한 멤버가 집합에 포함되어 있는지 여부를 판단합니다.
void sismemberCommand(client *c) {
robj *set;
if ((set = lookupKeyReadOrReply(c,c->argv[1],shared.czero)) == NULL ||
checkType(c,set,OBJ_SET)) return;
if (setTypeIsMember(set,c->argv[2]->ptr))
addReply(c,shared.cone);
else
addReply(c,shared.czero);
}
키를 조회해서 값이 없거나 집합이 아니라면 함수를 종료시킵니다.
이후, *setTypeIsMember 함수를 통해 집합에 해당 멤버가 있는지 확인합니다.
존재한다면 클라이언트에게 1을 반환하고 존재하지 않으면 0을 반환합니다.
*setTypeIsMember
/* Check if an sds string is a member of the set. Returns 1 if the value is a
* member of the set and 0 if it isn't. */
int setTypeIsMember(robj *subject, sds value) {
return setTypeIsMemberAux(subject, value, sdslen(value), 0, 1);
}
집합을 검사해서 멤버가 집합에 포함되어 있는지 확인합니다.
포함되어 있다면 1을 반환하고 없다면 0을 반환합니다.
그 외 SET 명령어들
4. SCARD / O(1)
키로 지정한 집합에 저장된 멤버의 수를 반환합니다.
5. SMEMBERS / O(N)
키로 지정한 집합의 모든 멤버를 반환합니다.
6. SPOP / O(1)
키로 지정한 집합의 멤버를 무작위로 반환합니다.
이때 반환한 데이터는 삭제되며, count 옵션을 통해 반환 개수를 정할 수 있습니다 .
7. SSCAN / 모든 호출에 대해 O(1) 이므로 호출 횟수에 따라 O(1) - O(N)
키로 지정한 집합의 멤버를 일정 단위 개수만큼 조회합니다.
(KEYS, SMEMBERS) vs SCAN
KEYS와 SMEMBERS와 같은 명령어는 대규모 키 컬렉션에 의해 호출되었을 때 서버를 몇 초동안 차단할 수 있습니다.
하지만 SCAN은 점진적으로 반복 작업을 수행하면서, 한 번의 호출에서 적은 수의 요소만 반환하기 때문에 대규모 키 컬렉션에 대해서도 서버를 오래 차단하지 않고 사용할 수 있습니다.
더 자세히 설명하자면 KEYS 명령은 한 번에 모든 키를 조회하지만, SCAN 명령은 지정된 개수(*count)만큼의 키를 순차적으로 조회합니다. 이로 인해 KEYS 명령은 모든 키를 찾는 동안 다른 명령을 처리할 수 없지만, SCAN 명령은 명령을 분할하여 중간에 다른 명령을 처리할 수 있습니다.
*이때 꼭 count만큼 조회를 하는 것은 아닙니다. Redis 내부에서 처리 시간을 고려해서 개수를 조절합니다.
SCAN은 어떻게 점진적으로 반복 작업을 수행하나요?
SCAN은 커서 기반 반복자입니다. 즉, 명령을 호출할 때마다 서버는 사용자가 다음 호출에서 커서 인수로 사용해야 하는 업데이트 된 커서를 반환합니다. 그러므로 한 번의 명령이 끝난 후, cursor를 통해 다음 명령을 실행시킬 수 있습니다.
이렇게 분산호출을 한 덕분에 Redis는 명령어처리의 blocking을 최소화 할 수 있습니다.
redis 127.0.0.1:6379> scan 0
1) "17"
2) 1) "key:12"
2) "key:8"
3) "key:4"
4) "key:14"
5) "key:16"
6) "key:17"
7) "key:15"
8) "key:10"
9) "key:3"
10) "key:7"
11) "key:1"
redis 127.0.0.1:6379> scan 17
1) "0" # 반환된 커서가 0
2) 1) "key:5"
2) "key:18"
3) "key:0"
4) "key:2"
5) "key:19"
6) "key:13"
7) "key:6"
8) "key:9"
9) "key:11"
반복은 커서가 0으로 설정되면 시작되고, 서버에서 반환된 커서가 0이면 종료됩니다.
하지만 다음과 같은 단점이 있습니다.
- 기본적으로 scan 의 경우 table 의 한 블럭을 가져오는 것이라서, 여기에 개수가 많으면 시간이 많이 걸릴 수도 있습니다.(다만, 리해싱 테이블이 bitmasking 크기만큼 커지므로, 한 블럭이 극단적으로 커질 가능성은 높지 않습니다.)
- set/sorted set/hash 의 내부 구조가 hash table 이나 skiplist 가 아닐 경우(ziplist 로 구현되어 있을 경우), 한 컬렉션의 모든 데이터를 가져오므로, KEYS 명령과 비슷한 문제가 그대로 발생할 수 있습니다.
- 명령의 옵션으로 count 값을 지정할 수 있지만, 정확히 그 개수를 보장하지 않습니다.
- 순회가 시작(cursor 값을 0으로 지정한 scan 명령)된 이후에 추가된 항목은 전체 순회(full iteration; scan 명령의 반환된 cursor값이 0)가 끝날 때까지 반환되지 않습니다(cursor가 이미 지나갔으므로).
- hash table이 확장/축소/rehashing 될 때 다시 스캔하지 않기 때문 같은 항목이 여러 번 반환 될 수 있습니다. 반환된 키 값으로 다른 명령을 실행하려면 주의해야 합니다.
출처 : Redis의 SCAN은 어떻게 동작하는가? - kakao tech
나의 생각
대규모 프로젝트에서는 밀리초의 차단도 큰 영향을 끼칠 수 있기 때문에 반드시 SCAN 명령어를 사용해야 합니다.
하지만 소규모 프로젝트, 개발 단계에서는 KEYS를 사용해도 괜찮지 않을까 생각합니다. (필자의 주관적인 의견)
Stackoverflow에서 성능 비교를 한 글을 보았을 때 50,000개의 레코드를 기준으로 KEYS는 3,000 마이크로세컨드, SCAN은 14,000 마이크로세컨드 동안 동작했습니다.
이러한 것을 보아 키의 수가 적은 경우 SCAN 보다는 KEYS를 사용하는 것이 더 빨라 좋은 방법이 될 수 있습니다.
(적절히 트레이드 오프를 고려하셔야 합니다. 속도 vs blocking)
Sorted Set 자료형
Sorted Set은 이름에서 알 수 있듯이 순서가 있는 Set 형입니다.
게임 회사 등 실시간 랭킹에 활용하는 경우가 많으며 레디스의 특징이라고도 할 수 있는 자료형입니다.
유스케이스
위에서 말했듯이 순서가 있기 때문에 랭킹과 같은 유스케이스에서 효과적으로 사용할 수 있습니다.
제가 자주하는 게임인 롤을 예시로 들어 설명하겠습니다.
롤에서는 점수가 있으며 이 점수는 게임 한 판을 할 때마다 변합니다.
이러한 점수를 내림차순 순위로 보여주기 위해서는 두 가지 방법이 있습니다.
@OrderBy
@Target({METHOD, FIELD})
@Retention(RUNTIME)
public @interface OrderBy {
/**
* An <code>orderby_list</code>. Specified as follows:
*
* <pre>
* orderby_list::= orderby_item [,orderby_item]*
* orderby_item::= [property_or_field_name] [ASC | DESC]
* </pre>
*
* <p> If <code>ASC</code> or <code>DESC</code> is not specified,
* <code>ASC</code> (ascending order) is assumed.
*
* <p> If the ordering element is not specified, ordering by
* the primary key of the associated entity is assumed.
*/
String value() default "";
}
Spring에서는 OrderBy라는 어노테이션을 지원합니다.
OrderBy 어노테이션을 사용하면 SQL의 ORDER BY와 유사한 역할을 하며, 컬렉션이 로드될 때 적용됩니다.
@Entity
public class Player {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private int lp;
private int level;
private int wins;
private int losses;
private double winRate;
}
@Entity
public class Leaderboard {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToMany
@OrderBy("lp DESC") // LP 기준으로 내림차순 정렬
private List<Player> players;
}
@OrderBy를 사용해 정렬을 한 코드입니다.
Leaderboard의 players를 조회하면 ORDER BY가 자동으로 적용되며 정렬된 요소를 볼 수 있습니다.
하지만 문제가 있습니다.
저장을 할 때 정렬된 순서로 저장을 하는 것이 아닌, 조회를 할 때마다 ORDER BY를 통해 정렬을 하기 때문에 인덱스를 통해 정렬을 하지 않거나, 대량의 데이터를 정렬할 때 성능상 이슈가 발생할 수 있습니다.
이를 위한 대안으로 Redis의 SortedSet이 있습니다.
Redis의 SortedSet은 데이터를 저장하는 순간 정렬을 하기 때문에 조회를 할 때마다 정렬을 하지 않아도 된다는 장점이 있습니다.
명령어와 함께 자세히 설명하겠습니다.
명령어
1. ZADD / O(logN)
키로 지정한 순서 집합에 지정한 점수와 멤버 쌍을 추가합니다.
void zaddCommand(client *c) {
zaddGenericCommand(c,ZADD_IN_NONE);
}
zaddGenericCommand의 주요한 코드와 함께 설명하겠습니다.
for (j = 0; j < elements; j++) {
double newscore;
score = scores[j];
int retflags = 0;
ele = c->argv[scoreidx+1+j*2]->ptr;
int retval = zsetAdd(zobj, score, ele, flags, &retflags, &newscore);
if (retval == 0) {
addReplyError(c,nanerr);
goto cleanup;
}
if (retflags & ZADD_OUT_ADDED) added++;
if (retflags & ZADD_OUT_UPDATED) updated++;
if (!(retflags & ZADD_OUT_NOP)) processed++;
score = newscore;
}
위 코드는 zaddGenericCommand 코드의 일부이며, 정렬된 집합에 요소를 추가하거나 기존 요소의 점수를 업데이트 하는 역할을 합니다.
for 루프를 통해 추가할 요소의 개수 만큼 반복을 하며, score이라는 변수에 현재 요소의 점수를 가져와 초기화 합니다.
그리고 현재 요소의 이름을 배열에서 가져와 ele라는 변수를 초기화 합니다.
zsetAdd 함수를 통해 함수를 정렬된 집합에 추가하거나 업데이트 합니다. (중요한 함수)
이후, retflags를 확인해 요소가 추가되었는지, 업데이트되었는지, 또는 아무 작업도 수행되지 않았는지 확인합니다.
마지막으로 최종 점수를 score 변수에 저장하며 끝이납니다.
ZINCRBY의 경우 최종 점수 (score)을 반환하며, ZADD의 경우 추가된 요소 수를 반환합니다.
ZADD의 추가 옵션
XX - 이미 존재하는 요소만 업데이트 합니다.
NX - 새로운 요소만 추가합니다. 기존 요소는 업데이트 하지 않습니다.
LT - 새 점수가 현재 점수보다 낮은 경우에만 기존 요소를 업데이트 합니다.
GT - 새 점수가 현재 점수보다 큰 경우에만 기존 요소를 업데이트 합니다.
CH - 새로 추가된 요소 수에서 반환 값을 변경된 총 요소수로 수정합니다.
INCR - ZADD를 ZINCRBY 처럼 동작하게 합니다. (점수를 증가시킵니다)
2. ZRANGE / O(logN + M)
키로 지정한 순서 집합에 지정한 점수 범위에 있는 멤버 목록을 오름차순으로 추출합니다.
/* ZRANGE <key> <min> <max> [BYSCORE | BYLEX] [REV] [WITHSCORES] [LIMIT offset count] */
void zrangeCommand(client *c) {
zrange_result_handler handler;
zrangeResultHandlerInit(&handler, c, ZRANGE_CONSUMER_TYPE_CLIENT);
zrangeGenericCommand(&handler, 1, 0, ZRANGE_AUTO, ZRANGE_DIRECTION_AUTO);
}
zrangeGenericCommand 함수와 함께 설명하겠습니다.
/* Step 2: Parse the range. */
switch (rangetype) {
case ZRANGE_AUTO:
case ZRANGE_RANK:
/* Z[REV]RANGE, ZRANGESTORE [REV]RANGE */
if ((getLongFromObjectOrReply(c, c->argv[minidx], &opt_start,NULL) != C_OK) ||
(getLongFromObjectOrReply(c, c->argv[maxidx], &opt_end,NULL) != C_OK))
{
return;
}
break;
case ZRANGE_SCORE:
/* Z[REV]RANGEBYSCORE, ZRANGESTORE [REV]RANGEBYSCORE */
if (zslParseRange(c->argv[minidx], c->argv[maxidx], &range) != C_OK) {
addReplyError(c, "min or max is not a float");
return;
}
break;
case ZRANGE_LEX:
/* Z[REV]RANGEBYLEX, ZRANGESTORE [REV]RANGEBYLEX */
if (zslParseLexRange(c->argv[minidx], c->argv[maxidx], &lexrange) != C_OK) {
addReplyError(c, "min or max not valid string range item");
return;
}
break;
}
if (opt_withscores || store) {
zrangeResultHandlerScoreEmissionEnable(handler);
}
위 함수는 rangeType에 따라 파싱 방법을 다르게 처리하는 함수입니다.
ZRANGE_AUTO 및 ZRANGE_RANK
- getLongFromObjectOrReply 함수를 사용해 min, max 인덱스 사이에 있는 값을 opt_start와 opt_end로 파싱합니다.
- 순위 기반 범위를 처리합니다.
ZRANGE_SCORE
- zslParseRange 함수를 사용하여 min, max 인덱스 사이에 있는 값을 range 구조체로 파싱합니다.
- 점수 기반 범위를 처리합니다.
ZRNAGE_LEX
- zslParseLexRange 함수를 사용해 min, max 인덱스 사이에 있는 값을 lexrange 구조체로 파싱합니다.
- 사전식 범위를 처리합니다.
이와 같이 zrangeGenericCommand 함수는 다양한 명령어를 처리하며, ZRANGE는 범위 옵션을 설정하지 않은 경우 ZRANGE_AUTO를 전달인자로 전달합니다. 그러므로 지정된 범위(min, max)에 있는 값들을 추출할 수 있습니다.
ZRANGE의 옵션
ZRANGE <key> <min> <max> [BYSCORE | BYLEX] [REV] [WITHSCORES] [LIMIT offset count]
Key - SortedSet의 키 이름입니다.
min, max - 조회할 범위의 최소값과 최대값입니다. (min, max에 해당하는 값을 포함해서 추출합니다)
BYSCORE - 점수를 기준으로 범위를 지정합니다.
BYLEX - 사전식 순서를 기준으로 범위를 지정합니다.
REV - 결과를 역순으로 반환합니다.
WITHSCORES - 각 요소와 함께 해당 요소의 점수도 반환합니다.
LIMIT offset count - 반환할 결과의 수를 제한합니다.
BYSCORE, BYLEX를 지정하지 않았을 때 ZRANGE는 순위를 기준으로 추출합니다.
그 외 SortedSet 명령어들
1. ZRANK / O(logN)
키로 지정한 순서 집합에 지정한 멤버의 오름차순으로 순위를 추출합니다.
2. ZREVERANK / O(logN)
키로 지정한 순서 집합에 지정한 멤버의 높은 순서대로 순위를 가져옵니다.
3. ZRANGESTORE / O(logN + M)
키로 지정한 순서 집합에 지정한 점수 범위에 있는 멤버 목록을 지정한 키에 저장합니다.
ZRANGE와 유사하게 동작하지만, 지정한 키에 저장하며 반환 값은 집합에 포함된 요소의 개수입니다.
4. ZERM / O(M * logN)
키로 지정한 순서 집합에서 지정한 멤버를 삭제합니다.
5. ZCOUNT / O(logN)
키로 지정한 순서 집합에 지정한 범위에 있는 멤버의 수를 반환합니다.
PUB / SUB
Pub/Sub 모델은 발신자인 발행자가 수신자인 구독자에게 정보를 저장하지 않고 메시지를 보내는 패턴입니다.
Redis에서 Pub/Sub은 자료형이 아닌 기능 형태로 제공합니다.
유스케이스
Pub/Sub은 채널을 통해 발행자가 구독자에게 메시지를 보낼 수 있다는 장점이 있지만, 구독자가 채널을 구독하기 전의 메시지는 확인할 수 없다는 단점이 있습니다. 또한 네트워크등의 문제로 채널 접속에 문제가 생겨도 메시지를 받을 수가 없습니다.
쉽게 카카오톡으로 예시를 들어보겠습니다.
Redis의 pub/sub을 이용한 카카오톡 세계에서 A와 B라는 사람이 있다고 가정해보겠습니다.
인물 B는 A의 사수 입니다.
밤 12시에 B는 갑자기 A가 업무 중에 물어본 것이 기억나 정성스럽게 A가 구독하고 있는 채널로 설명이 담긴 메시지를 보냈습니다.
하지만 이미 A는 이미 잠자리에 든 상태였습니다. 따라서 A의 핸드폰의 와이파이는 꺼져 있었고, 메시지를 받지 못했습니다.
다음날 A는 업무 중에 같은 질문을 B에게 또 했습니다.
그러자 B는 A에게 "내가 어제 설명 해줬는데 왜 또 질문을 하니?" 라는 말과 함께 짜증을 냈습니다.
A는 속으로 "자기가 안 보내놓고서 왜 나한테 짜증이야!!" 라는 생각을 했습니다.
이는 Redis의 Pub/Sub 모델에서 흔히 발생할 수 있는 상황으로, 메시지가 발행될 때 구독자가 온라인 상태가 아니면 그 메시지를 놓칠 수 있다는 단점을 잘 보여줍니다.
물론 추가 로직 (메시지 저장)을 통해 위 단점을 해결할 수 있습니다.
밑의 올리브영 쿠폰 발급의 경우 redis List형에 저장을 해 메시지 유실을 방지하였습니다.
(댓글을 보니 최근에는 RabbitMQ로 변경했다고 합니다.)
https://oliveyoung.tech/blog/2023-08-07/async-process-of-coupon-issuance-using-redis/
명령어
1. SUBSCRIBE / O(N)
지정한채널을 구독합니다.
void subscribeCommand(client *c) {
int j;
if ((c->flags & CLIENT_DENY_BLOCKING) && !(c->flags & CLIENT_MULTI)) {
/**
* A client that has CLIENT_DENY_BLOCKING flag on
* expect a reply per command and so can not execute subscribe.
*
* Notice that we have a special treatment for multi because of
* backward compatibility
*/
addReplyError(c, "SUBSCRIBE isn't allowed for a DENY BLOCKING client");
return;
}
for (j = 1; j < c->argc; j++)
pubsubSubscribeChannel(c,c->argv[j],pubSubType);
markClientAsPubSub(c);
}
코드를 보면 조건문을 통해 특정 조건을 만족 시키는 경우에는 에러를 반환하는 것을 볼 수 있습니다.
이를 자세히 설명하자면, SUBSCRIBE 명령어가 왜 블로킹 작업을 하는지에 대해 알아야 합니다.
SUBSCRIBE 명령어는 채널을 구독하는 명령어입니다. 그리고 구독을 통해서 발행자가 채널에 발행하는 메시지를 수신할 수 있습니다.
이때 발행자가 채널에 발행하는 메시지를 실시간으로 수신하기 위해 구독자는 채널에 계속 대기를 해야합니다.
대기를 하는 도중에 다른 명령어를 실행하면 메시지 유실 가능성이 있어 블로킹 작업을 하는 것입니다.
CLIENT_DENY_BLOCKING 플래그는 클라이언트가 블로킹 명령을 거절할 수 있는지 확인하는 것입니다.
CLIENT_DENY_BLOCKING 플래그가 설정된 클라이언트는 블로킹 명령을 허용하지 않기 때문에 SUBSCRIBE 명령어가 실행되고 메시지 수신을 위해 다른 명령들을 블로킹 하는 것을 거부합니다.
그러나 CLIENT_DENY_BLOCKING 플래그가 설정되어 있어도 MULTI/EXEC 트랜잭션 내에 있으면 예외적으로 허용이 됩니다.
트랜잭션 내에서는 명령들이 즉시 실행되지 않고 큐에 쌓이기 때문에 블로킹 동작이 실제로 발생하지 않습니다.
그래서 CLIENT_DENY_BLOCKING 플래그가 설정되어 있고, MULTI/EXEC 트랜잭션 안에 있지 않은 클라이언트는 예외 메시지와 함께 구독을 할 수 없는 것입니다.
그 후, for 루프를 통해 클라이언트가 요청한 모든 채널을 구독합니다.
마지막으로 markClientAsPubSub 함수를 통해 클라이언트를 pub/sub 모드로 설정합니다.
이 과정을 통해 클라이언트는 대기 상태로 변경됩니다.
조금 더 자세히 설명하자면 (너무 깊게 들어가는게 아닌가 싶네요)
void markClientAsPubSub(client *c) {
if (!(c->flags & CLIENT_PUBSUB)) {
c->flags |= CLIENT_PUBSUB;
server.pubsub_clients++;
}
}
클라이언트에 CLIENT_PUBSUB 플래그를 설정합니다.
실제로 CLIENT_PUBSUB 플래그는 networking.c에서 클라이언트 유형을 결정하는데 사용이 됩니다.
int getClientType(client *c) {
if (c->flags & CLIENT_MASTER) return CLIENT_TYPE_MASTER;
/* Even though MONITOR clients are marked as replicas, we
* want the expose them as normal clients. */
if ((c->flags & CLIENT_SLAVE) && !(c->flags & CLIENT_MONITOR))
return CLIENT_TYPE_SLAVE;
if (c->flags & CLIENT_PUBSUB) return CLIENT_TYPE_PUBSUB;
return CLIENT_TYPE_NORMAL;
}
CLIENT_PUBSUB의 경우 CLIENT_TYPE_PUBSUB으로 반환이 되며, 이로 인해 대기 상태로 변경됩니다.
2. UNSUBSCRIBE / O(N)
구독자가 지정한 채널 구독을 종료합니다. 하나 이상의 채널을 종료할 수 있으며, 지정하지 않은 경우 모든 채널 구독을 종료합니다.
/* UNSUBSCRIBE [channel ...] */
void unsubscribeCommand(client *c) {
if (c->argc == 1) {
pubsubUnsubscribeAllChannels(c,1);
} else {
int j;
for (j = 1; j < c->argc; j++)
pubsubUnsubscribeChannel(c,c->argv[j],1,pubSubType);
}
if (clientTotalPubSubSubscriptionCount(c) == 0) {
unmarkClientAsPubSub(c);
}
}
c -> argc가 1인 경우는 구독 취소할 채널을 지정하지 않은 경우이며 모든 채널에 대한 구독을 취소합니다.
그 외의 경우에서는 pubsubUnsubscribe 함수를 통해 지정한 채널에 대해 구독을 취소하며, unmarkClientAsPubSub 함수를 통해 클라이언트를 Pub/Sub 상태에서 해제합니다.
그 외 다른 Pub/Sub 명령어
3. PUBSUBSCRIBE / O(N)
구독자가 지정한 채널을 구독합니다. SUBSCRIBE 명령어와 달리 패턴을 통해 채널 이름을 지정할 수 있습니다.
4. PUNSUBSCRIBE / O(N + M)
구독자가 지정한 채널을 구독 종료합니다. UNSUBSCRIBE 명령어와 다르게 패턴을 통해 채널 이름을 지정할 수 있습니다.
shared pub/sub은 클러스터에 대한 글을 작성할 때 같이 설명하겠습니다.
Redis Stream
후속편에서 더 자세히 설명하겠습니다.
본 편에서는 Pub/Sub과 차이점에 중점을 둬서 설명하겠습니다.
Conceptually, a Stream in Redis is list where you can append entries. Each entry has a unique ID and a value.
개념적으로 Redis의 Stream은 항목을 추가할 수 있는 목록입니다. 각 항목에는 고유한 ID와 값이 있습니다.
이와 같이 Redis Stream은 채팅 등과 같은 메시지 교환에도 활용할 수 있으며, 기본적으로는 강력한 메시지 처리 기능을 가진 추가형 자료구조를 사용하고 있습니다.
Pub/Sub과의 차이점에 대해 설명해줘
1. 데이터 저장
Pub/Sub은 발행/구독 플랫폼입니다. 발행된 메시지는 구독자의 여부에 상관 없이 휘발됩니다.
Redis Stream에서는 Stream 자체가 데이터 타입이자, 데이터 구조입니다. 메시지나 항목이 메모리에 저장되며 명령이 있을 때까지 삭제되지 않습니다.
스트림 자료형은 시간순으로 정렬된 항목(메시지)들의 리스트입니다.
각 항목은 고유한 ID와 하나 이상의 필드-값 쌍으로 구성됩니다.
2. 동기 / 비동기 통신
동기식 통신이란?
송신자와 수신자가 동시에 데이터를 주고 받는 특징을 가지고 있습니다.
응답을 받을 때까지 다른 작업을 수행할 수 없습니다.
비동기식 통신이란?
송신자와 수신자가 다른 작업을 수행하고 있어도 데이터를 받을 수 있다는 특징을 가지고 있습니다.
응답이 도착하면 알림을 받아 처리합니다.
동기 / 비동기 통신 개념을 Pub/Sub과 Redis Stream에 적용시키겠습니다.
Pub/Sub은 동기 통신을 합니다. 모든 참여자가 동시에 활성화되어 있어야 통신할 수 있습니다.
그에 비해 Redis Stream은 동기식과 비동기식 통신을 모두 허용합니다. (명령어와 함께 설명하겠습니다)
3. 블로킹 모드
Pub/Sub은 블로킹 모드만 지원합니다.
채널을 구독하면 클라이언트는 구독자 모드로 전환되며, 채널 관련 명령을 제외하곤 명령을 실행할 수 없습니다.
그에비해 Redis Stream은 구독자가 블로킹 모드에서 메시지를 읽을지 여부를 결정할 수 있습니다.
유스케이스
Redis pub/sub을 이용한 카카오톡 세계와 달리 Redis Stream을 이용한 카카오톡 세계에서는 수신자의 활동 여부에 상관 없이 데이터가 저장되기 때문에 개별 데이터 수신을 놓치지 않을 수 있게 되었습니다.
명령어
1. XADD / O(1)
지정한 키 스트림에 엔트리를 추가합니다. 트리밍을 할 때 N개의 엔트리를 백업하는 경우, 처리 시 O(N)의 시간 복잡도가 발생합니다.
2. XRANGE / O(N)
지정한 키 스트림에 지정한 엔트리 ID 범위 내 일치하는 모든 엔트리를 ID가 큰 순서대로 반환합니다.
3. XREAD / O(N)
지정한 하나 이상의 키 스트림에서 지정한 엔트리 ID 이후의 엔트리 데이터를 읽어옵니다.
4. XDEL / O(1)
지정한 키 스트림 내에 지정한 ID엥ㄴ트리를 삭제합니다.
다음 글
Spring 프레임워크에서 Redis를 활용하는 방법에 대해 작성해보려 합니다.
Jedis, Lettuce, Spring Data Redis등 다양한 라이브러리를 다뤄보려 합니다.