Redis 1편 - Redis란?

Redis 1편 - Redis란?

Redis는 인메모리 기반의 키-값 데이터베이스로, 빠른 응답 속도와 다양한 자료구조를 지원한다. 분산 캐시와 메세지 브로커로 활용되며, 영속화 기능도 제공한다.

Redis는 뭘까?

Redis ** (/ˈrɛdɪs/;[8][9] **Remote Dictionary Server)[8] is

an in-memory key–value database, used as a distributed cache and message broker, with

optional durability.[10] Because it holds all data in memory and because of its design, Redis offers low-latency reads and writes, making it particularly suitable for use cases that require a cache. Redis is the most popular NoSQL

database,[11][12][13] and one of the most popular databases overall.[14]

번역해보면 인메모리 기반의 키-값 데이터베이스라고 한다. 분산 캐시메세지브로커로 활용되고 선택적으로 디스크에 영속화도 지원해준다. 빠른 입출력을 위해 모든 데이터를 메모리에 저장해두기 때문에 반응이 빠르다. 이때문에 가장 인기있는 NoSQL이라고 한다.

오케이. 좋다는건 누구나 알고 있다. 결국은 쓰기 위해 만들어진 것. Redis에 대한 특징을 알아보고 그 특징 별로 사용법을 배워보자. 그리고 구체적인 사용 코드와 테스트를 통해 학습하려고 한다.

