[입 개발] Memcached 의 delete 에 대한 변천사…

최근에 twemproxy 에서 delete 가 제대로 안된다는 제보를 듣고, 코드를 살펴보길 시작했었는데요. 여기서 재미난 것을 발견해서 포스팅을 할려고 합니다.(오래간만의 포스팅이네요.)

먼저 최근의 memcached 소스의 delete 를 보면 다음과 같습니다. 코드를 보면 ntokens 가 3보다 클 경우, 뒤에 0이 나오거나 noreply 가 있을때만 허용이 되고 있습니다.

즉 다음과 같은 경우가 허용이 됩니다. 다만 number는 0만 허용이 됩니다.

delete <key>
delete <key> <number>
delete <key> <noreply>
delete <key> <number> <noreply>

다음 코드를 보면… 쉽게 이해가 되실껍니다.

static void process_delete_command(conn *c, token_t *tokens, const size_t ntokens) {
    char *key;
    size_t nkey;
    item *it;

    assert(c != NULL);

    if (ntokens > 3) {
        bool hold_is_zero = strcmp(tokens[KEY_TOKEN+1].value, "0") == 0;
        bool sets_noreply = set_noreply_maybe(c, tokens, ntokens);
        bool valid = (ntokens == 4 && (hold_is_zero || sets_noreply))
            || (ntokens == 5 && hold_is_zero && sets_noreply);
        if (!valid) {
            out_string(c, "CLIENT_ERROR bad command line format.  "
                       "Usage: delete <key> [noreply]");
            return;
        }
    }

    key = tokens[KEY_TOKEN].value;
    nkey = tokens[KEY_TOKEN].length;

    if(nkey > KEY_MAX_LENGTH) {
        out_string(c, "CLIENT_ERROR bad command line format");
        return;
    }

    if (settings.detail_enabled) {
        stats_prefix_record_delete(key, nkey);
    }

    it = item_get(key, nkey);
    if (it) {
        MEMCACHED_COMMAND_DELETE(c->sfd, ITEM_key(it), it->nkey);

        pthread_mutex_lock(&c->thread->stats.mutex);
        c->thread->stats.slab_stats[it->slabs_clsid].delete_hits++;
        pthread_mutex_unlock(&c->thread->stats.mutex);

        item_unlink(it);
        item_remove(it);      /* release our reference */
        out_string(c, "DELETED");
    } else {
        pthread_mutex_lock(&c->thread->stats.mutex);
        c->thread->stats.delete_misses++;
        pthread_mutex_unlock(&c->thread->stats.mutex);

        out_string(c, "NOT_FOUND");
    }
}

그런데 java의 spymemcached 나 ruby 라이브러리를 보면 실제 위의 파트를 보내지
않습니다. 그런데 python의 경우는 time을 지정할 경우는 값이 전달되면서 실제로
memcached에서 delete가 실패하게 됩니다. 디폴트로는 0이 전달되게 되므로 사실 큰 문제는 없습니다.

    def delete_multi(self, keys, time=0, key_prefix='', noreply=False):
        """Delete multiple keys in the memcache doing just one query.
        >>> notset_keys = mc.set_multi({'a1' : 'val1', 'a2' : 'val2'})
        >>> mc.get_multi(['a1', 'a2']) == {'a1' : 'val1','a2' : 'val2'}
        1
        >>> mc.delete_multi(['key1', 'key2'])
        1
        >>> mc.get_multi(['key1', 'key2']) == {}
        1
        This method is recommended over iterated regular L{delete}s as
        it reduces total latency, since your app doesn't have to wait
        for each round-trip of L{delete} before sending the next one.
        @param keys: An iterable of keys to clear
        @param time: number of seconds any subsequent set / update
        commands should fail. Defaults to 0 for no delay.
        @param key_prefix: Optional string to prepend to each key when
            sending to memcache.  See docs for L{get_multi} and
            L{set_multi}.
        @param noreply: optional parameter instructs the server to not send the
            reply.
        @return: 1 if no failure in communication with any memcacheds.
        @rtype: int
        """

        self._statlog('delete_multi')

        server_keys, prefixed_to_orig_key = self._map_and_prefix_keys(
            keys, key_prefix)

        # send out all requests on each server before reading anything
        dead_servers = []

        rc = 1
        for server in six.iterkeys(server_keys):
            bigcmd = []
            write = bigcmd.append
            extra = ' noreply' if noreply else ''
            if time is not None:
                for key in server_keys[server]:  # These are mangled keys
                    write("delete %s %d%s\r\n" % (key, time, extra))
            else:
                for key in server_keys[server]:  # These are mangled keys
                    write("delete %s%s\r\n" % (key, extra))
            try:
                server.send_cmds(''.join(bigcmd))
            except socket.error as msg:
                rc = 0
                if isinstance(msg, tuple):
                    msg = msg[1]
                server.mark_dead(msg)
                dead_servers.append(server)

        # if noreply, just return
        if noreply:
            return rc

        # if any servers died on the way, don't expect them to respond.
        for server in dead_servers:
            del server_keys[server]

        for server, keys in six.iteritems(server_keys):
            try:
                for key in keys:
                    server.expect("DELETED")
            except socket.error as msg:
                if isinstance(msg, tuple):
                    msg = msg[1]
                server.mark_dead(msg)
                rc = 0
        return rc

그런데 이게 twemproxy 에서는 문제를 일으킵니다. 현재 twemproxy는 다음과 같은 룰만 허용합니다.

delete <key>

즉 뒤에 0이 붙으면… 그냥 잘라버립니다. T.T 그래서 실제로 python-memcache를 쓸 경우, delete_multi를 하면 실제 delete가 안되는 거죠. 흑흑흑…

그런데… 여기서 이유를 찾은게 문제가 아니라… 왜 이 코드가 있을까요? 이 의문을 가진건 위의 memcached 코드에 0이 아니면 에러가 나게 된 코드가… github을 보면… 이 코드가 2009년 11월 25일에 커밋된 코드라는 겁니다.(지금이 2014년인데!!!)

그래서 memcached 코드를 1.4.0 부터 현재 버전까지 뒤져봤습니다. 해당 코드는 그대로입니다. -_- 약간 변경이 있긴한데… 위의 time을 쓰는 코드는 아니었습니다. 그럼 이건 뭐지 할 수 있습니다.

실제로 1.2.7 소스를 보니 defer_delete 라고 해서 시간을 주면 해당 시간 뒤에 삭제되는 명령이 있었습니다. 정확히는 to_delete라는 곳에 넣어두고, item의 expire time을 지워져야할 시간으로 셋팅해주는 겁니다.

그러니 그 관련 코드가 아직까지도 python-memcache에 생존해 있던 것이죠 T.T 흑흑흑, 아마 현재는 거의 아무도 안쓰지 않을가 싶은… 다른 언어도 만들어서 쓸 수 있지만… 최신 버전의 memcached에서는 불가능 하다는…

그냥 이런 이슈가 있다는 걸… 알고 넘어가시면 좋을듯 합니다. 실제로 delete key time noreply 로 현재의 memcached에 호출하면, 실제로는 내부적으로 에러로 처리되서 실제로 안지워지지만, 응답은 먹히는 버그가 있습니다만… 이건 noreply 명령이 여러개일때 실제로 어는것이 에러가 난지를 알 수가 없기 때문에 그냥 known issue로 하고… 못 고친다는…

[입 개발] memcached slabclass 에 대해서…

