캐시 스탬피드와 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 | 낡은 값을 즉시 주고 백그라운드로 갱신 |
lock은 single flight를 Redis 락으로 구현한 것이다. SET NX는 키가 없을 때만 값을 쓰므로, 동시에 시도한 클라이언트 중 하나만 성공한다.
콜드 스타트가 만료 측정을 덮지 않도록 캐시를 미리 채웠고, 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 |
rps는 초당 처리한 요청 수, p50·p99는 요청을 지연 순으로 줄 세웠을 때 50번째·99번째 백분위 값이다(백분위 통계).
스탬피드는 크다
방어가 없으면 만료마다 200명 중 125명이 원본까지 간다. 원본이 3.43ms밖에 안 걸리는데도 그 짧은 창에 3분의 2가 동시에 미스한다.
원본이 더 비쌀수록 이 숫자는 커진다. 재계산이 끝나기 전에 도착한 요청은 모두 미스하기 때문이다. 500ms짜리 질의라면 그 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는 Vattani 등의 논문이 제안한 확률적 조기 갱신이다. 논문의 Figure 3에 있는 조건은 한 줄이다.
1
Time() − Δ·β·log(rand()) ≥ expiry 이면 지금 재계산한다
Δ는 지난 재계산에 걸린 시간이다. log(rand())는 음수이므로 왼쪽 항은 현재 시각보다 조금 뒤를 가리키고, 그 거리는 Δ와 β에 비례한다. 그래서 재계산이 비쌀수록, 만료가 가까울수록 미리 갱신할 확률이 커진다. beta는 그 창의 폭을 정한다.
이 실험에서는 재계산이 3.43ms로 싸다. 그래서 beta=1의 창으로 이미 충분했다. beta를 키우면 아직 만료되지도 않은 값을 계속 새로 계산하게 되고, 그만큼 원본 호출이 늘었다.
beta는 재계산 비용과 짝지어 정할 값이다. 비싼 원본에 작은 beta면 창이 좁아 스탬피드를 못 막고, 싼 원본에 큰 beta면 쓸데없이 자주 갱신한다. 논문도 기본값을 1로 두고 이렇게 적는다. “The parameter β defaults to 1 and already provides effective prevention against cache stampedes.”(β는 기본값 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(PostgreSQL이 받는 최대 동시 접속 수)에 닿자 원본 자체가 실패했다. 원본을 커넥션 풀(최대 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회로 만드는 조치이고 재측정하지 않았다.
정리
- 원본이 3.43ms로 싸도 방어가 없으면 만료마다 200명 중 125명이 원본에 도달했다.
- 락 기반 single flight는 원본 호출을 84배 줄이면서 처리량과 p99도 가장 좋았다. 만료당 1.5회는 미스 판정과 락 획득 사이의 틈에서 나왔고, 락을 잡은 뒤 다시 읽는 조치는 재지 않았다.
- XFetch의 beta를 키우면 싼 원본에서는 조기 갱신이 원본 호출로 바뀐다.
- 예외를 삼키면 실패가 가장 빠른 요청이 된다. 오류율과 지연을 같은 표에 놓아야 보인다.
참고한 자료외부 출처 2 · 블로그 글 1
외부 출처
- data-ops-lab원본은 reports/data/t9-stampede.json, 표는 reports/01-experiment-report.md
- Optimal Probabilistic Cache Stampede PreventionVattani, Chierichetti & Lowenstein: (XFetch). Figure 3에 조건식과 β 기본값이 있다.
이 블로그의 관련 글
- 캐시 스탬피드 정리이 실험의 개념 짝
댓글
아직 댓글이 없습니다