`lua-resty-*` 캡슐화로 개발자가 다단계 캐싱에서 해방됩니다

API7.ai

December 30, 2022

OpenResty (NGINX + Lua)

이전 두 글에서 우리는 OpenResty의 캐싱과 캐시 스탬피드 문제에 대해 배웠는데, 이는 모두 기본적인 내용이었습니다. 실제 프로젝트 개발에서 개발자들은 모든 세부 사항이 처리되고 숨겨져 있어 바로 비즈니스 코드를 개발할 수 있는 즉시 사용 가능한 라이브러리를 선호합니다.

이는 분업의 이점입니다. 기본 컴포넌트 개발자들은 유연한 아키텍처, 좋은 성능, 코드 안정성에 초점을 맞추며 상위 비즈니스 로직에 대해 신경 쓰지 않습니다. 반면, 애플리케이션 엔지니어들은 비즈니스 구현과 빠른 반복에 더 관심을 가지며, 하층의 다양한 기술적 세부 사항에 방해받지 않기를 바랍니다. 이 사이의 간격은 래퍼 라이브러리로 채울 수 있습니다.

OpenResty의 캐싱도 같은 문제를 안고 있습니다. shared dictlru cache는 충분히 안정적이고 효율적이지만, 처리해야 할 세부 사항이 너무 많습니다. 유용한 캡슐화 없이는 애플리케이션 개발 엔지니어들에게 "마지막 마일"이 고통스러울 수 있습니다. 여기서 커뮤니티의 중요성이 드러납니다. 활발한 커뮤니티는 간극을 찾아내고 빠르게 채우려고 노력합니다.

lua-resty-memcached-shdict

캐시 캡슐화로 돌아가 봅시다. lua-resty-memcached-shdict는 OpenResty의 공식 프로젝트로, shared dict를 사용하여 memcached에 대한 캡슐화를 제공하며, 캐시 스탬피드와 만료된 데이터와 같은 세부 사항을 처리합니다. 만약 백엔드에서 캐시 데이터가 memcached에 저장되어 있다면, 이 라이브러리를 사용해 볼 수 있습니다.

이 라이브러리는 OpenResty에서 개발한 공식 라이브러리이지만, OpenResty 패키지에 기본적으로 포함되어 있지는 않습니다. 로컬에서 테스트하려면 먼저 소스 코드를 다운로드하여 로컬 OpenResty 검색 경로에 넣어야 합니다.

이 캡슐화 라이브러리는 이전 글에서 언급한 해결책과 동일합니다. lua-resty-lock을 사용하여 상호 배제를 구현하며, 캐시 실패 시 하나의 요청만 memcached에서 데이터를 가져와 캐시 폭풍을 방지합니다. 최신 데이터를 가져오지 못한 경우, 오래된 데이터를 반환합니다.

그러나 이 lua-resty 라이브러리는 OpenResty의 공식 프로젝트임에도 불구하고 완벽하지 않습니다:

  1. 첫째, 테스트 케이스가 없어 코드 품질이 일관되게 보장되지 않습니다.
  2. 둘째, 너무 많은 인터페이스 매개변수를 노출시켜, 필수 매개변수 11개와 선택적 매개변수 7개가 있습니다.
local memc_fetch, memc_store =
    shdict_memc.gen_memc_methods{
        tag = "my memcached server tag",
        debug_logger = dlog,
        warn_logger = warn,
        error_logger = error_log,

        locks_shdict_name = "some_lua_shared_dict_name",

        shdict_set = meta_shdict_set,  
        shdict_get = meta_shdict_get,  

        disable_shdict = false,  -- optional, default false

        memc_host = "127.0.0.1",
        memc_port = 11211,
        memc_timeout = 200,  -- in ms
        memc_conn_pool_size = 5,
        memc_fetch_retries = 2,  -- optional, default 1
        memc_fetch_retry_delay = 100, -- in ms, optional, default to 100 (ms)

        memc_conn_max_idle_time = 10 * 1000,  -- in ms, for in-pool connections,optional, default to nil

        memc_store_retries = 2,  -- optional, default to 1
        memc_store_retry_delay = 100,  -- in ms, optional, default to 100 (ms)

        store_ttl = 1,  -- in seconds, optional, default to 0 (i.e., never expires)
    }

