cache-stampede

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())  
    }  
}

최종 코드

위의 코드는 프로덕션에 적용하기에 몇 가지 문제가 있었습니다.

사용 예시

val products = throttle.execute(  
    inputs = productIds,  
    ttl = Duration.ofSeconds(1),
    keyFactory = ProductKeyFactory,  
) { throttledProductIds -> 
    // 추가적으로 리졸빙이 필요한 productIds만 걸러짐
    productRepository.findAllByIds(throttledProductIds).toList()  
}

최종 구현 gist

참고