오래간 만에 memcached 소슬 보니, 너무 잘못 이해하고 있거나 한것들이 많아서 처음부터 새로 보면서 이번에는 기록을 좀 남겨둘려고 합니다. 흔히들 memcached가 내부적으로 메모리를 어떻게 관리하는지 잘 아시지만, 코드 레벨로는 잘 모르실 수도 있기 때문에 그냥 정리합니다.

먼저 간단히 용어를 정리하자면…

  • chunk_size : key + value + flag 정보를 저장하기 위한 최소 크기: 기본 48
  • factor : item size 크기를 얼마만큼씩 증가시킬지 결정하는 값: 기본 1.25
  • Chunk_align_bytes : chunk 할당시에 사용하는 align : 8
  • item_size_max: 최대 item의 크기: 기본 1MB

그리고 사이즌 chunk_size * 1.25^(n-1) 형태로 증가하게 됨.

이제 slab.c를 보면 slabclass_t 를 볼 수 있습니다.

typedef struct {
    unsigned int size;      /* sizes of items */
    unsigned int perslab;   /* how many items per slab */

    void *slots;           /* list of item ptrs */
    unsigned int sl_curr;   /* total free items in list */

    unsigned int slabs;     /* how many slabs were allocated for this class */

    void **slab_list;       /* array of slab pointers */
    unsigned int list_size; /* size of prev array */

    unsigned int killing;  /* index+1 of dying slab, or zero if none */
    size_t requested; /* The number of requested bytes */
} slabclass_t;

static slabclass_t slabclass[MAX_NUMBER_OF_SLAB_CLASSES];

MAX_NUMBER_OF_SLAB_CLASSES 는 201로 정의되어 있습니다. 즉 최대 201개의 slabclass 가 만들어지는데, 실제로는 chunk_size 와 factor 값에 따라서 최대 item_size_max를 넘어가 버리면, slabclass는 거기까지만 사용됩니다.(slab_init 를 보면 쉽게 알 수 있습니다.)

/**
 * Determines the chunk sizes and initializes the slab class descriptors
 * accordingly.
 */
