이 글은 NHN Cloud의 Redis 야무지게 사용하기 를 정리한 글 입니다.
먼저 캐싱이란? 사용자의 입장에서 데이터의 원래 소스보다 더 빠르고 효율적으로 액세스 할 수 있는 임시 데이터 저장소이다.
동일한 데이터에 대해 반복적으로 액세스하는 상황에 캐시를 사용하는 것이 좋다. 즉, 데이터의 재사용 횟수가 1회 이상이여야 캐시가 의미있게 된다. 또한, 잘 변하지 않는 데이터일 수록 캐시를 사용하는게 더 효율적이다.
캐시로서의 redis는 가장 널리 사용되는 캐싱 솔루션이다.
redis를 캐시로 사용할 때 어떻게 배치하느냐에 따라 시스템의 성능에 큰 영향을 끼친다. 이를 캐싱 전략이라고도 부르며, 캐싱 전략은 데이터의 유형과 해당 데이터에 대한 액세스 패턴을 잘 고려해서 선택해야 한다.
먼저 애플리케이션에서 데이터의 읽는 작업이 많을 때 사용하는 전략을 살펴보자.
이 구조는 redis를 캐시로 쓸 때 가장 일반적으로 사용되는 방법이다. 애플리케이션은 데이터를 찾을 때 캐시에 먼저 확인하고 캐시에 데이터가 존재하면 데이터를 가져오는 작업을 반복한다.
만약, 캐시에 데이터가 존재하지 않아 cache miss가 일어나게 되면 애플리케이션은 DB에 접근해서 직접 데이터를 가지고 온 뒤, 캐시에 저장 및 반환하는 작업을 한다.
이 구조는 redis가 다운되더라도 바로 장애로 이어지지 않고, DB에서 데이터를 가지고 올 수 있다.
여기서 주의할 점은 하지만 캐시에 붙어있던 커넥션이 많았다면, 그 커넥션이 모두 DB로 붙기 때문에 DB에 갑자기 많은 부하가 몰릴 수 있다. 그래서 이런 경우 캐시를 새로 투입하거나, redis가 다운되어 DB에만 새로운 데이터를 저장했다면 처음에 cache miss가 많이 발생해서 성능에 저하를 가지고 올 수 있다.
이럴 때 미리 DB에서 캐시로 데이터를 밀어넣어주는 작업을 할 수 있는데, 이를 cache warming
이라고 한다.
Write-Around
전략은 DB에만 데이터를 저장한다. 일단 모든 데이터는 DB에 저장되고 cache miss가 발생한 경우 캐시에 데이터를 적재한다. 이 경우 캐시 내의 데이터와 DB 내의 데이터가 다를 수 있다는 단점이 있다.
Write-Through
전략은 DB에 데이터를 저장할 때 캐시에도 함께 저장하는 방법이다. 캐시는 항상 최신 정보를 가지고있다는 장점이 있지만, 저장할 때 마다 두 단계의 step을 거쳐야 하기 때문에 상대적으로 느리다고 볼 수 있다. 또한 저장하는 데이터가 재사용되지 않을 수 도있는데 무조건 캐시에 넣어버리기 때문에 일종의 리소스 낭비라고도 볼 수 있다.
따라서 이렇게 데이터를 저장할 때는 몇 분, 혹은 몇 시간동안만 데이터를 보관하겠다는 의미인 expire time
을 설정해주는 것이 바람직하다.
redis는 자체적으로 많은 자료구조를 제공하고 있다.
특정 상황에서 어떻게 자료구조를 효율적으로 사용할 수 있는지 알아보자.
String
의 Increment 함수를 사용하면 아주 간단하게 카운팅 할 수 있다.
위 예시를 살펴보자. score:a에 10이라는 값을 저장 한 후 INCR
함수를 사용하면 1이 증가한다. INCRBY
사용하면 직접 카운트할 개수를 지정해 그 값만큼 증가한다.
Bit를 이용하면 저장 공간을 굉장히 절약할 수 있다.
예를 들어, 우리 서비스의 오늘 접속한 유저 수를 세고 싶을 때 날짜 키 하나를 만들어 놓고 유저ID에 해당하는 bit를 1로 올려주면 된다. 한 개의 비트가 한 명을 의미하므로 천만 명의 유저는 천만 개의 비트로 표현할 수 잇고, 천만 개의 비트는 곧 1.2MB밖에 차지하지 않는다.
예제에서 처럼 SETBIT로 비트를 설정할 수 있고 BITCOUNT를 통해 1로 설정된 값을 모두 카운팅 할 수 있다.
하지만 이 방법을 이용하려면 모든 데이터를 정수로 표현할 수 있어야 한다.
즉, 유저ID 같은 값이 0이상의 정수 값일 때만 카운팅이 가능하며, 그런 sequential한 값이 없을 때에는 이 방법을 사용할 수 없다.
마지막으로 알아볼 카운팅 방법은 HyperLogLogs를 사용하는 것이다. HyperLogLogs는 모든 String 데이터 값을 유니크하게 구분할 수 있다. HyperLogLogs는 set과 비슷하지만 저장되는 데이터 개수와 저장되는 값이 몇백만, 몇천만 건 이든 상관없이 모든 값이 12KB로 고정되어 있기 때문에 대량의 데이터를 카운팅 할 때는 훨씬 더 유용하게 사용된다.
예를 들어, 우리 웹사이트에 방문한 IP가 유니크한게 몇 개가 되는지 혹은 하루종일 크롤링한 URL의 개수가 몇 개인지, 우리 검색 엔진에서 검색된 유니크한 단어가 몇 개가 되는지 등등 유니크한 값을 계산할 때 아주 적절하게 사용할 수 있다.
PFADD 커맨드로 데이터를 저장하고 PFCOUNT 커맨드로 유니크하게 저장된 값을 조회할 수 있다.
만약 일별로 데이터를 저장했는데 일주일 치를 취합해서 보고싶다면 PFMERGE 커맨드로 키들을 머지해서 확인할 수 있다.
Lists는 메세지 큐로 사용하는데 적합하다. 특히 자체적으로 blocking 기능을 제공하기 때문에 이를 적절히 사용하면 불필요한 polling 프로세스를 막을 수 있다.
클라이언트 A가 BRPOP 커맨드를 통해 myqueue에서 데이터를 꺼내오려하는데 현재 리스트 안에는 데이터가 없어 대기를 하고 있는 상황이다.
이 때 클라이언트 B가 hi라는 값을 넣어주면 클라이언트 A에서 바로 이 값을 확인할 수 있다.
또한, LPUSHX나 RPUSHX같은 커맨드를 사용하면 키가 존재할 때만 Lists에 데이터를 추가하는데 이 기능도 잘 사용하면 굉장히 유용하게 사용할 수 있다.
키가 이미 존재한다면 예전에 사용했던 큐라는 것이고, 사용했던 큐에만 메세지를 넣어줄 수 있기 때문에 비효율적인 데이터의 이동을 막을 수 있다.
인스타그램, 페이스북, 트위터 등과 같은 SNS에는 각 유저별로 타임라인이 존재하고 그 타임라인에 내가 팔로우한 사람들의 데이터가 보여지게 된다. 트위터에서는 각 유저의 타임라인에 보일 트윗을 캐싱하기위해 redis의 Lists를 사용하는데 이 때, RPUSHX 커맨드를 사용하여 트위터를 자주 이용하던 유저의 타임라인에만 새로운 데이터를 미리 캐시해 놓을 수 있다. 자주 사용하지 않는 유저는 캐싱 키 자체가 존재하지 않기 때문에 자주 사용하지 않는 유저를 위해 데이터를 미리 쌓아놓는 비효율적인 작업을 방지할 수 있다.
stream은 로그를 저장하는 가장 적절한 자료구조라고 볼 수 있다. 실제 서버에 로그가 쌓이는 것처럼 모든 데이터는 append-only 방식으로 저장되며 중간에 데이터가 변경되지 않는다.
예제에서는 XADD 커맨드를 이용해 mystream이라는 키에 데이터를 저장한다. 키 이름 옆에 *
는 ID 값을 의미하며 ID 값을 직접 저장할 수도 있지만 일반적으로 *
로 입력하면 redis가 알아서 저장하고 ID 값을 반환해준다.
반환되는 ID 값은 데이터가 저장된 시간을 의미한다. 이 값 뒤로는 hash처럼 key-value 쌍으로 데이터가 저장되는데, 예제에서는 sensor-id 값에 1234를 온도에는 19.8을 저장한 것을 의미한다.
redis는 In-memory 데이터 스토어이다. 모든 데이터가 메모리에 저장되어 있기 때문에 서버나 redis 인스턴스가 재시작되면 모든 데이터는 유실된다.
복제 구조를 사용하고 있더라도 데이터 유실에 있어 안전하다고 볼 수는 없다.
코드상의 버그가 있거나 혹은 휴먼 에러로 데이터를 날린 경우에는 바로 복제본에도 똑같이 적용되기 때문에 이런 경우에는 데이터를 복원할 수 없다.
따라서 redis를 캐시 이외의 용도로 사용한다면 적절한 데이터의 백업이 필요하다.
redis에서는 데이터를 영구적으로 저장하는 두 가지 방법을 제공한다.
예제를 살펴보면 AOF에는 key1에 a가 저장되었다가 apple로 변경되었고, key2가 들어왔다가 삭제된 기록 모두 남아있다. 하지만 RDB에는 key1이 apple인 값만 남아있다.
AOF 파일은 Append Only하게 동작하기 때문에 데이터가 추가되기만 해서 대부분 RDB 파일보다 커지게 되기 때문에, AOF 파일은 주기적으로 압축해서 재작성되는 과정을 거쳐야 한다.
RDB와 AOF 파일 모두 커맨드를 이용해 직접 파일을 생성할 수 있으며, 원하는 시점에 파일이 자동 생성되도록 설정할 수도 있다.
redis의 아키텍처는 크게 3가지로 나눌 수 있다.
단순히 복제만 연결된 상태를 말한다.
자동 페일오버 가능한 HA 구성 (High Availability)
스케일 아웃과 HA 구성 (High Availability)
redis는 싱글 스레드로 동작하기 때문에 한 사용자가 오래 걸리는 커맨드를 실행한다면 나머지 모든 요청들은 수행하지 못하고 대기하게 된다. 이로 인한 장애도 빈번하게 발생한다.
NO
ALLKEYS-LRU
NOEVICTION
: 기본값. 메모리가 가득 차면 더 이상 레디스는 새로운 키를 저장하지 않는다. 새로운 데이터를 입력하는게 불가능하기 때문에 이는 장애 상황으로 발생할 수 있다. VALATILE-LRU
: 가장 최근에 사용하지 않았던 키부터 삭제한다. 이 때 expire 설정이 있는 key값만 삭제한다. (만약 메모리에 expire 설정이 없는 key만 남아있다면 위와 같은 장애가 발생할 수 있다.)ALLKEYS-LRU
: 모든 키에 대해 LRU 방식으로 키를 삭제한다. 이 설정에서는 적어도 데이터가 가득 참으로 인한 장애가 발생할 가능성은 없다. 대규모 트래픽 환경에서 TTL(저장된 데이터의 만료시간)값을 너무 작게 설정한 경우 cache stampede 현상이 발생할 가능성이 존재한다. 1장에서 봤던 look-aside 패턴에서는 redis의 데이터가 없다는 응답을 받은 서버가 직접 DB로 데이터를 요청한 뒤 다시 redis에 저장하는 과정을 거친다. 하지만 키가 만료되는 순간 많은 서버에서 이 키를 같이 보고 있었다면? 모든 애플리케이션 서버들이 DB에 가서 같은 데이터를 찾게되는 duplicate read가 발생한다. 또 읽어온 값을 redis에 각각 write하는 duplicate write도 발생하게 된다. 이러한 상황을 Cache Stampede라고 한다. 이는 굉장히 비효율적인 상황이고, 이런 상황이 발생하면 처리량도 느려질 뿐 아니라 불필요한 작업들이 늘어나 장애로까지 이어질 수 있다.
redis의 데이터를 파일로 저장할 때 포크를 통해 자식 프로세스를 향상한다. 자식 프로세스로 백그라운드에서는 데이터를 파일로 저장하고 있지만, 원래 프로세스는 계속해서 일반적인 요청을 받아 데이터를 처리한다. 이게 가능한 이유는 copy on write라는 기능으로 메모리를 복사해서 사용하기 때문이다. 이로 인해 서버의 메모리 사용률은 2배로 증가하는 상황이 발생할 수 있다. 만약에 데이터를 영구 저장하지 않는다고 해도 복제 기능을 사용하고 있다면 주의해야 한다. 복제 연결을 처음 시도하거나, 혹은 연결이 끊겨 재시도를 할 때에 새로 RDB 파일을 저장하는 과정을 거치기 때문이다. 따라서 이런 경우에는 MAXMEMORY 값은 실제 메모리의 절반 정도로 설정해 주는 것이 바람직하다. 예상치 못한 상황에 메모리가 가득차서 장애가 발생할 가능성이 매우 높기 때문이다.
redis는 메모리를 사용하는 저장소이기 때문에 메모리 관리가 운영에서 제일 중요하다. 모니터링할 때도 유의해야할 점이 있는데 모니터링할 때 used_memory값이 아닌 user_memory_rss값을 보는게 더 중요하다. used_memory는 논리적으로 레디스에 얼만큼의 데이터가 저장되어 있는 지를 나타내고 used_memory_rss는 OS가 실제로 redis에 얼만큼의 메모리를 할당했는지를 보여준다. 따라서 실제 저장되어 있는 값은 적은데 rss 값은 큰 상황이 발생할 수 있고, 이 차이가 클 때 fragmentation이 크다고 할 수 있다. 주로 삭제되는 키가 많을 때 fragmentation이 증가한다. 예를 들어, 특정 시점에 키가 피크를 치고 다시 삭제되는 경우 혹은 TTL로 인해 삭제가 과도하게 많을 경우에 발생한다.
위 그래프는 피크를 친 시점에 그래프인데 초록색 used 그래프는 확 내려간 반면 노란색 rss 그래프는 아직 많이 차있는 것을 볼 수 있다.
이때 activefrag
라는 기능을 잠시 켜두면 도움이 된다. 실제 공식 문서에서도 이 값을 항상 켜두는 것보다는 단편화가 많이 발생했을 때 켜두는 것을 권장하고 있다.