캐시 스탬피드를 피하는 방법은?

API7.ai

December 29, 2022

OpenResty (NGINX + Lua)

이전 글에서 우리는 shared dictlru cache를 사용한 몇 가지 고성능 최적화 기술을 배웠습니다. 그러나 오늘의 주제인 "Cache Stampede"라는 중요한 문제를 남겨두었습니다.

Cache Stampede란 무엇인가?

다음과 같은 시나리오를 상상해봅시다.

데이터 소스는 MySQL 데이터베이스에 있고, 캐시된 데이터는 shared dict에 있으며, 타임아웃은 60초입니다. 캐시에 데이터가 있는 60초 동안 모든 요청은 MySQL이 아닌 캐시에서 데이터를 가져옵니다. 그러나 60초가 지나면 캐시된 데이터가 만료됩니다. 만약 이때 많은 동시 요청이 들어오면 캐시에서 조회할 데이터가 없게 됩니다. 그러면 데이터 소스의 조회 함수가 트리거되고, 모든 요청이 MySQL 데이터베이스로 향하게 되어 데이터베이스 서버가 차단되거나 다운될 수 있습니다.

이러한 현상을 "Cache Stampede"라고 부르며, 때로는 Dog-Piling이라고도 합니다. 이전 섹션에서 등장한 캐시 관련 코드 중 어느 것도 이에 대한 처리가 되어 있지 않습니다. 다음은 캐시 스탬피드가 발생할 가능성이 있는 의사 코드 예시입니다.

local value = get_from_cache(key)
if not value then
    value = query_db(sql)
    set_to_cache(value, timeout = 60)
end
return value

이 의사 코드는 논리적으로는 문제가 없어 보이며, 단위 테스트나 종단 간 테스트에서는 캐시 스탬피드를 유발하지 않을 수 있습니다. 오랜 스트레스 테스트를 통해서만 문제를 발견할 수 있습니다. 매 60초마다 데이터베이스에 정기적인 쿼리 급증이 발생합니다. 하지만 여기서 캐시 만료 시간을 더 길게 설정하면 캐시 스톰 문제가 감지될 가능성이 줄어듭니다.

어떻게 피할 수 있을까?

여러 가지 경우로 나누어 논의해보겠습니다.

1. 캐시를 능동적으로 업데이트하기

위의 의사 코드에서 캐시는 수동적으로 업데이트되며, 요청이 들어왔을 때 캐시 실패가 발견된 경우에만 데이터베이스에 새로운 데이터를 조회하러 갑니다. 따라서 캐시 업데이트 방식을 수동에서 능동으로 변경하면 캐시 스탬피드 문제를 우회할 수 있습니다.

OpenResty에서는 다음과 같이 구현할 수 있습니다.

먼저, ngx.timer.every를 사용하여 매분마다 실행되는 타이머 작업을 생성하여 MySQL 데이터베이스에서 최신 데이터를 가져와 공유 dict에 넣습니다:

local function query_db(premature, sql)
    local value = query_db(sql)
    set_to_cache(value, timeout = 60)
end

local ok, err = ngx.timer.every(60, query_db, sql)

그런 다음, 요청을 처리하는 코드의 논리에서 MySQL을 조회하는 부분을 제거하고 공유 dict 캐시를 가져오는 코드만 남깁니다.

local value = get_from_cache(key)
return value

위의 두 의사 코드 조각은 캐시 스탬피드 문제를 우회하는 데 도움을 줄 수 있습니다. 하지만 이 방법은 완벽하지 않습니다. 각 캐시는 주기적인 작업에 대응해야 하며(OpenResty에서 타이머 수에는 상한이 있음), 캐시 만료 시간과 스케줄링된 작업의 주기가 잘 맞아야 합니다. 이 기간 동안 실수가 있으면 요청이 계속 빈 데이터를 받을 수 있습니다.

따라서 실제 프로젝트에서는 일반적으로 락을 사용하여 캐시 스탬피드 문제를 해결합니다. 다음은 몇 가지 다른 락 방식입니다. 필요에 따라 선택할 수 있습니다.

2. lua-resty-lock

락을 추가하는 것에 대해 어려움을 느낄 수 있습니다. 무거운 작업이라고 생각할 수 있고, 데드락이 발생하면 상당히 많은 예외를 처리해야 한다고 생각할 수 있습니다.

OpenResty의 lua-resty-lock 라이브러리를 사용하여 락을 추가함으로써 이러한 우려를 완화할 수 있습니다. lua-resty-lock은 OpenResty의 resty 라이브러리로, 공유 dict를 기반으로 하며 비차단 락 API를 제공합니다. 간단한 예를 살펴보겠습니다.

resty --shdict='locks 1m' -e 'local resty_lock = require "resty.lock"
                            local lock, err = resty_lock:new("locks")
                            local elapsed, err = lock:lock("my_key")
                            -- query db and update cache
                            local ok, err = lock:unlock()
                            ngx.say("unlock: ", ok)'