void slabs_init(const size_t limit, const double factor, const bool prealloc) {
    int i = POWER_SMALLEST - 1;
    unsigned int size = sizeof(item) + settings.chunk_size;

    ......
    ......

    while (++i < POWER_LARGEST && size <= settings.item_size_max / factor) {
        /* Make sure items are always n-byte aligned */
        if (size % CHUNK_ALIGN_BYTES)
            size += CHUNK_ALIGN_BYTES - (size % CHUNK_ALIGN_BYTES);

        slabclass[i].size = size;
        slabclass[i].perslab = settings.item_size_max / slabclass[i].size;
        size *= factor;
        if (settings.verbose > 1) {
            fprintf(stderr, "slab class %3d: chunk size %9u perslab %7u\n",
                    i, slabclass[i].size, slabclass[i].perslab);
        }
    }

    power_largest = i;
    slabclass[power_largest].size = settings.item_size_max;
    slabclass[power_largest].perslab = 1;
    if (settings.verbose > 1) {
        fprintf(stderr, "slab class %3d: chunk size %9u perslab %7u\n",
                i, slabclass[i].size, slabclass[i].perslab);
    }

    ......
    ......

위의 소스에 나오는 size는 slab별로 할당되는 기본 사이즈의 크기이고 위의 slabclass 구조체에는 item_size_max(기본 1MB) 를 넣어주고, perslab 에는 item_size_max / size로 몇개의 아이템이 들어갈 수 있는지 들어가게됩니다.

그리고 이 slab 안에 array로 item 들이 할당되게 됩니다. 기본적으로 array의 크기는 16으로 설정되고 그 뒤로는 2배씩 증가하게 됩니다. 관련 함수는 grow_slab_list를 보시면 됩니다. 그리고 slab에서 사용하는 chunk가 항상 item_size_max 인것은 아니고, size * perslab으로 될 때도 있습니다.(do_slabs_newslab 에서 확인 가능, memory_allocate 를 이용함)

static int do_slabs_newslab(const unsigned int id) {
    slabclass_t *p = &slabclass[id];
    int len = settings.slab_reassign ? settings.item_size_max
        : p->size * p->perslab;
    char *ptr;

    if ((mem_limit && mem_malloced + len > mem_limit && p->slabs > 0) ||
        (grow_slab_list(id) == 0) ||
        ((ptr = memory_allocate((size_t)len)) == 0)) {

        MEMCACHED_SLABS_SLABCLASS_ALLOCATE_FAILED(id);
        return 0;
    }

    memset(ptr, 0, (size_t)len);
    split_slab_page_into_freelist(ptr, id);

    p->slab_list[p->slabs++] = ptr;
    mem_malloced += len;
    MEMCACHED_SLABS_SLABCLASS_ALLOCATE(id);

    return 1;
}

slabclass 는 여기까지 하고, 다음번에는 실제 item 의 추가 삭제시에 어떻게 되는가에 대해서 정리해보도록 하겠습니다.

[입 개발] Memcache 와 Redis 에서 incr/decr 은 어떻게 동작할까?

최근에 “memcache에서 incr/decr 의 경우에 int/long 에 따라서 특별한 이슈가 발생하지 않는가?” 에 대한 질문을 받았습니다. 먼저 결론부터 말하면 memcache/redis 에서 이로 인해서 발생하는 특별한 이슈는 없습니다. 기본적으로 memcache/redis 가 모든 값을 스트링 형태로 저장하기 때문입니다.(다만 redis는 좀 복잡합니다.) 그리고 이 값을 strtoll 같은 함수를 이용해서 integer 형태로 바꾼다음에 delta 값을 저장하는 형태기 때문에 그렇습니다. 그리고 memcache/redis 모두 incr/decr이 실제로는 하나의 코드로 동작합니다.( +/- 일뿐이니깐요)

memecache 쪽 소스를 살펴보면 process_arithmetic_command() 함수에서 incr/decr을 처리하게 됩니다.

static void process_arithmetic_command(conn *c, token_t *tokens, const size_t ntokens, const bool incr) {
    char temp[INCR_MAX_STORAGE_LEN];
    uint64_t delta;
    char *key;
    size_t nkey;

    assert(c != NULL);

    set_noreply_maybe(c, tokens, ntokens);

    if (tokens[KEY_TOKEN].length > KEY_MAX_LENGTH) {
        out_string(c, "CLIENT_ERROR bad command line format");
        return;
    }    

    key = tokens[KEY_TOKEN].value;
    nkey = tokens[KEY_TOKEN].length;

    if (!safe_strtoull(tokens[2].value, &delta)) {
        out_string(c, "CLIENT_ERROR invalid numeric delta argument");
        return;
    }

    switch(add_delta(c, key, nkey, incr, delta, temp, NULL)) {
    case OK:
        out_string(c, temp);
        break;
    case NON_NUMERIC:
        out_string(c, "CLIENT_ERROR cannot increment or decrement non-numeric value");
        break;
    case EOM:
        out_string(c, "SERVER_ERROR out of memory");
        break;

    case DELTA_ITEM_NOT_FOUND:
        pthread_mutex_lock(&c->thread->stats.mutex);
        if (incr) {
            c->thread->stats.incr_misses++;
        } else {
            c->thread->stats.decr_misses++;
        }
        pthread_mutex_unlock(&c->thread->stats.mutex);

        out_string(c, "NOT_FOUND");
        break;
    case DELTA_ITEM_CAS_MISMATCH:
        break; /* Should never get here */
    }
}

위의 소스를 보면 strtoull() 을 래핑한 safe_strtoull() 함수가 있는데, 처음에는 먼저 incr/decr 다음의 delta 값을 integer 형태로 바꾸는 부분입니다. 실제 value 값은 add_delta 함수에서 이루어집니다. 실제로 add_delta()는 do_add_delta() 를 호출하고 거기서 다시 safe_strtoull() 함수를 이용해서 value를 integer로 변환합니다. 그런데 여기서 중요한 점은 이런 연산과정이 아니라 strtoull() 함수입니다. 숫자형이 아니면 예를 들어 value 가 “123” 형태가 아닌 “abc” 이런 값이 있다면, 실패하고 이에 대해서 실패로 리턴하게 됩니다. 즉 숫자형태로 변환되지 않는 데이터를 넣으면 incr/decr 연산은 실패하게 됩니다. 즉 memcache에 바이너리 형태로 숫자값을 넣는다면 incr/decr은 쓸 수 없다가 되어버리는 것입니다. 결국 안에서 사용하는 형태는 항상 64bit이니 문제가 될 것이 없습니다.

그럼 redis는 어떨가요? redis 도 전체적으로 memcache 와 유사합니다. 그런데!!!, redis에는 string 형태에 encoding 형태가 REDIS_ENCODING_INT 인것이 있습니다. 그럼 왜 REDIS_ENCODING_INT 를 사용하는가 하면, 메모리를 최대한 줄이기 위해서 입니다. 실제 문자열의 경우 32bit long의 경우 4글자에 값이 들어가는데, 스트링형태로 저장하면 많은 메모리를 차지하게 됩니다. 이게 재미있는게, 최초의 set 에는 스트링으로 그대로 저장이 되고, incr/decr 이 발생하면 그 때, 이 결과값이 LONG 범위안에 들어가면 이 때 createStringObjectFromLongLong() 함수를 이용해서 REDIS_ENCODING_INT 형태로 저장하게 됩니다. 정정합니다. 오산돌구님 말씀대로 setGenericCommand를 호출하기 전에 파라매터에 대해서 tryObjectEncoding을 호출합니다. 여기서 long 범위에 들어가는 것 중에서 10000이하는 공유하는 shared.integers를 사용하고, 그 이외에는 실제로 REDIS_ENCODING_INT로 저장합니다. 소스는 다음과 같습니다. 결론적으로 최초에 들어갈 때 long 범위면 바로 변경되고, 그게 아니더라도 뒤에 변경할 수
있으면 변경된다고 보시면 될것 같습니다.(오산돌구님께 감사를 ㅎㅎㅎ)

robj *tryObjectEncoding(robj *o) {
    long value;
    sds s = o->ptr;

    if (o->encoding != REDIS_ENCODING_RAW)
        return o; /* Already encoded */

    /* It's not safe to encode shared objects: shared objects can be shared
     * everywhere in the "object space" of Redis. Encoded objects can only
     * appear as "values" (and not, for instance, as keys) */
     if (o->refcount > 1) return o;

    /* Currently we try to encode only strings */
    redisAssertWithInfo(NULL,o,o->type == REDIS_STRING);

    /* Check if we can represent this string as a long integer */
    if (!string2l(s,sdslen(s),&value)) return o;

    /* Ok, this object can be encoded...
     *
     * Can I use a shared object? Only if the object is inside a given range
     *
     * Note that we also avoid using shared integers when maxmemory is used
     * because every object needs to have a private LRU field for the LRU
     * algorithm to work well. */
    if (server.maxmemory == 0 && value >= 0 && value < REDIS_SHARED_INTEGERS) {
        decrRefCount(o);
        incrRefCount(shared.integers[value]);
        return shared.integers[value];
    } else {
        o->encoding = REDIS_ENCODING_INT;
        sdsfree(o->ptr);
        o->ptr = (void*) value;
        return o;
    }
}

void incrDecrCommand(redisClient *c, long long incr) {
    long long value, oldvalue;
    robj *o, *new;

    o = lookupKeyWrite(c->db,c->argv[1]);
    if (o != NULL && checkType(c,o,REDIS_STRING)) return;
    if (getLongLongFromObjectOrReply(c,o,&value,NULL) != REDIS_OK) return;

    oldvalue = value;
    if ((incr < 0 && oldvalue < 0 && incr < (LLONG_MIN-oldvalue)) ||
        (incr > 0 && oldvalue > 0 && incr > (LLONG_MAX-oldvalue))) {
        addReplyError(c,"increment or decrement would overflow");
        return;
    }
    value += incr;
    new = createStringObjectFromLongLong(value);
    if (o)
        dbOverwrite(c->db,c->argv[1],new);
    else
        dbAdd(c->db,c->argv[1],new);
    signalModifiedKey(c->db,c->argv[1]);
    notifyKeyspaceEvent(REDIS_NOTIFY_STRING,"incrby",c->argv[1],c->db->id);
    server.dirty++;
    addReply(c,shared.colon);
    addReply(c,new);
    addReply(c,shared.crlf);
}

소스를 보시면 longlong 형태로 변환하는 getLongLongFromObjectOrReply() 함수가 실패하면 역시 에러를 리턴하는 것을 알 수 있습니다. 이 때 역시, 사용자가 바이너리 형태로 데이터를 넣으면 incr/decr 류의 명령어는 사용할 수 없다는 것을 기억하시면 될 것 같습니다.

[입 개발] memcached 는 predictable 하고 Redis는 unpredictable 하다.

가끔씩 Redis와 memcached 둘 중에 어떤 것이 좋은가에 대한 질문이 들어옵니다. 이 질문에 대한 답변은 그때 그때 달라요입니다.  예를 들어, 캐시로만 사용할 것인지? 아니면 데이터 스토어로 사용할 것인지? 데이터 스트럭처의 사용이 필요한가 여부 등 여러가지 판단을 할 수 있는 기준이 있습니다. 여기서는 오직 캐시의 목적으로만 볼 때에 대한 그래서 AOF, RDB는 제외하고 이에 대해서 간단하게 얘기해볼려고 합니다.

보통의 제 답변은 생산성<성능 이면 Memcached, 생산성>성능 이면 Redis를 추천합니다. 데이터 스트럭처를 제공한다는 것은 개발자에게 엄청나게 편의성을 제공해주니깐요. 그럼 이제 위의 제목에 관심을 가지게 될 것 같습니다. 왜 memcached는 predictable 하고 Redis는 unpredictable 한가?  먼저 이 과정은, 많은 데이터가 set/get/del 될 때라는 가정입니다. 그리고 이는 내부의 메모리 할당 구조의 차이 때문입니다. 모두들 아시다시피, Redis는 필요한 크기 만큼에 대한 메모리를 할당해서 이를 사용합니다. 그런데 memcached는 slab 할당을 이용해서, redis 보다 이런 부분에서 조금 더 나은 성능을 보여줍니다. 이로 인해서 redis의 메모리는 좀 더 fragmentation 이 발생하기 쉽고,  이로 인해 memcached의 성능이 좀 더 predictable 하다고 할 수 있는 것입니다.

 

그런데, 이게 사실 그렇게 단순한 것 만은 아닙니다. memcached의 경우는 4G 메모리를 할당하면, 데이터 영역만 4G이므로, 이를 관리하기 위해서 메모리를 더 많이 사용하게 됩니다. 이로 인해서 의도한 것 이상으로 메모리를 사용하게 됩니다. 그런데 Redis는 딱 전체 메모리를 설정한 것 만큼만 사용하게 됩니다.(기본은 무한대로 계속 사용합니다.) 이럴 경우, swap 확률이 덜해서,  Redis가 좀 더 안정적인 성능을 보여줄 수도 있습니다.  다만 BGSAVE를 사용하면, 또 메모리 사용량이 늘어나게 됩니다.

 

그럼 이것만으로 끝일까요? Redis를 여러 대 사용한다면, 한 서버에 여러대의 Redis 인스턴스를 돌리게 되는데,( memcached 도 마찬가지입니다. ) 다음과 같은 구조가 됩니다.

ms

 

이렇게 되면 하나의 서버가 죽더라도 전체 데이터가 날라가지 않는 일부만 사라지거나, 문제가 적지만, AOF/RDB를 사용해야 한다면 성능상 문제가 발생할 수 있습니다.

즉, 간단한 캐시 레이어 하나를 설계하더라도, swap의 발생 가능성이 다양하고, 여러가지 의존관계가 생기게 됩니다. 그리고 이런 구조를 이해하는 것이 적절한 캐시 성능을

보장하게 됩니다.

[입 개발] twemproxy, redis, memcached 의 서버 사이드 proxy

혹시나 redis 에 관심이 많으신 분이 계시다면, 희소식이 있습니다. 아시는 분들은 이미 아시겠지만 twitter에서 만든 memcached 와 redis를 위한 서버 사이드 proxy 인 twemproxy 가 바로 그것입니다. 사실 memcached만 본다면 이미 moxi 라는 서버 사이드 proxy가 있기 때문에, 크게 생각을 안해도 되겠지만, redis를 쓰시는 입장에서는 꽤 구미가 당기는 소식일 수 밖에 없습니다. antirez도 열광하며, 블로그에 글을 올렸더군요. http://antirez.com/news/44

 

어차피 사용법이나 이런 것은 간단하지만, 간단하게 소개하자면, https://github.com/twitter/twemproxy 에서 해당 소스를 받을 실 수 있고, 혹시나 libtool이 설치가 안되어 있으면 autoreconf 시에 에러가 나므로, 설치해 주시면 될 것 같습니다. 두 번째로 설정을 보면 여러 그룹을 등록 할 수 있도록 되어 있는데, 여러개 다 안쓸 테니 그냥 conf/nutcraker.leaf.yml 정도만 수정하셔도  됩니다. 이 때 redis를 쓰시면 꼭 설정에 redis: true 가 있는지 확인하셔야 합니다.

 


redis: true

 

이게 없으면 memcached text protocol로 동작하게 됩니다. twemproxy는 memcached의 경우 binary protocol은 지원을 안해줍니다. 그 대신 연결이 안되는 서버는 접속 목록에서 제거해주는 기능도 있고 속도도 빠르다고 합니다.(아직 실제 서비스에서는 전 못써 봐서) 그리고, 사용하고 있는 업체들이 twitter, tumblr, pinterest 등으로 이미 트래픽으로는 검증된 회사들이라는 것도 장점이겠죠.

 

그럼 이제 좋은 얘기들은 널려 있으니, 어떤 분들은 쓰면 안되는지 간단하게 소개하려고 합니다. 일단 redis 기능 중에 구현안된 것들이 많습니다. 기본적인 기능은 사용이 가능하지만, 전체 서버를 대상으로 해야 하는 기능들은 계속 지원이 안될 것 같기도 합니다. 예를 들어, “AUTH”, “PING”, “MSET” 이런 것들이 안됩니다. 아마도 전체 서버로 리퀘스트를 요청한다음, 한 서버만 장애났을 때, 기존 프토토콜 앞에서 이를 전달할 수 있는 방법도 없고, rollback 도 어렵기 때문이지 않을까 싶긴 합니다.(개인적으로 저도 redis client-proxy, server-proxy를 만들어보다가 좌절하고 있는 부분이 바로 이 부분들 때문입니다. 그래서 twemproxy를 보니 과감하게 전부 패스군요 T.T)

그리고 keys 같은 명령도 쓰시는 분은 같은 이유로 안되므로, 쓰기가 곤란하실 듯합니다. 사실, keys는 쓰지 말라고 권장되어 있지만, 많은 분들이 쓰시고 계시므로 쿨럭…

 

그럼 간단하게 소개를 마칩니다. 다음번에는 소스에 대해서 좀 까보도록 하겠습니다.

 

 

 

[발 번역] Memcached를 걷어내자. 그건 너무 느리다.

*해당 블로그는KT ucloud 의 지원을 받고 있습니다.

해당 글은 http://blog.serverdensity.com/2012/05/11/removing-memcached-because-its-too-slow/ 글을 발번역한 것입니다. 오역에 주의하시길 바랍니다. DB에 로그등을 저장할 경우, DB의 저장 속도가 로그 생성 속도를 따라가지 못하는 케이스가 꽤 있고, 그 해결책으로 제시되는 방법 중에 하나가, 로그를 메모리 캐시 솔루션등에 일시적으로 모으고 그걸 배치 등으로 한번에 저장하는 방법을 많이 이용합니다. 그런데, 해당 기사는 속도 때문에 해당 아키텍처를 사용하다가 MongoDB 1.8에서  2.0으로 전환하면서 해당 솔루션을 제거했습니다. 왜 그런지에 대해서 알아두시면 나름 좋은 방법이 될 듯합니다.

개인적으로는 Server Density에서 사용한 이전 방법이 부하가 더 커질 경우에 사용할 수 있는 좋은 방법이라는 생각이 듭니다. 그리고, 필자의 의견에 동의를 못하는 것이, 필자가 말하는 것처럼, Moxi에서 24ms 이 나오는 것도 이상합니다. 보통 moxi를 사용하더라도 1ms 이하로 나오는 것이 정상이기 때문입니다. 댓글을 보면 Moxi나 Client Library 문제가 아닌가라는 의심이 나옵니다. 개인적으로는 Moxi보다는 클라이언트 라이브러리 이슈나 네트워크 위치나 설정에 따른 문제가 아닐까 의심됩니다. 저도 실제로 네트웍 이슈로 해당 속도가 느려지는 것을 경험해본적이 있습니다. 그리고 memcached 메모리가 swapping 되기 시작하면 속도가 꽤 느려질 수 있습니다. 그러나 필자가 말하는 컴포넌트를 하나 제거할 수 있다라는 장점은 상당히 큰 매력이네요. 여러가지 관점에서 고민해보시면 좋을 듯 합니다. @lqez 님도 비슷한 방법으로 재미를 보신걸로 알고 있습니다.

Memcached를 제거하자. 그건 너무 느리다.

Server Density 코드베이스를 변경하면서, Memcached 를 시스템에서 제거한 이야기를 짧게하려고 합니다. 여기에는 두가지 목적이 있습니다.

  1. UI 캐싱: 계정 데이터를 초기 로딩할 경우( server list, alert list, users lists 등 ) MongoDB 에서 바로 읽어서 해당 데이터가 변경될 때 까지 캐시해두고, 변경되면, 캐시를  제거했습니다.
  2. 병목 MongoDB  1.8에서의 글로벌 락의 성능 영향으로 인해서  모니터링 postback 을 MongoDB에 바로 저장할 수 없었습니다.  그래서 Memcached 에 먼저 데이터를 넣고, 수 많은 웹 클라이언트와는 대조적으로 몇 개의 데몬을 통해서 저장해야 했습니다.


Performance map

이 방법은 MongoDB 2.0이 나오기 전까지는 꽤 잘 동작했습니다. 꽤 똑똑한 Lock 양보 정책으로 Global Lock의 영향이 크게 감소되었고, 2.2에서는 database 수준의 락킹으로 더 좋아질 것입니다. 차후에는 동시성 관련 부분도 더 좋아질 것입니다.

이미 코드베이스에서는 Memcached를 이용하는 것에 영향을 받는 부분은 이미 제거해뒀고, 마침내 Memcached를 완전히 제거할 수 있다는 것을 성능 지표에서 보여주고 있습니다. MongoDB로의 직접 접근이 훨씬 빨라졌기 때문입니다. 확실히,  우리의 평균 DB 쿼리 응답 속도는 0.43 ms 이고 Memcached 로 부터는 24.2ms 가 나옵니다.

Database throughput

Response time

모든 종류의 데이터가 중요한 데이터 저장소로써, 다수의 MongoDB 클러스터가 있고( 시간관련 데이터들은 따로 분리되어 있지만 )  2개의 샤드가 있고 각각의 샤드는 4대의 데이터 노드로 구성되었습니다. 각각의 샤드는 미국내의 워싱턴 DC와 San Jose 에 있는 각각의 데이터 센터에 있고, 각 서버는 8GB 메모리에,  Intel Xeon-SandyBridge Quad Core 3.4Ghz CPUs, 100GB SSDs 장비에 Ubuntu 10.04 LTS를 이용해서 MongoDB를 구동중입니다.  각각은 2Gbps 네트웍으로 연결이 되어 있습니다. ( 역자 주:  8GB 메모리였다면, 메모리를 좀 더 올리는 것도 방법이 아니었을 까 합니다. SSD가 속도가 확실히 빠르긴 하지만, 일단 메모리가 많으면, SSD 보다 더 빠릅니다. )

Nodes

Memcached를 제거함으로써, 시스템이 더더욱 간단해지고, 코어 소프트웨어 스택은 단지 Apache, PHP, python, MongoDB, Ubuntu 로만 구성될 수 있을 것입니다.  이로 인해 Memcached를 독립된 클러스터에서 동작시킬 필요가 없어졌습니다.  Moxi proxy의 failover 처리를 위한 모니터링을 추가할 필요도 없어졌습니다., Ubuntu LTS를 통한 공식 버전을 사용하길 원할 경우에, 또 다른 불편인, PHP와 Python 에 memcached library를 추가할 필요도 없어졌습니다. 무엇보다 24ms 의 응답시간을 제거할 수 있었습니다.

[분산 캐시]Memcached 의 flush_all의 주의 사항을 읽고!!!

주의사항: 제 글에 잘못된 내용이 있어서 이를 수정합니다. 꼭 다음 글을 같이 읽어주시기 바랍니다.

http://geekdani.wordpress.com/2012/05/19/memcached-flush_all-%EB%91%90%EB%B2%88%EC%A7%B8/

(2012/05/19 11:13)

지난번  포스트”Redis와Memcache의flush는왜다를까?”

(https://charsyam.wordpress.com/2012/05/17/%EB%B6%84%EC%82%B0%EC%BA%90%EC%8B%9C-redis-%EC%99%80-memcache%EC%9D%98-flush%EB%8A%94-%EC%99%9C-%EB%8B%A4%EB%A5%BC%EA%B9%8C/)

라는 글을 올린 후, memcached에 공헌도 많이 하시고, 실제로 memcached 커미터 수준인 분께서 다음과 같은 댓글을 올려주셨습니다. 실제로 flush_all 의 사용법에 대해서 주의사항이 있다는 것입니다. 그리고 또 다른 고수님께서도 해당 문제에 대한 퀘스천 마크를 남기셨습니다.(아, 실력 부족이 여실히 들어나네요.  그래도 제 주변에는 고수분들이 많으셔서 다행입니다. 부러우시죠? 이분들이 제 자랑입니다.)

그런데 문제는 아무리 소스를 봐도 알 수가 없다는 것이었습니다.(아, 실력 부족) 다만 코드를 유심히 보면서 고민했던 것은, current_time 이나 oldest_live 가 바뀔 경우에는 해당 가능성이 있다는 것만을 발견했습니다. 그런데 current_time은 보면, clock_hander 에서 값을 현재시간으로 바뀌어주는 코드만 있고, 나머지 부분에서는 바뀌는 부분이 없습니다. 점점 더 미궁으로 빠지는 거죠.

그래서 다시 한번 질문을 올렸습니다. “케이스를 알려주세요” 라구요.

그러자 또 다른 고수 @GeekDani 님께서 다음과 같은 답변을 들어주시고, 감사히 해당 내용에 대해서 블로그 포스팅까지 해주셨습니다.

http://geekdani.wordpress.com/2012/05/19/memcached-flush-%EC%82%AC%EC%9A%A9%EC%8B%9C-%EC%A3%BC%EC%9D%98%EC%A0%90/

꼭 읽어보시길 바랍니다.

사실 이것만 봐도 대부분의 분들이 이해하실 수 있으실 것 입니다. 불안하신가요? 다만 다행인 것은 대부분의 사람들은 flush_all 다음에 옵션이 있다는 것을 모릅니다. 그리고 그냥 flush_all 만 했을 경우에는, 제가 말했듯이 계속 oldest_live 가 current_time 보다 항상 작기 때문에 문제가 바생하지 않습니다.

예전에 저도 이와 비슷하게 flush_all 을 여러 번 한적이 있는데, 문제가 없었습니다. 캐시 내용이 거의 몇 테라급인데도 말이지요. 아마도 다른 분들도 exptime을 추가로 주는 부분을 안 쓰실 가능성이 높습니다.

그럼 이 글을 왜 적었느냐? 그냥 넘어가기는 아쉬우니, 실제로 flush_all 에 delay 시간을 적어주면 어떻게 되는 것인가에 대한 코드의 조건을 잠시 살펴보도록 하겠습니다.

먼저 flush_all 은 이해를 하고 있으니, flush_all에 옵션을 주면 어떤 동작이 일어나는지 먼저 알고 있어야 합니다. 일단 정확하게 설명하면, flush_all 뒤의 옵션은 “특정 시간에 데이터를 모두 삭제하라” 입니다. .이거 관련해서 뒤에서 다시 한번 설명하겠습니다.

아래의 코드에서 exptime > 0 의 코드가 우리가 flush_all 에 옵션을 추가로 줄 경우 동작하게 되는 것입니다. 다시 살아나는 것을 보니, 분명히, oldest_live 값이 current_time 보다 커질 것이다라고 볼 수 있을껍니다.


if (exptime > 0)

settings.oldest_live = realtime(exptime) - 1;

else /* exptime == 0 */

settings.oldest_live = current_time - 1;

item_flush_expired();

옵션으로 시간이 지정되어있지 않으면 그냥 oldest_live 가 현재 시간으로 지정되지만, 시간이 지정되어 있으면 realtime 이라는 함수를 통해서 변경됩니다. 이게 왜 필요한가? 라고 물어볼 수 있는데, 여기서 memcached 만의 재미난 특징이 하나 있습니다. 아마 expire time 설정해 보신 분들은 한번씩 실수하게 되는 것인데 일단 코드 먼저 보시죠. Memcached의 119라인을 보시면 됩니다.


#define REALTIME_MAXDELTA 60*60*24*30

&nbsp;

static rel_time_t realtime(const time_t exptime) {

/* no. of seconds in 30 days - largest possible delta exptime */

&nbsp;

if (exptime == 0) return 0; /* 0 means never expire */

&nbsp;

if (exptime > REALTIME_MAXDELTA) {

/* if item expiration is at/before the server started, give it an

expiration time of 1 second after the server started.

(because 0 means don't expire).  without this, we'd

underflow and wrap around to some large value way in the

future, effectively making items expiring in the past

really expiring never */

if (exptime <= process_started)

return (rel_time_t)1;

return (rel_time_t)(exptime - process_started);

} else {

return (rel_time_t)(exptime + current_time);

}

}

REALTIME_MAXDELTA 라는 값이 지정되어 있는데 이 값보다 옵션으로 입력된 값이 크면 exptime – process_started 값을 던져줍니다. REALTIME_MAXDELTA 보다 작으면 현재 시간에 해당 값을 더해서 줍니다. 혹시나 process_started 값이 궁금하신 분들도 있으실텐데 그냥 프로세스 시작 값이 저장되어 있고 실제 current_time의 경우 이미 process_started 값이 빠져 있으니 신경안쓰셔서도 됩니다. 다만, exptime이 REALTIME_MAXDELTA 보다는 큰데 현재시간보다 적으면, 그냥 값을 1로 설정합니다. 이러면 그냥 지워졌다고 보시면 됩니다. 즉 원칙은 REALTIME_MAXDELTA 보다 적은 값은 상대 값이고, 그 이상의 값은 절대 값이라는 겁니다(겨우, 이걸 설명하려고 이렇게나 지면을!!! 퍽퍽퍽)

그런데 여기서 Expire time 관련한 아이템은 딱 두 가지 종류가 있습니다.

1)     Expire time 을 지정하지 않은 아이템

2)     Expire time 을 지정한 아이템( 2012/05/19 11:13 이 부분의 내용이 잘못되었습니다. 위에 지정한 부분을 같이 읽어주세요. 다만 잘못된 생각의 흐름을 남기기 위해서 글을 수정하지 않고 부분부분 표시만 해둡니다. 감사합니다.  )

