ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Redis 운영 관리
    책책책 책을 읽읍시다/프로그래밍 2023. 5. 6. 23:50

    저자 : 강대명

    Redis 운영관리

    들어가며


     레디스 구조에 대해 얇은 지식이라도 알고 싶은데 블로그도 검색하기 귀찮은데 11,900원 정도는 지불할 용의가 있는 사람들에게 추천하는 책이다. 혹은 배민같이 도서 구입비가 무제한인 회사에 다니면서 휴가가서 가볍게(70쪽) 뭐라도 읽고 싶은 개발자에게 추천한다. 나도 회사 복지인 반기 도서 구입비 10만원으로 구매해서 보아서 그나마 다행이었다. ebook 무료로 풀려도 될 것 같다... 그나마 RDB, AOF와 같은 이전에 몰랐던 내용들을 알게 되어서 의미가 있었다. 원했던 싱글스레드 동작 방식과 같이 구조적으로 자세한 지식(Real MySQL처럼)은 못 가져갔지만 말이다..

    발췌 내용 정리


    레디스 이해


     Remote Dictionary Server의 약자로 주요 특성은 아래와 같다.

    항목 내용
    Key-Value 스토어 단순 스트링에 대한 Key/Value(키/밸류) 구조를 지원한다.
    컬렉션 지원 List, Set, Sorted Set, Hash 등의 자료 구조를 지원한다.
    Pub/Sub 지원 Publish/Subcribe 모델을 지원하여 서버 간에 통지가 필요할 때, 이 기능이 매우 유용하다.
    디스크 저장
    (Persistent Layer)
    현재 메모리 상태를 디스크로 저장할 수 있는 RDB 기능과 현재까지의 업데이트 관련 명령을 저장할 수 있는 AOF 기능이 있다.
    복제(replication) 다른 노드에서 해당 내용을 복제할 수 있는 마스터/슬레이브 구조를 지원한다.
    빠른 속도 초당 100,000QPS(Queries Per Second) 수준의 높은 성능을 지원한다.

    Redis와 Memcached 비교

    Memcached는 캐시 솔루션이고 이러한 Memcached에 저장소의 개념이 추가된 것이 Redis라고 할 수 있다. '캐시'는 빠른 속도를 위해서 어떤 결과를 저장해 두는 것을 의미하며, 또한 '데이터가 사라지면 다시 만들 수 있다'는 전제를 내포하고 있다. 그런데 저장소라는 개념이 추가되면 '데이터가 유지되어야 한다'는 특성을 가지게 된다. 

    아래는 Redis와 Memcached를 비교한 표인데, 2014년 자료이므로 참고만 하고 절대적인 신뢰는 주지 말자..

    기능 Redis Memcached
    속도 초당 100,000QPS 이상 초당 100,000QPS 이상
    자료구조 Key-Value, List, Hash, Set, Sorted Set 지원 Key-Value만 지원
    안정성 특성을 잘못 이해할 경우, 프로세스 장애 발생 장애 거의 없음
    응답 속도의 균일성 Memcached에 비해서 균일성이 떨어질 수 있음 전체적으로 균일함

    마지막 항목의 '응답 속도의 균일성' 부분을 조금 더 자세히 알아보. 대규모 트래픽으로 인해 많은 데이터가 업데이트되면, Redis는 Memcached에 비해서 속도가 출렁인다. 이것은 Redis와 Memcached의 메모리 할당 구조가 다르기 때문에 발생하는 현상이. Redis는 jemalloc을 사용하는데, 매번 malloc과 free를 통해서 메모리 할당이 이루어진다. 반면 Memcached는 slab 할당자를 이용하여, 내부적으로는 메모리 할당을 다시 하지 않고 관리하는 형태를 취한다. 이로 인해서 Redis는 메모리 프래그멘테이선(framentation) 등이 발생하며 이 할당 비용 때문에 응답 속도가 느려진다. 다만, 이는 극단적으로 봤을 때 발생하는 일이다.

     

    Redis 운영과 관리


    Redis는 싱글 스레드다

    Redis는 싱글  스레드이기 때문에, 태생적으로 하나의 명령이 오랜 시간을 소모하는 작업에는 적합하지 않다. 그런데 이러한 특성을 이해하지 못하는 경우 장애가 발생하게 된다. 즉, 싱글 스레드이기 때문에 시간이 오래 걸리는 Redis 명령을 호출하면, 명령을 처리하는 동안에는 Redis가 다른 클라이언트의 요청을 처리할 수 없다. 6.0 버전부터는 멀티쓰레드가 지원되는 말이 있어 찾아봤는데, 이는 I/O 전용 멀티쓰레드이지 메인쓰레드는 여전히 싱글 스레드이다.

    Chat GPT가 알려준 레디스 6.0 멀티쓰레드

    흔히 하는 실수 1 : 서버에서는 keys 명령을 사용하지 말자

    현재 서버에 저장된 Key 목록을 볼 수 있는 명령어인데, 모든 Key를 대상으로 검색하기 때문에 주의해야 한다. https://github.com/redis/redis/blob/unstable/src/db.c 에서 소스 코드를 보자.

    void keysCommand(client *c) {
        dictIterator *di;
        dictEntry *de;
        sds pattern = c->argv[1]->ptr;
        int plen = sdslen(pattern), allkeys;
        unsigned long numkeys = 0;
        void *replylen = addReplyDeferredLen(c);
    
        di = dictGetSafeIterator(c->db->dict);
        allkeys = (pattern[0] == '*' && plen == 1);
        robj keyobj;
        while((de = dictNext(di)) != NULL) {
            sds key = dictGetKey(de);
    
            if (allkeys || stringmatchlen(pattern,plen,key,sdslen(key),0)) {
                initStaticStringObject(keyobj, key);
                if (!keyIsExpired(c->db, &keyobj)) {
                    addReplyBulkCBuffer(c, key, sdslen(key));
                    numkeys++;
                }
            }
            if (c->flags & CLIENT_CLOSE_ASAP)
                break;
        }
        dictReleaseIterator(di);
        setDeferredArrayLen(c,replylen,numkeys);
    }

    현재 설정된 db의 모든 Key를 처음부터 끝까지 stringmat chlen 함수를 이용해서 비교한다. 즉, Key가 몇 개에서 몇천 개 수준이라면 금방 결과가 나오겠지만 몇십만 개, 몇천만 개가 넘어가면 해당 작업에 아주 많은 시간을 소모하게 되고, 작업하는 동안 다른 클라이언트의 요청은 전혀 처리되지 않는다. Keys 명령을 꼭 사용해야 한다면 아래와 같은 방법을 이용할 수 있다.

     예를 들어 Key에 유저 목록을 저장하고 날짜별로 구분해야 하는 유저라면, Redis의 List, Set, Sorted Set을 이용해서 "register_userlist_20130801" 이름으로 자료 구조를 생성하여 "charsyam, bhkim, nadia" 등의 유저 Key를 넣어둔다. 그러면 최초에 해당 "register_userlist_20130801"응ㄹ 익어서 위의 "charsyam, bhkim, nadia" 등의 Key 목록을 알 수 있으므로, get이나 hget 등의 명령을 이용해서 해당 Key에 들어 있는 값을 가져올 수 있다.

    흔히 하는 실수 1 : flushall/flushdb 명령을 주의하자

    Redis에는 모든 데이터를 삭제하는 'flushall/flushdb'라는 명령이 있다. Redis는 db라는 가상의 공간을 분리할 수 있는 개념을 제공하고, select 명령으로 이동할 수 있다. 이를 통해 같은 Key 이름이라도 'db 0번'이나 'db 1번' 등, db 개수에 따라서 여러 개를 만들 수 있다. 이런 db 하나의 내용을 통째로 지우는 것이 flushdb 명령이고, 모든 db의 내용을 모두 지울 수 있는 것이 flushall 명령이다(따로 select 명령을 주지 않으면 기본적으로는 0번을 사용한다).

    flushall 명령은 전체 데이터를 다 지우며, keys 명령처럼 많은 시간이 필요하다. flushall을 이용해서 데이터 삭제 속도를 측정하면 다음과 같다(저자의 실험 장비는 Intel i7 3세대 샌디브릿지 쿼드코어 2.0GHz).

    아이템 개수 시간
    1,000,000개 0.86초
    10,000,000개 8.9초
    100,000,000개 90초

    아이템 개수에 비례해서 시간이 걸린다. 아이템이 1억개가 있는데 서비스 중에 flushall 명령을 실행하면, 90초 동안 다른 어떤 요청도 처리할 수 없다.

    이 역시 위의 src/db.c를 확인해보면 void flushallCommand(client *c)에서 long long emptyData(int dbnum, int flags, void(callback)(dict*))을 호출하고 long long emptyDbStructure(redisDb *dbarray, int dbnum, int async,void(callback)(dict*))을 호출하는데 여기서 server.dbnum(db의 개수)만큼 반복문이 돌며 dicEmpty라는 함수를 호출하며 db에 있는 내용을 지운다.

    /* Remove all keys from the database(s) structure. The dbarray argument
     * may not be the server main DBs (could be a temporary DB).
     *
     * The dbnum can be -1 if all the DBs should be emptied, or the specified
     * DB index if we want to empty only a single database.
     * The function returns the number of keys removed from the database(s). */
    long long emptyDbStructure(redisDb *dbarray, int dbnum, int async,
                               void(callback)(dict*))
    {
        long long removed = 0;
        int startdb, enddb;
    
        if (dbnum == -1) {
            startdb = 0;
            enddb = server.dbnum-1;
        } else {
            startdb = enddb = dbnum;
        }
    
        for (int j = startdb; j <= enddb; j++) {
            removed += dictSize(dbarray[j].dict);
            if (async) {
                emptyDbAsync(&dbarray[j]);
            } else {
                dictEmpty(dbarray[j].dict,callback);
                dictEmpty(dbarray[j].expires,callback);
            }
            /* Because all keys of database are removed, reset average ttl. */
            dbarray[j].avg_ttl = 0;
            dbarray[j].expires_cursor = 0;
        }
    
        return removed;
    }

    dicEmpty는 다시 _dicClear를 호출하는데 여기서 for문을 이용하여 실제 데이터를 일일이 삭제한다. 지우는 속도가 O(n)이기 때문에 데이터 양에 영향을 받게 된다. Redis가 싱글스레드이기 때문에 이런 작업을 피해야 한다. 반면 Memcached는 Redis처럼 모든 데이터를 삭제하지 않는다. 해당 명령어가 실행된 시간만 기록하고 이보다 이전에 저장된 Key는 get 명령을 통해서 접근할 때 없다고 하면서 실제로는 지운다.

    Redis Persistent

    Redis를 Memcached와 구분하는 특성 중에 하나는 Redis의 데이터를 디스크로 저장할 수 있는 Persistent 기능을 제공한다는 것이다. 그래서 서버에 장애가 발생하면 Redis는 디스크에 저장되어 있는 데이터를 기반으로 다시 복구할 수 있다. 하지만 이 디스크 저장 기능이 장애의 주된 원인이 되기 때문에 적절히 알고 사용해야 한다. 디스크 저장 기능인 Redis에서 제공하는 RDB, AOF 등의 기능에 대해 알아보자.

    RDB

    RDBMS로 오해하면 안된다. RDB(Redis Database)는 단순히 Redis 메모리 스냅샷을 파일로 저장한 파일의 확장자명이다. 현재의 메모리에 대한 스냅샷을 저장하는데 싱글스레드에서 어떻게 동작할까? Redis는 지속적인 서비스와 RDB 저장을 위해 fork를 통해서 자식 프로세스를 생성한다. 즉, 자식 프로세스가 생성되면 현재 메모리 상태가 복제되므로, 이것을 기반으로 데이터를 저장한다. RDB 저장을 위한 명령으로 SAVE와 BGSAVE가 있다. SAVE는 모든 작업을 멈추고 현재 메모리 상태에 대한 RDB 파일을 생성하기 때문에 해당 시간 동안 아무런 작업을 할 수 없다. BGSAVE는 백그라운드 SAVE의 약어로 실제로 자식 프로세스를 생성하는 fork 작업을 통해 자식 프롷세스에서 RDB 파일을 저장한다.

    RDB를 사용하려면 redis.conf에 설정해야 되는데, 기본값은 '사용함'이다.

    AOF

    Append Only File의 약어로, 기존의 DBMS에서 제공하는 WAL(Write Ahead Log)와 비슷한 기능을 한. 데이터를 저장하기 전에 AOF 파일에 현재 수행해야 할 명령을 미리 저장해두고, 장애가 발생하면 AOF를 기반으로 복구한다. 다음과 같은 순서도 데이터가 저장된다.

    1. 클라이언트가 Redis에 업데이트 관련 명령을 요청한다.
    2. Redis는 해당 명령을 AOF에 저장한다.
    3. 파일쓰기가 완료되면 실제로 해당 명령을 실행해서 메모리의 내용을 변경한다.

    AOF와 RDB 두 개의 파일이 있다면 Redis는 특정 시점을 기준으로 한 스냅샷인 RDB와 WAL 형태의 AOF 중에서, AOF는 항상 메모리에 반영하기 직전에 쓰기 때문에 AOF를 읽는다(AOF가 신규 데이터를 더 많이 가지고 있다고 생각). RDB의 경우, 한 시간 단위로 저장한다면 장애 발생 시 다음 저장할 때까지의 데이터는모두 유실되게 되지만, AOF는 매 작업마다 디스크에 기록을 남기기 때문에 모든 데이터가 남아있다.

    Redis가 메모리를 두 배로 사용하는 문제

    Redis가 운영되는 중에 장애를 일으키는 가장 큰 원인은 RDB를 저장하는 Persistent 기능으로, fork를 사용하기 때문이다. 이전에는 운영체제가 자식 프로세스를 생성하면, 부모 프로세스의 메모리를 모두 자식 프로세스에 복사해야 했다. 후에 COW(Copy on Write)라는 기술이 개발되었는데, fork 후 자식 프로세스와 부모 프로세스의 메모리에서 실제로 변경이 발생한 부분만 차후에 복사하게 되었다. 하지만 Redis와 같은 솔루션을 사용하는 곳은 대부분 Write가 많으므로 예전의 경우와 마찬가지로 메모리를 두 배로 사용하는 경우가 생긴다. 이때문에 메모리가 4GB인 장비가 있다고 할 때, 3GB를 Redis에서 사용하다가 RDB 저장을 위해 fork해서 자식 프로세스가 생성되면 메모리 부족 현상이 발생할 수 있다. 메모리 할당은 다음과 같은 기준으로 하는 것이 좋다.

     예를 들어 Core 4개를 가지고 있으며 메모리가 32GB인 장비를 사용한다면, 프로세스 별로 6GB 정도를 할당하는 것이 좋다. 즉, 4 * 6 = 24GB 정도의 메모리를 사용하면 된다. Redis가 싱글 스레드이기 때문에, 하나의 Redis 서버를 하나의 장비에서 사용하는 것보다는 멀티 코어를 활용하기 위해 여러 개의 Redis 서버를 한 서버에 띄우는 것이 성능면에서 좋다. 여러 개의 Redis 서버를 한 서버에 띄우면, RDB 저장으로 인해서 자식 프로세스가 생성된다. 즉 프로세스 4개와 RDB용 저장 프로세스를 합쳐 총 5개의 프로세스가 생성되더라도, 30GB(= 프로세스 5개 * 6GB)만 사용하므로 메모리에 여유가 있다.

    Redis의 장애 : Read는 가능한데 Write만 실패하는 경우

    Redis 서버는 동작하지 않는데, 정기적인 Heartbeat 체크(PING 명령을 이용한 주기적인 확인)에는 이상이 없다고 나오는 황당한 상황이 발생하기도 한다. 이러한 문제는 RDB 저장이 실패할 때, 기본 설정상 Write 관련 명령이 동작하지 않기 때문에 발생한다. 즉, Redis의 저장이 실패하면 해당 장비에 뭔가 이상이 있다고 생각하여 Write 명령을 더는 처리하지 않으며, 데이터가 변경되지 않도록 관리한. 일반적으로 Heartbeat 체크는 읽기 관련 명령을 이용하여 검삭하기 때문에, 현재 그런 상태에 있다는 것을 확인하지 못하고 서비스에 장애가 발생한 것으로 인식하기 때문이다.

    RDB 생성에 실패하는 이유로는 다음 다섯 가지 이다.

    1. RDB를 저장할 수 있을 정도의 디스크 여유 공간이 없는 경우
    2. 실제 디스크가 고장 난 경우
    3. 메모리 부족으로 인해서 자식 프로세스를 생성하지 못한 경우
    4. 누군가 강제적으로 자식 프로세스를 종료시킨 경우
    5. 그 외??

    이 문제를 해결하는 방법에는 2가지가 있다.

    첫째, Write 관련 요청으로 해당 상황이 맞는지 확인한다. 

    MISCONF Redis is configured to save RDB snapshots, but is currently not able to persist on disk. Commands that may modify the data set are disabled. Please check Redis logs for details about the error.

    둘째, info 이용해 다음 값을 체크한다.

    rdb_last_bgsave_status:ok

     

    Redis 복제


    Redis의 주요 특징 중 하나가 DBMS에서 제공하는 것과 유사한 복제(Replication) 기능이 있다는 것이다. 복제 기능은 장애 발생 시 빠른 서버 교체 등에 사용할 수 있다.

    Redis는 마스터/슬레이브 형태의 복제 모델을 제공한다. 이를 통해 마스터의 변경이 슬레이브로 전파된다. 한 대의 슬레이브는 오직 하나의 마스터만 가질 수 있다.

     

    Redis HA와 Sentinel

    Redis에서는 기본적으로 'Sentinel'이라는 데몬을 이용하여 마스터의 장애를 판별 -> 슬레이브를 마스터로 승격 -> 클라이언트에 통지 작업을 처리한다. Sentinel에서는 이미 장애가 발생한 마스터에 접속된 클라이언트를 알 수 없으므로 해당 알림을 원하는 클라이언트는 Redis Pub/Sub으로 Sentinel에 등록해야 한다.

    장애가 발생하였는지 판별은 기본적으로 PING 명령의 응답을 이용하는데, 응답이 없다고 해서 바로 해당 서버가 장애라고 판단하여 마스터를 변경하지는 않는다. Sentinel은 'SDOWN'과 'ODOWN'이라는 두 가지 상태로 장애를 인식한다. SDOWN은 'Subjectively Down'의 약어로 Sentinel 하나가 해당 서버가 장애라고 인식하는 주관적인 장애 상태다. ODWON은 'Objectively Down'의 약어로 여러 대의 Sentinel이 해당 서버가 장애라고 인식하는 객관적인 다운 상태다. Sentinel 설정을 보면 장애 발생 시 Failover를 위한 정족수 설정이 있는데, 이 값 이상의 Sentinel 서버가 장애라고 판단하면 해당 서버는 ODOWN 상태가 된다.

    댓글

Designed by Tistory.