Redis 는 데이터를 영구적으로 저장하는 Persistent Store 의 역할도 하지만, 주로 데이터의 접근을 빠르게 하기 위한 Cache 로 많이 사용됩니다.
Redis 는 메모리를 데이터 저장소로 이용하기 때문에, Disk를 사용하는 다른 솔루션 보다 적은 양의 데이터를 저장하게 되고, 이로 인해서 최대치 까지 데이터를 저장하면, 새로운 데이터를 저장하기 위해서 기존 데이터를 버려야 하는 작업을 해야 합니다. 이를 eviction 이라고 부릅니다. – 다만 Expire 가 되어서 사라지는 것은 Eviction 이라고 부르지 않습니다. (실제로 Redis Enterprise 솔루션을 보면 이렇게 eviction 되어야 하는 데이터를 flash disk 에 저장해서 좀 더 빠른 접근을 하게 하는 솔루션도 있습니다.)
eviction 을 위해서 사용하는 방식중에 가장 일반적인 방식중에 하나가 LRU 입니다. LRU는 Least Recently Used Algorithm 으로, 가장 오랫동안 참조되지 않은 페이지를 교체하는 기법입니다. 보통 OS에서 페이징에서 사용하는 메모리를 교체하는 방식에서 사용되고 있으면 보통 페이지 교체 알고리즘은 다음과 같습니다.
알고리즘 | 비고 |
FIFO(First In First Out) | 가장 먼저 메모리에 올라온 페이지를 교체 * Belady’s Anomaly(FIFO anomaly)가 발생할 수 있음 |
OPT(OPTimal Page Replacement) | 앞으로 가장 오랫동안 사용되지 않을 페이지를 교체 * 앞으로 프로세스가 사용할 페이지를 미리 알아야해서 불가능 |
LRU(Least Recently Used) | 가장 오랫동안 사용되지 않은 페이지를 교체 |
Count-Based | OPT와 비슷한 성능을 보여주고 같은 이유로 현실적으로 사용불가능 |
LFU(Least Frequently Used) | 참조 횟수가 가장 적은 페이지를 교체 |
MFU(Most Frequently Used) | 참조 횟수가 가장 많은 페이지를 교체 |
NUR(Not Used Recently) | 최근에 사용하지 않은 페이지를 교체(클럭 알고리즘) |
Random | 아무거나 교체… |
Redis 에서 지원해주는 알고리즘은 LRU와 LFU, RANDOM의 3가지를 제공하고 있습니다. 오늘은 여기서 LRU를 살펴보도록 하겠습니다.
보통 LRU를 구현하는 방식은 다음과 같습니다.
- Page에 데이터를 접근한 시간에 대한 시간 데이터를 저장해서 해당 값이 가장 오래된 페이지를 교체
- Page를 List 형태로 저장한 다음 사용된 Page를 항상 List 의 제일 앞으로 올리고, 데이터가 존재하지 않으면 가장 끝의 데이터를 삭제하고, 새로운 데이터를 List의 위에 올리는 방식으로 구현
다음 그림을 살펴보면 가장 사용되지 않은 페이지들이 교체되는 것을 볼 수 있습니다.


