English· Español· Deutsch· Nederlands· Français· 日本語· ქართული· 繁體中文· 简体中文· Português· Русский· العربية· हिन्दी· Italiano· 한국어· Polski· Svenska· Türkçe· Українська· Tiếng Việt· Bahasa Indonesia

un

게스트
1 / ?
수업 목록으로

4단계 패턴

결함은 순차적이고 비원자적인 네 단계에 있다:

// 결함
Value value = cache.get(key);
if (value == null) {
value = expensiveCompute(key);
cache.put(key, value);
}
return value;

1단계: 캐시 확인. 2단계: 미스. 3단계: 계산. 4단계: 저장. 네 단계 모두 원자적이지 않음. 1단계와 4단계 사이에 여러 스레드가 1단계를 실행할 수 있으며, 모두 null을 보게 됨.

멱등성 함정

이 패턴을 보호하는 추론: '두 스레드가 동일한 값을 계산하고 저장해도 괜찮다. 결과는 멱등적이다. 데이터 손상은 발생하지 않는다.'

이 추론: 정확성에 대해서는 맞다. 비용에 대해서는 치명적이다.

캐시 미스 상황에서 1,000개의 스레드가 동작할 때: 1,000개의 스레드가 각각 expensiveCompute(key)를 실행한다. expensiveCompute가 데이터베이스를 조회한다면, 1,000개의 데이터베이스 쿼리가 동시에 발생한다. 외부 서비스를 호출한다면, 1,000개의 HTTP 요청이 동시에 발생한다. 올바른 결과를 생성하는 시스템이 그 결과를 생성하는 비용 때문에 붕괴한다.

세 가지 트리거

Thundering Herd는 캐시 키가 여러 스레드에서 동시에 warm에서 cold로 전환될 때 발생한다:

Cold start: 서비스가 빈 캐시로 재시작된다. 첫 요청 파도: 모든 키가 미스된다. 모든 연산이 동시에 수행된다.

Service restart: 롤링 재시작으로 인스턴스 전반의 캐시가 초기화된다. 트래픽이 cold 인스턴스로 재분배된다.

TTL expiry: 트래픽이 많은 키가 만료된다. N개의 스레드가 모두 확인하고, 모두 미스하고, 첫 번째 스레드가 결과를 저장하기 전에 모두 연산을 수행한다.

세 가지 트리거 모두: 트래픽 급증과 상관관계가 있음. herd는 트래픽이 정점에 도달하고 캐시가 cold일 때 발동됨. 최악의 순간.

Elasticsearch EnrichCache 예시

Elasticsearch EnrichCache: 문서화된 주석에 '의도적으로 락을 걸지 않음(단순성을 위해)...경쟁 조건에서 동일한 키/값을 다시 put해도 OK'라고 되어 있음. 초당 10,000개 문서가 유입되고 enrich 캐시가 cold 상태일 때: 10,000개의 요청이 동시에 enrich 인덱스를 조회함. 가끔씩만 조회하도록 설계된 enrich 인덱스가 10,000개의 동시 쿼리를 받게 됨. 클러스터가 불안정해짐.

멱등성 논리: 코드 주석에서는 맞는 말임. 초당 10,000개 문서 처리 시에는 치명적임.

MOAD-0001과의 결합

MOAD-0001(Sedimentary Defect)은 고처리량 시스템에서 O(N²) 병목을 만듦. MOAD-0001을 수정하면(O(N²) → O(N)) 해당 워크스테이션의 병목이 해소됨. 처리량이 빨라지면 다운스트림으로 더 많은 요청이 전달됨. 이전에는 MOAD-0001 병목으로 보호받던 다운스트림 캐시들이 이제 상관관계가 있는 트래픽 급증을 받게 됨. 이전에는 발생하지 않았던 캐시에서 MOAD-0005가 발동됨. 하나의 MOAD를 고치면 다른 MOAD가 준비됨.

