[입 개발] 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 류의 명령어는 사용할 수 없다는 것을 기억하시면 될 것 같습니다.