다시 Redis 로 돌아와서 Redis 에서 이런 Eviction 이 동작하게 하기 위해서는 maxmemory 를 설정해야 합니다. maxmemory 설정이 없으면 32bit 에서는 3GB로 제한이 자동으로 설정되고 64bit 에서는 메모리가 부족할 때 까지 계속 저장하게 됩니다.
maxmemory 10G |
Redis Evicition Policy
Redis 에서는 다음과 같은 Evicition 정책을 제공합니다.
Policy | 비고 |
noeviction | 설정된 메모리 한계에 도달하면, 데이터 추가 명령 실행이 실패하게 됩니다. |
allkeys-lru | LRU 정책을 모든 Key 에 대해서 적용해서 데이터를 삭제하게 됩니다. |
volatile-lru | LRU 정책을 Expire 가 설정된 Key에 대해서 적용해서 데이터를 삭제하게 됩니다 |
allkeys-random | 모든 Key에 대해서 Random 하게 데이터를 삭제하게 됩니다. |
volatile-random | Exipre 가 설정된 Key 에 대해서 Random 하게 삭제하게 됩니다. |
allkeys-lfu | LFU 정책을 모든 Key 에 대해서 적용해서 데이터를 삭제하게 됩니다. |
volatile-lfu | LFU 정책을 Expire 가 설정된 Key 에 대해서 적용해서 데이터를 삭제하게 됩니다 |
volatile-ttl | Expire 가 설정된 key 에 대해서 ttl이 짧은 순서로 먼저 삭제 하게 됩니다. |
Eviction Process 어떻게 동작하는가?
Redis 에서 Evcition 은 메모리가 부족할 때 동작하게 되는데 다음과 같습니다.
- 클라이언트 명령을 실행했을 때
- Redis 가 메모리를 체크했을 때 현재 사용량이 maxmemory 설정보다 높을 때
- 새로운 커맨드가 실행되었을 때
보통 Redis 에서 Eviction 은 server.c 에서 processCommand 함수 안에서 performEvictions 함수를 호출합니다.
if (server.maxmemory && !scriptIsTimedout()) {
int out_of_memory = (performEvictions() == EVICT_FAIL);
/* performEvictions may evict keys, so we need flush pending tracking
* invalidation keys. If we don't do this, we may get an invalidation
* message after we perform operation on the key, where in fact this
* message belongs to the old value of the key before it gets evicted.*/
trackingHandlePendingKeyInvalidations();
/* performEvictions may flush slave output buffers. This may result
* in a slave, that may be the active client, to be freed. */
if (server.current_client == NULL) return C_ERR;
int reject_cmd_on_oom = is_denyoom_command;
/* If client is in MULTI/EXEC context, queuing may consume an unlimited
* amount of memory, so we want to stop that.
* However, we never want to reject DISCARD, or even EXEC (unless it
* contains denied commands, in which case is_denyoom_command is already
* set. */
if (c->flags & CLIENT_MULTI &&
c->cmd->proc != execCommand &&
c->cmd->proc != discardCommand &&
c->cmd->proc != quitCommand &&
c->cmd->proc != resetCommand) {
reject_cmd_on_oom = 1;
}
if (out_of_memory && reject_cmd_on_oom) {
rejectCommand(c, shared.oomerr);
return C_OK;
}
/* Save out_of_memory result at script start, otherwise if we check OOM
* until first write within script, memory used by lua stack and
* arguments might interfere. */
if (c->cmd->proc == evalCommand ||
c->cmd->proc == evalShaCommand ||
c->cmd->proc == fcallCommand ||
c->cmd->proc == fcallroCommand)
{
server.script_oom = out_of_memory;
}
}
evict.c 의 performEvictions 함수는 다음과 같습니다. performEvictions 함수는 상당히 길고 복잡한 동작을 진행합니다.
int performEvictions(void) {
/* Note, we don't goto update_metrics here because this check skips eviction
* as if it wasn't triggered. it's a fake EVICT_OK. */
if (!isSafeToPerformEvictions()) return EVICT_OK;
int keys_freed = 0;
size_t mem_reported, mem_tofree;
long long mem_freed; /* May be negative */
mstime_t latency, eviction_latency;
long long delta;
int slaves = listLength(server.slaves);
int result = EVICT_FAIL;
if (getMaxmemoryState(&mem_reported,NULL,&mem_tofree,NULL) == C_OK) {
result = EVICT_OK;
goto update_metrics;
}
if (server.maxmemory_policy == MAXMEMORY_NO_EVICTION) {
result = EVICT_FAIL; /* We need to free memory, but policy forbids. */
goto update_metrics;
}
unsigned long eviction_time_limit_us = evictionTimeLimitUs();
mem_freed = 0;
latencyStartMonitor(latency);
monotime evictionTimer;
elapsedStart(&evictionTimer);
/* Unlike active-expire and blocked client, we can reach here from 'CONFIG SET maxmemory'
* so we have to back-up and restore server.core_propagates. */
int prev_core_propagates = server.core_propagates;
serverAssert(server.also_propagate.numops == 0);
server.core_propagates = 1;
server.propagate_no_multi = 1;
while (mem_freed < (long long)mem_tofree) {
int j, k, i;
static unsigned int next_db = 0;
sds bestkey = NULL;
int bestdbid;
redisDb *db;
dict *dict;
dictEntry *de;
if (server.maxmemory_policy & (MAXMEMORY_FLAG_LRU|MAXMEMORY_FLAG_LFU) ||
server.maxmemory_policy == MAXMEMORY_VOLATILE_TTL)
{
struct evictionPoolEntry *pool = EvictionPoolLRU;
while(bestkey == NULL) {
unsigned long total_keys = 0, keys;
/* We don't want to make local-db choices when expiring keys,
* so to start populate the eviction pool sampling keys from
* every DB. */
for (i = 0; i < server.dbnum; i++) {
db = server.db+i;
dict = (server.maxmemory_policy & MAXMEMORY_FLAG_ALLKEYS) ?
db->dict : db->expires;
if ((keys = dictSize(dict)) != 0) {
evictionPoolPopulate(i, dict, db->dict, pool);
total_keys += keys;
}
}
if (!total_keys) break; /* No keys to evict. */
/* Go backward from best to worst element to evict. */
for (k = EVPOOL_SIZE-1; k >= 0; k--) {
if (pool[k].key == NULL) continue;
bestdbid = pool[k].dbid;
if (server.maxmemory_policy & MAXMEMORY_FLAG_ALLKEYS) {
de = dictFind(server.db[bestdbid].dict,
pool[k].key);
} else {
de = dictFind(server.db[bestdbid].expires,
pool[k].key);
}
/* Remove the entry from the pool. */
if (pool[k].key != pool[k].cached)
sdsfree(pool[k].key);
pool[k].key = NULL;
pool[k].idle = 0;
/* If the key exists, is our pick. Otherwise it is
* a ghost and we need to try the next element. */
if (de) {
bestkey = dictGetKey(de);
break;
} else {
/* Ghost... Iterate again. */
}
}
}
}
/* volatile-random and allkeys-random policy */
else if (server.maxmemory_policy == MAXMEMORY_ALLKEYS_RANDOM ||
server.maxmemory_policy == MAXMEMORY_VOLATILE_RANDOM)
{
/* When evicting a random key, we try to evict a key for
* each DB, so we use the static 'next_db' variable to
* incrementally visit all DBs. */
for (i = 0; i < server.dbnum; i++) {
j = (++next_db) % server.dbnum;
db = server.db+j;
dict = (server.maxmemory_policy == MAXMEMORY_ALLKEYS_RANDOM) ?
db->dict : db->expires;
if (dictSize(dict) != 0) {
de = dictGetRandomKey(dict);
bestkey = dictGetKey(de);
bestdbid = j;
break;
}
}
}
/* volatile-random and allkeys-random policy */
else if (server.maxmemory_policy == MAXMEMORY_ALLKEYS_RANDOM ||
server.maxmemory_policy == MAXMEMORY_VOLATILE_RANDOM)
{
/* When evicting a random key, we try to evict a key for
* each DB, so we use the static 'next_db' variable to
* incrementally visit all DBs. */
for (i = 0; i < server.dbnum; i++) {
j = (++next_db) % server.dbnum;
db = server.db+j;
dict = (server.maxmemory_policy == MAXMEMORY_ALLKEYS_RANDOM) ?
db->dict : db->expires;
if (dictSize(dict) != 0) {
de = dictGetRandomKey(dict);
bestkey = dictGetKey(de);
bestdbid = j;
break;
}
}
}
/* Finally remove the selected key. */
if (bestkey) {
db = server.db+bestdbid;
robj *keyobj = createStringObject(bestkey,sdslen(bestkey));
/* We compute the amount of memory freed by db*Delete() alone.
* It is possible that actually the memory needed to propagate
* the DEL in AOF and replication link is greater than the one
* we are freeing removing the key, but we can't account for
* that otherwise we would never exit the loop.
*
* Same for CSC invalidation messages generated by signalModifiedKey.
*
* AOF and Output buffer memory will be freed eventually so
* we only care about memory used by the key space. */
delta = (long long) zmalloc_used_memory();
latencyStartMonitor(eviction_latency);
if (server.lazyfree_lazy_eviction)
dbAsyncDelete(db,keyobj);
else
dbSyncDelete(db,keyobj);
latencyEndMonitor(eviction_latency);
latencyAddSampleIfNeeded("eviction-del",eviction_latency);
delta -= (long long) zmalloc_used_memory();
mem_freed += delta;
server.stat_evictedkeys++;
signalModifiedKey(NULL,db,keyobj);
notifyKeyspaceEvent(NOTIFY_EVICTED, "evicted",
keyobj, db->id);
propagateDeletion(db,keyobj,server.lazyfree_lazy_eviction);
decrRefCount(keyobj);
keys_freed++;
if (keys_freed % 16 == 0) {
/* When the memory to free starts to be big enough, we may
* start spending so much time here that is impossible to
* deliver data to the replicas fast enough, so we force the
* transmission here inside the loop. */
if (slaves) flushSlavesOutputBuffers();
/* Normally our stop condition is the ability to release
* a fixed, pre-computed amount of memory. However when we
* are deleting objects in another thread, it's better to
* check, from time to time, if we already reached our target
* memory, since the "mem_freed" amount is computed only
* across the dbAsyncDelete() call, while the thread can
* release the memory all the time. */
if (server.lazyfree_lazy_eviction) {
if (getMaxmemoryState(NULL,NULL,NULL,NULL) == C_OK) {
break;
}
}
/* After some time, exit the loop early - even if memory limit
* hasn't been reached. If we suddenly need to free a lot of
* memory, don't want to spend too much time here. */
if (elapsedUs(evictionTimer) > eviction_time_limit_us) {
// We still need to free memory - start eviction timer proc
startEvictionTimeProc();
break;
}
}
} else {
goto cant_free; /* nothing to free... */
}
}
/* at this point, the memory is OK, or we have reached the time limit */
result = (isEvictionProcRunning) ? EVICT_RUNNING : EVICT_OK;
cant_free:
if (result == EVICT_FAIL) {
/* At this point, we have run out of evictable items. It's possible
* that some items are being freed in the lazyfree thread. Perform a
* short wait here if such jobs exist, but don't wait long. */
if (bioPendingJobsOfType(BIO_LAZY_FREE)) {
usleep(eviction_time_limit_us);
if (getMaxmemoryState(NULL,NULL,NULL,NULL) == C_OK) {
result = EVICT_OK;
}
}
}
serverAssert(server.core_propagates); /* This function should not be re-entrant */
/* Propagate all DELs */
propagatePendingCommands();
server.core_propagates = prev_core_propagates;
server.propagate_no_multi = 0;
latencyEndMonitor(latency);
latencyAddSampleIfNeeded("eviction-cycle",latency);
update_metrics:
if (result == EVICT_RUNNING || result == EVICT_FAIL) {
if (server.stat_last_eviction_exceeded_time == 0)
elapsedStart(&server.stat_last_eviction_exceeded_time);
} else if (result == EVICT_OK) {
if (server.stat_last_eviction_exceeded_time != 0) {
server.stat_total_eviction_exceeded_time += elapsedUs(server.stat_last_eviction_exceeded_time);
server.stat_last_eviction_exceeded_time = 0;
}
}
return result;
}
메모리가 충분히 확보가 될 때까지 반복하면서, 먼저 eviction 을 위한 bestKey를 찾고 이것을 eviction 하게 됩니다. 처음에는 evictionPoolPopulate 라는 함수를 통해서 eviction pool 에 샘플링 데이터를 추가하게 됩니다. 샘플링 방법은 랜덤하게 데이터를 가져와서 idle time 을 구하고 여기서 이 idle 값을 가지고 eviction pool을 채우게 됩니다.
/* This is a helper function for performEvictions(), it is used in order
* to populate the evictionPool with a few entries every time we want to
* expire a key. Keys with idle time bigger than one of the current
* keys are added. Keys are always added if there are free entries.
*
* We insert keys on place in ascending order, so keys with the smaller
* idle time are on the left, and keys with the higher idle time on the
* right. */
void evictionPoolPopulate(int dbid, dict *sampledict, dict *keydict, struct evictionPoolEntry *pool) {
int j, k, count;
dictEntry *samples[server.maxmemory_samples];
count = dictGetSomeKeys(sampledict,samples,server.maxmemory_samples);
for (j = 0; j < count; j++) {
unsigned long long idle;
sds key;
robj *o;
dictEntry *de;
de = samples[j];
key = dictGetKey(de);
/* If the dictionary we are sampling from is not the main
* dictionary (but the expires one) we need to lookup the key
* again in the key dictionary to obtain the value object. */
if (server.maxmemory_policy != MAXMEMORY_VOLATILE_TTL) {
if (sampledict != keydict) de = dictFind(keydict, key);
o = dictGetVal(de);
}
/* Calculate the idle time according to the policy. This is called
* idle just because the code initially handled LRU, but is in fact
* just a score where an higher score means better candidate. */
if (server.maxmemory_policy & MAXMEMORY_FLAG_LRU) {
idle = estimateObjectIdleTime(o);
} else if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) {
/* When we use an LRU policy, we sort the keys by idle time
* so that we expire keys starting from greater idle time.
* However when the policy is an LFU one, we have a frequency
* estimation, and we want to evict keys with lower frequency
* first. So inside the pool we put objects using the inverted
* frequency subtracting the actual frequency to the maximum
* frequency of 255. */
idle = 255-LFUDecrAndReturn(o);
} else if (server.maxmemory_policy == MAXMEMORY_VOLATILE_TTL) {
/* In this case the sooner the expire the better. */
idle = ULLONG_MAX - (long)dictGetVal(de);
} else {
serverPanic("Unknown eviction policy in evictionPoolPopulate()");
}
/* Insert the element inside the pool.
* First, find the first empty bucket or the first populated
* bucket that has an idle time smaller than our idle time. */
k = 0;
while (k < EVPOOL_SIZE &&
pool[k].key &&
pool[k].idle < idle) k++;
if (k == 0 && pool[EVPOOL_SIZE-1].key != NULL) {
/* Can't insert if the element is < the worst element we have
* and there are no empty buckets. */
continue;
} else if (k < EVPOOL_SIZE && pool[k].key == NULL) {
/* Inserting into empty position. No setup needed before insert. */
} else {
/* Inserting in the middle. Now k points to the first element
* greater than the element to insert. */
if (pool[EVPOOL_SIZE-1].key == NULL) {
/* Free space on the right? Insert at k shifting
* all the elements from k to end to the right. */
/* Save SDS before overwriting. */
sds cached = pool[EVPOOL_SIZE-1].cached;
memmove(pool+k+1,pool+k,
sizeof(pool[0])*(EVPOOL_SIZE-k-1));
pool[k].cached = cached;
} else {
/* No free space on right? Insert at k-1 */
k--;
/* Shift all elements on the left of k (included) to the
* left, so we discard the element with smaller idle time. */
sds cached = pool[0].cached; /* Save SDS before overwriting. */
if (pool[0].key != pool[0].cached) sdsfree(pool[0].key);
memmove(pool,pool+1,sizeof(pool[0])*k);
pool[k].cached = cached;
}
}
/* Try to reuse the cached SDS string allocated in the pool entry,
* because allocating and deallocating this object is costly
* (according to the profiler, not my fantasy. Remember:
* premature optimization bla bla bla. */
int klen = sdslen(key);
if (klen > EVPOOL_CACHED_SDS_SIZE) {
pool[k].key = sdsdup(key);
} else {
memcpy(pool[k].cached,key,klen+1);
sdssetlen(pool[k].cached,klen);
pool[k].key = pool[k].cached;
}
pool[k].idle = idle;
pool[k].dbid = dbid;
}
}
그럼 이제 eviciton 동작은 알았는데, evcition 을 위한 값은 언제 설정하게 될까요? Redis 내부에는 실제 정보를 가지는 redisObject 라는 타입이 있습니다. LRU_BITS 는 24bits 입니다.
typedef struct redisObject {
unsigned type:4;
unsigned encoding:4;
unsigned lru:LRU_BITS; /* LRU time (relative to global lru_clock) or
* LFU data (least significant 8 bits frequency
* and most significant 16 bits access time). */
int refcount;
void *ptr;
} robj;
그럼 이 LRU 값은 언제 업데이트가 될까요? 실제로 db.c 파일의 lookupKey 를 보면 거기서 접근할 때 또는 생성시에 LRU_CLOCK이라는 함수를 통해서 업데이트가 되어집니다.
robj *lookupKey(redisDb *db, robj *key, int flags) {
dictEntry *de = dictFind(db->dict,key->ptr);
robj *val = NULL;
if (de) {
val = dictGetVal(de);
int force_delete_expired = flags & LOOKUP_WRITE;
if (force_delete_expired) {
/* Forcing deletion of expired keys on a replica makes the replica
* inconsistent with the master. The reason it's allowed for write
* commands is to make writable replicas behave consistently. It
* shall not be used in readonly commands. Modules are accepted so
* that we don't break old modules. */
client *c = server.in_script ? scriptGetClient() : server.current_client;
serverAssert(!c || !c->cmd || (c->cmd->flags & (CMD_WRITE|CMD_MODULE)));
}
if (expireIfNeeded(db, key, force_delete_expired)) {
/* The key is no longer valid. */
val = NULL;
}
}
if (val) {
/* Update the access time for the ageing algorithm.
* Don't do it if we have a saving child, as this will trigger
* a copy on write madness. */
if (!hasActiveChildProcess() && !(flags & LOOKUP_NOTOUCH)){
if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) {
updateLFU(val);
} else {
val->lru = LRU_CLOCK();
}
}
if (!(flags & (LOOKUP_NOSTATS | LOOKUP_WRITE)))
server.stat_keyspace_hits++;
/* TODO: Use separate hits stats for WRITE */
} else {
if (!(flags & (LOOKUP_NONOTIFY | LOOKUP_WRITE)))
notifyKeyspaceEvent(NOTIFY_KEY_MISS, "keymiss", key, db->id);
if (!(flags & (LOOKUP_NOSTATS | LOOKUP_WRITE)))
server.stat_keyspace_misses++;
/* TODO: Use separate misses stats and notify event for WRITE */
}
return val;
}
그리고 LRU_CLOCK 은 다음과 같이 구현되어 있습니다.
/* Return the LRU clock, based on the clock resolution. This is a time
* in a reduced-bits format that can be used to set and check the
* object->lru field of redisObject structures. */
unsigned int getLRUClock(void) {
return (mstime()/LRU_CLOCK_RESOLUTION) & LRU_CLOCK_MAX;
}
/* This function is used to obtain the current LRU clock.
* If the current resolution is lower than the frequency we refresh the
* LRU clock (as it should be in production servers) we return the
* precomputed value, otherwise we need to resort to a system call. */
unsigned int LRU_CLOCK(void) {
unsigned int lruclock;
if (1000/server.hz <= LRU_CLOCK_RESOLUTION) {
atomicGet(server.lruclock,lruclock);
} else {
lruclock = getLRUClock();
}
return lruclock;
}
그런데 제가 위에서 Redis LRU는 샘플링을 해서 데이터를 가져와서 이를 처리한다고 했습니다. evictionPoolPopulate 함수였죠. 그래서 Redis의 LRU는 엄밀한 LRU가 아니라 Approximated LRU algorithm을 구현하고 있습니다. 즉 LRU를 위해서 Best Candidate 을 구하는 것이 아니라, 적당히 구해온다는 것입니다.(Redis 는 그냥 Sampling Key를 통해서 가져옵니다.)
Redis 2.x(지금은 6.0 시대!!!) 에서 Redis 3.0으로 넘어가면서 좀 더 실제에 가까운 LRU 알고리즘으로 구현이 바뀌었다고 합니다.(지금 코드는 6.0 기반이라… 좀 더 개선이?) 아래 그림에 대해서 다음과 같이 설명이 되어있습니다.
- 밝은 회색 밴드는 Eviction 된 object
- 회색 밴드는 Evcition 되지 않은 object
- 녹색 밴드는 추가된 object

sampling이 5로 되어있는 것을 10으로 바꾸면 좀 더 좋은 결과를 보여주는데, 이 때 CPU 사용량이 더 늘어난다고 합니다.
LFU MODE
Redis 4.0 부터는 LFU(Least Frequently Used Eviction Mode) 가 추가되었습니다. LRU에 비해서 좀 더 좋은 성능을 보여준다고 하는데요. 이 부분은 다음번에 추가로 설명을 드리도록 하겠습니다. 참고로 hot keys 를 보기위해서는 LFU 모드를 사용해야만 하고 object freq <key> 명령을 통해서 hot key를 대략적으로 찾을 수 있습니다.
Reference
- Redis as an LRU cache
- Redis Memory LRU(Least Recently Used) 캐시
- 페이지 교체 알고리즘