Redis 란 무엇일까?

juhyeon·2020년 10월 2일
55

내가 Redis 에 대해 잘 모르고 있는 것 같아서 우아한 레디스 세미나 영상을 보고 공부한 것들을 정리해보았다 🤟🏻

REDIS는 다음 특징을 갖는 data structure 이다.
1. Remote 에 위치한
2. 프로세스로 존재하는
3. In-Memory : 메모리 기반의
4. “키-값” 구조 데이터 관리 시스템 : 비 관계형이며, 키-값 구조이기 때문에 별도 쿼리 없이도 데이터를 간단히 가져올 수 있다.

레디스는 위 특징을 가지고 크게 5가지 String, Set, Sorted Set, Hash, List 자료구조를 지원하는데,
서비스의 특성이나 상황에 따라 이걸 1) 캐시로 사용할 수도 있고, 2) Persistence Data Storage 로 사용할 수도 있다.

캐시를 사용할 때

서비스 사용자가 증가했을 때, 모든 유저의 요청을 DB 접근으로만 처리하게 된다면 DB 서버에 무리가 갈 수 밖에 없다. 물론 데이터베이스는 데이터를 디스크에 저장하기 때문에 서버의 장애와는 별개로 데이터를 유지할 수는 있지만, 요청이 증가하는 상황에서는 기존 성능을 기대하기 힘들다.

이런 맥락에서 캐시는 나중에 요청된 결과를 미리 저장해두었다가 빨리 제공하기 위해 사용한다.

이건 Factorial 개념과도 유사한데,
9999! 를 계산하고자할때 9998! 까지를 미리 계산해두고 어딘가에 저장해두었다면 그 다음부터 9999! 의 계산은 금방 처리할 수 있는 것과 같은 목적이다.
즉, 9998! 결과를 저장해두는 것과 아닌 것은 접근 속도부터가 다르기 때문에 캐시를 사용하고 있다.

보통 우리가 사용하는 Redis Cache 는 메모리 단 (In-Memory) 에 위치한다. 따라서 디스크보다 수용력(용량) 은 적지만 접근 속도는 빠르다.
요즈음 디스크는 SSD 를 많이 사용하고 있긴 하지만 그래도 디스크까지의 접근 속도는 메모리 단 보다 훨씬 느리되 충분한 용량이 확보된다는 장점도 있다.
이렇기 때문에 저장하려는 데이터 셋이 주어진 메모리 사이즈보다 크면 디스크는 쓰는게 낫다.

그럼 이 캐시는 어떻게 사용할까?

  • 일반적인 패턴 : Look aside cache
    이런 순서로 처리하는 방법이다.

    1. 웹 서버는 클라이언트 요청을 받아서, 데이터가 존재하는지 캐시를 먼저 확인한다.
    2. Cache 에 데이터가 있으면 그걸 꺼내주는데, 만약 없으면
    3. DB 에서 읽어서 -> 먼저 캐시에 저장한다음 클라이언트에게 데이터를 돌려준다.
  • Write Back
    데이터를 캐시에 전부 먼저 저장해놓았다가 특정 시점마다 한번씩 캐시 내 데이터를 DB insert 하는 방법이다.
    insert 를 1개씩 500번 수행하는 것보다 500개를 한번에 삽입하는 동작이 훨씬 빠름에서 알 수 있듯, write back 방식도 성능면에서 뒤쳐지는 방식은 아니다.
    하지만 어쨌든 여기서 데이터를 일정 기간동안은 유지하고 있어야 하는데, 이때 이걸 유지하고 있는 storage 는 메모리 공간이므로 서버 장애 상황에서 데이터가 손실될 수 있다는 단점이 있다. 그래서 다시 재생 가능한 데이터나, 극단적으로 heavy 한 데이터에서 write back 방식을 많이 사용한다.

Redis 는 어떤 특징을 가지고 있을까.

캐시로 많이 사용하는 MemcachedRedis 의 가장 큰 차이는 Collection 을 제공하냐의 여부이다. Redis 에서는 Collection 을 제공한다.

