[입 개발] Redis 버그 – Dataset 사이즈가 200GB가 넘어가면 죽는다구요?

오늘은 최근에 이슈가 되었던, Redis 버그에 대해서 분석해보는 시간을 가지도록 하겠습니다.
해당 이슈는 Redis issue 4493 를 보시면 됩니다.

실제로 해당 이슈는 2017년 11월 30일에 올라왔습니다. 실제로 현재 버전에는 다 패치가 되어있습니다. 그러나 DBMS나 캐시등의 툴은 큰 보안버그가 없는 이상 업데이트가 굉장히 느립니다.(성능에 영향을 주기 때문에…)

다른 건 일단 모두 제외하고 어떤 이슈가 있고, 어떻게 패치되었는지 확인해보도록 하겠습니다.

일단 데이터가 200GB가 넘어가면 죽는다는 뭔가 쉽게이해하기 어려운 상황입니다. Redis 는 기본적으로 Key에 512MB까지 그리고 Value에 512MB가 할당되고, 메모리가 넘치기 전까지는 저장이 되어야 합니다. 그런데 보통 특정 사이즈가 문제가 되는것은, 누구나 예측이 되는 아주 간단한 이유가 있습니다. 바로 overflow 나 underflow, 이런 생각을 가지고 가면, 문제가 좀 더 해결하기 쉽지만, 갑자기 Redis가 죽으면 알기가 힘들죠.(이건 다, 우리는 현재 모든 정보를 알고 있기 때문에 쉽게 이해를 할 수 있는…)

Redis 는 보통 죽기 전에 자신의 정보를 뿌리고 죽는데, 위의 링크를 보시면 다음과 같은 정보들이 있습니다. 우와 755GB 메모리를 가진 장비에 211GB의 메모리를 쓰고 있네요. 부럽습니다. 사실 메모리 영역만 보면 사실 큰 문제가 될께 없어보입니다.

Memory
used_memory:226926628616
used_memory_human:211.34G
used_memory_rss:197138104320
used_memory_rss_human:183.60G
used_memory_peak:226926628616
used_memory_peak_human:211.34G
used_memory_peak_perc:117.84%
used_memory_overhead:137439490190
used_memory_startup:486968
used_memory_dataset:89487138426
used_memory_dataset_perc:39.43%
total_system_memory:811160305664
total_system_memory_human:755.45G

Keyspace
db2:keys=2147483651,expires=0,avg_ttl=0

그런데 Keyspace를 보니 조금 다르네요. db2의 key가 2147483651개가 있습니다. 흐음…

다음과 같이 signed 변수들의 범위를 보면

      char : 127
      short : 32767
      int : 2147483647

입니다. 흐음 일단 key의 개수가 int의 범위를 넘어갔네요. 2147483651을 음수로 바꾸면
-2147483645 이 됩니다. 흐음… 이렇게 바뀌면 문제가 발생할 수도 있겠네요. 그런데 뭔가 이상합니다.

분명히 해당 info정보에서는 제대로 2147483651 가 나와있는데요?
해당 정보를 출력하는 info Command는 실제로 genRedisInfoString 라는 함수를 이용합니다.
아래 코드를 보면 keys, vkeys는 long long 입니다. 그걸 사용하고 있는 dictSize 함수는 매크로로 그냥 값을 가져옵니다.
실제로 redisDb 구조체는 dict를 가지고 있고 dict는 다시 dictht 라는 해시 테이블을 가지고 있습니다. 거기서 used 변수를
가져오는게 dictSize 함수입니다.

#define dictSize(d) ((d)->ht[0].used+(d)->ht[1].used)

typedef struct redisDb {
    dict *dict;                 /* The keyspace for this DB */
    dict *expires;              /* Timeout of keys with a timeout set */
    dict *blocking_keys;        /* Keys with clients waiting for data (BLPOP)*/
    dict *ready_keys;           /* Blocked keys that received a PUSH */
    dict *watched_keys;         /* WATCHED keys for MULTI/EXEC CAS */
    int id;                     /* Database ID */
    long long avg_ttl;          /* Average TTL, just for stats */
} redisDb;

