https://kirstenhines.com/product/wildebeest-stampede/
Request -> Application Cache -> Caching Server (Redis/Memcache) -> DB (RDMS/NoSQL)
캐시 히트에 실패했을 때 오리진 디비에서 값을 읽어 다시 채워 넣는 경우를 생각해 봅시다.
이때 캐싱 되지 않은 데이터에 순간적인 요청이 발생하면 어떻게 될까요?
따로 처리를 하지 않는다면 오리진 디비에 부하가 그대로 전달될 것입니다.
1,000 tps 정도를 예측하던 디비가 10,000 tps의 쿼리를 받아 장애가 생길 수도 있겠죠.
캐시 히트의 실패로 오리진 디비에 장애가 발생하는 문제가 cache-stampede입니다.
cache-stampede 문제 해결 결과
저는 커머스 서비스를 운영하고 있었습니다.
마케팅과 이벤트의 효과로, 전체의 0.1%도 안 되는 상품군에 의해 평소의 10배가 넘는 트래픽이 간헐적으로 발생했습니다.
이때 cache-stampede 문제를 해결함으로써,
일체의 인프라 환경을 업그레이드하지 않고 레이턴시 문제를 해결했습니다.
max 31s -> 2s

p50, 130ms -> 650na

쓰로틀링
먼저 프론트 엔지니어에겐 익숙한 개념인, 디바운스와 쓰로틀링에 대한 설명이 필요합니다.
둘 다 대량의 이벤트가 들어올 때 부하를 조절하는 기능을 합니다.
디바운스는 연속적으로 이벤트가 들어올 때 그중 하나의 이벤트만 처리하고,
쓰로틀링은 연속적으로 이벤트가 들어올 때, x 시간 동안 하나의 이벤트를 처리합니다.
자세한 설명은 이곳을 참고하면 좋을 것 같습니다.
쓰로틀링을 통해 x 시간 동안 10,000 tps가 발생해도 하나의 쿼리만 처리할 수 있습니다.
디바운스를 채택하지 않은 것은 데이터 최신성이 중요한 경우가 있기 때문입니다.
예를 들어 0.5초 동안의 쿼리를 묶고, 해당 쿼리가 아직 계산 상태에 있더라도,
0.5초 뒤에 들어오는 쿼리는 새롭게 처리하기 위함입니다.
간단하게는 이렇게 풀 수 있을 것 같습니다.
/**
* val product = throttle.execute(
* key = "product",
* ttl = Duration.ofSeconds(1),
* f = { productRepository.findById(productId) },
* )
*/
class Throttle {
private val cache = mutableMapOf<String, Result<*>>()
fun <T> execute(
key: String,
ttl: Duration,
f: () -> T,
): T {
val result = cache[key]
if (result != null && result.isExpired(ttl).not()) {
return result.value as T
}
return doExecute(f = f).also {
cache[key] = Result(value = it, createdAt = OffsetDateTime.now())
}
}
@Synchronized
private fun <T> doExecute(f: () -> T): T {
return f()
}
}
private class Result<T>(
val value: T,
val createdAt: OffsetDateTime,
) {
fun isExpired(ttl: Duration): Boolean {
return createdAt.plus(ttl).isBefore(OffsetDateTime.now())
}
}
최종 코드
위의 코드는 프로덕션에 적용하기에 몇 가지 문제가 있었습니다.
- 벌크 처리에 대해 더 세부적인 처리가 필요합니다.
- 상품 A, B, C, D, E를 조회할 때,
A, B, C가 쓰로틀링 되어 있으면,
D, E만 벌크 쿼리 하는 처리가 필요합니다.
- 상품 A, B, C, D, E를 조회할 때,
- cache size 제한과 eviction 전략이 필요합니다.
- 코루틴을 지원해야합니다.
사용 예시
val products = throttle.execute(
inputs = productIds,
ttl = Duration.ofSeconds(1),
keyFactory = ProductKeyFactory,
) { throttledProductIds ->
// 추가적으로 리졸빙이 필요한 productIds만 걸러짐
productRepository.findAllByIds(throttledProductIds).toList()
}
최종 구현 gist
참고
- What is Cache Stampede ?
- Cache Stampede 문제는 순식간에 시스템을 붕괴시킬 수 있지만 종종 간과된다.
- Avoiding cache stampede at DoorDash
- debounce를 통한 cache-stampede 해결
- How to Correctly Debounce and Throttle Callbacks in React
- debouncing-throttling-explained-examples
- kotlin debounce
- 앞선 job들 cancel 시키고, 마지막 suspend를 실행하는 코드
- Techniques to Improve Cache Speed(Youtube)]
- expire 시간을 저장하고, 클라 request 때 random화하면 stampede 문제 완화 가능
- instagram Thundering Herds & Promises
- How a Cache Stampede Caused One of Facebook’s Biggest Outages