[분산캐시] Redis 와 memcache의 flush는 왜 다를까?

Twitter에서 열심히 놀던 중에, 고수님이신 @zerocool_kor 님께서 아래와 같은 트윗을 날리셨습니다.

저는 물론 다 알고 계실분이 왜 이런 질문을 올리실까 하면서 FLUSHDB 라는 명령을 언급했습니다. Redis 에는 FLUSHDB라는 명령이 전체 데이터를 삭제하는 기능을 합니다.

그런데 다음과 같은 내용을 알려주시는 겁니다. 역시나 직접 테스트 다 해보시고 이상한 동작을 문의하신거죠.

그렇습니다. FLUSHDB를 하는 동안 다른 redis의 동작이 대기하는 현상이 있는 것입니다. 사실 이것은 Redis 구조상 아주 당연한 일입니다. 왜냐하면 간단하게 설명하면 Redis 가 싱글 스레드로 동작하기 때문입니다. Memcached도 마찬가지입니다. 다만 Memcached의 경우는 실제로는 멀티 스레드인데, 데이터에 접근하는 부분은 Mutex로 인해서 동기화되어서, Command 파싱까지는 멀티 스레드가 실제 데이터의 읽기 등은 전부 싱글 스레드처럼 동기화되어서 동작하게 됩니다.( 똑똑하신 분들은 이건 실제 싱글 스레드처럼 동작하는 serialization 이 아니라고도 하시지만 전 무식해서 그냥 이렇게 설명합니다.) 그래서 redis, memcached의 경우, 시간을 많이 먹는 단일 작업이 들어가면 전체적으로 성능이 떨어지는 것을 체감하시게 됩니다.

 

그래서 제가 다음과 같은 답변을 드립니다. 실제 싱글 스레드이므로 다른 동작이 블록되는 것이 맞는듯 합니다. 다만 워낙 동작이 달라서, 해당 지연이 눈에 띄지 않는 것이라는 내용입니다.(물론 이건 다시 정리한 표현이니 다르다고 돌은 던지지 마세요.)

Redis에서 약 200만개의 데이터를 임시로 넣어두고, 추가로 200만개를 넣으면서 FLUSHDB 명령을 날리니 약 1.1초 정도의 시간이 걸리고 그 동안에 블록이 되는 것을 볼 수 있었습니다. 시간 자체는 크게 의미를 가지지 마시기 바랍니다. 장비마다 다 틀립니다. 여기서 사용한 코드는 다음과 같습니다.

 


import redis

 

r = redis.StrictRedis( host='127.0.0.1', port=1212 )

for i in xrange(0,4000000):

r.set( str(i), str(i) )

print i

 

그런데 재미있는 것은 같은 싱글 스레드 구조인 memcached 에서는 이런 지연이 없다는 것입니다. 즉, flush_all 이라는 명령을 주면 데이터가 바로 사라집니다. 왜 이런 차이가 있는 것일까요? 무지무지하게 궁금하지 않습니까?(안 궁금하시면 저만 알고 넘어가도록, 퍽퍽퍽!!!)

 

일단 힌트는 고수이신 @zerocool_kor 님이 말씀해주십니다.

네 그렇습니다. Memcached는 flush_all 이 들어온 시점에 데이터를 지우지 않습니다. 나중에 get 을 할 때, 발견하고 지워버리는 거죠. 간단하죠? 믿을 수 없다라는 분들을 위해서 이제 소스로 설명을 드리겠습니다. 먼저 Redis 부터 보시죠. Redis는 2.4.13 버전을 기준으로 합니다.

 

Redis의 db.c 파일의 198 라인에 flushdbCommand 라는 함수가 있습니다. 이 함수를 보시면 그냥 간단히 다음과 같습니다.

 


void flushdbCommand(redisClient *c) {

server.dirty += dictSize(c->db->dict);

signalFlushedDb(c->db->id);

dictEmpty(c->db->dict);

dictEmpty(c->db->expires);

addReply(c,shared.ok);

}

 

간단하죠? 여기서 실제로 dictEmpty 함수에서 삭제가 일어납니다. Redis는 내부적으로 dict 라는 자료구조 형태를 이용하는데, 이건 다음번에 다시 소개를 드리도록 하고 이번에 패스( 왜냐하면 제가 잘 모르니 ㅋㅋㅋ)

 

