스토리지 계층

스토리지 계층 (Storage Layer)

CockroachDB가 데이터를 디스크에서 어떻게 읽고 쓰는지가 궁금하다면 바로 이 스토리지 계층 이야기예요. 데이터는 결국 키-값(KV) 쌍으로 디스크에 저장되고, 그걸 담당하는 게 스토리지 엔진이에요. CockroachDB는 RocksDB에서 영감을 받아 직접 만든 Pebble이라는 임베디드 키-값 저장소를 사용해요. 이 페이지에서는 Pebble이 데이터를 어떻게 관리하고, MVCC와 가비지 컬렉션이 어떻게 동작하는지 풀어드릴게요.

출처: CockroachDB 공식 문서 — Storage Layer

개요

각 CockroachDB 노드는 노드 시작 시 지정되는 store를 최소 하나씩 포함해요. 이 store가 디스크에서 데이터를 읽고 쓰는 곳이에요. 데이터는 스토리지 엔진을 사용해 디스크에 키-값 쌍으로 저장되는데, 엔진은 주로 블랙박스 API처럼 취급돼요. Pebble은 RocksDB에서 영감을 받았지만 다음 점에서 달라요:

  • Go로 작성됐고 RocksDB의 큰 기능 집합 중 일부만 구현해요.
  • CockroachDB에 유리한 최적화를 포함해요.

내부적으로 각 store에는 스토리지 엔진 인스턴스가 두 개 있어요. 하나는 임시 분산 SQL 데이터용이고, 다른 하나는 노드의 나머지 모든 데이터용이에요. 또한 노드의 모든 store가 공유하는 블록 캐시도 있어요. store들은 range 복제본 컬렉션을 가지는데, 한 range의 복제본이 같은 store나 같은 노드에 두 개 이상 배치되는 일은 절대 없어요.

Pebble

Pebble은 Cockroach Labs가 개발한 RocksDB에서 영감을 받은 임베디드 키-값 저장소예요. 키-값 저장소라서 키-값 계층에 매핑하기 쉽고, 원자적 쓰기 배치와 스냅샷을 제공해 트랜잭션의 일부를 지원하며, CockroachDB가 저장 엔진을 쓰는 방식에서 영감을 받은 최적화를 포함해요. 엔진은 접두사 압축으로 키를 효율적으로 저장해요. Pebble은 데이터 저장 관리를 **Log-structured Merge-tree(LSM 트리)**를 사용해요.

LSM 트리와 SST

LSM은 계층적 트리로, 각 레벨에는 그 레벨이 참조하는 데이터를 저장하는 디스크 파일이 있어요. 이 파일을 정렬된 문자열 테이블(SST 또는 SST 파일)이라고 불러요. SST는 정렬된 키-값 쌍 목록의 디스크 상 표현이며, 불변이라 컴팩션 과정에서도 절대 수정되지 않아요.

Pebble은 물리 저장 공간을 줄이기 위해 SSTable과 blob 값 데이터를 압축해요. 기본 프로파일 fastestamd64arm64 플랫폼에서 MinLZ1을 사용하고 낮은 CPU 오버헤드에 최적화되어 대부분의 워크로드에 적합해요. 고급 저장 튜닝을 위해 CockroachDB는 storage.sstable.compression_algorithm 클러스터 설정을 노출해요. 프로파일 값은 압축 노력이 증가하는 순서로 fastest, fast, balanced, good이고, 더 높은 노력 프로파일은 Zstd1을 더 자주 사용해 일부 워크로드의 압축을 개선할 수 있지만 쓰기·컴팩션·압축 해제 읽기의 CPU 사용을 늘릴 수도 있어요. storage.sstable.compression_algorithm을 바꿔도 기존 SST를 즉시 재압축하지 않아요. SST는 불변이므로 새 설정은 Pebble이 컴팩션·수집·복원 등으로 새 SST를 쓰거나 재작성할 때 적용돼요. 전환 중에는 store가 여러 알고리즘으로 압축된 SST를 함께 담을 수 있어요.

LSM 레벨

