본문으로 건너뛰기

04 · 저장과 압축 — vmstorage와 Gorilla 계열 압축

  • 지표는 Time Series(이름+레이블, 거의 불변)와 Sample(timestamp+value, 계속 쌓임)로 분리 저장됩니다 — IndexDB/DataDB 정규화의 근거.
  • 저장 경로: 로우 파싱 → Canonical Name 정규화 → TSID 변환(캐시 hit=빠른 경로, miss=New TSID 발급=카디널리티 폭발 원인) → 압축 → 파티션(인메모리→Small→Big, Merge Multiplier로 비슷한 크기끼리 합침).
  • 압축 비결은 Gorilla 계열 차분 인코딩: Gauge→Delta, Counter→Delta-of-Delta(단조증가값이 압축에 극단적으로 유리) + ZigZag/Varint로 바이트 절약. 네이버 실운영 실측 0.92바이트/데이터포인트(원래 16바이트 대비 ~17배 압축).
  • retention은 기본 1개월, IndexDB는 단건 삭제 비용을 피하려 3단계(Current/Previous/Next) 로테이션으로 통째로 드롭합니다.

vminsert가 흩뿌린 데이터를 실제로 디스크에 눕히는 컴포넌트가 vmstorage입니다. 여기서는 지표 하나가 TSID로 바뀌어 파티션에 저장되기까지의 경로를 따라갑니다. 이어서 VM의 메모리·스토리지 효율을 떠받치는 Gorilla 계열 차분 압축을 다룹니다.

관련 문서: 02 아키텍처 · 03 수집 · 05 쿼리·운영 컴포넌트 · 실전 01 카디널리티

vmstorage — 저장을 책임지는 컴포넌트

vmstorage는 vminsert로부터 지표를 받아 월별 파티션 단위로 저장하고 vmselect의 쿼리에 블록 단위로 응답하는 상태 저장(stateful) 컴포넌트입니다.

지표 하나의 내부 표현: Time Series + Sample

지표는 두 부분으로 나뉩니다.

부분내용특성
Time Series지표 이름 + 레이블 (예: request_total{path="/", code="200"})거의 변하지 않는다
Sample타임스탬프(Unix time) + 값 (예: value=120)계속 쌓인다

이 분리가 저장 효율을 좌우합니다. 변하지 않는 부분과 계속 쌓이는 부분을 따로 관리하면 압축 효율이 크게 좋아집니다. VM이 이름·레이블을 IndexDB에, 타임스탬프·값을 DataDB에 나눠 담는 근거입니다(일종의 DB 정규화). IndexDB/DataDB 분리 자체는 02 아키텍처가 주로 설명합니다.

저장 경로: 로우 파싱 → TSID 변환 → 압축 → 파티션

데이터가 들어와 눕기까지 네 단계를 거칩니다.

1. vminsert가 vmstorage에 블록 단위로 데이터 전송
2. 로우 바이트를 최대 1만 행 단위의 구조화된 로우
   (metric name / value / timestamp)로 파싱
3. 각 로우를 TSID로 변환
4. 샤드로 나눠 큐에 쌓고 → 플러시 → 인메모리 파트로 저장
   (이때 Delta / Delta-of-Delta 차분 압축 + 인코딩 압축)

TSID 변환 — 정규화, 캐시, 캐시미스

TSID 변환 — 캐시 hit이면 빠른 경로, 캐시·IndexDB 모두 miss면 New TSID 발급
도식 텍스트
  • 로우 — metric name + labels
  • Canonical Name 정규화 — 64bit TSID로 변환
  • TSID 캐시
  • IndexDB 조회
  • TSID — 빠른 경로
  • New TSID 발급
  • ⚠ 남발 = 카디널리티 폭발
  • hit
  • miss
  • hit
  • miss

저장 전 모든 로우는 TSID(64bit 정수, 시계열의 내부 ID) 로 바뀝니다. 변환은 두 단계입니다.

  1. 들어온 로우를 Canonical Name으로 정규화합니다. 레이블 순서가 달라도 집합이 같으면 같은 형태가 되도록 정렬한 뒤 그 정규화된 이름을 64bit TSID로 변환합니다.
  2. 매번 정규화하기엔 비용이 크므로 TSID 캐시(인메모리 매핑)를 둡니다. 동일한 이름이 다시 들어오면 캐시에서 곧바로 TSID를 꺼냅니다 — 빠른 경로.

