Workspace IndexAlgorithms › Day 79

Cache Consistency, Invalidation, and Stampede Prevention TODO

Algorithms · Day 79 / 100 · E. Data & Storage Engines (Day 69-81)

Concept

A cache starts carrying a consistency problem the moment the source of truth and the copy diverge; policies broadly split into expiration-based (TTL) and invalidation-based (delete or update on write). Updating the cache on the write path can let concurrent writes land out of order and leave a stale value behind, so deleting is usually safer than updating. A cache stampede happens when a popular key expires and many requests hit the origin simultaneously, which can momentarily take the origin down. The countermeasures are single-flight (letting only one request query the origin), adding random jitter to expiration times, proactive early recomputation before expiry, and stale-while-revalidate (serving the stale value briefly while a refresh is in flight). Whichever policy you pick, you first have to define 'how much staleness is acceptable' before a choice is even possible.

A large share of outages don't come from missing a cache — they come from the cache emptying all at once and the backend collapsing under the exposed load.

Code & Formula

# 캐시 일관성·무효화·스탬피드 방지 — TTL 만료 순간 다수 요청이 몰리는 스탬피드를
# single-flight(락)로 한 요청만 원본을 조회하게 막는 것을 시연한다.

import time
import threading

origin_calls = 0
origin_lock = threading.Lock()

def slow_origin_fetch(key):
    global origin_calls
    with origin_lock:
        origin_calls += 1
    time.sleep(0.05)  # 원본 DB/서비스 호출을 흉내
    return f"value-for-{key}"

class SingleFlightCache:
    def __init__(self):
        self.store = {}       # key -> (value, expires_at)
        self.inflight = {}    # key -> threading.Event (동시 요청 합류용)
        self.lock = threading.Lock()

    def get(self, key, ttl=1.0):
        with self.lock:
            entry = self.store.get(key)
            if entry and entry[1] > time.time():
                return entry[0]
            if key in self.inflight:
                event = self.inflight[key]
            else:
                event = threading.Event()
                self.inflight[key] = event
                event = None  # 이 스레드가 원본을 조회할 담당자
        if event is not None:
            event.wait()
            return self.store[key][0]
        value = slow_origin_fetch(key)
        with self.lock:
            self.store[key] = (value, time.time() + ttl)
            waiter = self.inflight.pop(key)
            waiter.set()
        return value

cache = SingleFlightCache()
results = []
def worker():
    results.append(cache.get("hot-key"))

threads = [threading.Thread(target=worker) for _ in range(20)]
for t in threads: t.start()
for t in threads: t.join()

print("concurrent requests:", len(threads))
print("origin fetches actually made:", origin_calls)  # 1 이어야 stampede 방지 성공
print("all results identical:", len(set(results)) == 1)

Exercise

Build a load test that hits the same key concurrently, then compare origin query counts and p99 latency between a plain TTL cache and one using single-flight.

Practical Connection

Values like prices, order-book snapshots, or oracle responses — where the origin is expensive and access is concentrated — carry the highest stampede risk, and here 'acceptable staleness' maps directly to the price accuracy users actually see.

If you study this on a given day, add a note link and a ✅ to this line in the source curriculum (docs/knowledge/dev-100-curriculum.md) and this spot will lead straight to the note body. You can also write directly on this page — but regenerating overwrites it, so it's safer to keep anything you want to save as markdown under docs/algorithms/.


한국어

캐시 일관성·무효화·스탬피드 방지 TODO

Algorithms · Day 79 / 100 · E. 데이터·스토리지 엔진 (Day 69–81)

개념

캐시는 원본과 사본이 갈라지는 순간부터 일관성 문제를 안고 시작하며, 정책은 크게 만료 기반(TTL)과 무효화 기반(쓰기 시 삭제·갱신)으로 나뉜다. 쓰기 경로에서 캐시를 갱신하면 동시 쓰기 순서가 뒤바뀌어 오래된 값이 남을 수 있어, 보통은 갱신보다 삭제가 더 안전하다. 캐시 스탬피드는 인기 키가 만료되는 순간 다수 요청이 동시에 원본으로 몰리는 현상으로, 원본이 순간적으로 무너질 수 있다. 대응은 단일 비행(single-flight)으로 한 요청만 원본을 조회하게 하거나, 만료 시각에 무작위 지터를 주거나, 만료 전에 미리 갱신하는 조기 재계산, 그리고 갱신 중 낡은 값을 잠시 제공하는 stale-while-revalidate이다. 어떤 정책이든 "허용 가능한 낡음의 정도"를 먼저 정의해야 선택이 가능하다.

장애의 상당수는 캐시가 없어서가 아니라 캐시가 한꺼번에 비면서 뒤쪽 시스템이 무너지는 형태로 발생한다.

코드 · 수식

# 캐시 일관성·무효화·스탬피드 방지 — TTL 만료 순간 다수 요청이 몰리는 스탬피드를
# single-flight(락)로 한 요청만 원본을 조회하게 막는 것을 시연한다.

import time
import threading

origin_calls = 0
origin_lock = threading.Lock()

def slow_origin_fetch(key):
    global origin_calls
    with origin_lock:
        origin_calls += 1
    time.sleep(0.05)  # 원본 DB/서비스 호출을 흉내
    return f"value-for-{key}"

class SingleFlightCache:
    def __init__(self):
        self.store = {}       # key -> (value, expires_at)
        self.inflight = {}    # key -> threading.Event (동시 요청 합류용)
        self.lock = threading.Lock()

    def get(self, key, ttl=1.0):
        with self.lock:
            entry = self.store.get(key)
            if entry and entry[1] > time.time():
                return entry[0]
            if key in self.inflight:
                event = self.inflight[key]
            else:
                event = threading.Event()
                self.inflight[key] = event
                event = None  # 이 스레드가 원본을 조회할 담당자
        if event is not None:
            event.wait()
            return self.store[key][0]
        value = slow_origin_fetch(key)
        with self.lock:
            self.store[key] = (value, time.time() + ttl)
            waiter = self.inflight.pop(key)
            waiter.set()
        return value

cache = SingleFlightCache()
results = []
def worker():
    results.append(cache.get("hot-key"))

threads = [threading.Thread(target=worker) for _ in range(20)]
for t in threads: t.start()
for t in threads: t.join()

print("concurrent requests:", len(threads))
print("origin fetches actually made:", origin_calls)  # 1 이어야 stampede 방지 성공
print("all results identical:", len(set(results)) == 1)

연습

동일 키를 동시에 조회하는 부하 테스트를 만들어, 단순 TTL 캐시와 single-flight을 적용한 캐시의 원본 조회 횟수와 p99를 비교해 보기.

실무 · Verex 연결

가격·오더북 스냅샷·오라클 응답처럼 원본이 비싸고 접근이 몰리는 값일수록 스탬피드 위험이 크고, 여기서 허용 가능한 낡음은 곧 사용자에게 보이는 가격 정확도와 직결된다.

공부한 날 원본 커리큘럼(docs/knowledge/dev-100-curriculum.md)의 이 줄에 노트 링크와 ✅ 를 붙이면, 이 자리는 노트 본문으로 바로 이어집니다. 노트 없이 이 페이지에 바로 적어도 됩니다 — 다만 다시 생성하면 덮어쓰이므로, 남길 글은 docs/algorithms/ 의 마크다운으로 쓰는 편이 안전합니다.

← 78. 벡터 DB와 ANN 인덱스(HNSW·IVF-PQ)80. 외부 정렬·병합 전략과 병렬 정렬 (TAOCP 3권) →