Expire Time을 지정하지 않은 아이템의 경우는 크게 상관이 없습니다. 그냥 그 시간이 되면 사라지는 겁니다.(lazy delete이긴 하지만) 문제는 2)의 케이스입니다. 먼저 Expire time이 flush_all 에 지정한 옵션 값보다 작다면, 아무 문제가 없습니다. 그런데 초반에 Expire Time을 한 20년 뒤로 잡아두었다면 어떻게 될까요? 사실 이건 flush_all에 delay 시간을 주느냐 안주느냐와는 상관이 없이 문제가 발생합니다. 그래서 사용되는 코드가 아까 슬쩍 지나간 item_flush_expired(); 입니다. Thread.c 의 505라인에 있는데, 단순히 lock을 호출하고 do_item_flush_expired() 을 호출합니다 이런 패턴은 외부에서 호출하는 함수는 Lock을 타고, 내부에서 사용하는 함수는 그냥 호출하게 할 수 있으므로 편리합니다. 이런 패턴을 뭐라고 하는데 까먹었네요. ㅋㅋㅋ

do_item_flush_expired() 는 thread.c 의 548 Line에 있습니다. 소스코드는 다음과 같습니다.


void do_item_flush_expired(void) {

int i;

item *iter, *next;

if (settings.oldest_live == 0)

return;

for (i = 0; i < LARGEST_ID; i++) {

/* The LRU is sorted in decreasing time order, and an item's timestamp

* is never newer than its last access time, so we only need to walk

* back until we hit an item older than the oldest_live time.

* The oldest_live checking will auto-expire the remaining items.

*/

for (iter = heads[i]; iter != NULL; iter = next) {

if (iter->time >= settings.oldest_live) {

next = iter->next;

if ((iter->it_flags & ITEM_SLABBED) == 0) {

do_item_unlink_nolock(iter, hash(ITEM_key(iter), iter->nkey, 0));

}

} else {

/* We've hit the first old item. Continue to the next queue. */

break;

}

}

}

}

