[Redis] Expire 를 어떻게 관리할까?

Hocaron·2024년 12월 8일
2

DB

목록 보기
15/15

Redis는 키에 만료 시간을 설정하는 기능을 제공한다.

처음에는 TTL 설정이 되어있기때문에 Redis의 가용메모리가 많을 것이라고 생각했지만, 실제 모니터링 결과 예상보다 가용 메모리가 적었다.🫠 Redis의 만료 관리 방식, 관련된 구조체, 그리고 실제 코드 동작을 살펴보면서 왜 이런 결과가 나오게 되었는지 살펴보자.

Redis의 만료 관리

Redis의 만료 관리는 Passive(수동 삭제)Active(능동 삭제) 두 가지 방식으로 이루어진다.

Passive Expiration (수동 만료)

  • 키에 접근할 때, 만료된 키라면 해당 키를 즉시 삭제한다.
  • 이 방식은 명령이 실행될 때 만료 여부를 확인한다.

Active Expiration (능동 만료)

  • Redis는 주기적으로 스캔 작업을 수행하여 만료된 키를 제거한다.
  • activeExpireCycle 함수를 통해 실행된다.
  • CPU 리소스를 제한하여 Redis의 성능에 큰 영향을 미치지 않도록 설계되었다.

Redis의 구조체 분석

Redis의 데이터 저장 구조

  • redisObject: Redis의 모든 데이터를 추상화한 구조체 (문자열, 리스트, 해시, 집합 등 모든 데이터 타입을 포괄).
  • dictEntry: Redis의 딕셔너리 항목을 나타내는 구조체로, 각 항목의 키-값 쌍을 저장.
  • dict: 해시 테이블 기반의 전역 딕셔너리로, 키-값 데이터 및 만료 정보를 저장.
  • redisDb: Redis 데이터베이스로, 여러 개의 DB 인스턴스를 관리.
  • expires: 만료 정보가 저장된 딕셔너리로, 만료 키와 만료 시간을 매핑한다.

구조체 분석

redisObject 구조체

Redis의 모든 데이터(문자열, 리스트, 해시 등)는 redisObject로 추상화된다. 모든 데이터 유형은 이 구조체를 기반으로 저장된다.

struct redisObject {
    unsigned type:4;         // 데이터 타입 (0: String, 1: List, 2: Set, 3: Zset, 4: Hash)
    unsigned encoding:4;     // 인코딩 방식 (RAW, EMBSTR, INT, HT 등)
    unsigned lru:LRU_BITS;   // LRU/LFU 용도 (접근 시간 또는 빈도)
    int refcount;            // 참조 카운트 (가비지 컬렉션에 사용)
    void *ptr;               // 실제 데이터를 가리키는 포인터
};

dictEntry 구조체

Redis의 딕셔너리 항목(키-값 쌍)을 나타내는 구조체로, 각 키-값의 정보를 저장한다.

typedef struct dictEntry {
    void *key;                  // 키 (보통 sds 구조체로 저장됨)
    union {
        void *val;              // 값 (모든 타입의 포인터)
        uint64_t u64;           // 64비트 정수
        int64_t s64;            // 64비트 정수
        double d;               // 64비트 부동 소수점 값
    } v;
    struct dictEntry *next;     // 해시 충돌 시 체이닝(연결리스트)
} dictEntry;

dict 구조체

Redis의 핵심 자료구조인 해시 테이블을 관리하는 구조체다. 키를 해싱한 값(해시 값)에 따라 버킷 인덱스를 결정하고, 그 인덱스에 dictEntry의 포인터가 연결된다.

typedef struct dict {
    dictType *type;             // 딕셔너리의 타입 (해시 함수, 비교 함수 등)
    void *privdata;             // 딕셔너리의 사용자 정의 데이터
    dictht ht[2];               // 해시 테이블 (리사이징을 위해 2개)
    long rehashidx;             // 리해싱 중인 위치
    unsigned long iterators;    // 현재 활성화된 이터레이터의 수
} dict;

dict와 dictEntry의 관계
dict의 table 배열은 버킷(bucket) 배열로, 해시 버킷에 연결 리스트의 시작 노드를 가리킨다.
dictEntry는 연결 리스트의 노드로, 해시 충돌이 발생할 때 체이닝 방식으로 연결된다.

