메트릭이 초당 수만 개씩 쏟아지는데, Prometheus 한 노드가 이걸 다 받아서 몇 주씩 보관한다. 처음엔 막연히 "샘플 하나당 timestamp 8B + value 8B = 16B씩 쌓겠지" 생각했다. 그런데 그렇게 저장한다면 활성 시리즈 수십만 개짜리 환경에서 디스크가 진작 터졌어야 한다.
실제로 Gorilla 논문(Pelkonen et al., VLDB 2015)은 실운영 데이터에서 샘플 하나가 평균 약 1.37바이트까지 줄어든다고 보고한다. 16바이트가 1.37바이트면 대략 12배다. 이 숫자가 어떤 자료구조에서 나오는지, 그리고 압축이 정확히 언제 일어나는지 Prometheus tsdb 소스와 공식 Storage 문서를 따라가며 정리했다.
하나의 시리즈(series) 는 라벨 집합으로 유일하게 식별된다. 메트릭 이름조차 __name__ 라벨일 뿐이다. 즉 up{job="api"} 는 {__name__="up", job="api"} 라는 라벨 집합이고, 이 시리즈는 (timestamp int64 ms, value float64) 샘플의 시퀀스를 갖는다.
실제 저장 단위는 시리즈별 청크(chunk) 다. 기본 XORChunk 하나는 최대 120 샘플을 담거나 청크 시간 범위를 넘으면 잘린다(cut). 이 "120 샘플에서 자른다"는 규칙이 뒤에서 메모리를 억제하는 핵심 장치가 된다.
가장 크게 오해했던 부분이다. 스크레이프된 샘플이 들어오면 경로는 둘로 갈라진다.
scrape → Appender
│
├─(1) WAL append ── wal/ 세그먼트, record: series/samples/tombstones/exemplars
│ · 비압축, 순차 write. 크래시 복구 전용.
│
└─(2) Head 메모리 청크에 append (Gorilla 압축 인코딩)
· headChunk 가 꽉 참(120샘플/범위) → chunks_head/ 로 flush 후 mmap
· append 중인 활성 청크만 힙에 상주, 나머지는 mmap 참조만 유지
포인트는 디스크로 먼저 내려가는 건 비압축 WAL이고, Gorilla 압축은 메모리 Head 청크에 append하는 시점에 일어난다는 것이다. WAL은 순차 append라 빠르고 오직 내구성(크래시 복구)을 위한 로그다. 진짜 저장 표현은 압축된 in-memory 청크다.
압축 시점(메모리 append)과 디스크 내구성 시점(WAL write)은 완전히 분리돼 있다.
청크를 120 샘플에서 자르고 mmap으로 넘기는 이유는 힙 관리다. append 중인 활성 청크만 GC가 훑는 힙에 두고, 꽉 찬 청크는 chunks_head/ 파일로 밀어 OS 페이지 캐시에 맡긴다. 그러면 활성 시리즈가 수십만 개여도 상주 힙이 "시리즈 헤더 + 마지막 청크" 수준으로 억제된다.
chunkenc/xor.go가 하는 일은 두 축의 압축이다.
타임스탬프 — delta-of-delta. 스크레이프 간격은 거의 일정하다(예: 15s). 그래서 DoD = (tₙ − tₙ₋₁) − (tₙ₋₁ − tₙ₋₂) 는 대부분 0이다. Prometheus 구현은 제어비트로 DoD 크기를 분기한다.
| 조건 | 제어비트 | 데이터 비트 |
|---|---|---|
| DoD == 0 | 0 | 0 |
| ∈ 14비트 범위 | 10 | 14 |
| ∈ 17비트 범위 | 110 | 17 |
| ∈ 20비트 범위 | 1110 | 20 |
| 그 외 | 1111 | 64 |
간격이 흔들리지 않으면 타임스탬프 하나가 단 1비트로 줄어든다. (첫 샘플은 raw, 둘째는 delta 그대로 저장한다.)
값 — 이전 값과 XOR. 값이 그대로면 XOR == 0 이라 0 한 비트다. 다르면 1 뒤에 다음 분기가 붙는다.
10 + 이전 창 재사용, 의미 비트만 기록.11 + leading-zero 5비트 + 의미블록 길이 6비트 + 의미 비트.게이지처럼 조금씩 변하는 float은 XOR 결과의 상·하위 비트가 0으로 몰려서, 실제 기록되는 의미 비트가 얼마 안 된다. 이 두 축이 합쳐져 논문의 평균 1.37바이트/샘플이 나온다.
xor.go append 로직을, 15s(=15000ms) 간격에 값 고정 42.0으로 손으로 따라가면 이렇게 수렴한다.
t0=1000, v0=42.0 → t0 raw(varint) + v0 raw(64bit) (첫 샘플, 원본)
t1=16000, v1=42.0 → delta=15000 기록 + (v XOR==0 → '0' 1비트)
t2=31000, v2=42.0 → DoD=(31000-16000)-(16000-1000)=0 → '0' 1비트
+ 값 XOR==0 → '0' 1비트 ⇒ 이 샘플 총 2비트
카운터/게이지가 일정 간격으로 같은(혹은 규칙적으로 증가하는) 값을 뱉으면 샘플당 몇 비트로 수렴한다. 반대로 값이 매 스크레이프 크게 요동치면 XOR 의미 비트가 커져 압축률이 뚝 떨어진다. "고카디널리티·고엔트로피가 저장을 키운다"는 통념이 여기서 정량적으로 드러난다.
재시작. 크래시/셧다운 후 기동 시 chunks_head/의 mmap 청크를 참조로 되살린 뒤 WAL을 리플레이한다. 단, 이미 mmap 청크에 반영된 구간은 건너뛰고 그 이후 샘플만 메모리로 올린다. WAL이 무한정 자라지 않도록 checkpoint로 잘린다 — 오래된 세그먼트를 순회하며 아직 필요한 series/샘플 레코드만 새 checkpoint로 옮기고 원본을 삭제한다.
Compaction. Head 청크 범위(기본 2h)를 벗어난 데이터는 백그라운드로 디스크의 영구 블록으로 잘려 나가고, 그만큼 Head와 WAL이 truncate된다. 이후 인접 블록들은 더 큰 블록으로 병합된다. 공식 문서에 따르면 블록 크기는 보존 기간의 10% 또는 31일 중 작은 값까지 커진다. 블록 하나의 구조는 이렇다.
01ABC.../
├─ chunks/000001 압축된 청크 바이트 (세그먼트 파일)
├─ index 역색인(postings) + 심볼테이블 + 시리즈→청크 위치
├─ meta.json min/maxTime, 샘플/시리즈 수, compaction level
└─ tombstones 삭제 마커
블록은 불변(immutable) 이다. 그래서 delete API는 데이터를 즉시 지우지 않고 tombstone에 (시리즈, 시간범위)만 기록한다. 실제 물리 삭제는 다음 compaction이 블록을 재기록할 때 반영된다.
index 파일은 라벨 매칭을 위한 inverted index다. label=value → 그 라벨을 가진 시리즈 ID의 오름차순 리스트(postings list) 를 담는다. 모든 라벨 문자열은 심볼 테이블에 중복 제거되어 offset으로만 참조된다(index 크기 절감).
up{job="api"} 쿼리는 __name__="up" postings와 job="api" postings를 정렬 리스트 교집합으로 좁힌 뒤, 각 시리즈 레코드의 청크 위치(min/maxTime + chunks 파일 offset)로 실제 데이터를 읽는다. postings가 오름차순이라 교집합이 두 커서를 앞으로만 밀며 진행하는 선형 merge(또는 큰 쪽을 건너뛰는 galloping)로 끝나는 게 포인트다. 정규식 =~ 은 해당 라벨의 값들을 훑어 postings를 합집합한다.
핵심 한 줄: Prometheus TSDB는 "① WAL 비압축 append로 내구성 → ② 메모리 Head 청크에 Gorilla 압축 → ③ 청크가 차면 mmap → ④ 2시간 경과분을 불변 블록으로 compaction" 하는, 쓰기 경로와 압축 시점이 분리된 append-only 엔진이다.
파고들며 바로잡은 오해 세 가지:
다음에 더 파볼 만한 것: Head append의 isolation(append id 기반 MVCC 유사 메커니즘), index v2 포맷의 postings offset table, 그리고 Thanos·Mimir가 이 블록 포맷을 오브젝트 스토리지로 확장하는 방식.
prometheus/prometheus 소스: tsdb/chunkenc/xor.go, tsdb/head.go, tsdb/wlog/, tsdb/index/