Cassandra 스토리지 엔진

Cassandra 스토리지 엔진

Apache Cassandra의 스토리지 엔진은 높은 성능의 쓰기 중심 워크로드에 최적화되어 있어요. 관계형 데이터베이스가 전통적으로 쓰던 B-트리 대신, Log Structured Merge(LSM) 트리를 기반으로 설계되었고, 이는 '읽기 조회 없이 쓰기만 하는' 구조, 즉 append-only 방식을 채택했기 때문이에요. 덕분에 쓰기 경로에는 읽기 조회나 병목 현상이 거의 생기지 않죠.

쓰기 경로가 크게 최적화된 대신, 읽기 성능과 쓰기 증폭(write amplification) 측면에서는 어느 정도 트레이드오프가 있어요. 레디 성능을 높이기 위해 Cassandra는 SSTable에서 데이터에 접근할 때 Bloom filter를 사용해요. Bloom filter는 매우 효율적이라서 읽기와 쓰기 성능이 대체로 고르게 잘 유지됩니다.

컴팩션(compaction)은 LSM 트리의 '병합(merge)' 단계에 필요한 백그라운드 활동이에요. 디스크의 여러 작은 SSTable을 읽어 병합하고, 업데이트와 삭제를 처리하고, 새로운 SSTable로 다시 쓰는 과정에서 쓰기 증폭이 발생합니다. Cassandra에서 데이터를 쓸 때마다 데이터가 여러 번 다시 쓰이는데, 이를 쓰기 증폭이라고 하고 이는 데이터베이스 워크로드에 백그라운드 I/O를 더해요.

핵심 스토리지 엔진은 인메모리 데이터를 담당하는 멤테이블(memtable)과 디스크 위에 있는 불변(immutable) SSTable(Sorted String Table)로 구성됩니다. SSTable 안의 데이터는 정렬된 채로 저장되어 컴팩션 중 효율적인 병합 정렬(merge sort)을 가능하게 해요. 여기에 더해 커밋 로그(commit log)라고 불리는 write-ahead log(WAL)가 크래시와 트랜잭션 복구를 위한 복원력을 보장합니다.

쓰기 경로(write path)의 단계 순서는 다음과 같아요:

  • 커밋 로그에 데이터 기록
  • 멤테이블에 데이터 쓰기
  • 멤테이블에서 데이터 플러시(flush)
  • 데이터를 SSTable로 디스크에 저장

출처: 문서

본문

커밋 로그에 쓰기 기록

쓰기 연산이 발생하면 Cassandra는 데이터를 디스크의 로컬 append-only 커밋 로그에 기록해요. 이 동작은 Cassandra 노드에 일어나는 모든 쓰기를 기록함으로써 구성 가능한 지속성(durability)을 제공합니다. 예상치 못한 종료가 발생하면 커밋 로그가 데이터를 영구적으로 지속해 주고, 시작 시 커밋 로그에 남아 있던 변경(mutation)은 멤테이블에 다시 적용돼요. 커밋 로그는 여러 테이블이 공유합니다.

모든 변경은 커밋 로그 세그먼트(segment)에 쓰기 최적화된 형태로 저장되어, 디스크에 쓰기 위해 필요한 seek 횟수를 줄여줘요. 커밋 로그 세그먼트는 commitlog_segment_size 옵션에 의해 크기가 제한되고, 정의된 크기에 도달하면 새 커밋 로그 세그먼트가 만들어집니다. 모든 데이터가 SSTable로 플러시되면 커밋 로그 세그먼트는 보관(archive)되거나 삭제되거나 재활용(recycle)될 수 있어요. Cassandra가 특정 시점보다 오래된 데이터를 SSTable에 기록하면 커밋 로그 세그먼트는 잘립니다(truncate). Cassandra를 멈추기 전에 nodetool drain을 실행하면 멤테이블의 모든 데이터가 SSTable로 기록되고, 시작 시 커밋 로그와 동기화할 필요가 사라집니다.

  • commitlog_segment_size: 기본 크기는 32MiB인데, 거의 항상 충분해요. 다만 커밋 로그 세그먼트를 아카이빙한다면(commitlog_archiving.properties 참고) 더 세밀한 단위로 보관하고 싶을 수 있어요. 이때는 8MiB나 16MiB가 합리적입니다. commitlog_segment_sizecassandra.yamlmax_mutation_size 기본값도 결정해요. 기본적으로 max_mutation_sizecommitlog_segment_size 크기의 절반입니다.

