MergeTree 가족 (Replacing, Summing, Aggregating, Collapsing, VersionedCollapsing)
머지 시점에 중복 제거, 합산, 집계 상태 병합, 상쇄를 수행하는 MergeTree 변형 엔진의 동작과 선택 기준, FINAL의 비용과 대안, 각 엔진에서 흔히 생기는 오해를 정리합니다.
공통 원리: 머지 때 일어난다
MergeTree 변형 엔진은 모두 같은 정렬 키를 가진 행들을 머지 시점에 처리합니다. INSERT 직후에는 중복이 그대로 있고, 머지가 언제 어떤 파트를 합칠지는 백그라운드가 정하므로, 쿼리 결과가 "결국에는" 정리되지만 "지금 당장" 정리되어 있다는 보장은 없습니다. 이 한 문장을 이해하면 가족 엔진의 오해 대부분이 풀립니다.
| 엔진 | 같은 키의 행을 | 대표 용도 |
|---|---|---|
ReplacingMergeTree([ver[, is_deleted]]) | 하나만 남김 (버전이 큰 것) | 변경 사항 CDC, 최신 상태 테이블 |
SummingMergeTree([columns]) | 숫자 열을 합산 | 카운터, 단순 롤업 |
AggregatingMergeTree | 집계 상태(AggregateFunction)를 병합 | uniq, quantile 등 복합 집계 롤업 |
CollapsingMergeTree(sign) | sign이 +1과 -1인 쌍을 상쇄 | 변경 이력 누적을 상태로 접기 |
VersionedCollapsingMergeTree(sign, ver) | 순서 무관하게 쌍을 상쇄 | 비순서 적재되는 상태 변화 |
GraphiteMergeTree | Graphite 롤업 규칙 적용 | Graphite 저장소 |
여기서 "같은 키"는 정렬 키(ORDER BY) 전체를 뜻합니다. 기본 키가 아닙니다.
ReplacingMergeTree
가장 많이 쓰는 변형입니다. 정렬 키가 같은 행 가운데 ver 열이 가장 큰 행(없으면 마지막에 들어온 행)만 남깁니다.
CREATE TABLE users
(
user_id UInt64,
email String,
plan LowCardinality(String),
updated_at DateTime64(3),
is_deleted UInt8 DEFAULT 0
)
ENGINE = ReplacingMergeTree(updated_at, is_deleted)
ORDER BY user_id;
-- 최신 상태 읽기
SELECT * FROM users FINAL WHERE plan = 'pro';is_deleted열을 두 번째 인수로 주면 값이 1인 최신 행은 머지 때 함께 사라집니다(clean_deleted_rows설정 또는FINAL조회 시 제거). CDC로 DELETE 이벤트를 받을 때 쓰는 패턴입니다.ver가 같으면 나중에 들어온 행이 남습니다. 동시에 들어오는 갱신이 있으면ver에 밀리초나 LSN처럼 단조 증가 값을 씁니다.- 중복 제거는 같은 파티션 안에서만 일어납니다. 갱신 행이 다른 파티션(다른 월)에 들어가면 영원히 둘 다 남습니다. 상태 테이블은 보통 파티션을 두지 않거나 키 해시로 둡니다.
FINAL의 비용과 대안
FINAL은 읽기 시점에 모든 파트를 병합 정렬하며 중복을 걷어 냅니다. 최근 버전은 FINAL이 병렬로 처리되고 파티션이 분리된 범위를 건너뛰어 예전보다 훨씬 빠르지만, 여전히 일반 조회보다 느리고 메모리를 더 씁니다.
| 방법 | 설명 | 적합 |
|---|---|---|
SELECT ... FINAL | 정확. do_not_merge_across_partitions_select_final = 1로 파티션 간 병합 생략 | 상태 테이블 조회 전반 |
argMax(col, ver) GROUP BY key | 집계로 최신 행 뽑기. FINAL보다 빠른 경우 많음 | 특정 열 몇 개만 필요할 때 |
LIMIT 1 BY key + ORDER BY ver DESC | 키별 최신 한 행 | 상위 N 패턴 |
| 그냥 조회 | 중복 허용 | 대략의 집계, 중복이 결과에 거의 영향 없을 때 |
-- FINAL 없이 최신 상태
SELECT user_id, argMax(plan, updated_at) AS plan, argMax(email, updated_at) AS email
FROM users
GROUP BY user_id;팁 테이블 설정
apply_final_by_default또는 세션 설정final = 1을 켜면 그 테이블을 읽는 모든 쿼리에 FINAL이 자동으로 붙습니다. BI 도구가 FINAL을 못 붙일 때 유용합니다.
SummingMergeTree
정렬 키가 같은 행의 숫자 열을 더해 한 행으로 만듭니다. 더할 열을 지정하지 않으면 정렬 키가 아닌 모든 숫자 열이 대상입니다. 숫자가 아닌 열은 임의의 한 행 값이 남습니다.
CREATE TABLE daily_counts
(
day Date,
event LowCardinality(String),
country LowCardinality(String),
cnt UInt64,
bytes UInt64
)
ENGINE = SummingMergeTree((cnt, bytes))
ORDER BY (day, event, country);
-- 읽을 때는 항상 다시 합산
SELECT day, event, sum(cnt) FROM daily_counts GROUP BY day, event;- 읽을 때 반드시
sum()으로 다시 집계합니다. 머지가 끝나지 않은 행이 여럿일 수 있습니다. - 모든 집계 열이 0이 되는 행은 머지 때 삭제됩니다.
- 평균이나 고유 수는 못 합니다.
sum과count를 따로 저장해 나누거나, AggregatingMergeTree로 갑니다. Map타입 열은 키별로 합산됩니다. 라벨별 카운터에 편리합니다.
AggregatingMergeTree
AggregateFunction(f, T) 타입 열에 집계의 중간 상태를 저장하고, 머지 때 상태를 병합합니다. uniq, quantile, avg, argMax처럼 단순 합으로 안 되는 집계를 롤업할 때 씁니다. 보통 머티리얼라이즈드 뷰의 대상 테이블로 쓰입니다.
CREATE TABLE hourly_stats
(
hour DateTime,
event LowCardinality(String),
users AggregateFunction(uniq, UInt64),
p95 AggregateFunction(quantile(0.95), UInt32),
cnt SimpleAggregateFunction(sum, UInt64)
)
ENGINE = AggregatingMergeTree
ORDER BY (hour, event);
CREATE MATERIALIZED VIEW hourly_stats_mv TO hourly_stats AS
SELECT toStartOfHour(ts) AS hour, event,
uniqState(user_id) AS users,
quantileState(0.95)(duration) AS p95,
count() AS cnt
FROM events
GROUP BY hour, event;
-- 읽기: -Merge 접미사
SELECT hour, event, uniqMerge(users) AS users, quantileMerge(0.95)(p95) AS p95, sum(cnt)
FROM hourly_stats
GROUP BY hour, event
ORDER BY hour;- 쓸 때
-State, 읽을 때-Merge. 쌍이 맞지 않으면 오류나 엉뚱한 값이 나옵니다. SimpleAggregateFunction은sum,max,min,any,anyLast,groupBitOr처럼 중간 상태가 값 자체인 함수에 쓰며, 상태 직렬화가 없어 가볍습니다.- 상태 열은 압축이 잘 안 되고(특히
uniq상태) 크기가 큽니다. 롤업 단위(시간, 일)를 너무 잘게 잡지 마세요.uniqCombined나uniqHLL12상태는uniq보다 작고 정확도 손실이 작습니다.
컴비네이터와 상태 함수는 집계 함수와 컴비네이터에서 자세히 다룹니다.
CollapsingMergeTree와 VersionedCollapsingMergeTree
행에 sign 열(+1 또는 -1)을 두고, 같은 키의 +1과 -1 쌍을 머지 때 상쇄합니다. "이전 상태를 -1로 한 번 더 넣고 새 상태를 +1로 넣는" 방식으로 상태 변경을 누적하면, 머지 뒤에는 최신 상태만 남습니다.
CREATE TABLE sessions
(
session_id UInt64,
duration UInt32,
pages UInt16,
sign Int8
)
ENGINE = CollapsingMergeTree(sign)
ORDER BY session_id;
-- 상태 갱신: 이전 행을 -1 로 취소하고 새 행을 +1 로
INSERT INTO sessions VALUES (1, 120, 3, -1), (1, 180, 5, 1);
-- 읽기: sign 을 곱해 집계
SELECT session_id, sum(duration * sign) AS duration, sum(pages * sign) AS pages
FROM sessions
GROUP BY session_id
HAVING sum(sign) > 0;CollapsingMergeTree는 -1이 +1보다 먼저 들어왔다고 가정합니다. 순서가 뒤바뀌어 들어오는 분산 적재에서는VersionedCollapsingMergeTree(sign, version)을 써서 버전으로 쌍을 맞춥니다.- 취소 행을 넣으려면 이전 값을 전부 알아야 합니다. 상태의 전체 값을 늘 가지고 있는 소스(애플리케이션 DB의 변경 이벤트)에만 현실적입니다. 그렇지 않으면 ReplacingMergeTree가 훨씬 단순합니다.
어떤 걸 고를까
| 요구 | 선택 |
|---|---|
| 키별 최신 상태, 소스가 전체 행을 보내 줌 | ReplacingMergeTree(ver, is_deleted) |
| 중복 적재 방지 (같은 행이 두 번 올 수 있음) | ReplacingMergeTree, 또는 INSERT 블록 중복 제거 (INSERT 모범 사례) |
| 카운터, 합계 롤업 | SummingMergeTree |
| uniq, 분위수, argMax 롤업 | AggregatingMergeTree + MV |
| 증감이 있는 상태 누적, 취소 행을 만들 수 있음 | CollapsingMergeTree 또는 Versioned |
| 삭제, 갱신이 가끔 | 일반 MergeTree + 경량 삭제, 경량 갱신 |
흔한 오해
- "ReplacingMergeTree인데 중복이 보여요." 정상입니다. 머지 전이거나 다른 파티션에 있습니다.
FINAL이나argMax로 읽으세요. - "OPTIMIZE FINAL을 매 5분 돌려요." 큰 테이블에서는 디스크를 계속 다시 쓰는 셈입니다. 읽기 쪽에서 해결하세요.
- "SummingMergeTree인데 합이 틀려요." 읽을 때
sum()없이 그대로 보면 머지 안 된 행이 여러 개입니다. - "AggregatingMergeTree 테이블을 그냥 SELECT 했더니 바이너리가 나와요." 상태 열은
-Merge로 읽어야 합니다. - "복제본마다 결과가 달라요." 머지 진행 상태가 복제본마다 다를 수 있습니다.
FINAL을 쓰면 같아집니다.