dict.ht[0].table
+---------+---------+---------+---------+
|  NULL   | key1    | key4    | key3    |  (각 버킷의 첫 번째 노드)
+---------+---------+---------+---------+
                 |
                 v
        +-----------+      +-----------+
        | key4      | ---> | key2      |   (체이닝으로 연결)
        +-----------+      +-----------+

redisDb 구조체

Redis는 여러 개의 데이터베이스를 관리할 수 있다. redisDb는 단일 데이터베이스의 구조체다.

struct redisDb {
    dict *dict;                // 키-값 데이터를 저장하는 딕셔너리
    dict *expires;             // 만료 시간이 설정된 키-값 쌍을 저장하는 딕셔너리
    dict *blocking_keys;       // 블로킹 명령어 관련 딕셔너리
    dict *ready_keys;          // 준비된 키 (차단 해제된 키)
    dict *watched_keys;        // 트랜잭션 감시 중인 키
};

redisDb 구조는 Redis의 데이터베이스이며, 각 데이터베이스에 대해 dict, expires, blocking_keys 등 다양한 메타데이터가 저장된다.

Redis 구조체 흐름

  1. 키-값 데이터 삽입
  • set 명령이 들어오면 dict에 새로운 dictEntry가 생성된다.
  • 키는 sds(String Data Structure)로 저장되고, 값은 redisObject로 저장된다.
  • dict는 해시 테이블로 체이닝(Chaining) 방식으로 충돌을 해결한다.
  1. 만료 시간 관리
  • 만료 시간이 설정되면 redisDb.expires에 만료 시간이 등록된다.
  • dict에 실제 데이터, expires에 만료 시간이 저장된다.
  1. 메모리 관리
  • refcount 필드를 통해 객체의 참조 카운트를 관리한다.
  • 참조 카운트가 0이 되면 가비지 컬렉션을 통해 해제한다.

만료 정보는 어디에 저장될까?

Redis의 만료 정보는 전역 expires 딕셔너리에 저장된다. 이 딕셔너리는 키에 대한 만료 시간을 따로 관리한다.
dict *expires 구조체는 “키”에 대응하는 만료 시간(Unix 타임스탬프)을 저장한다.
이 구조를 통해 Redis는 빠르게 만료 여부를 확인할 수 있다.

Expire 관련 핵심 코드 분석

  1. 만료 시간 설정

만료 시간 설정은 setExpire 함수에 의해 수행된다. 이 함수는 특정 키에 대한 만료 시간을 설정하는 역할을 한다.

void setExpire(client *c, robj *key, long long when) {
    if (dictReplace(server.db[c->db->id].expires, key, createObject(OBJ_STRING, sdsfromlonglong(when)))) {
        incrRefCount(key);
    }
}
  • server.db[c->db->id].expires: 만료 정보를 저장하는 전역 expires 딕셔너리
  • dictReplace: 만료 시간을 “키-값”으로 저장. 키는 데이터 키, 값은 만료 시간(Unix timestamp)
  • createObject(OBJ_STRING, sdsfromlonglong(when)): 만료 시간을 Redis 객체로 변환하여 저장
  1. 수동 만료 관리 (Passive Expiration)

Redis 명령으로 키에 접근할 때마다 만료 확인이 이루어진다. 관련 함수는 lookupKey 함수로, 아래와 같이 동작한다.

robj *lookupKey(redisDb *db, robj *key) {
    robj *val;

    val = dictFind(db->dict, key);
    if (val == NULL) return NULL;

    if (dictFind(db->expires, key) != NULL) {
        if (checkIfExpired(db, key)) {
            propagateExpire(db, key);
            dbDelete(db, key);
            return NULL;
        }
    }

    return val;
}
  • dictFind(db->dict, key): 데이터베이스에서 키에 해당하는 값을 찾음
  • dictFind(db->expires, key): 키에 대한 만료 시간이 있는지 확인
  • checkIfExpired: 현재 시간이 만료 시간보다 큰지 확인
  • 만료되었으면 propagateExpire를 통해 이벤트를 전파하고, dbDelete로 키를 삭제
  1. 능동 만료 관리 (Active Expiration)

