이번 시간에는 Redis 메모리가 maxmemory에 닿았을 때 정책마다 어떤 키를 지우고 hit ratio가 얼마나 달라지는지, Docker의 Redis와 Python 스크립트로 측정하며 배워가는 시간입니다.
예를 들어서 상품 조회 캐시로 쓰던 Redis의 hit ratio(요청 가운데 캐시에서 값을 찾은 비율)가 어느 날부터 떨어지기 시작했다고 해볼게요. Redis에 장애가 난 것도 아니고 응답도 빨랐어요.
INFO stats를 보니 evicted_keys가 계속 늘고 있었어요. 메모리가 설정한 한도 maxmemory에 닿아서 Redis가 키를 지우고 있었고, 그 키 가운데 자주 읽히던 키가 섞여 있었던 거예요.
Redis는 메모리가 한도에 닿으면 maxmemory-policy에 적힌 규칙대로 쓰기를 거부하거나 키를 골라 지워요.
정책 이름의 앞부분은 지울 후보(allkeys는 모든 키, volatile은 TTL이 붙은 키)이고, 뒷부분은 고르는 기준이에요.
lru: 가장 오래전에 접근한(읽거나 쓴) 키를 지웁니다.lfu: 접근 빈도가 낮은 키를 지웁니다.lrm: 가장 오래전에 쓴 키를 지웁니다.random: 무작위로 지웁니다.ttl: 만료가 가장 가까운 키를 지웁니다.noeviction: 키를 지우지 않고 쓰기를 거부합니다.
숫자는 모두 노트북의 Docker 환경에서 잰 값이에요. hit ratio는 접근 분포와 메모리 크기에 따라 크게 달라지니, 절댓값보다 같은 조건에서 정책끼리 비교한 차이를 봐 주세요.
실습 준비
이 글의 명령은 모두 실습 코드의 redis-eviction/ 디렉터리에서 실행합니다.
compose.yaml: Redis 8.10과 Python 컨테이너common.py: Redis 연결, 실험 데이터 적재load()(다른 스크립트가 불러 씀)policy.py: 정책별 hit ratio, 핫 키 이동, 한 번 훑는 조회를 재는 스크립트samples.py:maxmemory-samples별로 근사 LRU가 지운 키를 세는 스크립트
Redis와 Python 컨테이너를 띄울게요. Redis는 영속화를 끄고(--save "", --appendonly no) 컨테이너 메모리 한도를 1GB로 둬요. compose.yaml은 이렇습니다.
services:
redis:
image: redis:8.10.2
command: redis-server --save "" --appendonly no
mem_limit: 1g
ports:
- "7350:6379"
app:
image: python:3.12-slim
command: sh -c "pip install -q --root-user-action=ignore redis==6.4.0 && sleep infinity"
mem_limit: 512m
volumes:
- .:/app
working_dir: /app
compose 프로젝트 이름을 revict로 두고, 자주 쓰는 명령을 셸 함수로 만들어 놓을게요. 아래 블록은 파일로 만들지 말고 실습 디렉터리에서 연 터미널에 그대로 붙여 넣어 주세요. 이후 명령은 모두 이 터미널에서 실행하고, 터미널을 새로 열면 다시 붙여 넣으시면 됩니다.
export COMPOSE_PROJECT_NAME=revict
docker compose up -d
rcli() { docker compose exec -T redis redis-cli --no-raw "$@"; }
py() { docker compose exec -T app python -u "$@"; }
먼저 기본값을 볼게요.
rcli CONFIG GET maxmemory
rcli CONFIG GET maxmemory-policy
rcli CONFIG GET maxmemory-samples
1) "maxmemory"
2) "0"
1) "maxmemory-policy"
2) "noeviction"
1) "maxmemory-samples"
2) "5"
maxmemory가 0이면 64비트 환경에서는 메모리 제한이 없어요. 제한이 없으니 정책도 쓰일 일이 없죠. 그래서 캐시로 쓰려면 maxmemory와 정책을 직접 정해 주셔야 합니다.
실습 1: 정책마다 hit ratio가 다릅니다
실험 조건은 이렇습니다.
- 키
item:0~item:49999, 값 1KB maxmemory 24mb. 키 약 1만 7천 7백 개, 전체의 35% 정도가 들어가요.- 요청의 80%는 앞쪽 1만 개 키(요청이 몰리는 키, 이하 핫 키)에서 고르고, 20%는 나머지 4만 개에서 균등하게 골라요.
GET이 miss면 바로SET해요. DB를 읽어 캐시를 채우는 Cache Aside 흐름을 흉내 낸 거예요.- 정책마다
FLUSHALL,CONFIG RESETSTAT뒤에 캐시를 채우는 요청 10만 개를 먼저 보내고, 이어지는 요청 20만 개로 hit ratio를 재요. volatile-ttl에서만 모든 키에 300~600초 사이의 TTL을 무작위로 붙여요.- 정책마다 난수 시드를 바꿔 3번 돌려요.
policy.py는 위 조건대로 요청 키를 만들고, GET을 100개씩 파이프라인으로 보낸 뒤 miss가 난 키를 SET해요. 첫 인자로 실험(hit, shift, scan), 둘째 인자로 쉼표로 이은 정책 목록을 받습니다. 핫 키를 옮기는 shift는 이 글에서는 쓰지 않아요.
출력의 값은 이렇게 읽으시면 돼요.
hit: 애플리케이션이 센 hit ratio예요.keyspace_hits,keyspace_misses,evicted_keys: 측정 구간 동안INFO stats값이 늘어난 양이에요.oom:SET이 메모리 부족 오류로 거부된 수예요.
마지막 volatile-lru는 TTL 없는 키만 있을 때 어떻게 되는지 보려고 넣었어요.
py policy.py hit allkeys-lru,allkeys-lfu,allkeys-random,allkeys-lrm,volatile-ttl,noeviction,volatile-lru
allkeys-lru seed=1 hit=0.749 keyspace_hits=149725 keyspace_misses=50275 evicted_keys=50204 oom=0 dbsize=17711
allkeys-lru seed=2 hit=0.751 keyspace_hits=150210 keyspace_misses=49790 evicted_keys=49721 oom=0 dbsize=17737
allkeys-lru seed=3 hit=0.755 keyspace_hits=150927 keyspace_misses=49073 evicted_keys=49039 oom=0 dbsize=17717
allkeys-lfu seed=1 hit=0.838 keyspace_hits=167547 keyspace_misses=32453 evicted_keys=32467 oom=0 dbsize=17717
allkeys-lfu seed=2 hit=0.838 keyspace_hits=167625 keyspace_misses=32375 evicted_keys=32363 oom=0 dbsize=17743
allkeys-lfu seed=3 hit=0.837 keyspace_hits=167381 keyspace_misses=32619 evicted_keys=32638 oom=0 dbsize=17719
allkeys-random seed=1 hit=0.707 keyspace_hits=141449 keyspace_misses=58551 evicted_keys=58471 oom=0 dbsize=17715
allkeys-random seed=2 hit=0.709 keyspace_hits=141882 keyspace_misses=58118 evicted_keys=58011 oom=0 dbsize=17722
allkeys-random seed=3 hit=0.705 keyspace_hits=141016 keyspace_misses=58984 evicted_keys=58878 oom=0 dbsize=17718
allkeys-lrm seed=1 hit=0.711 keyspace_hits=142173 keyspace_misses=57827 evicted_keys=57755 oom=0 dbsize=17707
allkeys-lrm seed=2 hit=0.712 keyspace_hits=142318 keyspace_misses=57682 evicted_keys=57585 oom=0 dbsize=17737
allkeys-lrm seed=3 hit=0.710 keyspace_hits=141977 keyspace_misses=58023 evicted_keys=57993 oom=0 dbsize=17706
volatile-ttl seed=1 hit=0.709 keyspace_hits=141713 keyspace_misses=58287 evicted_keys=58180 oom=0 dbsize=17348
volatile-ttl seed=2 hit=0.706 keyspace_hits=141188 keyspace_misses=58812 evicted_keys=58740 oom=0 dbsize=17344
volatile-ttl seed=3 hit=0.707 keyspace_hits=141460 keyspace_misses=58540 evicted_keys=58462 oom=0 dbsize=17357
noeviction seed=1 hit=0.819 keyspace_hits=163869 keyspace_misses=36131 evicted_keys=0 oom=36131 dbsize=17801
noeviction seed=2 hit=0.820 keyspace_hits=163939 keyspace_misses=36061 evicted_keys=0 oom=36061 dbsize=17801
noeviction seed=3 hit=0.819 keyspace_hits=163889 keyspace_misses=36111 evicted_keys=0 oom=36111 dbsize=17800
volatile-lru seed=1 hit=0.819 keyspace_hits=163869 keyspace_misses=36131 evicted_keys=0 oom=36131 dbsize=17801
volatile-lru seed=2 hit=0.820 keyspace_hits=163916 keyspace_misses=36084 evicted_keys=0 oom=36084 dbsize=17801
volatile-lru seed=3 hit=0.819 keyspace_hits=163889 keyspace_misses=36111 evicted_keys=0 oom=36111 dbsize=17800
allkeys-lfu: 0.837~0.838로 가장 높아요. 핫 키 1만 개가 모두 메모리에 남고 나머지 자리에 일부 키가 들어갔을 때의 이론값은 0.8 + 0.2 × (7700 / 40000) ≒ 0.84이니 거의 그 값이에요.allkeys-lru: 0.749~0.755예요. 나머지 4만 개 키로 가는 20%의 요청은 대부분 miss가 나고, 그때마다 새 키가 들어오면서 다른 키를 밀어내요. 한 번만 읽힌 키라도 방금 들어왔으면 남고, 잠시 읽히지 않은 핫 키가 대신 밀려나는 일이 생깁니다.allkeys-random,allkeys-lrm,volatile-ttl: 모두 0.705~0.712로 거의 같아요.random은 접근 기록을 보지 않아요.volatile-ttl은 TTL을 무작위로 붙였으니 접근 패턴과 무관하게 지워 사실상 무작위가 됐습니다. 캐시 키는 miss일 때 한 번 쓰이고 그 뒤로는 읽히기만 하니, 쓴 시간만 보는allkeys-lrm도 핫 키를 지키지 못해요.noeviction,volatile-lru(TTL 없는 키만):evicted_keys가 0이고 miss마다SET이 거부됐어요(oom이 miss 수와 같아요). 두 정책의 결과가 같으니,volatile-*정책이 TTL 있는 키가 없으면noeviction처럼 동작한다는 걸 확인할 수 있어요.
그런데 noeviction의 hit ratio 0.82가 LRU보다 높죠. 이 실험에서는 처음부터 핫 키에 요청이 몰리니까, 메모리가 찰 때까지 들어간 키의 대부분이 핫 키였어요. 그 뒤로는 새 키가 들어가지 못하고 처음 들어간 키가 그대로 남습니다.
실습 2: 한 번 훑는 조회가 섞이면
배치 작업이나 전체 목록을 내려받는 요청처럼, 평소에 잘 안 읽히는 키를 한꺼번에 한 번씩 읽는 일이 있어요. Cache Aside에서는 이런 조회도 miss마다 캐시를 채우죠.
allkeys-lru와 allkeys-lfu에서 다음 순서로 재 볼게요.
- 캐시를 채우는 요청 10만 개를 보내고, 요청 5만 개로 평소 hit ratio를 잽니다.
- 핫 키 1만 개 가운데 메모리에 남아 있는 수를
EXISTS로 셉니다. - 나머지 키 4만 개(
item:10000~item:49999)를 한 번씩 읽고, miss면SET합니다. - 남은 핫 키 수를 다시 세고, 요청 5천 개씩 여섯 구간의 hit ratio를 잽니다.
py policy.py scan allkeys-lru,allkeys-lfu
allkeys-lru seed=1 스캔 전 hit=0.758 남은 핫 키 9037->22 스캔 뒤 5천 요청씩 hit=0.207 0.419 0.567 0.651 0.709 0.743
allkeys-lru seed=2 스캔 전 hit=0.771 남은 핫 키 8968->32 스캔 뒤 5천 요청씩 hit=0.216 0.423 0.566 0.654 0.701 0.749
allkeys-lru seed=3 스캔 전 hit=0.757 남은 핫 키 8585->21 스캔 뒤 5천 요청씩 hit=0.225 0.418 0.564 0.645 0.719 0.757
allkeys-lfu seed=1 스캔 전 hit=0.835 남은 핫 키 9983->9582 스캔 뒤 5천 요청씩 hit=0.805 0.826 0.824 0.823 0.825 0.824
allkeys-lfu seed=2 스캔 전 hit=0.839 남은 핫 키 9998->9607 스캔 뒤 5천 요청씩 hit=0.812 0.808 0.826 0.823 0.829 0.824
allkeys-lfu seed=3 스캔 전 hit=0.836 남은 핫 키 9995->9588 스캔 뒤 5천 요청씩 hit=0.810 0.818 0.831 0.820 0.822 0.824
LRU에서는 훑는 조회 한 번으로 메모리에 남은 핫 키가 8천 6백~9천 개에서 20~30개로 줄었어요. 4만 개를 새로 넣는 동안 마지막으로 접근한 시간이 가장 오래된 쪽이 핫 키였기 때문이에요.
직후 구간의 hit ratio는 0.21~0.23까지 떨어졌고, 요청 3만 개가 지나서야 평소 수준에 가까워졌어요. 그동안의 miss는 실제 서비스라면 모두 DB 조회입니다.
LFU에서는 핫 키가 9천 9백여 개에서 9천 6백 개 정도로 줄었을 뿐이고, 직후 구간도 0.81 정도를 유지했어요. 한 번 읽힌 키는 카운터가 낮아서 다음 키를 넣을 때 먼저 지워지거든요.
LFU는 키마다 0~255 범위의 8비트 카운터를 둬요. 카운터는 접근이 없으면 줄어들어요. lfu-decay-time(기본 1, 단위 분)이 지날 때마다 1씩 빼요.
실습 3: LRU는 근사입니다
Redis는 모든 키를 최근 접근 순으로 정렬해 두지 않아요. 그러려면 키마다 연결 리스트 포인터가 필요해 메모리를 더 쓰거든요.
대신 키를 지워야 할 때 무작위로 몇 개를 뽑아 그 안에서 가장 오래된 키를 골라요. 뽑는 개수가 maxmemory-samples(기본 5)입니다.
Redis 3.0부터는 뽑은 키 가운데 좋은 후보를 16칸짜리 후보 풀(eviction pool)에 모아 두고 다음 선택에도 써요.
allkeys-lru,maxmemory 0에서 키 1만 개(k:0~k:9999)를 넣습니다. 1000개씩 그룹 10개로 나눕니다.- 그룹 0부터 9까지 1.2초 간격으로 그룹 전체를
GET합니다. 그룹 0이 가장 오래전에, 그룹 9가 가장 최근에 읽힌 그룹이 됩니다. 간격을 1초보다 길게 둔 건 Redis가 키의 접근 시간을 1초 단위로 기록하기 때문이에요(소스의LRU_CLOCK_RESOLUTION이 1000ms입니다). notify-keyspace-events Ee로 키가 지워질 때 나오는evicted이벤트를 구독합니다. 지워진 키를GET으로 확인하면 확인하는 동작이 접근 시간을 바꾸니까 이벤트로 모아요.maxmemory를 지금used_memory로 맞추고, 새 키 1000개(new:*)를 넣어 키 약 1000개가 지워지게 합니다.
samples.py가 이 순서를 실행하고, 쉼표로 이은 샘플 수 목록을 인자로 받아요.
정확한 LRU라면 지워지는 1000개는 모두 그룹 0이어야 하겠죠. 샘플 수 1, 5, 10에서 3번씩 돌려 볼게요.
py samples.py 1,5,10
samples= 1 지운 키 1001 그룹0 비율 0.09 그룹별[0..9] 87 117 100 87 90 87 99 84 98 108 새 키 44 OOM 4
samples= 1 지운 키 1002 그룹0 비율 0.10 그룹별[0..9] 105 91 91 94 97 91 93 111 84 96 새 키 49 OOM 3
samples= 1 지운 키 1001 그룹0 비율 0.10 그룹별[0..9] 96 89 95 72 97 126 83 98 101 98 새 키 46 OOM 3
samples= 5 지운 키 1007 그룹0 비율 0.39 그룹별[0..9] 395 399 211 2 0 0 0 0 0 0 새 키 0 OOM 0
samples= 5 지운 키 1005 그룹0 비율 0.38 그룹별[0..9] 380 378 236 11 0 0 0 0 0 0 새 키 0 OOM 0
samples= 5 지운 키 1005 그룹0 비율 0.38 그룹별[0..9] 384 397 218 6 0 0 0 0 0 0 새 키 0 OOM 0
samples=10 지운 키 1006 그룹0 비율 0.62 그룹별[0..9] 627 364 15 0 0 0 0 0 0 0 새 키 0 OOM 0
samples=10 지운 키 1009 그룹0 비율 0.60 그룹별[0..9] 602 385 22 0 0 0 0 0 0 0 새 키 0 OOM 0
samples=10 지운 키 1006 그룹0 비율 0.60 그룹별[0..9] 602 398 6 0 0 0 0 0 0 0 새 키 0 OOM 0
마지막 실행에서 그룹별 첫 키의 OBJECT IDLETIME(초): [11, 10, 8, 7, 6, 5, 3, 2, 1, 0]
마지막 줄은 그룹마다 첫 키의 OBJECT IDLETIME이에요. 마지막으로 접근한 뒤 지난 초를 돌려주며, 접근 시간을 바꾸지 않아요. 그룹 0이 11초, 그룹 9가 0초로 순서대로 오래됐습니다.
- samples 1: 표본이 하나라 사실상 무작위예요. 그룹 0의 비율이 0.09~0.10으로 모든 그룹에서 비슷하게 지워졌고, 방금 넣은 새 키도 44~49개 지워졌어요. 쓰기 3~4개는 메모리 부족 오류로 거부됐습니다.
- samples 5(기본): 그룹 0, 1, 2에서 주로 지웠고 그룹 3이 조금 섞였어요. 그룹 0의 비율은 0.38~0.39입니다. 가장 오래된 그룹만 골라 지우지는 못하지만, 대부분 오래된 세 그룹에서 골랐어요.
- samples 10: 그룹 0의 비율이 0.60~0.62로 올랐고 그룹 2 이후는 거의 지우지 않았어요.
샘플 수를 늘리면 정확한 LRU에 가까워지는 대신, 키를 지울 때마다 더 많은 키를 살펴봐야 하니 CPU를 더 써요.
공식 문서는 기본값 5로 시작하고, 10으로 올려 miss가 줄어드는지 확인해 보라고 권해요. CONFIG SET maxmemory-samples로 운영 중에도 바꿀 수 있습니다.
LFU, LRM, volatile-ttl도 같은 방식으로 표본을 뽑으니 이 설정을 함께 써요.
정리
범위: Redis 8.10.2(redis:8.10.2 이미지), Python redis 6.4.0, 노트북 Docker, 1KB 값 키 5만 개와 maxmemory 24mb, hit ratio는 정책마다 시드 3개.
- 같은 24MB에서 hit ratio는
allkeys-lfu0.837~0.838,allkeys-lru0.749~0.755,allkeys-random·allkeys-lrm·volatile-ttl0.705~0.712였고, 이 접근 분포에서는 접근 빈도를 보는allkeys-lfu가 가장 높았습니다. noeviction과 TTL 없는 키만 있는volatile-lru는 miss마다 쓰기를 거부했어요. 이 글에 싣지 않은 추가 측정에서는 핫 키가 바뀌자noeviction의 hit ratio가 0.24에서 올라오지 못했습니다. 캐시에는noeviction을 쓰지 말고, TTL 있는 키가 없는 인스턴스에volatile-*를 쓰지 마세요.- 나머지 키 4만 개를 한 번씩 훑자 LRU는 남은 핫 키가 8천 6백~9천 개에서 20~30개로 줄고 hit ratio가 0.21~0.23으로 떨어졌지만, LFU는 0.81 정도를 지켰습니다. 반대로 이 글에 싣지 않은 추가 측정에서는 핫 키를 바꾼 직후 첫 구간 hit ratio가 LFU 0.487, LRU 0.572였어요. 훑는 조회가 섞이면
allkeys-lfu를, 핫 키가 자주 바뀌면 LRU를 함께 비교해 보세요. - 가장 오래된 그룹이 지워진 비율은
maxmemory-samples1에서 0.09~0.10, 5에서 0.38~0.39, 10에서 0.60~0.62였습니다. 기본값 5로 시작하고, 10으로 올려 miss가 줄어드는지 확인해 보세요. - 이 글에 싣지 않은 추가 측정에서는
maxmemory를 24MB로 낮춰 키 3만 2천여 개가 지워진 뒤에도 프로세스 실제 메모리(RSS)는 5초 뒤까지 90.7MB 그대로였고, 값 크기가 섞인 키 70%를 지워 생긴 단편화(할당자가 갖고 있지만 쓰지 않는 공간)는 active defrag를 켜자 30초 만에 RSS가 473.1MB에서 189.2MB로 줄었습니다. 컨테이너 메모리 한도는maxmemory와 같게 두지 말고 넉넉하게 잡으세요.