(2012/05/19 11:13 코드를 보면 지금까지 사용하던 exptime 이 아니라 time입니다. 이것은 서로 다른 동작입니다. 꼭 상단의 @GeekDani 님 글을 참고하시기 바랍니다.)

코드를 보면 현재 설정된 oldest_live 보다 더 이후의 expire_time을 가지고 있는 것은 미리 삭제해버립니다. 즉 flush_all [딜레이 시간] 이 들어가더라도, 몇몇 아이템은 그 순간 사라지게 됩니다. 여기서 재미난 추측을 하나 해볼 수 있습니다. Expire Time을 굉장히 길게 설정한 아이템이 많다면? Redis 처럼 operation이 블록되는 현상이 벌어질 수 있을 것 같습니다.(뭐, 테스트는 다음에 해보겠습니다.)

그래서 flush_all 을 하게 되면 oldest_live 가 현재 시간으로 설정되었다가, 다시 flush_all 100을 하게 되면, 일부데이터는 바로 사라지지만, 다시 데이터가 복구된 거 같은 효과가 발생합니다. 이 때 oldest_live 값이 current_time 보다 커지기 때문에 get 시에 영향을 안 받는 거죠. 다음 코드 생각나시죠?


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");

}

}

그런데 기억하셔야 할 것은 flush_all [expire time] 이 된 것은 delay가 된 것일 뿐입니다. 즉 해당 시간이 되면 다시 flush_all과 같아집니다. 주의하시기 바랍니다.