Collection 은 개발의 편의성과 난이도에서 이점을 볼 수 있다고 하는데, 제공해주는 것들이 많기 때문이다. 예를 들어보자.

  • 대상 사용자가 많은 경우 랭킹을 산출하는 서버를 구현하기

    • 이 때 디스크기반 storage 를 사용하게 된다면, 가져와야 하는 데이터 셋이 많아질수록 디스크 접근 횟수가 많아지므로 속도가 점점 느려질 수 밖에 없다.

    • Redis 의 Sorted Set 을 사용하면 랭킹 서버를 쉽게 구현 가능하며 replication 까지도 가능하다. 하지만 이렇게 제공하는 걸 가져다가 쓴다는 건 한계에 종속적이 되긴 한다.

  • 친구 리스트를 관리할 때 데이터를 key-value 형태로 저장해야 한다면

    • 같은 친구 리스트를 읽은 후, 서로 다른 클라이언트에서 리스트에 서로 다른 친구를 추가하고자 했다고 가정해보자.

    • 이런 상황에서 친구 리스트의 최종 상태는 -> 두 클라이언트가 추가한 사람 A, B 가 전부 반영되지 않을 수 있다.

    • 이런걸 race condition 이라고 하는데, 지금 회사에서 스터디중인 데이터 중심 애플리케이션 설계 #트랜젝션 파트에서 이걸 다루고 있길래 도움이 될만한 그림을 가져와봤다.

      그림속 상황에 매치시켜보면,
      친구리스트에 BC 를 동시에 추가하게 될 경우

      T1[친구 B 추가] -> T2[친구 C 추가] -> T1[B 추가한걸 최종상태에 반영(쓰기)] -> T2[C 추가한걸 최종상태에 반영(쓰기)]

      각 트랜젝션에서는 이런 순서로 로직을 처리하게 된다.
      그럼 우연한 타이밍에 3번째와 4번째 프로세스가 순서대로 진행되면서 리스트 덮어쓰기가 발생하고,
      최종 상태에서도 결국 context switching 때문에 T1 과 T2 둘 중 뭐가 먼저 발생할지 예측할 수 없기 때문에 친구리스트가 [A,B] or [A,C] 랜덤으로 유지될 수 있다.

Redis 자료구조는 Atomic 하다는 특징 때문에 이런 race condition 을 피할 수 있다. 즉, Redis Transaction 은 한번의 딱 하나의 명령만 수행할 수 있다. 이에 더하여 single-threaded 특성 을 유지하고 있기 때문에 다른 스토리지 플랫폼보다는 이슈가 덜하다고 한다.
하지만 이 특징이 더블클릭 같은 동작으로 같은 데이터가 2번씩 들어가게 되는 불상사는 막을 수 없기 때문에 별도 처리가 필요하다.

따라서 레디스는 remote data storage 로서 여러 서버에서 같은 데이터를 공유하고 보고싶을 때 많이 사용한다. 그래서 우리는 인증 토큰을 저장하거나 유저 API limit 을 두는 상황 등에서 레디스를 많이 사용하고 있다.

Redis Collection

Redis 가 다양한 자료구조 (Collection) 를 지원함으로서 개발자는 비즈니스 로직에만 집중할 수 있게 되었다.

Redis Collection 에서 지원하는 자료구조 몇개를 간단히 정리해보았다.

  1. String : 가장 일반적인 형태로, key - value 로 저장하는 형태이다.
  2. Set : 중복된 데이터를 담지 않기 위해 사용하는 자료구조이다. 중복된 데이터를 여러번 저장하면 최종 한번만 저장된다.
    이걸 사용했을 때 모든 데이터를 전부 다 갖고올 수 있는 명령이 있으므로 주의해서 사용해야 한다.
  3. List : Array 형식의 데이터 구조이다. List 를 사용하면 처음과 끝에 데이터를 넣고 빼는건 속도가 빠르지만 중간에 데이터를 삽입하거나 삭제하는건 어려움이 있다.
  4. Sorted Set : 유저 랭킹 보드서버 같은 구현에서 사용할 수 있다. 그럼 이때 데이터 삽입은 ZADD key 점수 value 명령어로 수행하고 정렬된 값은 zrange 를 통해 가져올 수 있다.