대부분의 매개변수는 "새로운 memcached 핸들러 생성"으로 단순화할 수 있습니다. 현재와 같이 모든 매개변수를 사용자에게 던져주는 방식은 사용자 친화적이지 않으므로, 관심 있는 개발자들이 PR을 통해 이를 최적화하는 것을 환영합니다.

또한, 이 캡슐화 라이브러리의 문서에서는 다음과 같은 추가 최적화 방향을 제안합니다.

  1. lua-resty-lrucache를 사용하여 Worker-레벨 캐시를 증가시키는 것, 즉 server-레벨 shared dict 캐시뿐만 아니라.
  2. ngx.timer를 사용하여 비동기 캐시 업데이트 작업을 수행하는 것.

첫 번째 방향은 매우 좋은 제안입니다. 왜냐하면 worker 내부의 캐시 성능이 더 좋기 때문입니다. 두 번째 제안은 실제 시나리오에 따라 고려해야 할 사항입니다. 그러나 일반적으로 두 번째 제안을 권장하지 않습니다. 타이머의 수에 제한이 있을 뿐만 아니라, 여기서 업데이트 로직이 잘못되면 캐시가 다시 업데이트되지 않을 수 있기 때문에 큰 영향을 미칩니다.

lua-resty-mlcache

다음으로, OpenResty에서 자주 사용되는 캐싱 캡슐화인 lua-resty-mlcache를 소개하겠습니다. 이 라이브러리는 shared dictlua-resty-lrucache를 사용하여 다중 계층 캐싱 메커니즘을 구현합니다. 다음 두 코드 예제를 통해 이 라이브러리의 사용법을 살펴보겠습니다.

local mlcache = require "resty.mlcache"

local cache, err = mlcache.new("cache_name", "cache_dict", {
    lru_size = 500,    -- L1 (Lua VM) 캐시의 크기
    ttl = 3600,   -- 히트에 대한 1시간 TTL
    neg_ttl  = 30,     -- 미스에 대한 30초 TTL
})
if not cache then
    error("failed to create mlcache: " .. err)
end

첫 번째 코드를 보겠습니다. 이 코드는 mlcache 라이브러리를 도입하고 초기화 매개변수를 설정합니다. 일반적으로 이 코드를 init 단계에 넣고 한 번만 수행합니다.

필수 매개변수인 캐시 이름과 딕셔너리 이름 외에도, 세 번째 매개변수는 12개의 옵션을 가진 딕셔너리로, 선택적이며 기본값을 사용합니다. 이는 lua-resty-memcached-shdict보다 훨씬 우아합니다. 우리가 인터페이스를 설계한다면, mlcache의 접근 방식을 채택하는 것이 좋습니다. 인터페이스를 가능한 한 간단하게 유지하면서 충분한 유연성을 유지하는 것입니다.

다음은 두 번째 코드로, 요청이 처리될 때의 논리 코드입니다.

local function fetch_user(id)
    return db:query_user(id)
end

local id = 123
local user , err = cache:get(id , nil , fetch_user , id)
if err then
    ngx.log(ngx.ERR , "failed to fetch user: ", err)
    return
end

if user then
    print(user.id) -- 123
end

보시다시피, 다중 계층 캐시가 숨겨져 있으므로, 캐시를 가져오고 캐시가 만료될 때 콜백 함수를 설정하기 위해 mlcache 객체를 사용해야 합니다. 이 뒤의 복잡한 논리는 완전히 숨겨질 수 있습니다.

이 라이브러리가 내부적으로 어떻게 구현되었는지 궁금할 수 있습니다. 다음으로, 이 라이브러리의 아키텍처와 구현을 살펴보겠습니다. 아래 이미지는 mlcache의 저자인 Thibault Charbonnier가 OpenResty Con 2018에서 발표한 슬라이드입니다.

mlcache architecture

다이어그램에서 볼 수 있듯이, mlcache는 데이터를 L1, L2, L3 세 계층으로 나눕니다.

L1 캐시는 lua-resty-lrucache로, 각 Worker가 자신의 복사본을 가지며, N개의 Worker가 있으면 N개의 데이터 복사본이 있으므로 데이터 중복이 발생합니다. 단일 Worker 내에서 lrucache를 운영하면 잠금이 발생하지 않으므로 성능이 높고, 1차 캐시로 적합합니다.