결국, 최초에 문제가 발생할 수 있을 만한 부분에서 결국 문제가 발생한 것입니다.

다시 정리하자면.

1)     그냥 flush_all 은 문제 없다. flush_all [expire_time] 을 아는 사람도 거의 없다.

2)     Flush_all 하고 나서 다시 flush_all [expire_time] 하면 해당 시간 이후로 expire_time 이 지정된 아이템과, 이미 get에서 제거된 아이템들을 제외하고는 전부 다시 보이게 된다.

3)     그러나 다시 해당 delay 시간이 지나가면 flush_all이 된다.

4)     그리고 flush_all에 옵션을 줄 수 있는 라이브러리가 별로 없는듯 하다.(php와 python 라이브러에는 그냥 flush_all 만 있네요.. 크게 주의하지 않으셔도 될 듯 합니다.)

5)     당연한 얘기지만, flush_all [expire_time] 주고 나서 그 사이에 set한 데이터들은 모두 사라진다. 까먹지 말자.

 

결론(2012/05/19 11:13 @GeekDani 님의 글을 참고로 수정합니다.)

  • flush_all 후 flush_all [exptime] 하면 exptime까지는 보여집니다. (물론 flush_all [exptime] 전에 GET 했던 것은 살아나는 효과는 일어나지 않습니다.)
  • flush_all [exptime] 후 exptime 안에서 SET은 지나면 사라진다.
  • flush되는 것과 expire되는 것은 처리가 다르다.
  • 기타 등등

[분산캐시] 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

&nbsp;

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;

&nbsp;