Collection 은 분명 편리하지만 사용할 때 주의할 점도 있다.

  • 하나의 컬렉션에 너무 많은 아이템을 담으면 좋지 않다.
    가능하면 10000개 이하의, 몇천개 수준의 데이터셋을 유지하는게 Redis 성능에 영향주지 않는다.

  • Expire 은 Collection 의 아이템 개별로 걸리지않고, 전체 Collection 에 대해서만 걸린다.
    즉, 10000 개의 아이템을 가진 Collection 에 expire 가 걸려있다면, 그 시간 이후에 10000 개의 아이템이 모두 삭제된다.

Redis 운영하기

1. 메모리 관리를 잘 하자

Max Memory : Redis 가 아는, 자기가 사용하는 메모리

레디스는 메모리 할당이나 해제같은 관리에 메모리 풀을 사용하지 않는다. Memory Allocate 의 구현에 따라서 레디스 성능이 왔다갔다 할 수 있다.

게다가, 메모리 파편화 때문에 Max Memory 를 설정하더라도 이보다 더 사용하게 될 수도 있으므로 별도 관리가 필요하다.
이게 무슨 말인지 좀 더 설명해보겠다.

메모리 페이지 사이즈가 4096 일때, 우리가 1byte 만 달라고 요청하더라도 실제로 jemalloc 은 4096 byte 를 가져와야한다. jemalloc메모리를 페이지 단위로 반환하기 때문이다.

따라서 만약 여기서 또 내가 + 4096 byte 를 더 달라고 요청하면? 나는 실제로는 4097 만 쓰고있지만, 8K 만큼을 할당받아서 사용하고 있는 꼴이 된다.

메모리 파편화 가 바로 이런 현상인데, 4.x 대부터 메모리 파편화를 완화시키고자 jemalloc 에 힌트를 주는 기능이 들어갔었다. 하지만 jemalloc 버전에 따라 다르게 동작할 수 있기 때문에 확신을 주는 기능은 아니라고 한다.

그럼 Redis 를 운영하며 메모리 파편화를 좀 덜 일으킬 수 있는 방법은 없을까?

메모리 파편화를 좀 완화시키고 싶다면,

  • 다양한 사이즈를 가지는 데이터보다는 유사한 크기의 데이터를 가지는 경우가 유리하다. 그래서 메모리를 유사한 크기로 두고 관리해주어야 한다.

  • 큰 메모리를 사용하는 instance 하나보다는 적은 메모리를 사용하는 instance 여러개가 안전하다. 관리는 귀찮을 수 있지만 운영의 안정성은 높을 수 있다.

두 방법을 접목시키면, 결국 이런 식으로 표현할 수 있다.

24 GB instance < 8GB Instance * 3

Redis는 쓰기 요청이 발생하면 copy on write 방식으로 작동한다. 따라서 쓰기 작업을 시작하는 순간 필연적으로 fork() 를 수행해서 갱신할 메모리 페이지를 복사한 후 쓰기 연산하는 구조이다. 당연히 여기서 최대 메모리를 2배 까지 쓰게 될 수 있다. 이건 우리가 셋팅하는 maxmemory 설정 의도와 조금 다르게 동작하는 부분이므로 주의가 필요하다. 또, 참고로 read 수행에는 메모리 복사가 발생하지 않는다.

Redis 의 쓰기연산이 이렇게 동작하고 있기 때문에 위와 같이 인스턴스를 쪼개서 운영하고 관리하는 게 좋다.
8GB Instance 를 썼을때 write → fork() 하면 32GB Instance (8GB * 3 + 8GB) 가 되는데, 24GB Instance 1개 였을때 fork() 하면 최종적으로는 48GB 의 메모리를 써야한다.

그럼 메모리가 부족할때는 어떤 조치를 하면 좋을까.

이럴 땐 좀 더 메모리 공간이 여유로운 장비로 마이그레이션이 필요한데, 메모리가 빡빡하면 마이그레이션 중에도 문제가 발생할 수도 있다. 따라서 메모리를 줄이기 위한 설정들이 있는지 정리해보았다.

  • 메모리 줄이기 위한 설정들
    • HashHashTable 을 하나 더 사용한다.
    • Sorted SetSkiplistHash Table 을 둘 다 사용한다. 값으로도 찾아야하고, 인덱스로도 찾아야하므로.
    • SetHash Table 을 사용한다.