캐시에 없으면(캐시미스) IndexDB(디스크 인덱스, prefix별로 엔트리 정리)를 조회합니다. IndexDB에도 없으면 그 시계열은 처음 보는 시계열이므로 새 New TSID 를 발급합니다 — 위 흐름의 miss 경로입니다. 흔히 말하는 “새 시계열이 만들어지는” 순간입니다. New TSID가 짧은 시간에 폭발적으로 발급되는 상황이 곧 카디널리티 폭발인데, 그 원인·지표·설계 원칙은 실전 01 카디널리티가 주인입니다. 여기서는 발급 지점까지만 짚습니다.

값 수준으로 따라가기 — 샘플 1건이 저장되는 모습

앞의 개념들이 실제 값에서 어떻게 맞물리는지 예시 하나로 따라가 봅니다. 다음 스크레이프 한 줄이 들어왔다고 하겠습니다.

node_cpu_seconds_total{mode="system", instance="10.0.0.7:9100"}  48291.5  @1735689600

① Time Series와 Sample로 쪼갭니다. 이름·레이블(거의 불변)과 타임스탬프·값(계속 쌓임)이 분리됩니다.

부분
Time Seriesnode_cpu_seconds_total{mode="system", instance="10.0.0.7:9100"}
Sample(ts=1735689600, value=48291.5)

② Canonical Name으로 정규화한 뒤 TSID를 발급합니다. 레이블을 키 기준으로 정렬해 순서 차이를 없앱니다 — instancemode보다 앞으로 옵니다. 정규화된 이름이 캐시·IndexDB에 모두 없으면 처음 보는 시계열로 판단해 New TSID를 발급합니다.

정규화: node_cpu_seconds_total{instance="10.0.0.7:9100", mode="system"}
       → 캐시·IndexDB 미스 → New TSID 발급: 0x0A3F...C21  (64bit)

③ IndexDB에는 prefix별 역색인 엔트리로, DataDB에는 TSID 열로 눕습니다. 이름·레이블은 IndexDB에 한 번만 적히고 이후 쌓이는 값들은 DataDB에서 TSID를 키로 이어 붙습니다. 이 샘플 하나로 IndexDB에는 이름 엔트리 1개 + 레이블 엔트리 2개가 생깁니다.

저장소prefix / 키
IndexDBMetricName → TSID위 canonical 이름 → 0x0A3F...C21
IndexDBTag(instance="10.0.0.7:9100") →이 레이블을 가진 시계열 역색인
IndexDBTag(mode="system") →이 레이블을 가진 시계열 역색인
DataDB0x0A3F...C21 (TSID)(1735689600, 48291.5), (1735689615, 48293.1), ...

다음 스크레이프에서 같은 시계열이 다시 오면 ②의 정규화·발급을 건너뛰고 캐시에서 TSID를 바로 얻어(빠른 경로), Sample만 DataDB의 같은 TSID 열 뒤에 붙습니다. 값이 이렇게 한 열로 이어지기 때문에 아래의 Delta / Delta-of-Delta 압축이 극대화됩니다.

파티션 — 인메모리 → Small → Big

파티션 — 인메모리에서 Small로 플러시, Small에서 Big으로 머지
도식 텍스트
  • 인메모리 파츠
  • Small 파티션
  • Big 파티션
  • 플러시
  • 머지 (Merge Multiplier ↑)
  • 아주 큰 블록

vmstorage 내부의 데이터는 인메모리 파트 → Small 파티션 → Big 파티션 으로 굳어갑니다.

  • 인메모리에서 플러시가 일어나면 디스크의 Small 파티션으로 이동하며 머지됩니다.
  • Small 파티션들이 모여 Big 파티션으로 머지됩니다. 인메모리에 아주 큰 블록이 뭉텅이로 들어오면 곧바로 Big 파티션으로 가기도 합니다.
  • 파티션 디렉토리는 월별로 YYYYMM 형태로 이름이 붙습니다.

머지에는 Merge Multiplier 알고리즘을 씁니다. (합쳐질 파트들의 총 출력 크기) / (가장 큰 입력 파트 크기) 값이 클수록 좋습니다. 비슷한 크기의 파트끼리 합칠수록 가성비가 좋습니다.

1MB×4 + 10MB  → 합 14 / 최대 10 = 1.4   (큰 놈에 억지로 끼움, 비효율)
1+2+3+3+3     → 합 14 / 최대  3 = 4.67  (비슷한 것끼리 잘 합침, 효율적)

Deduplication — minScrapeInterval과 watcher