/* 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라인을 보시면 됩니다.

 

왜 미션크리티컬한 곳에서 Repcached 를 사용하면 안되는가?

memcached 에 대한 설명을 하다보면 항상 받게 되는 질문이 하나 있습니다. “memcached로 replication을 하는 방법은 없나요?” 라는 질문, 여기에 대한 항상 저의 답변은 “캐시는 캐시로 쓰는게 좋다” 입니다. 그래도 또 원하고 원하는 질문이 바로 replication 입니다. 결국 이런 상황에서 소개해 드리게 되는 것이 repcached라는 것입니다. repcached는 memcached 1.2.8을 기본으로 Master/Master 리플리케이션 기능을 추가해둔 것입니다. (http://repcached.lab.klab.org/ 에서 다운 받을 수 있고 libevent 1.4.x 를 설치하시길 바랍니다. libevent 2.0.x대에서는 컴파일 되지 않습니다.)

그런데 이렇게 좋은 기능을 제공하는 repcached의 경우 미션 크리티컬한 곳에서는 사용하면 문제가 발생합니다. 왜 그럴까요? 원인은 일반적으로 분산 프로그램에서는 리플리케이션의 실패(정확하게는 오퍼레이션들의 실패) 에 대해 2pc등을 통해서 모두 성공할때만 커밋하게 해서 일관성을 유지하는 등의 방어적인 부분이 들어있습니다. 그런데 repcached의 경우는 그런 부분이 없습니다. 소스를 살펴보면 process_command 는 process_update_command 를 호출하고 이는 결과적으로 complete_nread를 호출하게 됩니다.

store_item 을 하게되면 현재 메모리에 저장하게 되고 replication_call_rep를 통해서 실제로 replication을 할 서버에 데이터를 전달하게 됩니다. 그런데 소스 코드는 다음과 같습니다.


ret = store_item(it, comm);
 if (ret == 1) {
#ifdef USE_REPLICATION
 {
 if( c != rep_conn ){
 replication_call_rep(ITEM_key(it), it->nkey);
 }
 out_string(c, "STORED");
 }
#else
 out_string(c, "STORED");
#endif /* USE_REPLICATION */

}

즉 replication_call_rep 에 대한 에러 체크가 전혀 없습니다. 물론 에러 체크를 한다고 해도 store_item 등으로 인한 부분을 rollback하는 코드가 필요한데 이런 부분도 없습니다. 실제로 메모리로만 처리하므로 그런 일이 별로 없긴 하지만, memcached 서버등에서 결과를 처리하지 못하고 에러를 리턴하는 케이스들도 존재하기 때문입니다.

이런 이유로 인해서 repcached를 미션 크리티컬 한 곳에서 쓰는 것은 위험합니다. 결국 캐시는 캐시답게 사용하는 것이 좋지 않을까 라는 생각이 듭니다.

Memcached 에서의 Consistent Hashing

오늘은 제가 자주 사용하는 Memcached의 Consistent Hashing 에 대해서 알아보려고 합니다.( 자주 쓰기는 하는데, 내부를 잘 알지는 못해서 헛소리를 찍찍 할 수도 있지만, 실력이 모자란거니, 저런 모자란 넘 하시면서 웃으면서 넘어가시면 좋겠습니다. 어차피 욕하셔도, 전 상처받지 아니합니다. 그냥 일기에, 그분 이름 빨간색으로 좀 적어두고, 죽을때까지 기억하기만 할꺼예요. 전 쿨하니깐요. )

제가 가장 좋아하는 기술에 접근 방법은, 왜 이런 기술이 나왔는가? 왜 필요한가? 그래서 어떻게 구현되었는가? 이런순으로 따라가는 겁니다. 왜 필요한지? 문제가 무엇인지? 그래서 어떻게 해결하는지에 알아야만, 저는 그제서야 살짝 이해를 하기 시작합니다.(머리가 나빠서 사실 제대로 이해못하는다는데 제 주둥이를 건다는 쿨럭…)

Why Need Consistent Hashing?

Wikipedia 에서 Consistent Hashing 을 찾아보면 다음과 같은 내용이 나옵니다.

History

Originally devised by Karger et. al. at MIT for use in distributed caching. The idea has now been expanded to other areas also. An academic paper from 1997 introduced the term “consistent hashing” as a way of distributing requests among a changing population of Web servers. Each slot is then represented by a node in a distributed system. The addition (joins) and removal (leaves/failures) of nodes only requires K / n items to be re-shuffled when the number of slots/nodes change.[1]

This same concept, however, appeared in 1996 within the Super Proxy Script technique created by SHARP for optimizing use by web browsers of multiple caching HTTP proxies.[2]

Consistent hashing has also been used to reduce the impact of partial system failures in large Web applications as to allow for robust caches without incurring the system wide fallout of a failure.[3]

The consistent hashing concept also applies to the design of distributed hash tables (DHTs). DHTs use consistent hashing to partition a keyspace among a distributed set of nodes, and additionally provide an overlay network that connects nodes such that the node responsible for any key can be efficiently located.

 

영어가 어렵습니다. 간단하게 바꾸면, MIT의 karger 라는 사람이 웹 서버 수가 변화하는 가운데분산 리퀘스트를 처리하기 위해서 고안했다고 합니다. 이게 무슨 소리일까요? 간단하게 설명하자면 [그림 1]과 같은 상황입니다. K는 요청하는 유저 수, N 은 웹 서버의 수입니다.

[그림 1] User Request 를 Proxy 가 각 서버로 분배하는 상황

그런데, 여기서 [그림 2] 처럼 서버가 몇 대가 장애가 나고 수시로 추가된다고 합시다.

[그림 2] 한대의 서버가 장애로 인해서 서비스 제외된 상황

이렇게 되면 계속 들어오는 리퀘스트 들은 나머지 4대로 분배되게 됩니다. 이걸 보면 이게 무슨 문제가 있냐고 생각을 하게 됩니다. 사실 별 문제 없습니다. 단순히 각각의 리퀘스트 들이 전혀 연관관계가 없다면 말입니다. 이미 세션이 연결되어 있어서 각각의 유저는 기존의 서버로 연결되기를 원하는 상황이 된다면, 조금 문제가 복잡해질 수 있습니다.( 뭐, 해당 유저가 전에 어느 서버로 갔었는지 기억해두고 있다가 매번 그곳으로 보내주는 방식도 있지만, 이건 유저 별로 여분의 추가 작업이 필요합니다. ) 즉, 전체 사용자들이 서버 수에 맞춰서 다시 재분배 된다는 겁니다. 이건 데이터가 추가될 때도 마찬가지 현상이 발생합니다.

그럼 Consistent Hashing이 어떤 기능을 하는지 유추가 되시나요?(오오, 천재시군요. 전 유추 못하는데…) 즉, Consistent Hashing 은 서버의 개수, 즉 슬롯의 개수가 변화하더라도, 전체 데이터에 대한 재분배가 필요 없고, K/N 개의 아이템만 재분배를 하면 되는 방식입니다. 앞에서 서버가 5대 였으니, 한 대당, 20000명을 처리하고 있었는데, 한대가 장애가 나면, 이 20000 명만 서버를 재분배하고 나머지 유저들은 기존 서버에서 그대로 처리가 됩니다.

오오오, 대단히 재미난 기술 인거 같지 않습니까? 그럼 어떤 방식으로 처리가 되는 것일까요? 여기에는 HashRing 이라는 개념이 들어갑니다. Ring 이라는 게, 둥근 것, 즉, 꼬리에 꼬리는 무는 해시라고 해야될까요?

Consistent Hasing

먼저 A,B,C,D 서버가 4대가 있다고 가정합니다.( 아, 그림 그리기 힘드네요. 서버 수를 더 줄이줄 좋을 것 같습니다. T.T, 4대도 많아요 )

[그림 3]과 같이 처음에는 서버 한대만 존재한다면, 모든 리퀘스트는 해당 서버로 가게 됩니다.