하지만 SkiplistHash Table 자료구조도 내부적으로 동작하는 메모리 단편화나 포인터 같은게 있기 때문에 결과적으로는 메모리를 적지 않게 사용한다. 따라서 다른 대안도 생각해 볼 만한데,
만약 1개 컬렉션에 데이터가 많다면 ziplist 를 사용하는게 속도는 조금 느려지지만 메모리는 적게 쓰는 방법이다. 게다가 원래 쓰는 자료구조 대신 내부적으로 ziplist 를 쓰도록 간단히 설정만 바꿔줄 수도 있다.

  • zip list 를 쓸 수 있는 이유
    in-memory 특성 상, 적당한 사이즈의 데이터까지는 특정 알고리즘을 안쓰고 그냥 풀서치(선형 탐색)를 해도 빠르다. 실제로 zip list 를 쓴 것과 안쓴것의 메모리 사용량은 2-30% 정도 차이가 난다고 한다.

2. O(N) 관련 명령은 주의하자

Redis 는 Single Threaded 이다. 따라서 레디스가 동시에 처리할 수 있는 명령 갯수는 한번에 1개이다. 하지만 생각만큼 그렇게 느린건 아닌게, 단순한 get / set 의 경우, Redis 는 초당 10만개를 처리할 수 있다고 한다.

하지만 주의할 점도 있는게, single-threaded 이기 때문에 처리 시간이 긴 명령어가 들어오면 그 뒤 명령어들은 전부 대기가 필요하다. 즉, 만약 1개에 1초가 걸리는 작업을 하게 되버리면? 최악의 경우 99999 개의 명령은 1초동안 그냥 대기해야 하는 것이다. 이런건 99999개의 타임아웃이 발생할 수 있는 상황이다.

그럼 레디스가 프로세스를 어떻게 처리하는지 알아보겠다.

TCP 에서는 패킷이 끊어져서 올 수 있다. 이럴때 패킷이 들어와서 명령 하나를 실행시키는 과정이 어떻게 수행되는지 정리하였다.

  1. 패킷 하나가 들어오면 processInputBuffer 에서 패킷을 하나의 command 로 만든다.
  2. command 가 완성됐는지 확인하고
  3. command 가 완성되었으면 processCommandAndReset 이라는 걸 해서 다시 타고 들어온 후
  4. 그 시점에 완성된 command 하나를 그냥 실행시켜버린다.

따라서 이런 상황에서 해당 패킷 하나가 처리되는 동안 뒤의 패킷은 아무것도 못하고 그냥 쌓이는 것이다. 이후 패킷 처리가 완료되어 루프를 탈출해야만 다시 그 다음 명령 (packet -> command) 들을 처리할 수 있다.

이러한 동작 과정에서 알 수 있듯 Redis 는 한번에 하나의 명령만 실행할 수 있기 때문에 긴 처리시간을 요하는 명령어를 쓰면 불리하다. 그럼 보통 '긴' 명령이 될 수 있는, 대표적인 O(N) 명령들은 무엇이 있을까.

  • KEYS : 모든 아이템을 순회하는 명령이다. 하지만 아이템이 많아지면 서버에서 exception 을 트리거하니 주의해야한다.
    예를 들어, Key 가 백만개 이상인데 확인을 위해 KEYS 명령을 사용하는 경우는 결국 모니터링 스크립트가 일초에 한번씩 이걸 호출하게 되는 것이다. 😭

  • FLUSHALL, FLUSHDB : 데이터를 다 날린다.

  • Delete Collections : Collection 내 아이템을 전부 삭제하는 명령이다. 예를 들어 100만개 아이템 삭제를 의도했다면, 이 명령을 처리하는 데에만 1~2초 정도 걸리므로 이 시간동안 아무것도 못하게 된다.

  • Get All Collections : 10만개를 매번 다 가져온다면? 당연히 느려진다.

그럼 KEYS 는 어떻게 대체하는게 좋을까?

KEYS 대신 scan 명령을 사용하는 것으로 하나의 긴 명령을 짧은 여러번의 명령으로 바꿀 수 있다. 이 짧은 명령들 텀 사이에 다른 get / set 같은 명령들을 또 실행시킬 수 있다. 이 사이에 굉장히 처리를 잘 시켜준다.

같은 맥락으로, Collection 의 모든 아이템을 다 가져와야할때는 Collection 의 일부만 가져오거나 (Sorted Set), 큰 컬렉션을 다른 여러개의 컬렉션으로 나누어서 저장한다. 1개당 몇 천개 수준으로 저장해야 좋다.