멱등성 트랩이 잘못 생각하는 점

Elasticsearch 주석은 잘못된 질문에 적용된 신중한 엔지니어링 추론을 보여줌. 멱등성: 추론할 가치가 있는 실제 속성. 트랩: 정확성에서 멈추고 비용까지 분석하지 않는 것.

'두 스레드가 동일한 결과를 쓰는 것은 괜찮다'는 추론이 왜 결함을 초래하는가? 이 추론은 어떤 점을 올바르게 보았고, 어떤 점을 놓쳤는가?

computeIfAbsent & singleflight

해결책: 검사와 계산을 원자적으로 만든다. 하나의 스레드가 계산하고, 나머지 스레드들은 그 결과를 기다린다.

Java: computeIfAbsent

// 결함: 원자적이지 않은 4단계
Value value = cache.get(key);
if (value == null) {
value = expensiveCompute(key);
cache.put(key, value);
}
return value;

// FIX: atomic check-and-compute
return cache.computeIfAbsent(key, k -> expensiveCompute(k));

computeIfAbsent: 키가 없으면 정확히 한 번만 계산하고 저장한 뒤 반환합니다. 동일 키에 대해 computeIfAbsent를 호출한 다른 모든 스레드는 첫 번째 계산이 끝날 때까지 대기합니다. N배 계산이 발생하지 않으며, 스탬피드도 없습니다.

Go: singleflight.Group

var g singleflight.Group

func getOrCompute(key string) (Value, error) {
v, err, _ := g.Do(key, func() (interface{}, error) {
return expensiveCompute(key)
})
return v.(Value), err
}

singleflight: 동일한 키에 대한 계산이 이미 실행 중이면, 같은 키를 요청한 모든 호출자는 대기하며 단일 결과를 공유합니다. 한 번의 계산, N개의 대기자, 하나의 결과 공유. 'flight' 추상화: 진행 중인 요청을 중복 제거합니다. [BLOCK_TYPE SECTION/STEP]

Lock vs singleflight
[BLOCK_TYPE SECTION/STEP]

키당 락(naive per-key lock)은 직렬화합니다: 스레드 1이 계산하고, 스레드 2가 대기하며, 스레드 3이 대기합니다. 스레드 1이 완료되면 스레드 2가 진입하여 캐시를 확인합니다(히트). 스레드 3이 진입하여 캐시를 확인합니다(히트). N-1회의 락 획득과 캐시 읽기가 발생합니다. [BLOCK_TYPE SECTION/STEP]

singleflight은 중복 제거를 수행합니다: 스레드 1이 계산하고, 스레드 2부터 N까지는 모두 스레드 1의 결과를 기다립니다. 별도의 락 획득이 없습니다. 별도의 캐시 읽기가 없습니다. 한 번의 계산, 하나의 결과, N개의 대기자에게 배포. 키당 락보다 더 적은 연산을 수행합니다. [BLOCK_TYPE SECTION/STEP]

둘 다 스탬피드를 방지합니다. singleflight은 불필요한 작업을 더 철저하게 방지합니다. [BLOCK_TYPE SECTION/STEP]

패턴 다시 작성하기 [BLOCK_TYPE SECTION/STEP]

구체적인 시나리오에 수정 사항을 적용하세요. [BLOCK_TYPE SECTION/STEP]

// 고트래픽 Java 서비스의 사용자 프로필 캐시
public UserProfile getProfile(String userId) {
UserProfile profile = profileCache.get(userId);
if (profile == null) {
profile = database.loadProfile(userId);  // expensive: 50ms DB query
profileCache.put(userId, profile);
}
return profile;
}

매일 오전 2시에 서비스가 재시작됩니다. 오전 8시에 10,000명의 사용자가 동시에 프로필을 요청합니다.

결함을 식별하고, 언제 발생하는지 설명한 후 computeIfAbsent를 사용하여 다시 작성하세요.