Sqlite를 공부하면서 문득 이런 생각이 들었습니다.
사실 전통적인 RDB들도 in-memory 모드로 동작을 할 수 있을텐데, 왜 굳이 redis를 사용하는 것일까요?
이에 대해 공부해봤습니다.
그리고 생각해보니, 운영체제 기능 중에 tmpfs 라는 것도 있을텐데, RAM에 tmpfs를 올리고 그 위에서 RDB를 동작하게 하면 in-memory mode가 없는 데이터베이스들도 충분히 Redis만큼 빠르게 동작할 수 있지 않을까요?
비록 RDB를 인메모리 모드로 작동시키거나, tmpfs를 사용하더라도 Redis보다 느릴 수 있는 다양한 이유가 있습니다. 우선,
데이터 구조와 알고리즘은 여전히 디스크 기반 작업을 가정하고 설계되어 있습니다. 예를 들어, B-tree 인덱스 구조는 디스크의 블록 단위 읽기/쓰기에 최적화되어 있습니다.
파일시스템 추상화 계층이 가져오는 오버헤드가 있습니다. 운영체제의 파일시스템 호출, 페이지 캐시 관리 등의 부가적인 작업이 발생합니다. Redis는 이러한 중간 계층 없이 메모리에 직접 접근합니다.
데이터베이스 엔진이 여전히 파일 I/O를 가정하고 동작할 수 있습니다. 예를 들어 트랜잭션 로깅이나 버퍼 관리 같은 기능들이 불필요하게 동작할 수 있습니다.
결국 메모리에서 동작한다는 것은 단순히 데이터의 물리적 위치만의 문제가 아니라, 전체 시스템 아키텍처가 어떤 가정하에 설계되었는지의 문제입니다. Redis의 진정한 강점은 설계부터 메모리 기반 작업에 최적화되었다는 점에 있다고 볼 수 있습니다.
우선 데이터구조부터 다릅니다. Redis는 메모리 접근 패턴을 고려해 설계된 자료구조들을 사용합니다. 예를 들어, 정렬된 데이터를 저장할 때 Redis는 Skip List라는 자료구조를 사용하는데, B-tree가 디스크의 블록 단위 접근에 최적화된 것처럼, Skip List는 메모리의 포인터 기반 접근에 최적화되어 있습니다.
두 번째로, Redis는 메모리 단편화를 최소화하기 위한 전략을 사용합니다. jemalloc이라는 특별한 메모리 할당자를 사용하여 메모리 할당과 해제를 효율적으로 관리합니다.
세 번째로, Redis는 직접적인 메모리 접근을 합니다. 일반적인 데이터베이스는 디스크에 있는 데이터를 메모리로 읽어오고, 수정된 데이터를 다시 디스크에 쓰는 과정이 필요합니다. 하지만 Redis는 이러한 중간 과정 없이 메모리에 직접 접근하므로, 추가적인 복사 작업이 필요 없습니다.
네 번째로, Redis의 원자적 연산들은 메모리 수준에서 최적화되어 있습니다. 예를 들어 INCR 명령은 단순히 값을 읽고 수정하고 쓰는 것이 아니라, 메모리 레벨에서 단일 연산으로 처리됩니다.
마지막으로, Redis의 이벤트 루프 방식도 메모리 작업에 최적화되어 있습니다. 단일 스레드로 동작하면서도 비동기 I/O를 활용하여 메모리 작업을 효율적으로 처리합니다.
Skip List가 메모리 환경에서 B-tree보다 더 효율적인 주요 이유는 구조적 단순성과 메모리 접근 패턴에서 찾을 수 있습니다.
첫째, Skip List는 매우 단순한 노드 구조를 가집니다. 각 노드는 단순히 키값과 다음 노드를 가리키는 포인터들만 포함합니다. 반면 B-tree의 노드는 키 배열, 자식 포인터 배열, 그리고 노드 관련 메타데이터를 모두 저장해야 하므로 더 많은 메모리를 차지합니다. 이러한 구조적 차이로 인해 Skip List는 노드당 약 40-50% 더 적은 메모리를 사용합니다.
둘째, Skip List의 검색 과정은 캐시 친화적입니다. 검색 시 단순히 포인터를 따라가는 순차적인 메모리 접근만 필요합니다. B-tree는 각 노드 내에서 이진 검색을 수행해야 하므로, 메모리 접근 패턴이 더 복잡하고 캐시 미스가 발생할 가능성이 높습니다.
셋째, Skip List의 갱신 연산이 더 효율적입니다. 새로운 노드를 삽입하거나 기존 노드를 삭제할 때, Skip List는 일부 포인터만 수정하면 됩니다. B-tree는 노드 분할이나 병합이 필요한 경우가 있어, 더 많은 메모리 재구성 작업이 발생합니다.
jemalloc은 크게 세 가지 핵심 메커니즘을 통해 메모리 단편화를 효과적으로 최소화합니다.
첫째, jemalloc은 크기 클래스(Size Classes) 체계를 사용합니다. 메모리 할당 요청을 미리 정의된 크기 범주로 나누어 관리하는데, 이는 비슷한 크기의 메모리 블록들을 함께 관리함으로써 내부 단편화를 줄여줍니다. 예를 들어, 8바이트부터 시작하여 16, 32, 48, 64바이트 등으로 세분화된 크기 클래스를 제공합니다.
둘째, jemalloc은 청크(Chunk) 기반 할당 방식을 사용합니다. 대규모 메모리 영역을 청크 단위로 관리하며, 각 청크는 동일한 크기 클래스의 객체들만 저장합니다. 이러한 구조는 외부 단편화를 감소시키고 메모리 할당/해제 성능을 향상시킵니다.
셋째, jemalloc은 효율적인 빈 공간 재사용 정책을 구현합니다. 메모리가 해제되면 해당 공간을 즉시 적절한 크기 클래스의 풀로 반환하여, 향후 비슷한 크기의 할당 요청에 재사용할 수 있도록 합니다. 이는 메모리 풀링(Memory Pooling) 효과를 통해 단편화를 줄이고 할당 성능을 개선합니다.
넷째, 멀티스레드 환경에서 특히 뛰어난 성능을 보여줍니다. 각 스레드별로 독립적인 arenas를 할당하여 스레드 간 경합을 최소화하고, 이는 멀티코어 시스템에서 높은 확장성으로 이어집니다.
그러게요...?
Redis가 INCR 명령을 처리하는 과정은 다음과 같습니다. 먼저 Redis는 키에 해당하는 값을 찾아 메모리에서 직접 접근합니다. Redis의 메모리 관리는 자체 할당자를 통해 이루어지므로, 값의 위치를 정확히 알고 있어 추가적인 검색 과정이 필요하지 않습니다.
이어서 Redis는 해당 메모리 위치에서 정수값을 직접 증가시킵니다. 이 과정에서 중요한 점은 Redis가 단일 스레드로 동작한다는 것입니다. 따라서 별도의 락이나 동기화 메커니즘이 필요하지 않습니다.
전통적인 데이터베이스는 보통 "Thread-per-Connection" 모델을 사용합니다. 각 클라이언트 연결마다 별도의 스레드를 할당하여 요청을 처리하는 방식입니다.
반면 Redis는 이벤트 루프 기반의 단일 스레드 모델을 사용합니다. 이는 Node.js의 동작 방식과 매우 유사한데, 하나의 메인 스레드가 모든 클라이언트의 요청을 비동기적으로 처리합니다.
Redis의 이벤트 루프는 다음과 같이 동작합니다:
전통적인 블로킹 I/O 방식에서는 각 I/O 작업마다 사용자 공간과 커널 공간 사이의 컨텍스트 스위칭이 발생합니다. 프로세스가 read() 시스템 콜을 호출할 때마다 커널 모드로 전환되고, 데이터가 준비되지 않은 경우 프로세스는 블로킹됩니다.
반면 epoll과 같은 I/O 멀티플렉싱 기술은 이벤트 통지 메커니즘을 사용합니다. 프로세스는 관심 있는 파일 디스크립터들을 한 번에 커널에 등록하고, 커널은 이벤트가 발생했을 때만 프로세스에 알려줍니다. 이는 다음과 같은 이점을 제공합니다.
첫째, 커널은 내부적으로 효율적인 데이터 구조를 사용하여 이벤트를 관리합니다. epoll의 경우 최적화된 자료구조를 사용하여 O(1)의 시간 복잡도로 이벤트를 처리할 수 있습니다.
둘째, 불필요한 시스템 콜과 컨텍스트 스위칭이 최소화됩니다. 프로세스는 실제로 이벤트가 발생한 경우에만 커널 모드로 전환됩니다.
셋째, 커널은 준비된 이벤트들을 배치(batch) 처리할 수 있습니다.
jemalloc은 나중에 시간이 나면 따로 공부를 하는 것으로...