Redis Replication

Redis Replication 은 Async Replication 이기 때문에 Replication Lag 이 발생할 수 있다.

  • Replication Lag : AB 가 서로 연계된 서비스인 경우, A 에 있는 데이터가 바뀌었다고 해보자. 이때 AB replication 이 발생하기 직전의 틈이 발생할 수 있다. 부하에 따라서는 이 틈이 커질 수 있는 이슈가 발생할 수 있다.

그럼 primary nodesecondary node 사이의 replica 관계 설정은 어떻게 할까?

secondaryReplicaofslaveof 명령을 통해 설정할 수 있다. 이 때에는 master (primary) 의 host | ip, port 를 전달해야 한다.
이 상황에서는 내부적으로 secondaryprimarysync 명령을 전달한다.그럼 이 때 primary node 는 현재 메모리 상태를 저장해서 주기 위해 fork() 를 수행하여 secondary node 에 전달하는데, 이게 모든 이슈의 근원이 된다.

redis replication 은 DBMS 로 따지자면 row 기반 replication 보다는 statement replication 과 유사하다. 즉, 쿼리 단위로 전송하는 개념인데, 이렇기 때문에 NOW() 같은 nondeterministic 명령을 전송하면 primary 와 secondary 에서 다른 결과를 낳게 될 수 있다.

이 뿐 아니라, redis replication 은 이런 특징들을 주의해야 한다.

  • replication 과정에서 fork() 가 발생하므로 메모리 부족이 발생할 수 있다.
  • Redis-cli —rds 명령은 현재 상태의 메모리 스냅샷을 가져오므로 같은 문제를 발생시킨다.
  • AWS 와 같은 클라우드의 Redis 는 좀 다르게 구현되어서, fork 없이 replication 하기 때문에 좀 느리지만 해당 부분이 안정적이다.
  • 다수의 redis 서버 각각이 많은 replica 를 두고 있으면 네트워크 이슈 등으로 동시에 replication 이 재시도되도록 했을 때 문제가 발생할 수 있다.

Redis Cluster


HA 는 기본적으로 오리지널 redis 와 같다. primary 가 죽으면 secondaryprimary 로 승격되는 구조를 채택한다.
그리고 primary 1 데이터가 변경되면 secondary 1 만 같이 변경되는데, 승격도 이런식으로 합의되어 있다.

Redis Cluster 에서는 primary 마다 slot range 가 할당된 채로 분배되어 있다. 이 때 primary 는 자기 slot 에 해당되는 키가 클라이언트로부터 도착하면(set Slot0_key abc), ok 응답 후 저장한다.
하지만 요청은 받았지만 자기 슬롯에 해당하는 키가 아니면, -MOVED 에러를 송출한 뒤 해당되는 다른 primary 노드를 지정한 채로 다시 응답을 보낸다. 그럼 클라이언트는 다시 응답받은 primary 노드로 요청을 전송해야 한다.
이런 처리들도 결국 깔끔하게 구현하기 위해선 라이브러리가 필요하다.

이런 Redis Cluster 는 장단점을 정리하자면 다음과 같다.

  • 장점
    • 자체적인 Primary / Secondary Failover (죽으면 자동으로 승격처리 된다.)
    • Slot 단위의 데이터 관리 : 이 키는 어디로 보내라 ~ 라는 명시적인 관리가 가능하다.
  • 단점
    • 메모리 사용량이 더 많다.
    • Migration 자체는 관리자가 시점을 결정해야 한다.
    • 라이브러리 구현이 필요하다

Redis Failover

Redis Failover 는 크게 세가지 종류가 있다.
1) Redis cluster failover
2) Coordinator 기반 failover
3) VIP | DNS 기반 failover

이 중 1) Redis cluster failover 는 위에서 봤었으니, 이번엔 2) Coordinator 기반 failover3) VIP | DNS 기반 failover 을 살펴보겠다.

Coordinator 기반 Failover

Coordinator 기반 failover 패턴에서는 zookeeper , etcd, consul 등의 코디네이터를 사용해서 정보를 저장하고 관리한다.