L2 캐시는 shared dict입니다. 모든 Worker가 단일 캐시 데이터 복사본을 공유하며, L1 캐시가 히트하지 않으면 L2 캐시를 조회합니다. ngx.shared.DICT는 스핀락을 사용하여 작업의 원자성을 보장하는 API를 제공하므로, 여기서 경쟁 조건에 대해 걱정할 필요가 없습니다.

L3L2 캐시도 히트하지 않을 경우로, 콜백 함수를 실행하여 외부 데이터베이스와 같은 데이터 소스를 조회한 후 L2에 캐시합니다. 여기서 캐시 폭풍을 방지하기 위해 lua-resty-lock을 사용하여 단 하나의 Worker만 데이터 소스에서 데이터를 가져오도록 합니다.

요청 관점에서:

  • 먼저, Worker 내부의 L1 캐시를 조회하고, L1이 히트하면 바로 반환합니다.
  • L1이 히트하지 않거나 캐시가 실패하면, Worker 간의 L2 캐시를 조회합니다. L2가 히트하면 반환하고 결과를 L1에 캐시합니다.
  • L2도 미스하거나 캐시가 무효화되면, 콜백 함수를 호출하여 데이터 소스에서 데이터를 조회하고 L2 캐시에 기록합니다. 이것이 L3 데이터 계층의 기능입니다.

이 과정에서 볼 수 있듯이, 캐시 업데이트는 엔드포인트 요청에 의해 수동적으로 트리거됩니다. 요청이 캐시를 가져오지 못하더라도, 후속 요청이 업데이트 로직을 트리거할 수 있어 캐시 안전성을 극대화합니다.

그러나 mlcache가 완벽하게 구현되었음에도 불구하고, 여전히 한 가지 문제점이 있습니다. 바로 데이터의 직렬화와 역직렬화입니다. 이는 mlcache의 문제가 아니라, lrucacheshared dict의 차이점 때문입니다. lrucache에서는 table을 포함한 다양한 Lua 데이터 타입을 저장할 수 있지만, shared dict에서는 문자열만 저장할 수 있습니다.

L1, 즉 lrucache 캐시는 사용자가 접촉하는 데이터 계층이며, 우리는 string, table, cdata 등 다양한 데이터를 캐시하고 싶습니다. 문제는 L2는 문자열만 저장할 수 있고, 데이터가 L2에서 L1으로 올라갈 때, 문자열에서 사용자에게 직접 제공할 수 있는 데이터 타입으로 변환해야 한다는 것입니다.

다행히, mlcache는 이러한 상황을 고려하여 newget 인터페이스에서 선택적 함수 l1_serializer를 제공합니다. 이 함수는 L2에서 L1으로 데이터가 올라갈 때 데이터 처리를 담당합니다. 다음은 테스트 케이스 세트에서 추출한 샘플 코드입니다.

local mlcache = require "resty.mlcache"

local cache, err = mlcache.new("my_mlcache", "cache_shm", {
l1_serializer = function(i)
    return i + 2
end,
})

local function callback()
    return 123456
end

local data = assert(cache:get("number", nil, callback))
assert(data == 123458)

간단히 설명하겠습니다. 이 경우, 콜백 함수는 숫자 123456을 반환합니다. new에서 설정한 l1_serializer 함수는 들어오는 숫자에 2를 더한 후 L1 캐시를 설정하며, 이는 123458이 됩니다. 이러한 직렬화 함수를 통해 데이터가 L1L2 사이에서 변환될 때 더 유연해질 수 있습니다.

요약

다중 캐시 계층을 사용하면 서버 측 성능을 극대화할 수 있으며, 많은 세부 사항이 숨겨져 있습니다. 이 시점에서 안정적이고 효율적인 래퍼 라이브러리는 우리에게 많은 노력을 절약해 줍니다. 오늘 소개한 두 래퍼 라이브러리가 캐싱을 더 잘 이해하는 데 도움이 되길 바랍니다.

마지막으로, 이 질문을 생각해 보세요: 캐시의 공유 딕셔너리 계층이 필요한가요? lrucache만 사용할 수는 없을까요? 의견을 남기고 나와 공유해 주세요. 또한 이 글을 더 많은 사람들과 공유하여 함께 소통하고 성장할 수 있기를 바랍니다.