LSM의 레벨은 L0부터 L6까지 구성돼요. L0가 최상위, L6가 최하위예요. 새 데이터는 L0에 추가되고(LIMIT나 SST 수집으로) 시간이 지나며 낮은 레벨로 병합돼요. 각 레벨은 SST 집합과 연관되며, 각 레벨 안의 SST는 겹치지 않음이 보장돼요. 예를 들어 한 SST가 키 [A-F)를 담으면 다음 SST는 [F-R)를 담는 식이에요. L0는 트리에서 유일하게 겹치는 키를 담는 SST가 허용되는 레벨이에요. 이 예외는 대량 데이터 수집(예: 수집 문장)을 지원하고 memtable 플러시를 더 쉽고 효율적으로 만들기 위해 필요해요.

쓰기·읽기 증폭

*쓰기 증폭(write amplification)*은 저장 엔진에 논리적으로 커밋된 데이터 양에 대한 디스크에 기록된 데이터 양을 측정해요. 값이 커밋되면 CockroachDB는 값들을 WAL(쓰기-앞 로그)에 쓴 뒤 플러시 중에 SSTable로 써요. 컴팩션은 값의 수명 동안 여러 번 그 SSTable을 재작성해요. 대부분의 쓰기 증폭과 쓰기 대역폭은 컴팩션에서 발생해요. 엔진이 컴팩션을 너무 적게 하면 L0의 크기가 너무 커져 역전된 LSM이 되는데 이것도 나쁜 결과를 초래해요. 반면 WAL 쓰기는 노드의 전체 쓰기 대역폭·IOPS에서 작은 부분이에요.

*읽기 증폭(read amplification)*은 논리 읽기 하나를 충족하기 위해 조회되는 SSTable 파일 수를 측정해요. 값 조회가 여러 LSM 레벨이나 SST 파일을 검색해야 할 때 읽기 증폭이 높아지는데, 특히 LSM이 역전된 상태일 때 그래요. 읽기·쓰기 증폭의 균형을 유지하는 게 스토리지 엔진 성능에 중요해요. 정상 동작하는 클러스터에서도 약간의 읽기 증폭은 예상되는데, 예를 들어 메트릭에서 읽기 증폭 계수가 10 미만이면 건강한 것으로 간주돼요.

값 분리 (Value separation)

스토리지 엔진은 값 분리로 성능을 최적화할 수 있어요. 엔진이 키 값과 비교해 충분히 큰 값을 가진 키-값 쌍을 만나면, 그 키를 값이 LSM 밖 blob 파일에 있는 위치를 가리키는 포인터와 함께 LSM에 저장해요. 이 간접화 덕분에 컴팩션은 큰 값을 반복해서 재작성하는 대신 포인터만 복사할 수 있어요. 값 분리는 키 크기에 비해 값이 큰 워크로드(예: 페이로드)에 특히 유리하고, 쓰기 증폭을 약 50% 줄이는 대신 저장 공간을 약 20% 희생해요. 실제로는 저장소 용량 증가(훨씬 저렴)를 대가로 I/O와 저장 대역폭(비싼 자원)을 훨씬 적게 쓰게 돼요. 값 분리를 활성화하려면 관련 클러스터 설정을 켜면 돼요.

컴팩션

LSM에서 SST를 병합해 L0에서 L6로 옮기는 과정을 컴팩션이라고 해요. 엔진은 데이터를 최대한 빨리 컴팩트하려고 해요. 결과적으로 하위 레벨은 덜 최근에 갱신된 키를 담은 더 큰 SST를, 상위 레벨은 더 최근에 갱신된 키를 담은 더 작은 SST를 담게 돼요. 컴팩션은 LSM이 효율적으로 동작하는 데 필수인데, L0에서 L6로 갈수록 각 레벨은 아래 레벨의 약 1/10 만큼의 데이터를 가져야 해요. 컴팩션이 뒤처지면 역전된 LSM이 될 수 있어요.

SST 파일은 컴팩션 중에 절대 수정되지 않아요. 대신 새 SST가 쓰이고 옛 SST가 삭제돼요. 이 설계는 순차 디스크 접근이 랜덤 접근보다 훨씬 빠르다는 사실을 이용해요. 두 SST AB를 병합해야 하면 그 내용(키-값 쌍)을 메모리로 읽고, 정렬·병합한 뒤 새 파일 C를 열어 디스크에 새로 쓰고, 마지막에 옛 파일 AB를 삭제해요. 이 단계는 개념적으로 병합 정렬(merge sort)과 비슷해요. 기본적으로 스토리지 엔진은 컴팩션 동시성 3을 사용해요. IOPS와 CPU 여유가 충분하면 COCKROACH_COMPACTION_CONCURRENCY 환경 변수로 이 설정을 높이는 걸 고려할 수 있는데, 이는 시나리오에 따라 LSM을 더 빠르게 재구성하고 일부 워크로드의 전반적 성능을 높일 수 있어요. Cockroach Labs는 이 설정의 비기본값을 사용하기 전에 반드시 워크로드로 테스트하길 강력히 권장해요.

