`lua-resty-*` 캡슐화로 개발자가 다단계 캐싱에서 해방됩니다
API7.ai
December 30, 2022
이전 두 글에서 우리는 OpenResty의 캐싱과 캐시 스탬피드 문제에 대해 배웠는데, 이는 모두 기본적인 내용이었습니다. 실제 프로젝트 개발에서 개발자들은 모든 세부 사항이 처리되고 숨겨져 있어 바로 비즈니스 코드를 개발할 수 있는 즉시 사용 가능한 라이브러리를 선호합니다.
이는 분업의 이점입니다. 기본 컴포넌트 개발자들은 유연한 아키텍처, 좋은 성능, 코드 안정성에 초점을 맞추며 상위 비즈니스 로직에 대해 신경 쓰지 않습니다. 반면, 애플리케이션 엔지니어들은 비즈니스 구현과 빠른 반복에 더 관심을 가지며, 하층의 다양한 기술적 세부 사항에 방해받지 않기를 바랍니다. 이 사이의 간격은 래퍼 라이브러리로 채울 수 있습니다.
OpenResty의 캐싱도 같은 문제를 안고 있습니다. shared dict
와 lru cache
는 충분히 안정적이고 효율적이지만, 처리해야 할 세부 사항이 너무 많습니다. 유용한 캡슐화 없이는 애플리케이션 개발 엔지니어들에게 "마지막 마일"이 고통스러울 수 있습니다. 여기서 커뮤니티의 중요성이 드러납니다. 활발한 커뮤니티는 간극을 찾아내고 빠르게 채우려고 노력합니다.
lua-resty-memcached-shdict
캐시 캡슐화로 돌아가 봅시다. lua-resty-memcached-shdict
는 OpenResty의 공식 프로젝트로, shared dict
를 사용하여 memcached
에 대한 캡슐화를 제공하며, 캐시 스탬피드와 만료된 데이터와 같은 세부 사항을 처리합니다. 만약 백엔드에서 캐시 데이터가 memcached
에 저장되어 있다면, 이 라이브러리를 사용해 볼 수 있습니다.
이 라이브러리는 OpenResty에서 개발한 공식 라이브러리이지만, OpenResty 패키지에 기본적으로 포함되어 있지는 않습니다. 로컬에서 테스트하려면 먼저 소스 코드를 다운로드하여 로컬 OpenResty 검색 경로에 넣어야 합니다.
이 캡슐화 라이브러리는 이전 글에서 언급한 해결책과 동일합니다. lua-resty-lock
을 사용하여 상호 배제를 구현하며, 캐시 실패 시 하나의 요청만 memcached
에서 데이터를 가져와 캐시 폭풍을 방지합니다. 최신 데이터를 가져오지 못한 경우, 오래된 데이터를 반환합니다.
그러나 이 lua-resty
라이브러리는 OpenResty의 공식 프로젝트임에도 불구하고 완벽하지 않습니다:
- 첫째, 테스트 케이스가 없어 코드 품질이 일관되게 보장되지 않습니다.
- 둘째, 너무 많은 인터페이스 매개변수를 노출시켜, 필수 매개변수 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을 통해 이를 최적화하는 것을 환영합니다.
또한, 이 캡슐화 라이브러리의 문서에서는 다음과 같은 추가 최적화 방향을 제안합니다.
lua-resty-lrucache
를 사용하여Worker
-레벨 캐시를 증가시키는 것, 즉server
-레벨shared dict
캐시뿐만 아니라.ngx.timer
를 사용하여 비동기 캐시 업데이트 작업을 수행하는 것.
첫 번째 방향은 매우 좋은 제안입니다. 왜냐하면 worker 내부의 캐시 성능이 더 좋기 때문입니다. 두 번째 제안은 실제 시나리오에 따라 고려해야 할 사항입니다. 그러나 일반적으로 두 번째 제안을 권장하지 않습니다. 타이머의 수에 제한이 있을 뿐만 아니라, 여기서 업데이트 로직이 잘못되면 캐시가 다시 업데이트되지 않을 수 있기 때문에 큰 영향을 미칩니다.
lua-resty-mlcache
다음으로, OpenResty에서 자주 사용되는 캐싱 캡슐화인 lua-resty-mlcache
를 소개하겠습니다. 이 라이브러리는 shared dict
와 lua-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
는 데이터를 L1
, L2
, L3
세 계층으로 나눕니다.
L1
캐시는 lua-resty-lrucache
로, 각 Worker
가 자신의 복사본을 가지며, N
개의 Worker
가 있으면 N
개의 데이터 복사본이 있으므로 데이터 중복이 발생합니다. 단일 Worker
내에서 lrucache
를 운영하면 잠금이 발생하지 않으므로 성능이 높고, 1차 캐시로 적합합니다.
L2
캐시는 shared dict
입니다. 모든 Worker
가 단일 캐시 데이터 복사본을 공유하며, L1
캐시가 히트하지 않으면 L2
캐시를 조회합니다. ngx.shared
.DICT는 스핀락을 사용하여 작업의 원자성을 보장하는 API를 제공하므로, 여기서 경쟁 조건에 대해 걱정할 필요가 없습니다.
L3
은 L2
캐시도 히트하지 않을 경우로, 콜백 함수를 실행하여 외부 데이터베이스와 같은 데이터 소스를 조회한 후 L2
에 캐시합니다. 여기서 캐시 폭풍을 방지하기 위해 lua-resty-lock
을 사용하여 단 하나의 Worker
만 데이터 소스에서 데이터를 가져오도록 합니다.
요청 관점에서:
- 먼저,
Worker
내부의 L1 캐시를 조회하고,L1
이 히트하면 바로 반환합니다. L1
이 히트하지 않거나 캐시가 실패하면,Worker
간의L2
캐시를 조회합니다.L2
가 히트하면 반환하고 결과를L1
에 캐시합니다.L2
도 미스하거나 캐시가 무효화되면, 콜백 함수를 호출하여 데이터 소스에서 데이터를 조회하고L2
캐시에 기록합니다. 이것이L3
데이터 계층의 기능입니다.
이 과정에서 볼 수 있듯이, 캐시 업데이트는 엔드포인트 요청에 의해 수동적으로 트리거됩니다. 요청이 캐시를 가져오지 못하더라도, 후속 요청이 업데이트 로직을 트리거할 수 있어 캐시 안전성을 극대화합니다.
그러나 mlcache
가 완벽하게 구현되었음에도 불구하고, 여전히 한 가지 문제점이 있습니다. 바로 데이터의 직렬화와 역직렬화입니다. 이는 mlcache
의 문제가 아니라, lrucache
와 shared dict
의 차이점 때문입니다. lrucache
에서는 table
을 포함한 다양한 Lua 데이터 타입을 저장할 수 있지만, shared dict
에서는 문자열만 저장할 수 있습니다.
L1, 즉 lrucache
캐시는 사용자가 접촉하는 데이터 계층이며, 우리는 string
, table
, cdata
등 다양한 데이터를 캐시하고 싶습니다. 문제는 L2
는 문자열만 저장할 수 있고, 데이터가 L2
에서 L1
으로 올라갈 때, 문자열에서 사용자에게 직접 제공할 수 있는 데이터 타입으로 변환해야 한다는 것입니다.
다행히, mlcache
는 이러한 상황을 고려하여 new
와 get
인터페이스에서 선택적 함수 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
이 됩니다. 이러한 직렬화 함수를 통해 데이터가 L1
과 L2
사이에서 변환될 때 더 유연해질 수 있습니다.
요약
다중 캐시 계층을 사용하면 서버 측 성능을 극대화할 수 있으며, 많은 세부 사항이 숨겨져 있습니다. 이 시점에서 안정적이고 효율적인 래퍼 라이브러리는 우리에게 많은 노력을 절약해 줍니다. 오늘 소개한 두 래퍼 라이브러리가 캐싱을 더 잘 이해하는 데 도움이 되길 바랍니다.
마지막으로, 이 질문을 생각해 보세요: 캐시의 공유 딕셔너리 계층이 필요한가요? lrucache
만 사용할 수는 없을까요? 의견을 남기고 나와 공유해 주세요. 또한 이 글을 더 많은 사람들과 공유하여 함께 소통하고 성장할 수 있기를 바랍니다.