Redis는 키에 만료 시간을 설정하는 기능을 제공한다.
처음에는 TTL 설정이 되어있기때문에 Redis의 가용메모리가 많을 것이라고 생각했지만, 실제 모니터링 결과 예상보다 가용 메모리가 적었다.🫠 Redis의 만료 관리 방식, 관련된 구조체, 그리고 실제 코드 동작을 살펴보면서 왜 이런 결과가 나오게 되었는지 살펴보자.
Redis의 만료 관리는 Passive(수동 삭제)와 Active(능동 삭제) 두 가지 방식으로 이루어진다.
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; // 실제 데이터를 가리키는 포인터
};
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;
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 | (체이닝으로 연결) +-----------+ +-----------+
Redis는 여러 개의 데이터베이스를 관리할 수 있다. redisDb는 단일 데이터베이스의 구조체다.
struct redisDb {
dict *dict; // 키-값 데이터를 저장하는 딕셔너리
dict *expires; // 만료 시간이 설정된 키-값 쌍을 저장하는 딕셔너리
dict *blocking_keys; // 블로킹 명령어 관련 딕셔너리
dict *ready_keys; // 준비된 키 (차단 해제된 키)
dict *watched_keys; // 트랜잭션 감시 중인 키
};
redisDb 구조는 Redis의 데이터베이스이며, 각 데이터베이스에 대해 dict, expires, blocking_keys 등 다양한 메타데이터가 저장된다.
Redis의 만료 정보는 전역 expires 딕셔너리에 저장된다. 이 딕셔너리는 키에 대한 만료 시간을 따로 관리한다.
dict *expires 구조체는 “키”에 대응하는 만료 시간(Unix 타임스탬프)을 저장한다.
이 구조를 통해 Redis는 빠르게 만료 여부를 확인할 수 있다.
만료 시간 설정은 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);
}
}
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;
}
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;
}
}
}
(핵심 기능만 유지한 단순화된 코드)
구분 | 수동 만료 (Passive) | 능동 만료 (Active) |
---|---|---|
작동 방식 | 키 접근 시 만료를 확인 후 삭제 | 주기적으로 만료된 키 삭제 |
장점 | CPU 사용량 낮음 | 메모리 사용 최적화 가능 |
단점 | 메모리 누수 발생 가능 | CPU 자원 소모 |