캐시 스탬피드 — 만료 순간 DB 가 몰리는 이유와 막는 법
캐시 적중률은 95% 인데 정각마다 DB 부하가 순간적으로 치솟아요. 캐시가 만료되는 그 순간 요청이 한꺼번에 DB 로 몰리는 현상과 이를 막는 네 가지 방법을 정리했어요.
캐시를 잘 쓰고 있었어요. 적중률 95%, DB 부하도 안정적이었고요.
그런데 하루에 몇 번씩 DB 가 순간적으로 치솟아요. 몇 초 만에 끝나서 알림도 안 울리는데, 그때마다 응답 시간이 튀어요.
시각을 보니 규칙이 있어요. 정각마다예요. 캐시 만료 시간을 한 시간으로 잡아 뒀는데, 같은 시각에 만든 항목들이 같은 시각에 만료되고 있었어요.
만료되는 순간 그 데이터를 찾는 모든 요청이 동시에 캐시를 놓치고, 전부 DB 로 갑니다.
캐시가 있어서 안전하다고 믿었던 구간이 가장 위험한 구간이 되는 거예요. 그리고 적중률 지표는 여전히 95% 라 아무 신호도 안 줍니다.
왜 그런가
캐시는 "없으면 만들어서 넣는다"로 동작해요. 문제는 이 동작이 동시에 여러 곳에서 일어날 때예요.
인기 있는 데이터일수록 이 문제가 커요. 초당 천 건의 요청이 같은 키를 보고 있다면, 그 키가 만료되는 순간 천 개의 요청이 전부 캐시를 놓쳐요. 그리고 천 개가 전부 DB 로 가서 같은 데이터를 천 번 조회해요.
여기서 이름이 붙어요. 몰려드는 무리가 만드는 현상이라 스탬피드라고 불러요.
더 나쁜 건 되먹임이에요. DB 가 그 부하로 느려지면 조회가 오래 걸리고, 오래 걸리는 동안 캐시는 여전히 비어 있으니 새 요청도 계속 DB 로 가요. 부하가 사라지지 않고 쌓입니다.
만료 시각이 같은 것도 문제를 키워요. 배포 직후에 캐시를 채우면 모든 항목의 만료 시각이 비슷해져요. 그러면 한 시간 뒤에 전부 동시에 만료돼요. 앞 편에서 본 배포 직후 부하가 한 시간 주기로 반복되는 셈이에요.
정리하면 원인이 둘이에요. 같은 키에 요청이 몰리는 것, 그리고 여러 키의 만료가 같은 시각에 몰리는 것. 대응도 각각 달라요.
코드로 보면
한 번만 채우고, 만료를 흩는다:
value = cache.get(key)
if value is None:
value = db.query() # 천 개가 동시에 여기로
cache.set(key, value, ttl=3600)
· 1) 잠금 — 한 요청만 채우고 나머지는 기다린다
if cache.add(lock_key, ttl=10): # 원자적 획득
value = db.query()
cache.set(key, value, ttl=3600)
cache.delete(lock_key)
else:
잠깐 기다렸다 캐시를 다시 본다
· 2) 만료를 흩는다 — 정각에 몰리지 않게
ttl = 3600 + random(0, 600)
· 3) 만료 전에 미리 갱신 — 요청이 기다리지 않는다
남은 수명이 짧아지면 확률적으로 미리 다시 계산✕ 문제 지점 · ✓ 해법 · 그대로 돌려 보기 전에 자기 환경에 맞게 고쳐 주세요.
실전에서는
대응이 세 갈래고, 상황에 따라 고릅니다.
첫째, 잠금. 한 요청만 DB 로 보내고 나머지는 기다리게 해요. 가장 직관적이고 효과도 확실해요. 다만 기다리는 요청들이 대기하는 동안 자원을 잡고 있고, 채우는 요청이 실패하거나 느리면 전부 같이 느려져요. 잠금에 만료를 반드시 두어야 하고요 — 안 그러면 채우던 프로세스가 죽었을 때 영원히 잠긴 채로 남아요.
둘째, 만료 시각 흩기. 고정된 수명 대신 무작위를 조금 섞어요. 구현이 한 줄이고 부작용이 거의 없어서 기본으로 깔아 둘 만해요. 다만 같은 키에 요청이 몰리는 문제는 못 풀어요 — 그건 첫 번째 문제고 이건 두 번째 문제니까요.
셋째, 만료 전 갱신. 캐시가 만료되기 전에 미리 새로 계산해서 넣어요. 남은 수명이 짧아질수록 갱신할 확률을 높이는 방식이 우아해요. 그러면 대부분의 요청은 캐시를 그대로 쓰고, 소수의 요청만 갱신을 맡아요. 사용자가 기다리는 시간이 아예 없다는 게 큰 장점이에요.
여기에 하나 더 있어요. 오래된 값을 잠시 쓰는 방법이요. 만료된 값을 바로 버리지 않고, 새로 계산되는 동안 옛 값을 돌려줘요. 데이터가 조금 오래돼도 괜찮은 경우라면 이게 가장 부드러워요. 사용자는 항상 즉시 응답을 받고, DB 는 한 번만 조회됩니다.
그리고 앞 편들과 연결돼요. 이건 결국 핫 키 문제의 한 형태고, 뒤에서 볼 핫 키 편과 뿌리가 같아요. 그리고 몰려드는 부하를 다루는 방법이라는 점에서 급증 편과도 이어집니다.
처방
- 만료 시각에 무작위를 섞으세요. 한 줄이고 부작용이 거의 없으니 기본으로 깔아 두세요.
- 인기 있는 키에는 잠금을 두세요. 한 요청만 채우고 나머지는 기다리게 하세요. 잠금에 만료를 반드시 두세요.
- 만료 전 갱신을 검토하세요. 사용자가 기다리지 않는다는 게 큰 장점이에요.
- 오래된 값을 잠시 허용할 수 있는지 보세요. 가능하면 가장 부드러운 해법입니다.
- 캐시가 없을 때의 부하를 알고 있으세요. "캐시가 다 날아가면 DB 가 견디는가"는 반드시 답이 있어야 하는 질문이에요.
- 캐시 계층 자체의 장애도 대비하세요. 캐시 서버가 죽으면 모든 요청이 DB 로 갑니다. 그때를 위해 동시 실행 제한이 필요해요.
- 배포 직후 캐시를 채울 때도 흩으세요. 한꺼번에 채우면 한 시간 뒤에 한꺼번에 만료됩니다.
- 적중률만 보지 말고 미스가 몰리는 패턴을 보세요. 평균 적중률 95% 여도 특정 순간에 0% 가 될 수 있습니다.
지금 확인해 보세요
미스가 몰리는 순간이 있는가:
· 적중률의 평균이 아니라 시간에 따른 변화를 본다
1분 단위 캐시 적중률 그래프
평균 95% 여도 특정 순간에 0% 로 떨어지면 스탬피드다
· 그 순간과 DB 부하를 겹쳐 본다
sum(rate(cache_misses_total[1m]))
sum(rate(db_queries_total[1m]))
두 봉우리가 겹치면 확정이다
· 만료 시각이 몰려 있는지
키들의 남은 수명 분포를 표본으로 확인한다
특정 시각에 몰려 있으면 흩어야 한다
· 캐시가 전부 날아갔을 때를 가정해 본다
스테이징에서 캐시를 비우고 부하를 준다
DB 가 견디는지가 이 편의 최종 질문이다핵심 한 줄캐시가 있어서 안전한 게 아니다. 만료되는 순간이 가장 위험하다.