dictEmpty 함수는 다음과 같습니다. Dict.c 의 584라인에 있습니다.


void dictEmpty(dict *d) {

_dictClear(d,&d->ht[0]);

_dictClear(d,&d->ht[1]);

d->rehashidx = -1;

d->iterators = 0;

}

 

다시 _dictClear 함수를 봅니다. Dict.c의 358 라인에 있습니다. 소스를 보시면 루프를 돌면서 하나씩 제거해가는게 보입니다. 이게 200만개를 지우는 거죠.

 


int _dictClear(dict *d, dictht *ht)

{

unsigned long i;

 

/* Free all the elements */

for (i = 0; i < ht->size && ht->used > 0; i++) {

dictEntry *he, *nextHe;

&nbsp;

if ((he = ht->table[i]) == NULL) continue;

while(he) {

nextHe = he->next;

dictFreeEntryKey(d, he);

dictFreeEntryVal(d, he);

zfree(he);

ht->used--;

he = nextHe;

}

}

/* Free the table and the allocated cache structure */

zfree(ht->table);

/* Re-initialize the table */

_dictReset(ht);

return DICT_OK; /* never fails */

 

간단히 정리하면 다음과 같은 순서로 흘러갑니다.

 

 

이제 memcached 의 소스를 봐야합니다. 뭐 이것은 두 부분을 봐야 하는데요. 먼저 flush_all을 통해서 어떤 동작이 일어나는지 알아보도록 하겠습니다. 이것도 간단합니다. Memcached는 1.4.13 버전을 기준으로 합니다.

 

Memcached.c 의 3290 라인을 살펴봅니다. 이 코드는 static void process_command(conn *c, char *command) 라는 함수 안에 존재합니다. 중요한 부분만 발췌하면, settings.oldest_live 가 셋팅되는 부분만 보면 됩니다. 이게 뭐하는 거냐 하면, flush_all 이 들어온 시간을 기록해둡니다. 아까 @zerocool_kor 님의 멘션을 기억하세요. 기준 시간을 정해놓고 이거 이전께 들어오면 그냥 다 NULL 입니다. 이러는 겁니다. 참 쉽죠잉?

 


time_t exptime = 0;

&nbsp;

set_noreply_maybe(c, tokens, ntokens);

&nbsp;

pthread_mutex_lock(&c->thread->stats.mutex);

c->thread->stats.flush_cmds++;

pthread_mutex_unlock(&c->thread->stats.mutex);

&nbsp;

if(ntokens == (c->noreply ? 3 : 2)) {

settings.oldest_live = current_time - 1;

item_flush_expired();

out_string(c, "OK");

return;

}

&nbsp;

exptime = strtol(tokens[1].value, NULL, 10);

if(errno == ERANGE) {

out_string(c, "CLIENT_ERROR bad command line format");

return;

}

&nbsp;

/*

If exptime is zero realtime() would return zero too, and

realtime(exptime) - 1 would overflow to the max unsigned

value.  So we process exptime == 0 the same way we do when

no delay is given at all.

*/

if (exptime > 0)

settings.oldest_live = realtime(exptime) - 1;

else /* exptime == 0 */

settings.oldest_live = current_time - 1;

item_flush_expired();

out_string(c, "OK");

return;

 

이제 실제로 get 할 때 어떻게 될 것인가를 봐야합니다. 이것 저것 다 빼고나면 실제로 items.c의 483라인의 do_item_get 이라는 함수가 호출됩니다.

 

못 믿기시겠다는 분은 gdb로 디버깅 걸어보시면 됩니다. Gdb 설명은 생략합니다. 다음이 해당 함수의 소스입니다. 참 길죠잉?

 


item *do_item_get(const char *key, const size_t nkey, const uint32_t hv) {

mutex_lock(&cache_lock);

item *it = assoc_find(key, nkey, hv);

if (it != NULL) {

refcount_incr(&it->refcount);

/* Optimization for slab reassignment. prevents popular items from

* jamming in busy wait. Can only do this here to satisfy lock order

* of item_lock, cache_lock, slabs_lock. */

if (slab_rebalance_signal &&

((void *)it >= slab_rebal.slab_start && (void *)it < slab_rebal.slab_end)) {

do_item_unlink_nolock(it, hv);

do_item_remove(it);

it = NULL;

}

}

pthread_mutex_unlock(&cache_lock);

int was_found = 0;

&nbsp;

if (settings.verbose > 2) {

if (it == NULL) {

fprintf(stderr, "> NOT FOUND %s", key);

} else {

fprintf(stderr, "> FOUND KEY %s", ITEM_key(it));

was_found++;

}

}

&nbsp;

if (it != NULL) {

if (settings.oldest_live != 0 && settings.oldest_live <= current_time &&

it->time <= settings.oldest_live) {

do_item_unlink(it, hv);

do_item_remove(it);

it = NULL;

if (was_found) {

fprintf(stderr, " -nuked by flush");

}

} else if (it->exptime != 0 && it->exptime <= current_time) {

do_item_unlink(it, hv);

do_item_remove(it);

it = NULL;

if (was_found) {

fprintf(stderr, " -nuked by expire");

}

} else {

it->it_flags |= ITEM_FETCHED;

DEBUG_REFCNT(it, '+');

}

}

&nbsp;

if (settings.verbose > 2)

fprintf(stderr, "\n");

&nbsp;

return it;

}

 

핵심만 보여달라는 분을 위해서 여기를 보시면 됩니다.

 


if (settings.oldest_live != 0 && settings.oldest_live <= current_time &&

it->time <= settings.oldest_live) {

…

…

}

 

보면 settings.oldest_live 가 current_time(현재시간) 보다 적고, 0이 아니라는 것은 한번 이상 flush_all이 실행되었다는 뜻입니다. 해당 초기값이 0이거든요. 그리고 발견된 item의 시간과 oldest_live를 비교하면, 해당 key가 flush_all 이전인지 이후인지 판단이 가능하겠죠? 그래서 memcached 에서는 실제로 아이템이 지워지는 시간이 거의 없는 것 처럼 느껴지는 겁니다.

 

그러나 뭐든지 장점과 단점이 있는 법!!!, memcached는 실제 키를 찾고 값을 비교하고 expire time이 지난 뒤에 삭제하는 형태이므로, get이 기존보다 조금 느려지게 됩니다. 뭐 그래도 엄청 빠니, 크게 신경을 안 써도 된다는 장점도 있습니다.

 

그럼 우리의 궁금점은 왜 -_-, why, 어째서, how come!!! Redis는 일일이 다 삭제하는 코드를 만든 것일까요? Redis 개발자가 memcached 개발자보다 덜똑똑해서( 물론, memcached 개발자가 괴물인건 분명합니다 1980년 생인데, 무슨 초천재를 보는듯한 T.T 난 뭐했나!!!) 일까요?

 

저는 개인적으로 redis 와 memcached의 구조적인 차이 때문이라고 생각합니다. Memcached 가 캐시 자체에만 집중했다면, 그래서 메모리를 관리하는 구조도 서로 다릅니다. Memcached는 slab 할당과 chunk 구조를 이용합니다. 반대로 Redis는 pub/sub 등의 기능과 key의 변경을 noti 받는 기능이 있습니다. 그래서 실제 flush가 되기 전에 이런 키들에 대해서 키가 삭제된다는 알람을 줘야 합니다. 해당 코드가 아까 살짝 넘어간 signalFlushedDb 라는 함수입니다. 그래서 어느 구조가 더 좋다라고 말할 수 없을 것 같습니다. 그래서 조금이라도 처리속도가 느려지면 안되는 경우에 Redis의 FLUSHDB는 위험할 수도 있습니다.

 

여담으로, Redis는 싱글 스레드다라고 말하면 안믿는 분들이 계십니다. 그럼 백그라운드로 메모리를 백업하는 BGSAVE는 어떻게 만들어진거냐!!! 말도 안된다라는 분들에게 한마디 하자면, BGSAVE는 fork 한 후에 child Process 가 처리해주는 겁니다. 속지마세요. 코드가 궁금하신 분은 rdb.c의 499라인을 보시면 됩니다.