대략적으로 이런 과정으로 Health Checking 과 Failover 가 진행된다.

  1. Redis#1 사용을 공지받은 후 API 서버들은 Redis#1 만 사용하고 있는 상황이다. 그러다가 Redis#1 이 죽으면
  2. Health Checker 가 죽음을 감지하고 Redis#2 를 Primary 로 승격시킨다.
  3. Health Checker 는 coordinator 에 current redis 가 #2 라고 업데이트 한다.
  4. 코디네이터의 notification 같은 기능으로, API 서버에 current redis 가 #2 라고 알려준다. 이벤트 발생에 대해 알려주는 개념이다.
  5. 어플리케이션도 이제 #2 와 통신하게 된다.

이 Failover 방법은 코디네이터 기반으로 설정을 이미 관리하고 있다면 동일한 방식으로 쭉 관리가 가능하지만, 그게 아니라면 해당 기능을 이용하도록 별도 개발이 필요하다.

VIP or DNS 기반 Failover

VIP 기반 Failover

VIP 기반 Failover 로직은 레디스 서버마다 Vertual IP 가 할당되어 있고, API Server 는 특정 VIP 를 가지고 있는 레디스에만 접속하는 방법이다.
예시가 될 만한 그림을 보자.

여기서 API 서버는 10.0.1.1VIP 로 가지고 있는 레디스에만 접속한다.

그렇게 Redis#1 에만 접근하다가 #1 이 죽어서 접속이 안되면 Health Checker 는 그걸 감지하고 #2Primary 노드로 승격시킨다.
그 다음 Health CheckerVIP 10.0.1.1 을 #2 로 할당한다. (VIP 는 계속 바뀔 수 있다.)

따라서 그때부터 #2 와 통신이 가능하다.
그 다음 이제 Health Checker#1 에 있던 기존 연결을 모두 끊어주어서 클라이언트의 재접속을 유도한다.

DNS 기반 Failover

DNS 기반 Failover 전략도 VIP 방식과 같은 로직을 따르지만 도메인을 할당한다는 차이만 존재한다.

VIP 와 DNS Failover 방식은 결국 코드를 바꾸는게 아니기 때문에 다른 서비스에서도 쓸 수 있다. 하지만 기존 연결을 끊어주는 시간이 존재하기 때문에 trade off 를 감수해야한다는 특징은 있다.

그럼 VIP 기반과 DNS 기반의 Failover 둘 중에선 어떤 방법을 택하는게 나을까?

어떤 솔루션들은 DNS 를 캐싱해버린다. 따라서 이런경우 health checker 가 무언가를 바꾸어도 요청이 제대로 가질 않을 수 있다. 따라서 사용하는 언어별 DNS 캐싱 정책을 잘 알아야 하며, 또 사용하는 툴에 따라서는 한 번 가져온 DNS 정보를 다시 호출하지 않는 경우도 존재하므로 이슈가 발생할수도 있다.
이런 문제는 VIP 를 사용하면 겪을 일이 없다. 하지만 DNS 는 그냥 DNS 서버만 바꿔주면 되니까 좀 더 싼 비용으로 해결이 가능하다는 장점이 있다.

Reference

profile
Just do it~ 😎

7개의 댓글

comment-user-thumbnail
2020년 11월 4일

잘 읽고 갑니다!!! 좋은 글 감사합니다 😊

답글 달기
comment-user-thumbnail
2021년 2월 27일

강좌도 같이 보고왔습니다. 좋은 글 감사합니다.

답글 달기
comment-user-thumbnail
2021년 4월 16일

잘 보았스빈다

답글 달기
comment-user-thumbnail
2023년 2월 27일

Agree. Thanks for the useful article. Well done.
My Sutter Online

답글 달기
comment-user-thumbnail
2023년 6월 14일

Thank you so much for sharing all this wonderful information It is so appreciated!! You have good humor in your blogs. So much helpful and easy to read!
https://www.mycenturahealth.vip/

답글 달기
comment-user-thumbnail
2023년 6월 19일

Your post is really an amazing piece to read. Just now, I gave it a careful read. Many thanks for the time and effort you put into providing us with such vital information.

https://www.flyingtogether.website/

답글 달기
comment-user-thumbnail
2023년 6월 21일

Your generosity in providing all this valuable information is greatly appreciated. The tone of your blog posts is humorous. Excellent resource, and very readable.

https://www.aimproviderportal.online/

답글 달기