max_mutation_size를 명시적으로 설정했다면 commitlog_segment_size는 최소한 max_mutation_size의 두 배로 설정해야 해요.

  • commitlog_sync: periodic 또는 batch 중 하나예요.
    • batch: 배치 모드에서는 커밋 로그가 디스크에 fsync될 때까지 Cassandra가 쓰기를 승인하지 않아요.
    • periodic: 주기 모드에서는 쓰기가 즉시 승인되고, 커밋 로그는 commitlog_sync_period 밀리초마다 동기화돼요.
  • commitlog_sync_period: 'periodic' fsync 사이의 대기 시간. 기본값: 10000ms

기본값: periodic

예상치 못한 종료가 발생하면 Cassandra는 동기화가 지연되는 경우 동기화 주기(또는 그 이상)만큼의 데이터를 잃을 수 있어요. batch 모드를 사용한다면 커밋 로그를 별도의 전용 장치에 저장하는 것을 권장합니다.

  • commitlog_directory: 이 옵션은 기본적으로 주석 처리되어 있어요. 자기 HDD에서 실행할 때는 데이터 디렉터리와는 별개의 스핀들(spindle)이어야 해요. 설정하지 않으면 기본 디렉터리는 $CASSANDRA_HOME/data/commitlog입니다.

기본값: /var/lib/cassandra/commitlog

  • commitlog_compression: 커밋 로그에 적용할 압축 설정이에요. 생략하면 커밋 로그는 압축 없이 기록됩니다. LZ4, Snappy, Deflate, Zstd 압축기를 지원해요.

기본값:

#   - class_name: LZ4Compressor
#     parameters:
  • commitlog_total_space: 디스크에서 커밋 로그에 사용할 총 공간이에요. 이 옵션은 기본적으로 주석 처리되어 있어요. 이 값 위로 공간이 올라가면 Cassandra가 가장 오래된 세그먼트의 모든 더티 테이블을 플러시하고 제거합니다. 따라서 커밋 로그 공간이 작으면 덜 활발한 테이블에 플러시 활동이 더 많이 일어나게 돼요. 기본값은 8192와 커밋 로그 볼륨 전체 공간의 1/4 중 더 작은 값입니다.

기본값: 8192MiB

멤테이블(Memtable)

Cassandra가 새로운 쓰기 요청을 받으면 데이터를 두 곳에 저장해요. 하나는 멤테이블이라 부르는 인메모리 write-back 캐시이고, 다른 하나는 커밋 로그입니다. 멤테이블은 쓰기를 버퍼링해 디스크를 거치지 않고 읽기를 제공하며, 커밋 로그는 새 변경을 추가함으로써 지속성을 보장해요. 보통 테이블당 하나의 활성 멤테이블이 있고, 키로 접근하는 데이터 파티션의 캐시 역할을 합니다.

memtable_allocation_type에 따라 멤테이블은 온전히 힙(heap) 안에 저장되거나 일부는 힙 밖(off-heap)에 저장될 수 있어요. 멤테이블 플러시 전에 Cassandra가 크래시하면, 커밋 로그를 재생(replay)해서 승인된 쓰기를 복원할 수 있습니다.

멤테이블은 구성 가능한 한계에 도달할 때까지 쓰기를 정렬된 순서로 저장해요. 한계에 도달하면 멤테이블은 디스크로 플러시되어 불변 SSTable이 됩니다. 플러시는 여러 방식으로 트리거될 수 있어요:

  • 멤테이블의 메모리 사용이 구성된 임계값을 초과할 때(memtable_cleanup_threshold 참고)
  • 커밋 로그가 최대 크기에 가까워져 커밋 로그 세그먼트를 해제하기 위해 멤테이블 플러시를 강제할 때

트리거 이벤트가 발생하면 멤테이블은 디스크로 플러시되는 큐에 들어갑니다. 플러시는 멤테이블 정렬 순서대로 데이터를 디스크에 기록하고, 토큰을 디스크 위치에 매핑하는 파티션 인덱스도 함께 생성돼요.