역전된 LSM

컴팩션 과정이 추가되는 데이터 양을 따라가지 못하고, 트리의 상위 레벨에 하위 레벨보다 더 많은 데이터가 쌓이면 LSM 형태가 역전될 수 있어요. 정상 동작 중 LSM은 ◣ 모양이어야 하고, 역전된 LSM은 ◤ 모양이에요. 역전된 LSM은 읽기 성능이 저하되고 과도한 컴팩션 부채가 생겨요. 이 상태에서는 엔진이 역전된 LSM을 정상 상태로 되돌리기 위한 큰 컴팩션 백로그를 가지게 돼요.

MVCC

CockroachDB는 동시 요청을 처리하고 일관성을 보장하기 위해 다중 버전 동시성 제어(MVCC)에 크게 의존해요. MVCC는 데이터 버전을 구분하고 커밋 타임스탬프를 추적하며 값의 가비지 컬렉션 만료를 식별하는 데 사용돼요. 모든 MVCC 데이터는 Pebble에 저장돼요. 스토리지 계층에 구현되어 있지만 MVCC 값은 트랜잭션 계층의 일관성 유지에 널리 쓰여요. 예를 들어 CockroachDB는 키가 마지막으로 읽힌 타임스탬프를 저장하는 읽기 타임스탬프 캐시를 유지하는데, 쓰기가 이 캐시의 최대값보다 낮은 타임스탬프에서 발생하면 잠재적 이상이 있음을 의미해요. 기본 격리 수준에서 트랜잭션은 더 늦은 타임스탬프로 재시작되어야 해요.

시간 여행 (Time-travel)

SQL:2011 표준에서 기술한 대로, CockroachDB는 MVCC로 가능해진 시간 여행 쿼리를 지원해요. 모든 스키마 정보도 MVCC 같은 모델을 뒤에 두고 있어서, SELECT ... AS OF SYSTEM TIME을 수행하면 그 시점의 스키마 정보를 사용해 쿼리를 구성해요. 이 도구로 가비지 컬렉션 기간만큼 과거까지 데이터베이스에서 일관된 데이터를 얻을 수 있어요.

가비지 컬렉션

CockroachDB는 디스크에 저장된 데이터 크기를 줄이기 위해 MVCC 값을 주기적으로 가비지 컬렉션해요. 가비지 컬렉션 기간보다 오래된 타임스탬프를 가진 새 MVCC 값이 있으면 옛 MVCC 값을 컴팩트해요. 가비지 컬렉션 기간은 클러스터·데이터베이스·테이블 레벨에서 복제 구역을 구성해 설정할 수 있어요. 가비지 컬렉션은 *보호된 타임스탬프(protected timestamp)*로 덮이지 않은 MVCC 값에서만 실행될 수 있어요. 보호된 타임스탬프 서브시스템은 백업처럼 과거 데이터에 의존하는 연산의 안전을 보장해요.

보호된 타임스탬프는 보호 레코드를 만들어 동작하는데, 이는 내부 시스템 테이블에 저장돼요. 백업 같은 장기 실행 작업이 특정 타임스탬프의 데이터를 가비지 컬렉션에서 보호하려 하면 그 데이터·타임스탬프와 연관된 보호 레코드를 만든다. 보호 레코드가 성공적으로 생성되면 지정된 데이터의 보호된 타임스탬프 이하의 MVCC 값은 가비지 컬렉션되지 않아요. 작업이 끝나면 레코드를 제거해 가비지 컬렉터가 이전에 보호됐던 값에 실행되도록 해요. 보호된 타임스탬프 덕분에 과거 데이터의 안전을 보장하면서 더 짧은 가비지 컬렉션 TTL을 쓸 수 있어요. TTL이 짧을수록 보관되는 이전 MVCC 값이 줄어들고, 하루 종일 행을 자주 갱신하는 워크로드의 쿼리 실행 비용을 낮출 수 있어요(현재 행 값을 찾으려면 이전 MVCC 값을 스캔해야 하기 때문).

더 알아보기