고성능의 열쇠: `shared dict`와 `lru` 캐시
API7.ai
December 22, 2022
이전 글에서 저는 OpenResty의 최적화 기술과 성능 튜닝 도구를 소개했는데, 여기에는 string
, table
, Lua API
, LuaJIT
, SystemTap
, flame graphs
등이 포함됩니다.
이들은 시스템 최적화의 초석이며, 이를 잘 숙지해야 합니다. 그러나 이를 아는 것만으로는 실제 비즈니스 시나리오에 직면하기에 충분하지 않습니다. 더 복잡한 비즈니스 환경에서 고성능을 유지하는 것은 체계적인 작업이며, 단순히 코드와 게이트웨이 수준의 최적화만이 아닙니다. 데이터베이스, 네트워크, 프로토콜, 캐시, 디스크 등 다양한 측면이 포함되며, 이는 아키텍트의 존재 의미입니다.
오늘의 글에서는 성능 최적화에서 매우 중요한 역할을 하는 구성 요소인 캐시를 살펴보고, OpenResty에서 어떻게 사용되고 최적화되는지 알아보겠습니다.
캐시
하드웨어 수준에서 대부분의 컴퓨터 하드웨어는 속도 향상을 위해 캐시를 사용합니다. 예를 들어, CPU에는 다단계 캐시가 있고, RAID 카드에는 읽기 및 쓰기 캐시가 있습니다. 소프트웨어 수준에서 우리가 사용하는 데이터베이스는 캐시 설계의 매우 좋은 예입니다. SQL 문 최적화, 인덱스 설계, 디스크 읽기 및 쓰기에서 캐시가 사용됩니다.
여기서, 자신만의 캐시를 설계하기 전에 MySQL의 다양한 캐시 메커니즘을 배우는 것을 권장합니다. 제가 추천하는 자료는 훌륭한 책인 High Performance MySQL: Optimization, Backups, and Replication입니다. 제가 수년 전 데이터베이스를 담당했을 때, 이 책으로부터 많은 도움을 받았으며, 이후의 많은 최적화 시나리오도 MySQL의 설계에서 빌려왔습니다.
캐시로 돌아가서, 우리는 프로덕션 환경에서의 캐시 시스템이 비즈니스 시나리오와 시스템 병목 현상을 기반으로 최적의 솔루션을 찾아야 한다는 것을 알고 있습니다. 이는 균형의 예술입니다.
일반적으로 캐시에는 두 가지 원칙이 있습니다.
- 하나는 사용자의 요청에 가까울수록 좋다는 것입니다. 예를 들어, 로컬 캐시를 사용할 수 있다면 HTTP 요청을 보내지 마십시오. CDN을 사용할 수 있다면 원본 사이트로 보내고, OpenResty 캐시를 사용할 수 있다면 데이터베이스로 보내지 마십시오.
- 두 번째는 이 프로세스와 로컬 캐시를 사용하여 문제를 해결하려고 노력하는 것입니다. 프로세스, 머신, 심지어 서버실을 넘어서면 캐시의 네트워크 오버헤드가 매우 커질 수 있으며, 이는 고동시 시나리오에서 매우 명확하게 나타납니다.
OpenResty에서 캐시의 설계와 사용도 이 두 가지 원칙을 따릅니다. OpenResty에는 shared dict
캐시와 lru
캐시 두 가지 캐시 구성 요소가 있습니다. 전자는 문자열 객체만 캐시할 수 있으며, 캐시된 데이터는 하나의 복사본만 존재하며 각 worker가 접근할 수 있으므로 worker 간 데이터 통신에 자주 사용됩니다. 후자는 모든 Lua 객체를 캐시할 수 있지만 단일 worker 프로세스 내에서만 접근할 수 있습니다. 캐시된 데이터는 worker 수만큼 존재합니다.
다음 두 개의 간단한 표는 shared dict
와 lru
캐시의 차이를 보여줍니다:
캐시 구성 요소 이름 | 접근 범위 | 캐시 데이터 타입 | 데이터 구조 | 만료된 데이터를 얻을 수 있음 | API 수 | 메모리 사용량 |
---|---|---|---|---|---|---|
shared dict | 여러 worker 간 | 문자열 객체 | dict, queue | 예 | 20+ | 데이터 한 조각 |
lru cache | 단일 worker 내 | 모든 Lua 객체 | dict | 아니오 | 4 | 데이터의 N 복사본 (N = worker 수) |
shared dict
와 lru
캐시는 좋고 나쁨이 없습니다. 시나리오에 따라 함께 사용해야 합니다.
- worker 간 데이터를 공유할 필요가 없다면,
lru
는 배열 및 함수와 같은 복잡한 데이터 타입을 캐시할 수 있으며 가장 높은 성능을 가지므로 첫 번째 선택입니다. - 그러나 worker 간 데이터를 공유해야 한다면,
lru
캐시에shared dict
캐시를 추가하여 두 단계 캐시 아키텍처를 구성할 수 있습니다.
다음으로, 이 두 가지 캐시 방법을 자세히 살펴보겠습니다.
Shared dict
캐시
Lua 글에서 shared dict
에 대해 구체적으로 소개했으므로, 여기서는 그 사용법을 간단히 복습해 보겠습니다:
$ resty --shdict='dogs 1m' -e 'local dict = ngx.shared.dogs
dict:set("Tom", 56)
print(dict:get("Tom"))'
NGINX 구성 파일에서 미리 메모리 영역 dogs
를 선언해야 하며, 그런 다음 Lua 코드에서 사용할 수 있습니다. 사용 중에 dogs에 할당된 공간이 충분하지 않다면, 먼저 NGINX 구성 파일을 수정한 후 NGINX를 다시 로드해야 합니다. 런타임에 확장 및 축소할 수 없기 때문입니다.
다음으로, shared dict 캐시에서 성능과 관련된 몇 가지 문제에 초점을 맞춰보겠습니다.
캐시 데이터의 직렬화
첫 번째 문제는 캐시 데이터의 직렬화입니다. shared dict
에는 string
객체만 캐시할 수 있으므로, 배열을 캐시하려면 설정 시 한 번 직렬화하고 가져올 때 한 번 역직렬화해야 합니다:
resty --shdict='dogs 1m' -e 'local dict = ngx.shared.dogs
dict:set("Tom", require("cjson").encode({a=111}))
print(require("cjson").decode(dict:get("Tom")).a)'
그러나 이러한 직렬화 및 역직렬화 작업은 CPU를 많이 소모합니다. 이러한 작업이 요청마다 수행된다면, flame graph에서 그 소모를 확인할 수 있습니다.
그렇다면 shared dictionary에서 이러한 소모를 피하는 방법은 무엇일까요? 여기에는 좋은 방법이 없습니다. 비즈니스 수준에서 배열을 shared dictionary에 넣지 않거나, JSON 형식으로 문자열을 직접 수동으로 결합하는 것입니다. 물론 이는 문자열 결합의 성능 소모를 가져오고 더 많은 버그를 유발할 수 있습니다.
대부분의 직렬화는 비즈니스 수준에서 분해할 수 있습니다. 배열의 내용을 분해하여 shared dictionary에 문자열로 저장할 수 있습니다. 그렇지 않다면 lru
에 배열을 캐시하고, 프로그램의 편의성과 성능을 위해 메모리 공간을 교환할 수도 있습니다.
또한, 캐시의 키는 가능한 짧고 의미 있게 만들어야 하며, 공간을 절약하고 이후 디버깅을 용이하게 합니다.
만료된 데이터
shared dict
에서 데이터를 읽는 get_stale
메서드도 있습니다. get
메서드와 비교하여, 만료된 데이터에 대한 추가 반환 값이 있습니다:
resty --shdict='dogs 1m' -e 'local dict = ngx.shared.dogs
dict:set("Tom", 56, 0.01)
ngx.sleep(0.02)
local val, flags, stale = dict:get_stale("Tom")
print(val)'
위의 예에서 데이터는 shared dict
에 0.01
초 동안만 캐시되며, 설정 후 0.02
초가 지나면 데이터가 만료됩니다. 이때 get
인터페이스를 통해 데이터를 얻을 수 없지만, get_stale
을 통해 만료된 데이터를 얻을 수도 있습니다. 여기서 "가능"이라는 단어를 사용한 이유는 만료된 데이터가 차지하는 공간이 다른 데이터를 위해 재활용될 가능성이 있기 때문입니다. 이는 LRU
알고리즘입니다.
여기까지 보면 의문이 생길 수 있습니다: 만료된 데이터를 얻는 것이 무슨 소용이 있을까요? shared dict
에 저장된 것은 캐시된 데이터라는 것을 잊지 마십시오. 캐시된 데이터가 만료되었다고 해서 원본 데이터가 반드시 업데이트된 것은 아닙니다.
예를 들어, 데이터 소스가 MySQL에 저장되어 있다고 가정해 봅시다. MySQL에서 데이터를 가져온 후 shared dict
에 5초 동안 타임아웃을 설정합니다. 그런 다음 데이터가 만료되면 두 가지 선택지가 있습니다:
- 데이터가 존재하지 않을 때 MySQL을 다시 쿼리하고 결과를 캐시에 넣습니다.
- MySQL 데이터가 변경되었는지 확인합니다. 변경되지 않았다면 캐시의 만료된 데이터를 읽고 만료 시간을 수정하여 계속 유효하게 만듭니다.
후자는 더 최적화된 솔루션으로, MySQL과의 상호 작용을 최소화하여 모든 클라이언트 요청이 가장 빠른 캐시에서 데이터를 얻을 수 있도록 합니다.
이때, 데이터 소스의 데이터가 변경되었는지 어떻게 판단할지가 우리가 고려하고 해결해야 할 문제가 됩니다. 다음으로, lru
캐시를 예로 들어 실제 프로젝트에서 이 문제를 어떻게 해결하는지 살펴보겠습니다.
lru
캐시
lru
캐시에는 new
, set
, get
, delete
, flush_all
5개의 인터페이스만 있습니다. 위의 문제와 관련된 것은 get
인터페이스뿐입니다. 먼저 이 인터페이스가 어떻게 사용되는지 이해해 보겠습니다:
resty -e 'local lrucache = require "resty.lrucache"
local cache, err = lrucache.new(200)
cache:set("dog", 32, 0.01)
ngx.sleep(0.02)
local data, stale_data = cache:get("dog")
print(stale_data)'
lru
캐시에서 get
인터페이스의 두 번째 반환 값은 직접 stale_data
이며, shared dict
처럼 get
과 get_stale
두 개의 다른 API로 나뉘지 않습니다. 이러한 인터페이스 캡슐화는 만료된 데이터를 사용하는 데 더 친화적입니다.
실제 프로젝트에서는 일반적으로 버전 번호를 사용하여 다른 데이터를 구분하는 것을 권장합니다. 이렇게 하면 데이터가 변경된 후에도 버전 번호가 변경됩니다. 예를 들어, etcd에서 수정된 인덱스를 버전 번호로 사용하여 데이터가 변경되었는지 표시할 수 있습니다. 버전 번호 개념을 도입하면 lru
캐시를 간단히 2차 캡슐화할 수 있습니다. 예를 들어, 다음의 의사 코드는 lrucache에서 가져온 것입니다.
local function (key, version, create_obj_fun, ...)
local obj, stale_obj = lru_obj:get(key)
-- 데이터가 만료되지 않았고 버전이 변경되지 않았다면 캐시된 데이터를 직접 반환
if obj and obj._cache_ver == version then
return obj
end
-- 데이터가 만료되었지만 여전히 얻을 수 있고 버전이 변경되지 않았다면 캐시의 만료된 데이터를 직접 반환
if stale_obj and stale_obj._cache_ver == version then
lru_obj:set(key, obj, item_ttl)
return stale_obj
end
-- 만료된 데이터를 찾을 수 없거나 버전 번호가 변경되었다면 데이터 소스에서 데이터를 가져옴
local obj, err = create_obj_fun(...)
obj._cache_ver = version
lru_obj:set(key, obj, item_ttl)
return obj, err
end
이 코드에서 볼 수 있듯이, 버전 번호 개념을 도입함으로써 우리는 만료된 데이터를 완전히 활용하여 데이터 소스의 압력을 줄이고 버전 번호가 변경되지 않을 때 최적의 성능을 달성합니다.
또한, 위의 솔루션에서 키와 버전 번호를 분리하고 버전 번호를 값의 속성으로 사용하는 큰 최적화가 있습니다.
우리는 더 일반적인 접근 방식은 버전 번호를 키에 쓰는 것입니다. 예를 들어, 키의 값이 key_1234
인 경우입니다. 이 방법은 매우 일반적이지만 OpenResty 환경에서는 낭비입니다. 왜 그렇게 말할까요?
예를 들어 보면 이해할 수 있습니다. 버전 번호가 매분 변경된다면, key_1234
는 1분 후 key_1235
가 되고, 1시간 동안 60개의 다른 키와 60개의 값이 생성됩니다. 이는 Lua GC가 59개의 키-값 쌍 뒤의 Lua 객체를 회수해야 함을 의미합니다. 객체 생성과 GC는 더 빈번한 업데이트에서 더 많은 리소스를 소모합니다.
물론, 이러한 소모는 버전 번호를 키에서 값으로 이동함으로써 간단히 피할 수 있습니다. 키가 얼마나 자주 업데이트되든, 두 개의 고정된 Lua 객체만 존재합니다. 이러한 최적화 기술이 매우 기발하다는 것을 알 수 있습니다. 그러나 간단하고 기발한 기술 뒤에는 OpenResty의 API와 캐시 메커니즘을 깊이 이해해야 합니다.
요약
OpenResty의 문서는 비교적 상세하지만, 이를 비즈니스와 결합하여 최대의 최적화 효과를 내는 방법을 체험하고 이해해야 합니다. 많은 경우, 문서에는 단 한두 문장만 있을 수 있지만, 예를 들어 만료된 데이터와 같은 경우, 이는 큰 성능 차이를 가져올 수 있습니다.
그렇다면, OpenResty를 사용하면서 비슷한 경험이 있었나요? 여러분의 경험을 공유해 주시기 바랍니다. 또한 이 글을 공유하여 함께 배우고 성장할 수 있기를 바랍니다.