lua-resty-lock은 공유 dict를 사용하여 구현되었기 때문에 먼저 shdict의 이름과 크기를 선언한 다음 new 메서드를 사용하여 새로운 lock 객체를 생성해야 합니다. 위의 코드 조각에서는 첫 번째 매개변수인 shdict의 이름만 전달했습니다. new 메서드에는 두 번째 매개변수가 있으며, 이를 사용하여 만료 시간, 락의 타임아웃 시간 등 여러 매개변수를 지정할 수 있습니다. 여기서는 기본값을 유지합니다. 이러한 매개변수는 데드락 및 기타 예외를 방지하기 위해 사용됩니다.

그런 다음 lock 메서드를 호출하여 락을 획득하려고 시도할 수 있습니다. 락을 성공적으로 획득하면 동일한 순간에 하나의 요청만 데이터 소스로 가서 데이터를 업데이트할 수 있습니다. 하지만 락이 선점, 타임아웃 등으로 인해 실패하면 오래된 캐시에서 데이터를 가져와 요청자에게 반환합니다. 이는 이전 수업에서 소개된 get_stale API로 이어집니다.

local elapsed, err = lock:lock("my_key")
# elapsed가 nil이면 락 획득 실패. err의 반환 값은 timeout, locked 중 하나
if not elapsed and err then
    dict:get_stale("my_key")
end

lock이 성공하면 데이터베이스를 조회하고 결과를 캐시에 업데이트하는 것이 안전하며, 마지막으로 unlock 인터페이스를 호출하여 락을 해제합니다.

lua-resty-lockget_stale을 결합하면 캐시 스탬피드 문제에 대한 완벽한 해결책을 얻을 수 있습니다. lua-resty-lock의 문서는 이를 처리하는 매우 완전한 코드를 제공합니다. 관심이 있다면 여기에서 확인할 수 있습니다.

더 깊이 들어가서 lock 인터페이스가 어떻게 락을 구현하는지 살펴보겠습니다. 흥미로운 구현을 만나면 항상 소스 코드에서 어떻게 구현되었는지 보고 싶어집니다. 이는 오픈 소스의 장점 중 하나입니다.

local ok, err = dict:add(key, true, exptime)
if ok then
    cdata.key_id = ref_obj(key)
    self.key = key
    return 0
end

공유 dict에 대한 글에서 언급했듯이, 공유 dict의 모든 API는 원자적 작업이며 경쟁 상태를 걱정할 필요가 없습니다. 따라서 공유 dict를 사용하여 락의 상태를 표시하는 것은 좋은 아이디어입니다.

위의 lock 구현은 dict:add를 사용하여 키를 설정하려고 시도합니다: 키가 공유 메모리에 존재하지 않으면 add는 성공을 반환하며, 이는 락이 성공적으로 획득되었음을 나타냅니다; 다른 동시 요청은 dict:add 코드 줄에 도달하면 실패를 반환하고, 반환된 err 정보를 기반으로 직접 반환할지 또는 여러 번 재시도할지 선택할 수 있습니다.

3. lua-resty-shcache

위의 lua-resty-lock 구현에서는 락 획득, 해제, 만료된 데이터 가져오기, 재시도, 예외 처리 등을 처리해야 하므로 여전히 상당히 번거롭습니다.

여기 간단한 래퍼가 있습니다: lua-resty-shcache, 이는 Cloudflare의 lua-resty 라이브러리로, 공유 사전과 외부 저장소 위에 한 층의 캡슐화를 제공하며 직렬화 및 역직렬화를 위한 추가 기능을 제공하므로 위의 세부 사항을 신경 쓸 필요가 없습니다:

local shcache = require("shcache")

local my_cache_table = shcache:new(
        ngx.shared.cache_dict
        { external_lookup = lookup,
          encode = cmsgpack.pack,
          decode = cmsgpack.decode,
        },
        { positive_ttl = 10,           -- 좋은 데이터를 10초 동안 캐시
          negative_ttl = 3,            -- 실패한 조회를 3초 동안 캐시
          name = 'my_cache',     -- "명명된" 캐시, 디버그 / 보고에 유용
        }
    )

local my_table, from_cache = my_cache_table:load(key)

이 샘플 코드는 공식 예제에서 추출되었으며 모든 세부 사항을 숨겼습니다. 이 캐시 캡슐화 라이브러리는 최선의 선택은 아니지만 초보자에게는 좋은 학습 자료입니다. 다음 글에서는 몇 가지 더 좋고 일반적으로 사용되는 캡슐화를 소개하겠습니다.

4. NGINX 지시문

OpenResty의 lua-resty 라이브러리를 사용하지 않는다면, NGINX 구성 지시문을 사용하여 락을 걸고 만료된 데이터를 가져올 수도 있습니다: proxy_cache_lockproxy_cache_use_stale. 그러나 여기서는 NGINX 지시문을 사용하는 것을 권장하지 않습니다. 충분히 유연하지 않으며, Lua 코드만큼 성능이 좋지 않기 때문입니다.

요약

캐시 스탬피드는 우리가 반복적으로 언급한 경쟁 문제와 마찬가지로 코드 리뷰와 테스트를 통해 발견하기 어렵습니다. 이를 해결하는 가장 좋은 방법은 코딩을 개선하거나 캡슐화 라이브러리를 사용하는 것입니다.

마지막 질문: 여러분이 익숙한 언어와 플랫폼에서 캐시 스탬피드와 같은 문제를 어떻게 처리하나요? OpenResty보다 더 나은 방법이 있나요? 댓글로 공유해주세요.