큐는 cassandra.yaml 파일의 memtable_heap_space 또는 memtable_offheap_space 설정으로 구성할 수 있어요. 플러시할 데이터가 memtable_cleanup_threshold를 초과하면 Cassandra는 다음 플러시가 성공할 때까지 쓰기를 차단합니다. 테이블을 수동으로 플러시하려면 nodetool flush 또는 nodetool drain(다른 노드와의 연결을 듣지 않고 멤테이블을 플러시)을 사용하면 돼요. 커밋 로그 재생 시간을 줄이기 위한 권장 사항은 노드를 다시 시작하기 전에 멤테이블을 플러시하는 것입니다. 노드가 멈추면 커밋 로그 재생이 그 전에 멤테이블에 있던 쓰기를 복원해요.

커밋 로그의 데이터는 멤테이블의 해당 데이터가 SSTable로 플러시된 뒤 제거됩니다.

SSTable

SSTable은 Cassandra가 디스크에 데이터를 영속화하는 데 사용하는 불변 데이터 파일이에요. SSTable은 테이블별로 유지되며, 불변이라 멤테이블이 플러시된 뒤에는 다시 쓰이지 않습니다. 따라서 파티션은 데이터가 추가되거나 수정됨에 따라 여러 SSTable 파일에 걸쳐 저장되는 것이 일반적이에요.

각 SSTable은 별도 파일로 저장된 여러 컴포넌트로 구성됩니다:

Data.db 실제 데이터, 즉 행(row)의 내용입니다.

Partitions.db 파티션 인덱스 파일로, 데코레이션된 파티션 키의 고유한 접두사(prefix)를 데이터 파일 위치에 매핑해요. 행 인덱스 파일에 인덱싱된 넓은 파티션의 경우 행 인덱스 파일의 위치에 매핑합니다.

Rows.db 행 인덱스 파일은 여러 행을 포함하고 한 인덱스 블록보다 큰 파티션에 대한 항목만 담아요. 그러한 모든 파티션에 대해 파티션 키 복사본, 파티션 헤더, 그리고 각 행 키를 동일하거나 더 높은 행 키를 가진 콘텐츠가 있는 첫 블록에 매핑하는 행 블록 구분자 인덱스를 저장합니다.

Index.db 파티션 키에서 Data.db 파일 내 위치로의 인덱스예요. 넓은 파티션의 경우 파티션 내 행에 대한 인덱스도 포함할 수 있어요.

Summary.db (기본적으로) Index.db 파일의 매 128번째 항목을 샘플링한 것입니다.

Filter.db SSTable 파티션 키의 Bloom filter입니다.

CompressionInfo.db Data.db 파일의 압축 청크 오프셋과 길이에 대한 메타데이터입니다.

Statistics.db 타임스탬프, 툼스톤(tombstone), 클러스터링 키, 컴팩션, 리페어, 압축, TTL 등에 대한 정보를 포함한 SSTable 메타데이터를 저장해요.

Digest.crc32 Data.db 파일의 CRC-32 다이제스트입니다.

TOC.txt SSTable의 컴포넌트 파일에 대한 일반 텍스트 목록입니다.

SAI.db* Storage-Attached 인덱스에 대한 인덱스 정보예요. 테이블에 SAI가 활성화된 경우에만 존재합니다.

Index.db 파일 유형은 Partitions.db와 Rows.db로 대체된다는 점에 유의하세요. 이 변경은 Cassandra CEP-25에 Big Trie 인덱스가 포함되면서 생긴 결과예요.

Data.db 파일 안에서 행은 파티션별로 정리됩니다. 이러한 파티션은 토큰 순서(기본 파티셔너인 Murmur3Partition을 사용할 때 파티션 키의 해시)로 정렬돼요. 파티션 안에서 행은 클러스터링 키 순서대로 저장됩니다.

SSTable은 블록 기반 압축을 사용해 선택적으로 압축될 수 있어요.

SSTable이 멤테이블에서 디스크로 플러시되거나 다른 노드에서 스트리밍되면, Cassandra는 여러 SSTable을 하나로 결합하는 컴팩션을 트리거합니다. 새 SSTable이 기록된 뒤에는 옛 SSTable을 제거할 수 있어요.

SSTable 버전

BigFormat#BigVersion에서 비롯합니다.

지금까지의 버전 번호는 다음과 같아요.

버전 0

  • b (0.7.0): SSTable 파일 이름에 버전 추가
  • c (0.7.0): bloom filter 컴포넌트가 문자열 대신 원시 키 바이트에 대해 해시 계산
  • d (0.7.0): data 컴포넌트의 행 크기가 int 대신 long이 됨
  • e (0.7.0): data 및 index 컴포넌트에 데코레이션되지 않은 키 저장
  • f (0.7.0): data 컴포넌트에서 bloom filter 구현 전환
  • g (0.8): metadata 컴포넌트에 flushed-at 컨텍스트 추적

