캐시 스탬피드와 single flight - 200명 중 125명이 원본까지 갔고, 예외를 삼키자 실패가 가장 빠른 요청이 됐다
엔지니어링 요약
Problem
캐시가 만료되는 순간 동시 요청이 전부 원본으로 몰린다는 것은 알려져 있지만, 실제로 몇 명이 가는지와 방어 수단들이 각각 무엇을 대가로 내는지는 재 본 적이 없었다. 특히 확률적 조기 갱신(XFetch)의 beta를 어떻게 정해야 하는지가 감이 없었다.
Decision
Redis 뒤에 orders 테이블 실제 질의(중앙값 3.43ms)를 두고 TTL 2초, 클라이언트 200개로 네 전략을 30초씩 3회 쟀다. naive, 락 기반 single flight, XFetch(beta 1·10·100), stale-while-revalidate다. 측정값은 만료 이벤트당 원본 호출 수이고, 콜드 스타트가 만료 측정을 덮지 않도록 캐시를 미리 채우고 naive를 제외한 전략은 첫 채움도 같은 락으로 감쌌다.
Result
방어가 없으면 만료마다 200명 중 125.5명이 원본에 도달했다. 락 기반 single flight는 1.5회로 84배 줄이면서 처리량이 가장 높고(7,048 rps) p99도 가장 낮았다(86.2ms). XFetch는 beta=1에서 1.7회였지만 beta=10·100에서 12.5·13.7회로 오히려 나빠졌다. 재계산이 3.43ms로 싸서 beta=1이면 창이 이미 충분한데 beta를 키우면 만료되지도 않은 값을 계속 갱신하기 때문이다. stale-while-revalidate는 원본 호출은 같았지만 처리량 절반에 낡은 값을 회당 3,466~4,556번 내줬다. 그리고 측정 결함 세 개를 잡았는데, 그중 하나는 첫 측정 전체를 폐기하게 만들었다.
캐시 스탬피드 노트에서 현상과 방어 수단을 정리했다. 이 글은 그것을 같은 조건에서 재 본 기록이다.
저장소는 data-ops-lab이고 재실행은 한 줄이다.
1
./.venv/bin/python experiments/stampede/run.py --seconds 30 --clients 200 --repeat 3
조건
Redis 앞에 클라이언트 200개, TTL 2초, 30초씩 3회. 원본은 sleep이 아니라 orders 테이블의 실제 범위 집계이고 중앙값 3.43ms다. 측정값은 만료 이벤트 하나당 몇 명이 원본에 도달하는가다.
| 전략 | 동작 |
|---|---|
| naive | 미스면 각자 계산한다 |
| lock | SET NX 승자만 계산하고 나머지는 대기 후 재조회 |
| early | XFetch. 만료 전에 확률적으로 미리 갱신한다 |
| swr | 낡은 값을 즉시 주고 백그라운드로 갱신 |
콜드 스타트가 만료 측정을 덮지 않도록 캐시를 미리 채웠고, naive를 제외한 전략은 첫 채움도 같은 락으로 감쌌다.
결과
| 전략 | run1 | run2 | run3 | 중앙값 | rps | p50 | p99 |
|---|---|---|---|---|---|---|---|
| naive | 124.6 | 125.5 | 130.5 | 125.5 | 6,210 | 15.0 ms | 104.4 ms |
| lock | 1.2 | 1.6 | 1.5 | 1.5 | 7,048 | 14.3 ms | 86.2 ms |
| early β=1 | 1.9 | 1.7 | 1.7 | 1.7 | 3,016 | 40.8 ms | 207.7 ms |
| early β=10 | 12.5 | 1.4 | 15.5 | 12.5 | 2,878 | 53.6 ms | 197.2 ms |
| early β=100 | 12.3 | 13.7 | 16.8 | 13.7 | 2,257 | 56.5 ms | 277.5 ms |
| swr | 1.6 | 1.9 | 1.5 | 1.6 | 3,467 | 47.3 ms | 151.0 ms |
스탬피드는 크다
방어가 없으면 만료마다 200명 중 125명이 원본까지 간다. 원본이 3.43ms밖에 안 걸리는데도 그 짧은 창에 3분의 2가 동시에 미스한다.
원본이 더 비쌀수록 이 숫자는 커진다. 500ms짜리 질의라면 그 창에 도착하는 요청이 전부 들어가므로, 재계산이 느린 시스템일수록 스탬피드가 치명적이라는 일반론이 여기서 산술로 확인된다.
single flight가 가장 싸다
락 기반이 만료당 1.5회로 84배 줄였고, 동시에 처리량이 가장 높고 p99가 가장 낮았다. 방어 중에 유일하게 아무것도 나빠지지 않은 선택지다.
대가는 대기다. 실행마다 2,673~2,743명이 계산 대신 기다렸다. 그런데 그 기다림이 원본을 때리는 것보다 싸다. 125명이 동시에 DB를 치는 것이 한 명이 치고 124명이 5ms 기다리는 것보다 비싸기 때문이다.
1.5이지 1.0이 아니다
이상적으로는 만료당 1회여야 한다. 1.5가 나온 이유는 미스 판정과 락 획득이 원자적이지 않기 때문이다.
1
2
3
클라이언트 A: GET → nil
승자: 계산(3.43ms) → SET → DEL lock
클라이언트 A: SET NX → 성공 → 또 계산
A는 승자가 값을 넣기 전에 nil을 읽었고, 락을 잡는 것은 승자가 놓은 후다. 그 사이에 값이 이미 있는데도 A는 확인하지 않는다.
고치는 방법은 알려져 있다. 락을 잡은 뒤 캐시를 한 번 더 읽는 것(double-checked locking)이다. 이 실험에는 넣지 않았고, 넣으면 1.0에 가까워질 것이다. 재지 않은 것을 그렇다고 적어 둔다.
XFetch의 beta는 재계산 비용에 맞춰야 한다
가장 의외였던 결과다. beta를 키울수록 나빠진다.
| beta | 만료당 원본 호출 | rps |
|---|---|---|
| 1 | 1.7 | 3,016 |
| 10 | 12.5 | 2,878 |
| 100 | 13.7 | 2,257 |
XFetch는 “재계산이 비쌀수록, 만료가 가까울수록 미리 갱신한다”는 식이고 beta가 그 창의 폭을 정한다. 재계산이 3.43ms로 싸기 때문에 beta=1이면 이미 충분히 넓다. beta를 키우면 아직 만료되지도 않은 값을 계속 새로 계산하게 되고, 그 일이 전부 순수한 낭비다.
즉 beta는 자유 파라미터가 아니라 재계산 비용과 짝지어진 값이다. 비싼 원본에 작은 beta면 창이 좁아 스탬피드를 못 막고, 싼 원본에 큰 beta면 쓸데없이 자주 갱신한다. 문서에서 “beta는 보통 1”이라고 하는 이유가 여기 있고, 그것을 늘리려면 근거가 있어야 한다.
(beta=10은 실행마다 12.5, 1.4, 15.5로 흔들렸다. 3회로는 분포를 말할 수 없어 나온 대로 적는다.)
stale-while-revalidate가 사는 것과 파는 것
원본 호출은 락과 같은 1.6회인데 처리량이 절반이고 p99가 1.75배다. 그리고 실행마다 3,466~4,556번 낡은 값을 내줬다.
교환이 분명하다. “아무도 재계산을 기다리지 않는다”를 사고 “누군가는 항상 옛 값을 읽는다”를 판다. 가격 표시나 재고처럼 낡은 값이 곧 오답인 데이터에는 쓸 수 없고, 추천 목록이나 집계 통계처럼 몇 초 낡아도 되는 곳에 맞는다.
측정에서 틀린 것 셋
이 실험에서 가장 오래 걸린 것은 전략을 구현하는 일이 아니라 측정이 거짓말하지 않게 만드는 일이었다.
1. XFetch 조건의 부호가 반대였다. 갱신 창은 만료 이전에 열려야 하는데 무작위 항을 빼는 식으로 썼다. 창이 한 번도 열리지 않았고, early는 조용히 naive가 됐다. 그 결과가 만료당 63.0회로 막으려던 대상보다 나빴다. 부호 하나가 전략을 무력화하는데 코드는 정상으로 보이고 숫자도 그럴듯하게 나온다.
2. redis-py 클라이언트 풀이 고갈됐고, 그 실패가 가장 빠른 요청으로 기록됐다. 이것이 첫 측정 전체를 폐기하게 만든 결함이다.
200스레드가 기본 풀 크기를 넘기자 MaxConnectionsError가 났다. 그런데 워커의 except Exception: pass가 그것을 삼켰고, 실패한 요청이 지연 표에 0.01ms짜리 성공으로 들어갔다. Redis 한 번 왕복을 따로 재니 0.39ms였다. 측정된 p50이 물리적 하한보다 39배 빨랐다.
그 결과 네 전략이 전부 만료당 6회로 수렴하는 것처럼 보였다. 방어가 잘 되는 것처럼 보였는데, 사실은 요청이 캐시에 도달하지도 못하고 있었다.
3. 원본이 스레드마다 PG 커넥션을 열었다. 200스레드가 max_connections=200을 치자 원본 자체가 실패했다. 원본을 풀(최대 20) 뒤로 옮겼다.
셋의 공통점이 하나다. 전부 그럴듯한 숫자를 만들었다. T1의 배리어, T2의 비상관 LATERAL과 병렬 스캔도 같은 종류였다. 결과가 기대와 맞아 보일 때가 도구를 의심할 때다.
그리고 2번에서 배운 것을 한 줄로 줄이면 이렇다. 예외를 삼키면 실패가 가장 빠른 요청이 된다. 지연 백분위는 성공한 요청만으로 계산되는데, 실패가 성공으로 섞여 들어가면 그 분포는 “빠르다”고 말한다. 부하 도구든 애플리케이션이든 오류율과 지연을 같은 표에 놓아야 이것이 보인다.
실무로 옮기면
- 기본은 single flight다. 가장 적게 막고 가장 빠르며 추가 개념이 없다.
- 락을 잡은 뒤 캐시를 다시 읽는다. 안 하면 만료당 1.5회가 남는다.
- XFetch의 beta를 근거 없이 키우지 않는다. 재계산 비용에 맞춰야 하고, 키우면 낭비가 된다.
- stale-while-revalidate는 낡은 값이 허용되는 데이터에만. 원본 호출은 같은데 지연과 정확성을 대가로 낸다.
- TTL에 지터를 준다. 이 실험은 키 하나만 봤지만, 실무에서는 여러 키가 같은 순간에 만료되는 것이 더 큰 문제다.
한계
- 원본이 싸다. 3.43ms라 재계산 창이 짧다. 500ms짜리 원본이면 스탬피드가 훨씬 크고 전략 간 균형도 달라진다.
- 핫 키 하나다. 실제 캐시는 키가 수천 개이고 만료 시점이 흩어져 있다. 키 하나는 최악이자 가장 선명한 경우다.
- 3회 반복이다. naive와 lock이 두 자릿수 차이로 안정적이라는 것까지는 말할 수 있고, beta=10의 변동은 말할 수 없다.
- TTL 지터를 재지 않았다. 그 자체가 방어 수단인데 이 실험에 없다.
- 단일 노드 Redis다. 락 저장소와 캐시가 다른 노드일 때, 또는 락 저장소가 죽었을 때의 동작은 여기서 말할 수 없다. 그 실패 모드는 분산락 실험에서 따로 다뤘다.
- double-checked locking을 구현하지 않았다. 1.5회를 1.0회로 만드는 조치이고 재측정하지 않았다.
정리
- 방어가 없으면 만료마다 200명 중 125명이 원본에 도달한다. 원본이 3.43ms여도 그렇다.
- 락 기반 single flight는 84배 줄이면서 처리량과 p99도 가장 좋다. 유일하게 아무것도 나빠지지 않는 선택지다.
- 1.5회가 남는 이유는 미스 판정과 락 획득이 원자적이지 않아서다. 락을 잡은 뒤 다시 읽어야 한다.
- XFetch의 beta는 재계산 비용에 맞춰야 한다. 싼 원본에 큰 beta는 순수한 낭비다.
- stale-while-revalidate는 “아무도 안 기다린다”를 사고 “누군가는 옛 값을 읽는다”를 판다.
- 예외를 삼키면 실패가 가장 빠른 요청이 된다. 오류율과 지연을 같은 표에 놓아야 보인다.
참고
- data-ops-lab — 원본은
reports/data/t9-stampede.json, 표는reports/01-experiment-report.md - 캐시 스탬피드 정리 — 이 실험의 개념 짝
- Vattani, Chierichetti & Lowenstein, Optimal Probabilistic Cache Stampede Prevention (XFetch)
댓글
아직 댓글이 없습니다