같은 시계열에 아주 짧은 간격으로 거의 같은 값이 여러 번 들어오면 전부 저장할 이유가 없습니다. dedup.minScrapeInterval 을 예컨대 10초로 두면 그 구간 내 중복 값 중 하나만 남기고 제거합니다.

Deduplication Watcher는 매시간(지터 포함)마다 확인하고 필요 시 파티션 전체 머지를 트리거합니다. 예를 들어 3월에 dedup 설정을 새로 넣으면, 이미 dedup 없이 저장돼 머지가 끝난 1~2월 파티션에도 소급 머지를 걸 수 있습니다. (읽기 시점 vmselect에서도 한 번 더 dedup이 걸립니다 — 05.)

Retention — 기본 1개월, watcher, Big 파티션 경계 문제

  • 데이터 보존 기간. 기본값 1개월, 최소 하루부터 최대 100년까지 설정 가능합니다.
  • Retention Watcher매분 실행되어 오래된 파티션·파트를 삭제합니다.
  • 주의: 파티션에 보존 기간 안에 든 샘플이 하나라도 있으면 그 파트 전체가 유지됩니다. Small 파티션은 경계에 맞게 깔끔히 삭제되지만, Big 파티션은 보존 경계에 걸치면 통째로 들고 있어야 합니다. 그래서 Big 파티션은 retention 경계에 맞아떨어지지 않을 수 있습니다.

IndexDB 3단계 로테이션 — 단건 삭제 회피

IndexDB 3단계 로테이션 — Next → Current → Previous → 통째로 드롭
도식 텍스트
  • Next — 다음 로테이션 준비
  • Current — 실시간 수신
  • Previous — 보존기간 내·쿼리 가능
  • 통째로 드롭
  • rotate
  • rotate
  • rotate

IndexDB에는 시간이 지날수록 삭제되거나 더 이상 수집되지 않는 시계열의 엔트리가 누적됩니다. 그렇다고 엔트리를 하나씩 지우면 인덱스 자료구조 특성상 단건 삭제 비용이 매우 큽니다. 그래서 VM은 IndexDB 자체를 통째로 굴려버립니다.

버전 1.133.0부터 3단계 로테이션이 정착됐습니다.

단계역할
Current새 시계열 데이터를 실시간으로 수신하며 활성화된 IndexDB
Previous보존 기간 내 오래된 데이터를 가져 쿼리가 가능한 IndexDB
Next다음 로테이션을 위해 미리 준비하는 IndexDB

Retention 기간에 도달하면 Next → Current, Current → Previous, Previous → 통째로 드롭됩니다. 예를 들어 retention이 365d이고 2026년 1월 1일에 시작했다면, 2026년 12월 31일 UTC 04시에 로테이션이 일어납니다. 단건 삭제 비용을 피하면서 서비스 단절 없이 IndexDB를 깨끗이 비우는 영리한 설계입니다.

압축 — Gorilla 계열 차분 인코딩

VM이 메모리와 스토리지를 아끼는 비결이 여기 있습니다. 원래 타임스탬프 8바이트 + 값 8바이트로 데이터포인트 하나에 16바이트가 필요하지만, VM은 Facebook이 2015년 발표한 Gorilla 압축의 계열 기법으로 이를 대폭 줄입니다.

Gauge → Delta, Counter → Delta-of-Delta

Gauge는 Delta로, Counter는 Delta-of-Delta로 차분한 뒤 ZigZag/Varint로 인코딩
도식 텍스트
  • 원본 — 110, 105, 98, 110
  • 저장 — 110, -5, -7, +12 (Gauge → Delta)
  • 원본 — 120, 130, 150, 170
  • 차분 — 10, 20, 20
  • 차분의 차분 — ≈ 0 (Counter → Delta-of-Delta)
  • ZigZag (부호 제거) — Varint (7bit + 연속플래그)
  • Gauge → Delta 인코딩: 위아래로 변동하는 값. 첫 값만 남기고 나머지는 차분(diff) 만 저장합니다. 원본 110, 105, 98, 110, 103110, -5, -7, +12, -7. 8바이트짜리 값들이 1~2바이트로 줄어듭니다.
  • Counter → Delta-of-Delta 인코딩: 단조 증가하는 값. 원본 120, 130, 140, 150...의 1차 차분은 100, 100, 100...(거의 일정), 그 차분의 차분(Delta-of-Delta)0, 0, 0...에 수렴합니다. 그래서 첫 값 + 첫 차분 뒤로는 “변화 없음"만 저장하면 되어 압축률이 뜁니다. 뒤에 100개, 1000개를 이어 붙여도 크기가 거의 늘지 않습니다.