버전 1

  • h (1.0): metadata 컴포넌트에서 최대 클라이언트 타임스탬프 추적
  • hb (1.0.3): metadata 컴포넌트에 압축 비율 기록
  • hc (1.0.4): metadata 컴포넌트에 파티셔너 기록
  • hd (1.0.10): maxtimestamp에 행 툼스톤 포함
  • he (1.1.3): metadata 컴포넌트에 조상 세대(ancestors generation) 포함
  • hf (1.1.6): 재생 위치가 1.1.5+ 밀리초 기반 id에 해당함을 나타내는 마커(CASSANDRA-4782 참고)
  • ia (1.2.0):
    • 컬럼 인덱스를 index 파일로 승격
    • 툼스톤의 삭제 시간 추정 히스토그램 기록
    • bloom filter(키와 컬럼)를 Murmur3으로 업그레이드
  • ib (1.2.1): metadata 컴포넌트에서 최소 클라이언트 타임스탬프 추적
  • ic (1.2.5): 컬럼 이름의 행별 bloom filter 생략

버전 2

  • ja (2.0.0):
    • 슈퍼 컬럼을 컴포짓(composite)으로 직렬화(실제 포맷 변경은 없고, 슈퍼 컬럼을 기대해야 하는지 여부를 알기 위한 마커에 가까움. 다만 새 포맷으로는 슈퍼 컬럼 스트리밍을 허용하지 않아야 하므로 메이저 버전 범프는 필요함)
    • SSTable 메타데이터에 최대 로컬 deletiontime 추적
    • metadata 컴포넌트에 bloom_filter_fp_chance 기록
    • data 파일에서 데이터 크기와 컬럼 수 제거(CASSANDRA-4180)
    • (comparator에 따른) 최대/최소 컬럼 값 추적
  • jb (2.0.1):
    • 압축 체크섬을 crc32에서 adler32로 전환
    • 압축된 데이터 체크섬
  • ka (2.1.0):
    • 새 Statistics.db 파일 포맷
    • 인덱스 요약을 다운샘플링할 수 있고 샘플링 수준이 영속화됨
    • 압축되지 않은 체크섬을 adler32로 전환
    • 레거시(로컬·원격) 카운터 샤드 존재 여부 추적
  • la (2.2.0): 새 파일 이름 포맷
  • lb (2.2.7): 커밋 로그 하한 포함

버전 3

  • ma (3.0.0):
    • bf 해시 순서 교환
    • 행을 네이티브하게 저장
  • mb (3.0.7, 3.7): 커밋 로그 하한 포함
  • mc (3.0.8, 3.9): 커밋 로그 간격 포함
  • md (3.0.18, 3.11.4): SSTable 최소/최대 클러스터링 수정
  • me (3.0.25, 3.11.11): SSTable이 비롯된 노드의 hostId 추가

버전 4

  • na (4.0-rc1): 압축되지 않은 청크, 보류 중인 리페어 세션, isTransient, 체크섬 처리된 SSTable 메타데이터 파일, 새 Bloom filter 포맷
  • nb (4.0.0): 기원 host id

버전 5

  • oa (5.0): 개선된 min/max, 파티션 수준 삭제 존재 마커, 키 범위(CASSANDRA-18134)
    • TTL 오버플로우를 막기 위한 Long deletionTime
    • 토큰 공간 커버리지

Trie-인덱스 기반 SSTable 버전 (BTI)

Cassandra 5.0은 Trie-인덱스 SSTable을 위한 새로운 SSTable 포맷인 BTI를 도입했어요. BTI 포맷을 사용하려면 cassandra.yaml에 다음과 같이 구성합니다.

sstable:
  selected_format: bti

버전은 BtiFormat#BtiVersion에서 비롯합니다.

구현 문서는 BtiFormat.md를 참고하세요.

버전 5

  • da (5.0): BTI 포맷의 초기 버전

예제 코드

다음 예제는 "ib" SSTable 버전과 일치하지 않는 모든 SSTable을 찾을 때 유용해요.

find /var/lib/cassandra/data/ -type f | grep -v -- -ib- | grep -v "/snapshots"

더 알아보기 (Learn more)