[Redis] Scan/SScan/ZScan/HScan 이야기…

Redis를 사용하다보면 특정 Key 목록을 가져오고 싶다는 욕망이 항상 생깁니다. 기본적으로 Single Thread 이기 때문에, 전체 Key를 도는 것은 너무 비용이 비싸서…
특정 Key단위로 나눠서 컬렉션에 넣고, 다시 이걸 이용해서 컬렉션을 만드는 짓(?)을 해야만 했었다는 문제가 있었습니다. 그래서, 결국, Redis에도 추가된 명령이 바로

scan/sscan/zscan/hscan 입니다.
scan은 전체 key 목록에서, sscan은 set 안에서, zscan 은 sorted set 안에서, hscan은 hash 안에서 키를 가져오는 명령입니다.

특징은 DB에서 처럼 cursor 라는 개념이 있고 이로 인해서 기본 10개 또는 지정된 개수만큼 가져오는 것이 가능합니다. 또한 keys 명령처럼 패턴으로도 찾을 수 있습니다.

다음과 같은 명령으로 간단하게 사용할 수 있습니다. scan 과 sscan/zscan/hscan의 차이는 key를 지정하는냐 마느냐의 차이 밖에 없습니다.(해당 키 안에서 동작해야하기 때문이죠.)

SCAN cursor [MATCH pattern] [COUNT count]
SSCAN key cursor [MATCH pattern] [COUNT count]
ZSCAN key cursor [MATCH pattern] [COUNT count]
HSCAN key cursor [MATCH pattern] [COUNT count]

샘플은 다음과 같습니다. 첫번째 결과가 다음 cursor 값인데 이것이 0이 나올때 까지 계속해서 호출하면 됩니다.

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

기본적으로 10개를 가져오게 되어있고, 가져올 개수(Count) 를 지정해줄 수 있습니다. 모든 scan은 scanGenericCommand 라는 공통 함수를 이용해서 처리가 됩니다.

void scanGenericCommand(redisClient *c, robj *o, unsigned long cursor) {
    ......
    ht = NULL;
    if (o == NULL) {
        ht = c->db->dict;
    } else if (o->type == REDIS_SET && o->encoding == REDIS_ENCODING_HT) {
        ht = o->ptr;
    } else if (o->type == REDIS_HASH && o->encoding == REDIS_ENCODING_HT) {
        ht = o->ptr;
        count *= 2; /* We return key / value for this type. */
    } else if (o->type == REDIS_ZSET && o->encoding == REDIS_ENCODING_SKIPLIST) {
        zset *zs = o->ptr;
        ht = zs->dict;
        count *= 2; /* We return key / value for this type. */
    }

    if (ht) {
        void *privdata[2];

        /* We pass two pointers to the callback: the list to which it will
         * add new elements, and the object containing the dictionary so that
         * it is possible to fetch more data in a type-dependent way. */
        privdata[0] = keys;
        privdata[1] = o;
        do {
            cursor = dictScan(ht, cursor, scanCallback, privdata);
        } while (cursor && listLength(keys) < count);
    } else if (o->type == REDIS_SET) {
        int pos = 0;
        int64_t ll;

        while(intsetGet(o->ptr,pos++,&ll))
            listAddNodeTail(keys,createStringObjectFromLongLong(ll));
        cursor = 0;
    } else if (o->type == REDIS_HASH || o->type == REDIS_ZSET) {
        unsigned char *p = ziplistIndex(o->ptr,0);
        unsigned char *vstr;
        unsigned int vlen;
        long long vll;

        while(p) {
            ziplistGet(p,&vstr,&vlen,&vll);
            listAddNodeTail(keys,
                (vstr != NULL) ? createStringObject((char*)vstr,vlen) :
                                 createStringObjectFromLongLong(vll));
            p = ziplistNext(o->ptr,p);
        }
        cursor = 0;
    } else {
        redisPanic("Not handled encoding in SCAN.");
    }

    ......

위의 코드에서 핵심적인 부분은, 찾아야 하는 key들을 keys라는 리스트에 추가하고, 이 리스트를 돌면서 패턴에 맞는 것들을 삭제한 결과를 돌려줍니다. “우와 대박이다.” 라고 생각할 수
있는데, 여기서 주의할 것이 있습니다. 기본적으로 딱 정해진 개수만큼 가져오는것이 아니라는 겁니다. 당연히 Redis는 다양한 자료구조를 지원하고 있고, 속도를 위해서 같은 자료구조라도
구현 방식이 여러가지입니다. 예를 들어, sorted set은 SKIPLIST 형태로 구현된것이 잇고, ziplist 형식도 있습니다. 그래서 또한 동작방식도 조금씩 다릅니다.(그래서 사실 이 글을 쓰고
있는거긴 한데 말입니다.)

다음과 같은 경우를 주의해야합니다.
1] 기본적으로 scan 의 경우 table 의 한 블럭을 가져오는 것이라서, 여기에 개수가 많으면 시간이 많이 걸릴 수도 있다.(다만, 리해싱 테이블이 bitmasking 크기만큼 커지므로, 한 블럭이 극단적으로 커질 가능성은 높지 않습니다.)
2] set/sorted set/hash 의 내부 구조가 hash table 이나 Skiplist 가 아닐 경우(ziplist 로 구현되어 있을 경우), 한 컬렉션의 모든 데이터를 가져오므로, keys와 비슷한 문제가 그대로 발생할 수 있다.

위의 경우만 아니면 keys가 필요할 때 scan 명령을 사용하는 것은 꽤 좋은 선택이 될것 같습니다.

기본적으로 사용법은 다음과 같습니다.(python 기준입니다.)

import redis
r = redis.StrictRedis('localhost', port=2000)

init = 0
while(True):
    ret = r.scan(init)
    print init
    init = ret[0]
    print ret[1]
    if (init == '0'):
        break