Passive 만료만으로는 메모리가 즉시 해제되지 않을 수 있다. 그래서 Redis는 주기적으로 activeExpireCycle을 호출해 만료된 키를 제거한다.

이 함수는 백그라운드에서 일정 시간마다 실행되며, 만료 키를 제거하는 역할을 한다.

void activeExpireCycle(int type) {
    static unsigned int current_db = 0; // 현재 검사 중인 DB 인덱스
    static int timelimit_exit = 0; // 이전 작업이 시간 제한을 넘겼는지 확인
    long long start = ustime(); // 작업 시작 시간
    long long timelimit = 1000; // 최대 실행 시간 1ms (단순화)

    // 각 DB에 대해 작업을 수행
    for (int j = 0; j < server.dbnum && timelimit_exit == 0; j++) {
        redisDb *db = server.db + (current_db % server.dbnum); // 현재 DB 선택
        current_db++; // 다음 루프에서 사용할 DB 인덱스
        if (dictSize(db->expires) == 0) continue; // 만료 키가 없으면 스킵

        int sampled = 0, expired = 0;
        long long now = mstime(); // 현재 시간(ms)
        unsigned long num_samples = 20; // 최대 샘플 수 (단순화)
        
        // 랜덤 샘플링 (20개의 키를 샘플링)
        for (unsigned long i = 0; i < num_samples; i++) {
            dictEntry *entry = dictGetRandomKey(db->expires); // 만료 딕셔너리에서 랜덤 키 선택
            if (entry == NULL) break; // 샘플링할 키가 없으면 종료

            long long ttl = dictGetSignedIntegerVal(entry) - now; // TTL 계산
            if (ttl <= 0) {
                // 만료된 키 삭제
                dbDelete(db, dictGetKey(entry));
                expired++;
            }
            sampled++;
        }

        if (sampled > 0) {
            double expired_ratio = (double)expired / sampled * 100;
            if (expired_ratio < 10) break; // 만료된 비율이 낮으면 중단
        }

        // 시간 제한 확인
        if (ustime() - start > timelimit) {
            timelimit_exit = 1;
            break;
        }
    }
}

(핵심 기능만 유지한 단순화된 코드)

  • 타임 슬라이스: 1초 동안만 작업을 수행
  • expireCycleTryExpire: db->expires의 만료 키를 스캔하여 만료된 키를 제거
    • Redis는 dictGetRandomKey()를 통해 해시 테이블의 슬롯을 랜덤으로 선택하고, 슬롯 안의 키 중 하나를 반환
    • 슬롯 안에 있는 키 중에 랜덤한 키를 선택하여 만료 여부를 확인한다.
    • Redis는 특정 루프당 샘플의 최대 개수(config_keys_per_loop)를 제한
    • Redis는 만료되지 않은 키의 비율(stat_expired_stale_perc)을 추정하여, 만료되지 않은 키가 많을수록 다음 작업에 더 많은 자원을 할당한다.
  • 조절 가능한 effort 파라미터: effort 수준을 높이면 만료 키 삭제에 더 많은 자원을 할당

Redis의 만료 전략

구분수동 만료 (Passive)능동 만료 (Active)
작동 방식키 접근 시 만료를 확인 후 삭제주기적으로 만료된 키 삭제
장점CPU 사용량 낮음메모리 사용 최적화 가능
단점메모리 누수 발생 가능CPU 자원 소모

정리

  • 만료 딕셔너리(expires), Passive Expiration 및 Active Expiration 이 있다.
  • Redis는 메모리 사용을 최적화하기 위해 만료 키를 적극적으로 삭제하는 방식과, 성능에 영향을 미치지 않기 위해 삭제 작업을 지연시키는 방식을 함께 사용한다.
  • 수동 만료로 인해 만료 키는 클라이언트 요청이 없으면 메모리에 남아있을 수 있다.
  • 능동 만료도 1ms 제한과 10% 만료 비율 조건으로 인해 만료된 키가 메모리에 남아있을 수 있다.

References

profile
기록을 통한 성장을

0개의 댓글