지금까지 Redis 는 주로 캐싱 용도로 많이 사용했고, 그외로 Pub/sub 처리, 실시간 인기순위 등에 사용했었다. 사실 실제 운영 환경에서 Redis 를 운용해본적은 없어서 조금 고민이 부족했던 부분이 많이 있었는데 실제 운영 환경에서는 어떤 고민들을 해야했고, 어떤 문제들이 발생하는지 공부한 내용을 정리한다.
1. 메모리 관리
Redis 는 태생적으로 메모리를 저장공간으로 사용하는 인메모리 데이터베이스로, 디스크를 이용하는 RDB 에 비해서 데이터 입출력 속도가 매우 빠르다. 그러나 성능이 좋은 만큼 비용은 훨씬 크다. 때문에 제한된 용량을 가지며 가격도 디스크에 비해 매우 비싸다.
기억 장치별 성능
최근에는 보조기억장치로 SSD(Solid State Disk) 를 주로 사용하는데 이는 하드 디스크보다 대략 100 배 빠른 성능을 보이기 때문에 메모리와의 성능 차이가 대폭 좁혀졌으나, 여전히 메모리보다는 100 ~ 1,000 배 정도 느린 수준이다. I/O 작업에 소요되는 시간을 러프하게 알아보면 HDD 이 수 ms 수준, SSD 이 수백 µs 수준, DRAM 은 수십 ns 수준이다.
데이터 I/O 속도에 차이가 나는 이유
HDD 의 경우 실제로 디스크(원판) 와 디스크 헤드를 사용하여 데이터를 저장한다. 그래서 데이터 공간에 접근하기 위해서는 축음기에 LP를 재생 시키는 것처럼 헤더와 디스크를 원하는 위치에 갖다놔야 했기 때문에 성능을 올리는데 있어 일종의 물리적한계가 있었다.
SSD 는 이와 다르게 랜덤 엑세스가 가능한 비휘발성 플래시 메모리를 사용한다. 플래시 메모리는 DRAM 과 마찬가지로 트랜지스터를 이용하여 데이터를 저장하는데, 저장된 데이터가 전원이 차단되어도 휘발되지 않는다는 중요한 차이가 있다. 이러한 차이는 플래시 메모리는 DRAM 처럼 게이트에 단순한 캐패시터 대신, Floating Gate 라는 게이트를 씀으로서 나타나는데, FG 는 절연성이 높은 산화막으로 절연되어 있어서 한번 전하가 주입되면 전하가 빠져나가지 않는 특성이 있다. (이러한 이유로 DRAM 과 다르게 SSD 는 전하가 절연막을 터널링하도록 고전압을 사용하며, 고전압에 의해 산화막이 점점 열화되기 때문에 내구성에서도 메모리와 차이를 보인다.) 여튼 이러한 구조적 차이 때문에 데이터의 읽고 쓰기 시 디바이스 컨트롤러를 거쳐 조금 더 복잡한 단계를 거치게 되고, 속도의 차이가 나타나게 된다.
이러한 이유로 Redis 가 쓸 수 있는 메모리 용량은 제한적일 수 밖에 없다. 만약 Redis 가 시스템에 할당된 메모리의 용량을 초과하여 메모리를 사용한다면 된다면 어떻게 될까? 두 가지 경우를 생각할 수 있겠다. 만약 시스템에서 swap 메모리 공간을 활성화하였다면 Redis가 점유하는 메모리의 일부가 디스크의 swap 영역으로 보내지게 되고, 해당 메모리 페이지에 대한 접근은 곧 디스크 IO 를 수반하게 된다. 즉, 인메모리 데이터베이스로서의 특성을 잃게 되며 기대한 성능이 나오지 않게될 것이다. 그런데 이러한 성능 저하가 우려된다고 swap 메모리 공간을 설정하지 않는다면 프로세스 자체가 OOM 으로 종료되어 모든 메모리 데이터를 잃게 될 위험이 있다.
이러한 상황을 방지하기 위해서 레디스에서는 maxmemory 라는 설정을 제공하여 레디스가 사용할 수 있는 최대 메모리를 제한하도록 하며, 만약 이 이상 메모리를 사용하고자 하면 eviction 이라는 기능을 통해 설정된 정책에 따라 가장 쓸모 없다고 판단되는 메모리를 퇴출시켜버린다.
하지만 maxmemory 를 물리 메모리 보다 낮게 설정했다고 하더라도, 메모리 단편화, 비동기적 메모리 해제, 운영체제의 메모리 관리 방식 등에 의해서 실제로 쓰는 메모리 양은 maxmemory 보다 더 크게 된다. (참고로 레디스는 jemalloc 이라는 메모리 할당자를 쓴다) 그래서 maxmemory 만 설정했다고 해서 마음 놓고 있을 것이 아니라 실제로 점유하고 있는 메모리 양을 모니터링하여 물리 메모리가 넘치지 않도록 관리하여야 한다.
memory 관련 지표들은 redis-cli 의 info memory 명령을 통해 확인 수 있다. (모니터링 툴로 더 쉽게 지표를 확인하고 시각화할 수도 있을 것 같다.) 주요한 지표들은 아래와 같다.
- used_memory : 레디스가 데이터 저장 및 내부 작업을 위해 할당한 총 메모리
- used_memory_rss : rss 는 Resident Set Size, 실제로 프로세스가 메모리에서 점유중인 크기
- mem_fragmentation_ratio : 메모리 단편화 비율로, 실제 점유중인 메모리 대비 레디스에서 사용하는 메모리 비율
- maxmemory : used_memory 의 허용 범위, used_memory 가 maxmemory 를 가득채우게 되면 eviction 이 시작됨
- maxmemory_policy : eviction 의 방식. 현재는 LRU 정책을 설정해놓았다.
(현재는 연습용 프로젝트의 레디스를 껐다 켜가지고 메모리 사용량이 적다.)
만약 메모리가 절대적으로 부족한 경우에는 더 돈을 써서 더 큰 메모리 인스턴스를 확보하거나 그게 여의치 않다면 메모리를 최대한 절약(?) 하고 메모리 파편화가 적도록 데이터 모델링을 수정할 필요가 있다.
메모리를 더 절약하는 대표적인 방법으로, Ziplist 라는 자료구조가 있다. 레디스의 컬렉션 중 Set, Sorted Set, Hash 같은 자료구조는 key-value 나 List 와 비교해서 더 많은 공간을 차지한다. 그런데 해당 자료구조는 원소의 수가 많은 경우에 더 효율적으로 연산을 하기 위한 구조로 만약 원소의 수가 작다면 List 자료구조를 사용하여 비슷한 연산속도를 가지면서도, 메모리 공간을 절약할 수 있게 된다. 이를 Ziplist 라고 하는데, 레디스 자체적으로 메모리 공간 절약을 위해, Set, Hash 가 일정 수준의 데이터 크기보다 작다면, Ziplist 를 대신 쓰도록 하는 기능을 제공하고 있다. 만약 메모리 공간이 부족하다면 Ziplist 를 사용하는 기준을 좀 더 늘여서 성능을 약간 희생하는 대신 공간을 더 확보할 수있다. 이는 hash-max-ziplist-entires / hash-max-ziplist-value 라는 설정값을 통해 변경이 가능하다. 기본 설정값은 아래와 같다.
이외에도 레디스의 데이터를 최대한 균일하게 하여 데이터 파편화를 줄이고, Expire 를 짧게 가져가서 쓸데 없는 메모리를 최대한 삭제해주는 등 여러 방편들이 있다. 이러한 것들은 알려진 best practice 가 있는 것 같진 않고 실무 레벨에서 경험을 통해 쌓아야 할 것 같다.
2. EXPIRE 설정
위에서 메모리가 부족한 경우 eviction 을 통해 정책에 따라 메모리를 추방하는 작업을 한다고 하였는데, 사실 이런 메모리 삭제는 보조적인 수단일 뿐이며, 메모리 공간을 최대한 효율적으로 사용하기 위해서는 EXPIRE (유효시간) 을 설정하여 데이터를 주기적으로 삭제하여 불필요한 데이터가 남아있지 않도록 해야한다. 게다가 eviction 은 어떤 key 를 지울지 선택하는 알고리즘에 의해 오버헤드가 발생하는 반면 expire 는 일반적으로 큰 부하를 주지 않는다. 기본적으로 이러한 Expire 작업은 100ms 에 한번씩 일어난다.
그런데 Expire 를 설정하는 경우에도 고려해야할 점이 있는데 대표적으로 아래 두가지 현상이다.
- 많은 키가 동시에 만료하는 경우 DB 부하가 일순간 급증함
- 요청이 몰리는 캐시 데이터가 Expire 되는 경우 다시 캐시가 갱신되는 사이에 요청이 DB 에 몰리면서 부하가 급증함. (특히 연산에 시간이 오래 걸리는 데이터)
첫번째는 키의 Expire 설정을 고르게 분산하는 방법으로 방지가 가능하다. 예를 들어 고정된 만료시간에 랜덤하게 오프셋을 추가하는 식으로 분산 시키거나 아예 랜덤한 Expire 를 가지도록 할 수도 있다. 데이터의 특성에 따라 다양한 선택지가 있을 것이다.
그러나 두번째 문제 상황은 단순히 데이터들의 Expire 를 고르게 설정한다고 해서 해결되는 문제가 아닐 것이다. 많은 요청이 몰리는 캐시 데이터가 만료되면 만료되는 데이터는 한개라고 해도 DB 에 순간적으로 요청이 폭증할 수 있기 때문이다. 만약 DB 에서 빠르게 응답이 가능한 경우 아니라면 요청은 더욱 증가한다. 이 현상은은 많은 요청들이 캐시를 look aside 한 후, 디비로 우르르 달려간다는 의미에서 캐시 스탬피드(Cache Stampede) 라고 한다.
이러한 현상은 연산에 오래걸리는 데이터의 Expire 시간을 길게 잡아서 최소화 할 수 있다. 그러나 Expire 시간은 결국 오기 때문에, 어플리케이션 레벨에서 여러가지 알고리즘을 통해 방지해야한다. 예를 들어 캐시가 요청 될 때 마다, 랜덤한 확률로 캐시를 갱신할 수 있다. (최근 논문에서 연산에 소요되는 시간을 인자로 하여 최적의 확률로 캐시를 갱신하는 알고리즘이 발표되기도 함)
아래에서 캐시가 만료 되었을 때 뮤텍스 락을 이용하여 하나의 스레드에서만 DB 접근을 허용하고, 다른 스레드는 해당 스레드가 캐시를 갱신하기를 기다렸다가, 캐시를 받아오는 방법을 소개한다. 수도 코드로 작성을 해보면 대강 아래와 같다
public String getData(String key) {
String data = redis.get(key);
if(Data != null){
// 캐시가 있는 경우 바로 반환
return data;
}
// 캐시가 없으면 락을 획득
boolean lock = tryGetLock();
if(lock){
try {
// 디비에서 데이터를 가지고와서 캐싱 처리
data = rdbRepository.find(key);
redis.set(key, data, expirationTime);
} finally {
// 락해제
releaseLock();
}
} else {
// 락을 획득하지 못한 경우 캐시 되기를 기다림
while(true){
Tread.sleep(delayTime);
data = redis.get(key);
if(data != null){
return data;
}
// 만약에 너무 늦어지는 경우 DB 요청을 하거나, 예외를 throw 하는 로직
}
}
}
우선 오늘은 캐싱 디비 관점에서 레디스의 운영상 고려할 점을 공부해 보았다. 레플리케이션, 샤딩 관련한 내용이나 레디스를 Persistent 하게 사용하기 위한 경우의 운영상의 고려사항은 다음 번에 다룰 예정이다.