[그림 3] HashRing 에 A 서버가 등록된 상황

 이 상황에 서버 B, C 가 추가되어서 [그림 4] 처럼 3대가 되었다고 하겠습니다.

[그림 4] HashRing 에 A, B, C 서버가 등록된 상황

 

이제 해당 서버에 들어오는 Key 들이 해시값이 A < Key <= B 라고 한다면,(아, 수식을 다 써보는군요.) 해당 Key는 자기보다 크고, 가장 가까운 서버로 할당이 됩니다. 즉, 여기에 Key 1이 들어왔다면, A < 1 <= B 라면 1은 B 서버에 저장됩니다. [그림 5]와 같습니다.

[그림 5] HashRing 에 A < 1 <= B 인 Key 1이 들어온 상황

자 이제 Key의 해시값이 B < 2 <= C 인 Key 2가 들어왔다고 합시다. 예상대로 [그림 6]과 같습니다.

[그림 6] HashRing 에 B < 2 <= C 인 Key 2이 들어온 상황

 이제 엄청 데이터가 많이 들어와서 [그림 7]과 같은 최종 모습이 되었다고 합시다.

[그림 7] 데이터가 최종적으로 들어 온 모습

이제 여기서 장애로 서버 B가 서비스를 하지 못하는 상태가 되었다고 가정합니다. 서버 B가 장애가 났으므로, Key 1은 유실이 됩니다. [그림 8]의 상황입니다.

[그림 8] 서버 B의 장애로 인해서 HashRing에서 B가 제거된 상황

Consistent Hashing 이 아니라면 [그림 8]의 장애 상황에서 전체 Key가 재조정되어야 할 수도 있습니다. 하지만 Consistent Hashing 방식을 이용하므로 Key 5를 요구하면 5의 해시값이 A < 5 <= C 가 되므로 해당 서버가 그대로 서비스를 하게 됩니다. 그럼 이때 원래 B로 할당되어야 했던 Key 1이 다시 들어오게 되면 어떻게 될까요? 예상하듯, A < 1 <= C 가 되어 버리므로 [그림 9] 와 같이 C로 할당되게 됩니다. (전 솔직히 예상 못했었어요 T.T )

[그림 9] 서버 B의 장애 상황에서 Key 1이 들어온 경우

 

 이런 식으로 Consistent Hashing 은 서버의 추가/장애시 K/N 의 Key만 재 분배 하면 되도록 되어있는 알고리즘입니다. Memcahed 에서 이런 방식을 사용하고 있습니다. 정확하게 하자면, memcached Client Library 에서 이렇게 키를 저장할 서버를 선택합니다. 자, 예제 1의 간단한 파이선 코드를 보시죠. 현재 Local에 20001, 20002, 20003, 20004 포트를 이용하는 memcached 서버 4대를 실행시켜 두었습니다.( python-memcached 모듈이 필요하므로, pip install python-memcached 로 설치하시기 바랍니다. )

 

import memcache 

ServerList=[ ‘127.0.0.1:20001’, ‘127.0.0.1:20002’, ‘127.0.0.1:20003’, ‘127.0.0.1:20004’ ]

 

if __name__==’__main__’:

    mc = memcache.Client( ServerList );

 

    for idx in range(1,100):

        mc.set( str(idx), str(idx) )

[예제 1] 간단한 1부터 100까지 memcached 에 저장하는 예제

 

 해당 코드를 실행한 후에 각각의 memcached 서버에 붙어서 확인해보면, 1~100 까지의 값들이 서버 4대에 분배되어 있는 것을 볼 수 있습니다.(고르게는 아니더군요 ^^) 확인 하는 방법은 다음과 같습니다.

 

charsyam@ubuntu:~$ telnet 127.0.0.1 20001Trying 127.0.0.1…

Connected to 127.0.0.1.

Escape character is ‘^]’.

get 1

VALUE 1 0 1

1

END

실제로 memcached 의 c client library인 libmemcached 의 소스를 보면 memcached_send 라는 함수가 있고, 이 안에 memcached_generate_hash_with_redistribution 라는 함수 안에서 key의 해시 값을 구하고, 해당 값이 포함되어야 하는 서버의 해시 값을 알려주게 됩니다.

uint32_t memcached_generate_hash_with_redistribution(memcached_st *ptr, const char *key, size_t key_length)

{

uint32_t hash= _generate_hash_wrapper(ptr, key, key_length);

_regen_for_auto_eject(ptr);

return dispatch_host(ptr, hash);

}

Memcached 를 이용할 때의 주의 사항

일반적으로 Consistent Hasing 을 사용하면 큰 문제가 없을 것 같지만, 간혹, 장비의 추가나, 삭제 상황에서, Old Data 가 남아있어서 이 값을 가져가게 되는 이슈가 발생할 수 있습니다. 어떤 상황에서 발생하게 될까요?(똑똑하신 분들은 이미 이해하셨을 테고, 저 같은 사람은 누가 알려주고, 설명을 해줘도 이해가 안가는 경우가 쿨럭…)

이전 [그림 8,9] 에서 한대의 서버가 장애가 나면, B로 들어가야 할 데이터만 다시 C로 들어가게 된다고 했습니다. 그럼 이 상황에는 원래 B일 데이터가 C에 저장됩니다. 그런데 다시 B가 복구가 되어서 HashRing 에 들어왔다고 가정합니다. 그럼 무슨 일이 발생하게 될까요? 끔직한 일? 힘든 일? 상상도 하기 싫은 일?, 정답은 아무 문제 없습니다.(헉, 어디서 돌이!!!) 당연히 뭔가 문제가 있었다면 이거 쓸 수가 없겠죠. 장애는 언제든지 발생하는 데 말입니다.

이 때, 다시 Key 1이 들어온다면 [그림 10]과 같은 상황이 벌어집니다. 그러나 Key 1의 해시는 B에 속하게 되므로, 데이터를 요청해도 C의 1이 응답으로 나가는 문제는 당장 발생하지 않습니다. 그로 인해 서비스는 문제 없이 잘 돌아갑니다. 여기서 문제가 되는 상황은 서버 B가 또 다시 장애가 나는 [그림 11]과 같은 상황입니다. 기존에 C 서버에 Old Key 1이 남아있던 걸 기억해야 합니다.

[그림 10] 서버 B의 복구 후 다시 Key 1이 들어온 경우

[그림 11] 서버 B의 재 장애 케이스

이 경우 Key 1을 요청하면 C 서버의 Old Key 1이 반환되어서 실제로 새로운 값이 아닌 오래된 값이 나갈 수 있습니다. 다만 memcached 를 목적에 맞게 사용하면 Key의 ExpireTime 이 지나서 사라지기 때문에 전혀 문제가 없을 수 있습니다. 위와 같은 문제는 ExpireTime 이내에 같은 서버가 다시 장애가 났을 때만 발생하는 문제입니다. (혹시나 해결책을 물어보신다면, 같은 장애가 발생했을 때, memcached의 해시 코드를 가져와서 실제로 남아있어야 하는 서버 이외에는 delete 를 호출하는 방법밖에 없습니다. 아니면 ExpireTime을 짧게 가져가면 쉽게 해결될 수도 있습니다.)

정리하며…

간단하게 Memcached 에서 사용하는 Consistent Hashing 과 이로 인해 발생할 수 있는 문제점에 대해서 간단하게 살펴보았습니다. 혹시나 틀린 내용이 있으면, 저에게 알려주시기 바랍니다.