Redis에 대한 특징

  • 우리 Redis는요? 인메모리 기반 이라서요…

    • 모든 데이터를 메모리에 저장하고 읽고 써요. 그래서 디스크 I/O 접근보다 매우 빨라서 응답 대기 시간(Latency) 도 짧답니다.
    • 실제 인메모리 캐시는 마이크로초 단위의 짧은 지연으로 초당 수백만 건의 처리량을 감당할 수도 있고, 대용량 트래픽에서도 DB 부하를 줄이고 사용자에게 즉각적인 응답을 주는데 핵심적인 역할을 해요.
  • 그리고 키-값 NoSQL 인데요…

    • 다양한 자료구조를 지원하는 유연성을 가지고 있어요.
    • String : 단순 문자열
      • O(1) 읽기/쓰기 성능
      • TTL 설정으로 손쉬운 만료 관리
    • List : 삽입 순서를 보장하는 리스트 형태
    • Set : 중복 없는 집합 데이터
    • Hash : 필드와 값의 매핑 형태로 객체 표현에 적절
    • Sorted Set : 멤버와 점수(score)를 함께 저장해서 점수 기반으로 자동 정렬되는 집합으로 ‘실시간 랭킹’, ‘리더보드’ 구현에 최적화
    • HyperLogLogs : 엄청 크고 유니크한 값 카운팅 할 때 사용할 수 있는 것들이다. 카운팅에 저장된 데이터는 확인할 수 없지만, 매우 적은 용량으로 큰 분량의 카운팅이 가능하다.
    • Streams : 로그나 메세징을 위한 자료구조로 append-only라 중간에 바뀌지 않는다. 시간 범위로 가져온다.
  • 분산 캐시 의 특징으로는요

    • 분산환경에서 캐시의 고가용성과 확장성을 제공해줘요.

    • 복제(Replication) : 하나의 Redis 인스턴스(마스터)의 데이터를 여러 슬레이브(읽기 전용 복제본)에 실시간으로 복제하여 읽기 부하를 분산해요. 마스터가 장애나면 슬레이브를 마스터로 승격시키는 센티넬(Sentinel) 구조도 지원됩니다.

    • 클러스터링(Clustering) : 완전한 대규모 확장을 위해 Redis Cluster 모드를 채택할 수 있어요. 키를 여러 노드에 샤딩(Sharding) 하여 저장함으로써 수평 확장과 * *고가용성**을 구현할 수도 있습니다. 내부적으로 16384개의 해시 슬롯을 사용하여 키를 노드에 분배하여 데이터가 고르게 퍼지도록 해요.

    • 전역 캐시(Global Cache) : 여러 애플리케이션 서버 인스턴스가 동일한 Redis 캐시를 공유하니까 데이터 일관성을 유지하기가 쉬워요. 그래서 전역적으로 관리하는 세션 데이터를 저장하면 로드밸런싱 환경에서도 어느 서버로 가던지 안정적인 세션 관리가 가능해집니다.

    • 계층적 캐시 : 모든 캐시를 Redis인 전역캐시에 의존하기보다, 지역 캐시와 함께 계층적으로 조합해 활용하면, Hot Key 문제 같이 특정 노드에 대한 과부하를 완화하고 대비할 수 있습니다. 1차적으로 로컬캐시에서 응답하고 없으면 2차 캐시, 3차적으로 DB 네트워킹을 통해 진행하는것입니다. 로컬캐시는 네트워킹도 필요없기 때문에 가장 빠르지만, 아무래도 로컬캐시 데이터 간의 싱크 문제를 신경은 꼭 써야합니다.

      지역캐시는 Application 내부 메모리에 유지되는 캐시로 EhCache, Guava Cache, Caffeine Cachce가 있습니다.
      전역캐시는 별도의 캐시 서버에 캐시를 저장하는 건데 Redis라고 할 수 있죠.

  • 메세지 브로커로써도 쓰여요.
    • Redis는 Publish/Subscribe (발행/구독) 메시징 기능을 기본적으로 제공하여, 간단한 실시간 메시지 브로커로 활용될 수 있어요.
    • 작동 방식: 한 쪽에서 특정 채널(channel)에 메시지를 발행(PUBLISH)하면, 해당 채널을 구독(SUBSCRIBE) 중인 모든 클라이언트가 실시간으로 메시지를 받아 처리하는 구조입니다.
    • 활용 예시: 채팅 시스템, 실시간 알림 브로드캐스트, 또는 캐시 무효화 통지(Cache Invalidation Broadcast)와 같은 시나리오에 적합합니다. 예를 들어, 다중 서버 환경에서 특정 데이터가 변경되면 Redis 채널을 통해 “invalidate [키]” 메시지를 보내 모든 서버의 로컬 캐시를 지우는 방식으로 분산 캐시 일관성 문제를 완화할 수 있어요.
    • 제약 사항: Redis Pub/Sub은 메시지를 브로드캐스트하지만, 내구성 있는 큐(queue)는 아닙니다. 즉, 구독자가 없을 때 발행된 메시지는 유실되며, 메시지 소비에 대한 확인(ACK) 개념이 없어요. 따라서 지속성이 필요하거나 메시지 손실이 허용되지 않는 중요한 업무 이벤트 스트림에는 Kafka나 RabbitMQ와 같은 전문 메시지 브로커가 더 적합합니다.
  • 영속화 특징은요.
    • Redis는 기본적으로 인메모리 데이터베이스이지만, 데이터 손실을 방지하고 재시작 시 데이터를 복구할 수 있도록 영속성(Persistence) 기능이 있습니다.
    • RDB (Redis Database) 스냅샷: 특정 시점의 Redis 메모리 데이터를 디스크에 스냅샷 파일(.rdb)로 덤프하는 방식입니다. 이는 재시작 시 빠르게 데이터를 로드할 수 있게 해주며, 주기적인 백업 용도로 쓰여요.
    • AOF (Append-Only File): Redis에 대한 모든 쓰기 명령을 로그 파일에 순차적으로 기록하는 방식입니다. 이 방식은 RDB보다 데이터 안정성이 높지만, 모든 쓰기 연산을 디스크에 동기식으로 기록할 경우 디스크 I/O 속도가 전체 성능의 병목이 될 수 있어요. AOF 파일이 커지면 리라이트(Rewrite) 과정에서 CPU와 디스크 부하가 발생할 수 있습니다.
    • 혼합 지속성: 운영 환경에서는 RDB와 AOF를 혼합하여 사용하는 것을 권장합니다. 예를 들어, 15분마다 RDB 스냅샷을 뜨고, 그 사이의 변경 사항은 AOF(매 1초마다 동기화, everysec)로 기록하면, 대부분의 복구는 RDB로 빠르게 하고 최근 1초 이내 데이터만 AOF로 적용하여 지속성과 성능 간의 균형을 맞출 수 있어요.
    • AOF 병목 완화: AOF 사용 시 디스크 병목이 발생한다면 appendfsync 옵션을 everysec로 조정하여 동기화 주기를 늘리거나, 고속 SSD 사용 등의 튜닝을 고려할 수 있어요. 극단적인 경우, 캐시 용도로 사용하며 데이터 유실을 감수할 수 있는 시나리오에서는 AOF를 아예 비활성화하고 RDB만 사용하거나 지속성 자체를 끄는 것도 가능합니다.

    AOF와 RDB파일이 함께 있으면 복구할 때 어떤 것을 먼저 읽게 될까?

    AOF는 항상 메모리 반영하기 직전에 쓰기 때문에 AOF가 신규데이터가 많다고 판단하고 AOF를 읽게 된다.
    RDB는 특정 시간 단위로 저장한다면, 장애 발생 시 다음 저장할 때 까지의 데이터가 모두 유실되지만, AOF는 매 작업마다 디스크에 기록을 남기기 때문에 모든 데이터가 있다.

    //file: `redis loadDataFromDisk 구현 소스 `
    void loadDataFromDisk(void) {
      long long start = ustime();
      /**AOF_ON이 켜져있으면 AOF를 우선적으로 먼저 처리한다.**/
      if (server.aof_state == AOF_ON) {
        int ret = loadAppendOnlyFiles(server.aof_manifest);
        if (ret == AOF_FAILED || ret == AOF_OPEN_ERR)
          exit(1);
        if (ret != AOF_NOT_EXIST)
          serverLog(LL_NOTICE, "DB loaded from append only file: %.3f seconds", (float)(ustime()-start)/1000000);
        updateReplOffsetAndResetEndOffset();
      } else {
      /** AOF가 켜져있지 않으면 RDB 저장소를 찾아서 읽는다. **/
        rdbSaveInfo rsi = RDB_SAVE_INFO_INIT;
        int rsi_is_valid = 0;
        errno = 0; 
        int rdb_flags = RDBFLAGS_NONE;
        if (iAmMaster()) {
          createReplicationBacklog();
          rdb_flags |= RDBFLAGS_FEED_REPL;
        }
    

Redis vs Memcached 뭐가 더 좋을까?

단순하게 Memcached는 단순한 캐시 솔루션, Redis는 저장소의 개념이 추가된 캐시 솔루션이다.
캐시는 빠른 속도를 위해 어떤 결과를 저장해두고, 데이터가 사라지면 다시 만들 수 있다 라는 전체가 내포되어있다. 여기에 Redis는 데이터가 유지되어야 한다 의 개념이 추가되었다.
따라서 Redis에 메모리를 스냅샷찍어 디스크저장하는 RDB 기능이나, AOF, Replication이 추가된것이다.

응답 속도의 균일성이 조금 떨어질 수 있나요?

Redis의 메모리 할당 구조는 jemalloc을 사용합니다. 그래서 매 번 malloc과 free를 통해서 메모리 할당이 됩니다.
이 과정에서 메모리 딘편화(fragmentation)이 발생하여 메모리 할당에 필요한 비용때문에 응답속도가 느려질 수 있습니다.
메모리가 충분히 있고 남지만 사용하지 못하는 경우가 발생하기때문에 그에 따른 비효율적인 메모리 사용이 발생할 수 있기 때문입니다.

그래서 메모리 할당 정책에 따라 공간,속도의 효율성이 차이가 나게되고 성능에 영향이 생깁니다.
그러나, 이는 아주 극단적이고 크리티컬한 상황에서 발생할 가능성의 문제고 slab 할당 구조를 가진 Memcached에 비해 균일하지 못한 평가입니다.
큰 규모의 서비스에서도 Redis가 사용되고 있기 때문에, 실제로는 큰 문제가 되지 않는다고 판단하고 있습니다.

메모리 단편화와 jemalloc에 대해

메모리 단편화(fragmentation) 는 공간이 충분히 있으면서도 작은 조각으로 나뉘어져 있어 할당이 불가능한 상황을 말합니다.

jemalloc은 구글의 tcmalloc과 함께 가장 많이 사용되는 메모리 할당자입니다. 메모리 할당/해제를 많이 하는 프로그램에서는 메모리 공간 활용과 정리가 속도에 유효한 영향을 미칩니다. je나 tc malloc 메모리 할당자를 사용하는 것 만으로 그 이전과 10~20% 정도 성능 향상을 기대할 수 있을 정도입니다.

//file: `redis 구현 내부에서 JEMALLOC 사용 예시`
#if defined(USE_JEMALLOC)
/* jemalloc 5 doesn't release pages back to the OS when there's no traffic.
 * for large databases, flushdb blocks for long anyway, so a bit more won't
 * harm and this way the flush and purge will be synchronous. */
    if(!(flags &EMPTYDB_ASYNC)){

/* Only clear the current thread cache.
 * Ignore the return call since this will fail if the tcache is disabled. */
je_mallctl("thread.tcache.flush",NULL, NULL, NULL, 0);

jemalloc_purge();
    }
            #endif

실무 Redis 사용 주의사항

보통 문제가 발생하는 경우는 Redis가 싱글스레드인 점을 고려하지 않게 작업할 때 발생한다.
하나의 명령이 오랜 시간을 소모하는 작업을 피해야한다. 그 기간동안 다른 명령을 처리할 수 없다.

문제 상황 1. keys 명령을 쓰는 경우

//file: `redis keys 구현 소스 src/`
void keysCommand(client *c) {
    ...

    /** 이구간에서 기본적으로 전체 키를 가져오는 작업을 한다. **/
    while ((de = kvs_di ? kvstoreDictIteratorNext(kvs_di) : kvstoreIteratorNext(kvs_it)) != NULL) {
        kvobj * kv = dictGetKV(de);
        sds key = kvobjGetKey(kv);

        /** 모든 키에 대해 stringmatchlen를 이용해 가져온다... 미쳤니? **/
        if (allkeys || stringmatchlen(pattern, plen, key, sdslen(key), 0)) {
            if (!keyIsExpired(c -> db, NULL, kv)) {
                addReplyBulkCBuffer(c, key, sdslen(key));
                numkeys++;
            }
        }
        if (c -> flags & CLIENT_CLOSE_ASAP)
            break;
    }
    if (kvs_di)
        kvstoreReleaseDictIterator(kvs_di);
    if (kvs_it)
        kvstoreIteratorRelease(kvs_it);
    setDeferredArrayLen(c, replylen, numkeys);
}
  • while ((de = kvs_di ? kvstoreDictIteratorNext(kvs_di) : kvstoreIteratorNext(kvs_it)) != NULL)
  • 위 구문에서 kvs_di 또는 kvs_it를 통해 모든 키를 가져오는 작업을 수행한다.
  • 모든 키를 String 비교하며 순회하기 때문에 키의 개수가 많을 경우 시간이 오래 걸린다.
  • 몇 십만개만 넘어가도 이거 하느라고 다른 작업 못할 것 같다.

keys의 대안

꼭 필요한 key 목록을 얻어야 하는 경우가 있다면, 방법을 우회해야 한다. listset, sorted set을 이용해서 자료구조 자체를 이용해서 키를 관리하는 방법을 사용해야 한다.
예를 들어 특정 일에 가입된 유저의 아이디를 얻어야 한다면, register_userlist_20250810 으로 생성한 뒤,
해당 list를 호출하면 userId인 Key 값을 얻을 수 있는 방법이다.
즉, 키를 넣어두는 자료구조를 만들어서 관리하는 것이다.
그러나 키의 개수가 또 많아지면 날짜별로 자료구조를 관리해야한다거나, 키를 관리하는 자료구조를 분할하거나 하는 문제가 있기도 하다.

문제상황 2. flushall/flushdb 사용하는 경우

레디스에는 논리적 공간 분리인 db가 있고, 이 db 별로 key를 관리한다.
예를 들어 db[0]의 user key와 db[1]의 user key는 서로 다른 key로 관리된다.
flushall은 모든 db를 비우는 명령어이고, flushdb는 현재 db를 비우는 명령어이다.

keys와 마찬가지로 flushall/flushdb 명령어는 모든 키를 순회하면서 삭제하는 작업을 수행한다.

//file: `redis flushall 구현 소스 src/`

longlong emptyDbStructure(redisDb *dbarray, int dbnum, int async, void(callback)(dict*)){
        /** async면 emptyDbAsync, 아니면 ebDestroy를 통해서 db를 비운다. **/
        for(int j = startdb;j <=enddb;j++){
            removed +=kvstoreSize(dbarray[j].keys);
            
            if(async){
                emptyDbAsync(&dbarray[j]);
            }else{
                ebDestroy(&dbarray[j].hexpires, &hashExpireBucketsType, NULL);
                kvstoreEmpty(dbarray[j].keys, callback);
                kvstoreEmpty(dbarray[j].expires, callback);
            }
            
            dbarray[j].avg_ttl =0;
            dbarray[j].expires_cursor =0;
        }

        return removed;
}

//file: `emptyDbAsync`
/* 비동기 DB 비우기: "빈 구조로 즉시 스위치" 후, 기존 구조는 백그라운드에서 해제 */
void emptyDbAsync(redisDb *db) {
    // 기존 구조 포인터 백업
    kvstore * oldkeys = db -> keys;
    kvstore * oldexpires = db -> expires;
    ebuckets oldHexp = db -> hexpires;

    /** 핵심1) O(1) 스왑
     *  - 새로 비어있는 kvstore/hexpires를 즉시 만들어 끼운다.
     *  - 호출(메인 스레드) 관점에서는 거의 즉시 반환됨. */
    db -> keys = kvstoreCreate( & dbDictType, 0, KVSTORE_ALLOC_META_KEYS_HIST);
    db -> expires = kvstoreCreate( & dbExpiresDictType, 0, 0);
    db -> hexpires = ebCreate();

    /** 핵심2) 실제 메모리 해제는 백그라운드 O(N)
     *  - 기존 구조물(oldkeys/oldexpires/oldHexp)은 lazyfree 큐로 넘긴다.
     *  - 총 해제 시간/CPU 코스트는 데이터량에 선형. */
    atomicIncr(lazyfree_objects, kvstoreSize(oldkeys));
    bioCreateLazyFreeJob(lazyfreeFreeDatabase, 3, oldkeys, oldexpires, oldHexp);
}
//file: `dictEmpty`
/* 동기 딕셔너리 비우기: 현재 테이블을 "그 자리에서" 모두 해제 */
void dictEmpty(dict *d, void(callback)(dict*)){

/** 핵심) O(N) 전수 해제
 *  - 메인 스레드가 직접 엔트리를 하나씩 free 하므로,
 *    큰 데이터셋일수록 호출이 오래 걸리고 이벤트 루프가 점유된다. */
_dictClear(d, 0,callback);

_dictClear(d, 1,callback);

// 리해시/자동 리사이즈 상태 초기화
d->rehashidx =-1;
d->pauserehash =0;
d->pauseAutoResize =0;
}

/* 실제 루프: 버킷을 돌며 엔트리를 하나씩 해제 */
static int _dictClear(dict *ht /* 생략 */) {
    for (unsigned long i = 0;i < ht -> size && ht -> used > 0;i++){
        dictEntry * he = ht -> table[i];
        while (he) {
            dictEntry * nextHe = he -> next;

            /** 핵심) 엔트리 단위 해제 (메인 스레드 점유) */
            dictFreeEntryKey(ht, he);
            dictFreeEntryVal(ht, he);
            hi_free(he);
            ht -> used--;

            he = nextHe;
        }
    }
    /** 버킷 배열 자체도 해제 후 재초기화 */
    hi_free(ht -> table);
    _dictReset(ht);
    return DICT_OK;
}
  • ASYNC(emptyDbAsync)
    • 호출 지연은 O(1) - 새 빈 구조로 즉시 스위칭
    • 실제 메모리 회수는 O(N) - lazyfree 워커가 뒤에서 천천히 지움
    • 체감 응답은 빠르지만, CPU/RSS 감소는 시간에 걸쳐 이뤄짐
  • SYNC(dictEmpty)
    • 호출 지연은 O(N) - 모든 엔트리를 하나씩 해제
    • 메인 스레드가 직접 해제하므로, 큰 데이터셋일수록 이벤트 루프 점유
    • 데이터가 클 수록 응답 지연이 길어지고, Redis 요청 처리량이 감소함

Redis와 다르게 Memcached의 flush_all은 실제 삭제하지 않고 해당 명령어가 실행된 시간을 기록한다.
그리고 이전에 저장된 Key의 get 명령을 통해 접근할 때 없다고 하면서 지운다.

문제상황 3. 사용 memory가 갑자기 두 배가 되는 경우

Redis의 RDB 스냅샷 기능은 fork를 사용한다. 이는 큰 문제를 야기시키기도 하는데 부모 프로세스의 Write 크기에 비례해 추가적인 메모리를 필요로 하기 때문이다.
초기에는 자식 프로세스를 생성할 때 부모 프로세스의 메모리를 모두 그대로 복사해 같은 크기의 메모리용량을 할당했다. 그러다 CopyOnWrite 라는 기술의 개발로 Write가 발생한 경우에 해당 변경 공유 데이터만 자식 프로세스에 복사되게 발전했다.
그러나, 여전히 부모 프로세스의 Write가 많을 경우 자식 프로세스가 부모 프로세스의 메모리를 모두 복사하게 되어 메모리 사용량이 두 배로 늘어나는 문제가 발생한다.

fork() 하면 부모/자식이 메모리를 ‘공유’ 시작한다.
그런데 부모가 어떤 페이지(page) 를 수정하려고 하면, 그 페이지 한 장(보통 4KB) 을 복사한 뒤에 수정한다.
COW는 운영체제(Linux) 동작이다. Redis 버전 최적화는 있어도 쓰기량이 많으면 추가 메모리 필요한 사실은 같다.

왜 “두 배”처럼 보일까? 계산 예시

  • 리눅스 메모리 페이지: 4KB
  • 초당 1만 건 쓰기/수정/삭제
  • RDB 저장(백그라운드)이 60초 걸림
  • 총 60만 건 변경
  • 각각이 다른 페이지에 있다면 → 60만 × 4KB ≈ 2.3GB 가 COW로 추가 필요

즉, 총 32GB 인 Redis 서버에서 Redis 서버 인스턴스로 30GB 를 쓰고 있다면, RDB 스냅샷을 찍는 순간 COW로 인해 30GB + 2.3GB = 32.3GB 가 필요하게 된다.
실제 메모리가 부족하여 문제가 발생하므로 여유 메모리를 두고 운영해야 한다.

그렇다면 Redis 서버에 메모리를 얼마나 할당하는게 좋을까?

Core4개 + 메모리 32GB 인 장비라면 프로세스 별로 6GB 정도 할당하는게 좋다.
6GB * 4 = 24GB 이고, 멀티 코어 활용을 위해 여러 Redis 서버를 한 서버에서 띄우는게 성능 면에서 좋다.
여러 Redis 서버를 하나의 서버에 띄우면 RDB 저장으로 자식 프로세스가 생성되는데 최대 부모 하나의 GB라고 생각하더라도
6GB * 4(Parent) + 6GB * 1(Child) = 30GB 가 되므로 여유 메모리 2GB가 남는다.

CopyOnWrite 문제를 최대한 줄이는 방법?

  • redis.conf의 RDB save <sec> <changes> (자동 RDB) 설정
    • save 60 10000 이면 60초마다 1만 건의 변경이 있을 때 RDB를 저장한다.
    • 조건 충족되면 RDB를 새로 쓰는 동안 COW가 발생하여 메모리가 추가적으로 필요하다.
    • 사용하지 말고 AOF를 appendfsync everysec로 설정하는 것을 권장한다.
  • RDB BGSAVE (수동 RDB)
    • 실행시 자식 프로세스가 생성되어 RDB 파일을 새로 쓰기 때문에 COW가 발생한다.
    • 서버 부하가 적을 때만 사용한다.
  • RDB 복제 (Replication)
    • 자신이 마스터 Redis이고, 슬레이브가 연결되면 전체 데이터 복제(full resync)를 수행한다.
    • 이 과정에서 RDB 파일을 만들게 되어서 COW가 발생한다.
    • 기존 슬레이브에 문제가 생겨서 급히 바꿔야할 상황이 아니라면 부하가 적을 때 슬레이브를 연결한다.
  • AOF redis.conf의 auto-aof-rewrite-percentage 100 파라미터
    • appendonly 파일이 100%로 커지면 appendonly 파일을 다시 쓰는 명령을 수행한다.
    • 다시쓰기는 AOF 자식 프로세스가 생성되어 작업하고, 이 과정에서 COW가 발생한다.
    • 해당 파라미터를 0으로 설정해 disable을 권장한다. 그리고 차라리 서버 부하가 적을 때 BGREWRITEAOF 명령을 수행한다.(AOF 파일이 커지는 것을 방지)
  • AOF BGREWRITEAOF (수동 AOF 다시쓰기)
    • AOF 자식 프로세스가 생성되어 appendonly 파일을 다시 쓰는 작업을 수행한다.
    • 이 과정에서 COW가 발생한다.
    • 서버 부하가 적을 때 사용한다.

문제상황 4. Read는 가능한데 Write만 실패하는 경우

Redis 운영 중에 RDB를 쓴다면, 서버는 동작하지 않는데 Ping을 이용한 Heartbeat는 통과하는 경우가 있다.
이는 RDB 저장이 실패하면 Write 명령은 처리하지 않고 데이터의 변경을 막는다.
Heartbeat는 읽기 명령이기 때문에 이전 RDB 실패 상황과 상관없이 통과한다.

RDB 저장 실패 원인

  1. RDB 저장할 디스크 여유 공간이 없는 경우
  2. 실제 디스크가 고장난 경우
  3. 메모리 부족으로 자식 프로세스 생성 못한경우
  4. 누군가 강제적으로 자식 프로세스를 종료시킨 경우

RDB/AOF 저장 실패 시 Write 명령을 막는 코드

//file: `redis/src/server.c 구현 소스`

/* Sometimes Redis cannot accept write commands because there is a persistence
 * error with the RDB or AOF file, and Redis is configured in order to stop
 * accepting writes in such situation. This function returns if such a
 * condition is active, and the type of the condition.
 *
 * Function return values:
 *
 * DISK_ERROR_TYPE_NONE:    No problems, we can accept writes.
 * DISK_ERROR_TYPE_AOF:     Don't accept writes: AOF errors.
 * DISK_ERROR_TYPE_RDB:     Don't accept writes: RDB errors.
 */
int writeCommandsDeniedByDiskError(void) {
    if (server.stop_writes_on_bgsave_err &&
        server.saveparamslen > 0 &&
        server.lastbgsave_status == C_ERR)
    {
        return DISK_ERROR_TYPE_RDB;
    } else if (server.aof_state != AOF_OFF) {
        if (server.aof_last_write_status == C_ERR) {
            return DISK_ERROR_TYPE_AOF;
        }
        /* AOF fsync error. */
        int aof_bio_fsync_status;
        atomicGet(server.aof_bio_fsync_status,aof_bio_fsync_status);
        if (aof_bio_fsync_status == C_ERR) {
            atomicGet(server.aof_bio_fsync_errno,server.aof_last_write_errno);
            return DISK_ERROR_TYPE_AOF;
        }
    }

    return DISK_ERROR_TYPE_NONE;
}
  • RDB 저장 실패 시 막는다
  • AOF 쓰기 오류시 막는다
  • AOF fsync(동기화) 오류시 막는다.

왜 이렇게 만들었나요?(설계 의도)

데이터 안전 우선: RDB/AOF 중 하나라도 구성상 치명적이면, 추가 손상을 막기 위해 쓰기를 멈춘다.
운영자 신호: 반환 타입으로 “무슨 계층에서 막혔는지(AOF vs RDB)”를 상위 레이어가 구분해, 로그/알림/문구를 정확히 낼 수 있다.
스레드 안전: fsync 상태와 errno는 BIO 스레드가 만지므로 atomicGet으로 읽어 경합을 회피한다.

정책적으로 이 상황에서도 Write를 허용할 수 있는데, 그것은 시스템 운영 정책에 따라 다르다.