
Redis가 빠른 이유가 무엇인가 라는 질문에
"읽는 속도가 빠른 메모리에 데이터를 두기 때문입니다"라고 대답했다면
틀린 것은 아니지만 절반만 맞았다.
인메모리라서 빠른 건 Memcached도 마찬가지며 단순 HashMap을 서버 프로세스 위에 얹어도 메모리에 대한 접근은 빨라진다.
그럼 더 고도화된 질문을 해보자.
메모리 접근이 빠른건 당연한거고, Redis는 그 빠른 접근을 초당 10만에서 수십만건(QPS) 수준의 처리량과 마이크로초(μs) 단위 레이턴시로 동작하는가?
이 대답을 위한 정보들을 한번 정리를 해보았다.
Redis의 커맨드 실행 자체는 싱글 스레드이다.
왜 멀티코어를 안하고 싱글코어를 한거지? 멀티코어가 많으면 연산이 더 빨라지는거 아니였나라고 생각할 수 있으나
Redis는 싱글 스레드의 단순함을 활용했다
멀티스레드 서버(스레드 풀이 있는 서버)가 느려지는 병목지점은 CPU 연산 자체가 아닌 두 두가지의 이유가 있다
컨텍스트 스위칭 비용: 스레드가 많아질수록 OS 스케쥴러가 스레드를 전환하는 비용들
1-1. 상태 저장 및 복원: CPU 레지스터, 프로그램 카운터(PC), 스택 포인터(SP) 등을 저장하고 로드하는 기본 비용
1-2. 캐시 메모리 오염 (가장 큰 비용): 기존 스레드가 사용하던 데이터가 CPU 캐시에서 밀려나고, 새 스레드의 데이터를 다시 채워 넣는(Cache Miss) 데 걸리는 시간
1-3. 커널 모드 전환: 유저 모드에서 커널 모드로 전환되고 다시 돌아오는 시스템 콜(System Call) 비용
락 경합(lock contention): 공유 자원(해시 테이블, 리스트등)에 여러 스레드가 동시에 접근하며 mutex/spinlock으로 보호하여햐 하고 이 락을 획득/해제하는 오버헤드와 대기 시간이 발생한다.
Redis는 커맨드 처리 구간에서 이 두 비용이 없다.
하나의 스레드로만 데이터를 관리하므로 락이 필요 없고, 컨텍스트 스위칭 이용또한 발생하지 않는다. INCR, HSET같은 커맨드가 원자적(atomic)이라는 보장도 사실 싱글스레드이기 때문에 애초에 끼어들어올 스레드가 없는데서 따라오는 것이다.
그럼 동시에 들어오는 클라이언트는 어떻게 처리하는거지?
싱글 스레드가 수천개의 클라이언트 커넥션을 동시에 다루려면 Blocking I/O로 대기 할 경우 수천개의 모든 클라이언트만큼의 스레드가 생성되게 된다
그렇기 때문에 여기서 이벤트 루프가 등장한다
Redis는 리눅스의 epoll을 감싼 자체 라이브러리를 사용한다.
동작 플로우를 살펴보면
epoll_wait()으로 "지금 읽기/쓰기가 가능한 소켓 목록"만 커널로부터 받아온다.(폴링이 아니라 이벤트 방식이라 O(1)에 가깝게 동작한다.)정리하면 "싱글 스레드 + 이벤트 루프" 조합은 락 없는 데이터 접근과 Non-blocking I/O 멀티플렉싱을 동시에 얻는 설계이다.
Redis는 데이터가 작을 때는 배열처럼 압축된 형태(listpack 등)로, 커지면 진짜 자료구조(hashtable, skiplist 등)로 전환하는 전략을 쓴다. 이론적 시간복잡도보다 실제 데이터 규모에서의 CPU 캐시 지역성을 우선한 설계다. (1-2 자료구조 파트에 따로 작성할 예정)
Redis는 자체 프로토콜인 RESP(REdis Serialization Protocal)를 사용한다. JSON처럼 중괄호를 매칭하고 escape를 처리하는 parser에 비하면 RESP는 훨씬 단순해진다.
인메모리 DB의 딜레마는 "빠르처리하면서 어떻게 데이터를 잃지 않는가"다.
Redis는 두 가지 영속화 방식(RDB 스냅샷, AOF)를 제공하는데 둘 다 메인 이벤트 루프를 블로킹하지 않도록 설계되어 있다는 것이 포인트이다.
BGSAVE(RDB 스냅샷)를 예로 들면:
1. Redis 프로세스가 fork()를 호출해 자식 프로세스를 만든다.
2. 리눅스는 fork 시 부모의 메모리를 통째로 복사하지 않으며 페이지 테이블만 복사하고 실제 메모리 페이지는 부모/자식이 공유한다
3. 자식 프로세스는 fork 시점의 메모리 스냅샷을 디스크에 순서대로 써내려간다. 자식 입장에서는 데이터가 절대 바뀌지 않으니 일관된 스냅샷이 보장된다.
4. 그 사이 부모 프로세스(메인 이벤트 루프)는 평소처럼 클라이언트 요청을 계속 처리한다. 만약 부모가 어떤 키에 쓰기를 하면, 그 페이지만 커널이 복제해서 부모용 페이지를 새로 만든다, 자식이 보고 있는 원래 페이지는 그대로 유지된다.
이 덕분에 "전체 데이터를 디스크에 쓰는" 무거운 작업이 메인 스레드의 커맨드 처리를 막지 않는다. 그러나 쓰기가 많은 워크로드에서 COW로 복제되는 페이지가 늘어나 메모리 사용량이 순간적으로 증가할 수 있다는걸 인지하고 있어야한다
그렇기에 "메모리를 최대치까지 채워 사용하지마라"라는 운영가이드가 있다.
AOF도 비슷하게 appendfsync everysec 설정으로 매 요청마다 fsync하지 않고 백그라운드 스레드가 1초 주기로 디스크에 flush하게 해서, 내구성과 성능 사이의 타협점을 사용자가 조절할 수 있게 한다.
"Redis는 싱글 스레드다"는 정확히는 "커맨드 실행이 싱글 스레드라는 뜻이지 프로세스 전체가 스레드 하나로 실행되고 있는게 아니다.
이전부터 있었던 것: 앞서 설명한 BGSAVE/BGREWRITEAOF용 fork 자식 프로세스, 그리고 오래된 키 삭제나 큰 객체 해제 같은 작업을 백그라운드 스레드(lazy free, UNLINK)로 넘기는 기능.
Redis 6.0부터 추가된 것: I/O 스레딩. 소켓에서 바이트를 읽어 RESP를 파싱하는 작업과, 응답을 소켓에 쓰는 작업을 여러 스레드로 병렬화할 수 있다(io-threads 설정). 다만 커맨드 실행 로직 자체(자료구조를 실제로 읽고 쓰는 부분)는 여전히 메인 스레드 하나가 담당한다.
Redis는 "네트워크 I/O만" 병렬화해서 CPU 코어는 더 활용하되, 데이터 정합성과 관련된 부분은 계속해서 싱글 스레드를 지키는 선택을 했다.
Redis는 메모리에 데이터를 두고 사용하는 것 뿐만이 아니라, 락 없는 싱글 스레드 실행 모델 + 캐시 친화적인 자료구조 인코딩 + 파싱 비용이 낮은 프로토콜 + 메인 스레드를 막지 않는 영속화 전략이 유기적으로 맞물려서 빠르다.
각 설계는 전부 트레이드 오프를 가지고 있다. 싱글 스레드는 무거운 커맨드 하나가 전체를 막을 수 있다는 리스크, listpack 같은 압축 인코딩은 임계값을 넘는 순간 변환 비용 발생, COW는 쓰기 폭주시 메모리 스파이크를 유발한다. "왜 빠른가"를 이해 한다면 "어떤 트레이드 오프를 해서 빠른가"까지 이해할 수 있으며 설정값 튜닝할 때 판단 기준을 마련할 수 있을 것 같다.
참고
Youtube 코딩기술사 싱글스레드 Redis가 수십만 QPS를 내는 구조적 비밀 https://www.youtube.com/watch?v=H-jSrhvnaLY&t=620s
컨텍스트 스위칭에 대한 설명 https://beststar-1.tistory.com/26
스레드 락 경합: https://ego2-1.tistory.com/39
BGSAVE, AOF: https://inpa.tistory.com/entry/REDIS-%F0%9F%93%9A-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%98%81%EA%B5%AC-%EC%A0%80%EC%9E%A5%ED%95%98%EB%8A%94-%EB%B0%A9%EB%B2%95-%EB%8D%B0%EC%9D%B4%ED%84%B0%EC%9D%98-%EC%98%81%EC%86%8D%EC%84%B1