typedef struct dict {
    dictType *type;
    void *privdata;
    dictht ht[2];
    long rehashidx; /* rehashing not in progress if rehashidx == -1 */
    unsigned long iterators; /* number of iterators currently running */
} dict;

typedef struct dictht {
    dictEntry **table;
    unsigned long size;
    unsigned long sizemask;
    unsigned long used;
} dictht;

sds genRedisInfoString(char *section) {
    sds info = sdsempty();
    ......

    /* Key space */
    if (allsections || defsections || !strcasecmp(section,"keyspace")) {
        if (sections++) info = sdscat(info,"\r\n");
        info = sdscatprintf(info, "# Keyspace\r\n");
        for (j = 0; j < server.dbnum; j++) {
            long long keys, vkeys;

            keys = dictSize(server.db[j].dict);
            vkeys = dictSize(server.db[j].expires);
            if (keys || vkeys) {
                info = sdscatprintf(info,
                    "db%d:keys=%lld,expires=%lld,avg_ttl=%lld\r\n",
                    j, keys, vkeys, server.db[j].avg_ttl);
            }
        }
    }
    return info;
}

그런데 used라는 변수는 unsigned long 입니다. 32bit에서는 문제가 될 수 있지만, 64bit에서는 8바이트가 할당되는 변수입니다.
64bit 에서 다음과 같은 코드를 돌려보시면 알 수 있습니다.

#include

int main(int argc, char *argv[]) {
printf(“%d\n”, sizeof(unsigned long));
return 0;
}

그럼 제대로 64bit가 되어있는데 무엇이 문제인가!!!, 처음부터 다 분석하면 어려우니 해당 문제를 일으키는 곳을 바로 확인해 보겠습니다. 코멘트를 자세히 읽어보면… 실제로 아이템을 추가할 때가 문제가 됩니다. 다시 앞으로 돌아가서 아이템 개수가 int의 범위를 넘어갔다는 것을 기억해둡니다.

다음 dictAddRaw 함수를 보면 index가 int 입니다. 헉… 바로 눈치 채시겠죠. 저기 int 로 인해서 아래의 ht->table[index]
라는 코드가 overflow 로 음수가 들어가게 됩니다. 바로 Redis는 저세상으로…

dictEntry *dictAddRaw(dict *d, void *key, dictEntry **existing)
{
    int index;
    dictEntry *entry;
    dictht *ht;

    if (dictIsRehashing(d)) _dictRehashStep(d);

    if ((index = _dictKeyIndex(d, key, dictHashKey(d,key), existing)) == -1)
        return NULL;

    ht = dictIsRehashing(d) ? &d->ht[1] : &d->ht[0];
    entry = zmalloc(sizeof(*entry));
    entry->next = ht->table[index];
    ht->table[index] = entry; /* used++;

    /* Set the hash entry fields. */
    dictSetKey(d, entry, key);
    return entry;
}

그럼 해당 실제 patch는 어떻게 적용되었을까요?

해당 코드를 보시면 해당 int는 long으로, 그 외에 많은 부분들이 64bit unsigned 또는 signed 로 변경된것을 볼 수 있습니다.
실제로 해당 PR은 두개로 나뉘어져 각각 반영되었고, Redis 4.0.7에 다음 두개의 commits으로 볼 수 있습니다.
dict: fix the int problem for defrag

dict: fix the int problem

즉 해당 버그를 피하실려면 최소한 4.0.7 이후의 버전을 선택하셔야 하고 가능하면 최신 버전을 고르시는 걸 추천드립니다.
그런데 21억개를 넘어가는 아이템이라… 웬만한 규모에서는 발생하지 않을 문제겠지만, 엄청 많은 데이터를 쓰는 곳에서만 발생할 수 있었던 문제로 보입니다.