단조 증가하는 Counter가 압축에 극단적으로 유리합니다 — 이 점만 기억하면 됩니다.

같은 원리가 타임스탬프 스트림에서는 더 극적으로 작동합니다. 스크레이프 주기가 일정하면 델타가 상수가 되고 델타의 델타는 0이 되므로 아래처럼 8바이트 타임스탬프 4개(32B)가 개념상 11B 수준으로 눕습니다.

타임스탬프가 Delta → Delta-of-Delta → ZigZag/Varint를 거쳐 32B에서 약 11B로 줄어드는 과정

Counter / Gauge 블록 판별 — 잦은 리셋이면 Gauge

그럼 어떤 시계열이 Counter인지 Gauge인지 VM은 어떻게 아나요? 블록 단위로 값의 패턴을 보고 판별합니다. 120, 130, 140이나 500, 600, 700처럼 단조 증가하면 Counter, 위아래로 오르내리면 Gauge로 매깁니다.

그런데 Counter도 언젠가 리셋됩니다. 리셋이 너무 잦으면 VM은 그 블록을 Gauge로 분류합니다. Counter 지표라도 잦은 리셋이 있으면 Delta-of-Delta의 이점을 잃고 Gauge 압축으로 떨어질 수 있습니다.

ZigZag + Varint — 음수·바이트 절약 계산 과정

차분 압축을 하면 -5, -7, -13 같은 음수가 생기는데, 음수는 2의 보수 표현상 상위 비트가 다 켜져 공간을 많이 차지합니다. 그래서 두 단계로 다듬습니다.

ZigZag 인코딩 — 부호를 없애고 절댓값이 작은 순서대로 양수에 매핑합니다.

원본:   0   -1   1   -2   2   -3 ...
ZigZag: 0    1   2    3   4    5 ...

예) -5 → 왼쪽 시프트(-10) XOR 산술우시프트(-1) = 9 (부호 있는 작은 음수가 작은 양수 9로 바뀐다)

<button class=“hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50” title=“코드 복사” aria-label=“코드 복사” data-copied-label=“복사됨!”

<div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>

Varint 인코딩 — ZigZag로 얻은 작은 양수를 7비트씩 쪼개 저장합니다. 각 바이트의 하위 7비트는 값, 최상위 1비트는 “다음 바이트가 이어진다"는 연속 플래그입니다. 9는 8비트 안에 들어가 한 바이트로 끝나고 300처럼 큰 값은 여러 바이트로 이어집니다.

9   → 0000_1001                 (연속 플래그 0, 1바이트)
300 → 1010_1100 0000_0010       (첫 바이트 플래그 1 → 이어짐, 2바이트)
0.92바이트/데이터포인트 — 실전 증거

이 기법들이 실제로 얼마나 효과적인지는 운영 수치가 증명합니다. 네이버 검색이 5년간 운영한 클러스터에서 555조 개의 데이터포인트가 약 510TB에 저장됩니다. 나눠 보면 데이터포인트당 0.92바이트입니다.

원래 16바이트가 필요한 데이터포인트를 0.92바이트로 저장 — 약 17배 압축입니다. 이 값은 벤치마크가 아니라 12.5억 활성 시계열 규모의 실운영 환경 실측치입니다(D2 기사 6475419). Gorilla 계열 압축이 실전에서 통한다는 증거입니다. 이 압축이 절약하는 것은 디스크만이 아닙니다. 더 많은 데이터가 메모리에 올라가므로 연산도 그만큼 빨라집니다. 이 규모의 전체 그림은 실전 02 초대규모 운영에서 다룹니다.

출처

  • 02_대사집_Inside_VictoriaMetrics.md(강민구, Inside VictoriaMetrics) — Time Series/Sample 분리·저장 경로·TSID 변환·캐시미스(16:0021:30), Delta/Delta-of-Delta·Counter/Gauge 판별·ZigZag·Varint(21:3027:00), 파티션·Merge Multiplier·dedup·retention·IndexDB 로테이션(27:00~33:30).
  • 01_대사집_..._멀티버스.md(손주식, DEVIEW 2023) — LSM 트리·TSID/역색인·Gorilla 압축의 역사적 맥락과 16바이트→차분 압축(15:00~20:00).
  • 03_기사_6475419_대규모메트릭저장소.md — 555조 데이터포인트 / 약 510TB / 0.92바이트/데이터포인트 실측치.
  • 골격: chapter9/victoriametrics.md §3.3.
마지막 수정 일자