CollapsingMergeTree 테이블 엔진

CollapsingMergeTree 테이블 엔진

설명

CollapsingMergeTree 엔진은 MergeTree에서 상속하고 병합 과정에서 행을 축소(collapse)하는 로직을 추가해요. CollapsingMergeTree 테이블 엔진은 특수 필드 Sign(값이 1 또는 -1일 수 있음)을 제외하고 정렬 키(ORDER BY)의 모든 필드가 동일하면 행 쌍을 비동기적으로 삭제(축소)해요. 반대 값 Sign의 쌍이 없는 행은 유지돼요. 자세한 내용은 문서의 Collapsing 섹션을 참고해요.

이 엔진은 저장 볼륨을 크게 줄일 수 있어 결과적으로 SELECT 쿼리의 효율을 높여요.

출처: 문서

본문

매개변수

이 테이블 엔진의 모든 매개변수는 Sign 매개변수를 제외하고 MergeTree와 같은 의미를 가져요.

  • Sign1이 "state" 행이고 -1이 "cancel" 행인 행 유형의 컬럼에 주어진 이름. 타입: Int8

테이블 생성하기

CREATE TABLE [IF NOT EXISTS] [db.]table_name [ON CLUSTER cluster]
(
    name1 [type1] [DEFAULT|MATERIALIZED|ALIAS expr1],
    name2 [type2] [DEFAULT|MATERIALIZED|ALIAS expr2],
    ...
)
ENGINE = CollapsingMergeTree(Sign)
[PARTITION BY expr]
[ORDER BY expr]
[SAMPLE BY expr]
[SETTINGS name=value, ...]
  • 쿼리 매개변수에 대한 설명은 query description을 참고해요
  • CollapsingMergeTree 테이블을 만들 때 MergeTree 테이블을 만들 때와 같은 쿼리 절들이 필요해요

Collapsing

데이터

주어진 객체에 대해 끊임없이 변화하는 데이터를 저장해야 하는 상황을 고려해요. 객체당 하나의 행을 두고 무언가 변할 때마다 업데이트하는 것이 논리적으로 들릴 수 있지만, 업데이트 연산은 스토리지의 데이터 재작성을 요구하므로 DBMS에게 비싸고 느려요. 데이터를 빠르게 써야 한다면 많은 수의 업데이트를 수행하는 것은 받아들일 수 없는 접근이에요. 하지만 객체의 변경 사항을 항상 순차적으로 쓸 수는 있어요. 이를 위해 특수 컬럼 Sign을 사용해요.

  • Sign = 1이면 행이 "state" 행이라는 뜻이에요: 현재 유효한 상태를 나타내는 필드를 포함하는 행
  • Sign = -1이면 행이 "cancel" 행이라는 뜻이에요: 같은 속성을 가진 객체의 상태를 취소하는 데 사용되는 행

예를 들어 사용자가 어떤 웹사이트에서 몇 페이지를 확인했고 얼마나 오래 방문했는지 계산하고 싶다고 해요. 어떤 특정 시점에 사용자 활동 상태로 다음 행을 써요.

┌──────────────UserID─┬─PageViews─┬─Duration─┬─Sign─┐
│ 4324182021466249494 │         5 │      146 │    1 │
└─────────────────────┴───────────┴──────────┴──────┘

나중에 사용자 활동의 변화를 등록하고 다음 두 행으로 써요.

┌──────────────UserID─┬─PageViews─┬─Duration─┬─Sign─┐
│ 4324182021466249494 │         5 │      146 │   -1 │
│ 4324182021466249494 │         6 │      185 │    1 │
└─────────────────────┴───────────┴──────────┴──────┘

첫 행은 객체(이 경우 사용자)의 이전 상태를 취소해요. "취소된" 행의 정렬 키 필드를 모두 Sign을 제외하고 복사해야 해요. 위 두 번째 행은 현재 상태를 포함해요. 사용자 활동의 마지막 상태만 필요하므로, 아래처럼 보여진 원래 "state" 행과 삽입한 "cancel" 행을 삭제해 객체의 무효(이전) 상태를 축소할 수 있어요.

┌──────────────UserID─┬─PageViews─┬─Duration─┬─Sign─┐
│ 4324182021466249494 │         5 │      146 │    1 │ -- old "state" row can be deleted
│ 4324182021466249494 │         5 │      146 │   -1 │ -- "cancel" row can be deleted
│ 4324182021466249494 │         6 │      185 │    1 │ -- new "state" row remains
└─────────────────────┴───────────┴──────────┴──────┘

CollapsingMergeTree는 데이터 파트 병합이 일어나는 동안 정확히 이 축소 동작을 수행해요. 각 변경마다 두 행이 필요한 이유는 Algorithm 단락에서 더 논의돼요.

이러한 접근 방식의 특이점

  • 데이터를 쓰는 프로그램은 취소할 수 있도록 객체의 상태를 기억해야 해요. "cancel" 행은 "state"의 정렬 키 필드 사본과 반대 Sign을 포함해야 해요. 이는 저장소의 초기 크기를 늘리지만 데이터를 빠르게 쓸 수 있게 해줘요
  • 컬럼에서 길게 늘어나는 배열은 쓰기 부하 증가로 엔진 효율을 떨어뜨려요. 데이터가 더 단순할수록 효율이 더 높아져요
  • SELECT 결과는 객체 변경 내역의 일관성에 크게 의존해요. 삽입을 위한 데이터를 준비할 때 정확해야 해요. 일관되지 않은 데이터로 예측할 수 없는 결과를 얻을 수 있어요. 예를 들어 세션 깊이 같은 비음수 지표의 음수 값

알고리즘

ClickHouse가 데이터 파트를 병합할 때, 같은 정렬 키(ORDER BY)를 가진 연속적인 행의 각 그룹은 최대 두 행으로 줄어들어요: Sign = 1인 "state" 행과 Sign = -1인 "cancel" 행. 즉, ClickHouse에서 항목은 축소돼요. 각 결과 데이터 파트에 대해 ClickHouse는 다음을 저장해요.

1. "state"와 "cancel" 행 수가 일치하고 마지막 행이 "state" 행이면, 첫 번째 "cancel" 행과 마지막 "state" 행
2. "state" 행이 "cancel" 행보다 많으면 마지막 "state" 행
3. "cancel" 행이 "state" 행보다 많으면 첫 번째 "cancel" 행
4. 다른 모든 경우에는 없음(어떤 행도 저장하지 않음)

또한 "state" 행이 "cancel" 행보다 최소 두 개 이상 많거나, "cancel" 행이 "state" 행보다 최소 두 개 이상 많으면 병합이 계속돼요. 그러나 ClickHouse는 이 상황을 논리적 오류로 취급하고 서버 로그에 기록해요. 이 오류는 같은 데이터가 두 번 이상 삽입되면 발생할 수 있어요. 따라서 축소는 통계 계산 결과를 바꾸지 않아야 해요. 변경 사항은 점차 축소되어 결국 거의 모든 객체의 마지막 상태만 남게 돼요.

Sign 컬럼은 병합 알고리즘이 같은 정렬 키를 가진 모든 행이 같은 결과 데이터 파트, 심지어 같은 물리 서버에 있을 것도 보장하지 않기 때문에 필요해요. ClickHouse는 SELECT 쿼리를 여러 스레드로 처리하며 결과의 행 순서를 예측할 수 없어요. CollapsingMergeTree 테이블에서 완전히 "축소된" 데이터를 얻기 위해서는 집계가 필요해요.

축소를 마무리하려면 GROUP BY 절과 부호를 고려하는 집계 함수로 쿼리를 작성해요. 예를 들어 수량을 계산하려면 count() 대신 sum(Sign)을 사용해요. 무언가의 합을 계산하려면 아래 예시처럼 sum(x) 대신 HAVING sum(Sign) > 0과 함께 sum(Sign * x)를 사용해요. 집계 count, sum, avg는 이렇게 계산할 수 있어요. uniq 집계는 객체에 축소되지 않은 상태가 하나 이상 있으면 계산할 수 있어요. minmax 집계는 CollapsingMergeTree가 축소된 상태의 내역을 저장하지 않으므로 계산할 수 없어요.

집계 없이 데이터를 추출해야 한다면(예: 최신 값이 특정 조건과 일치하는 행이 있는지 확인), FROM 절에 FINAL 수정자를 사용할 수 있어요. 결과를 반환하기 전에 데이터를 병합해요. CollapsingMergeTree의 경우 각 키에 대한 최신 state 행만 반환돼요.

예시

사용 예시

다음 예시 데이터가 주어졌어요.

┌──────────────UserID─┬─PageViews─┬─Duration─┬─Sign─┐
│ 4324182021466249494 │         5 │      146 │    1 │
│ 4324182021466249494 │         5 │      146 │   -1 │
│ 4324182021466249494 │         6 │      185 │    1 │
└─────────────────────┴───────────┴──────────┴──────┘

CollapsingMergeTree를 사용해 UAct 테이블을 만들어요.

CREATE TABLE UAct
(
    UserID UInt64,
    PageViews UInt8,
    Duration UInt8,
    Sign Int8
)
ENGINE = CollapsingMergeTree(Sign)
ORDER BY UserID

다음으로 데이터를 삽입할게요.

INSERT INTO UAct VALUES (4324182021466249494, 5, 146, 1)
INSERT INTO UAct VALUES (4324182021466249494, 5, 146, -1),(4324182021466249494, 6, 185, 1)

두 개의 INSERT 쿼리를 사용해 두 개의 다른 데이터 파트를 만들어요. 데이터를 단일 쿼리로 삽입하면 ClickHouse는 하나의 데이터 파트만 만들고 결코 병합을 수행하지 않아요. 다음으로 데이터를 선택할 수 있어요.

SELECT * FROM UAct
┌──────────────UserID─┬─PageViews─┬─Duration─┬─Sign─┐
│ 4324182021466249494 │         5 │      146 │   -1 │
│ 4324182021466249494 │         6 │      185 │    1 │
└─────────────────────┴───────────┴──────────┴──────┘
┌──────────────UserID─┬─PageViews─┬─Duration─┬─Sign─┐
│ 4324182021466249494 │         5 │      146 │    1 │
└─────────────────────┴───────────┴──────────┴──────┘

위 반환 데이터를 보고 축소가 일어났는지 확인해볼게요… 두 개의 INSERT 쿼리로 두 개의 데이터 파트를 만들었어요. SELECT 쿼리는 두 스레드에서 수행되었고 무작위 순서의 행을 얻었어요. 그러나 아직 데이터 파트 병합이 없었으므로 축소는 일어나지 않았어요. ClickHouse는 예측할 수 없는 알 수 없는 시점에 백그라운드에서 데이터 파트를 병합해요. 따라서 우리는 sum 집계 함수와 HAVING 절로 수행하는 집계가 필요해요.

SELECT
    UserID,
    sum(PageViews * Sign) AS PageViews,
    sum(Duration * Sign) AS Duration
FROM UAct
GROUP BY UserID
HAVING sum(Sign) > 0
┌──────────────UserID─┬─PageViews─┬─Duration─┐
│ 4324182021466249494 │         6 │      185 │
└─────────────────────┴───────────┴──────────┘

집계가 필요 없고 축소를 강제하고 싶다면 FROM 절에 FINAL 수정자를 사용할 수도 있어요.

SELECT * FROM UAct FINAL
┌──────────────UserID─┬─PageViews─┬─Duration─┬─Sign─┐
│ 4324182021466249494 │         6 │      185 │    1 │
└─────────────────────┴───────────┴──────────┴──────┘

이런 데이터 선택 방식은 비효율적이며 많은 양의 스캔 데이터(수백만 행)에는 권장되지 않아요.

다른 접근 방식의 예시

이 접근 방식의 아이디어는 병합이 키 필드만 고려한다는 점이에요. 따라서 "cancel" 행에서 Sign 컬럼을 사용하지 않고 합산할 때 이전 버전의 행을 상쇄하는 음수 값을 지정할 수 있어요.

이 예시에서는 아래 샘플 데이터를 사용할게요.

┌──────────────UserID─┬─PageViews─┬─Duration─┬─Sign─┐
│ 4324182021466249494 │         5 │      146 │    1 │
│ 4324182021466249494 │        -5 │     -146 │   -1 │
│ 4324182021466249494 │         6 │      185 │    1 │
└─────────────────────┴───────────┴──────────┴──────┘

이 접근 방식에서는 음수 값을 저장하도록 PageViewsDuration의 데이터 타입을 바꿔야 해요. 따라서 collapsingMergeTreeUAct 테이블을 만들 때 이 컬럼의 값을 UInt8에서 Int16으로 바꿔요.

CREATE TABLE UAct
(
    UserID UInt64,
    PageViews Int16,
    Duration Int16,
    Sign Int8
)
ENGINE = CollapsingMergeTree(Sign)
ORDER BY UserID

테이블에 데이터를 삽입해 접근 방식을 테스트해요. 예시나 작은 테이블의 경우에는 받아들일 수 있어요.

INSERT INTO UAct VALUES(4324182021466249494,  5,  146,  1);
INSERT INTO UAct VALUES(4324182021466249494, -5, -146, -1);
INSERT INTO UAct VALUES(4324182021466249494,  6,  185,  1);

SELECT * FROM UAct FINAL;
┌──────────────UserID─┬─PageViews─┬─Duration─┬─Sign─┐
│ 4324182021466249494 │         6 │      185 │    1 │
└─────────────────────┴───────────┴──────────┴──────┘
SELECT
    UserID,
    sum(PageViews) AS PageViews,
    sum(Duration) AS Duration
FROM UAct
GROUP BY UserID
┌──────────────UserID─┬─PageViews─┬─Duration─┐
│ 4324182021466249494 │         6 │      185 │
└─────────────────────┴───────────┴──────────┘
SELECT COUNT() FROM UAct
┌─count()─┐
│       3 │
└─────────┘
OPTIMIZE TABLE UAct FINAL;

SELECT * FROM UAct
┌──────────────UserID─┬─PageViews─┬─Duration─┬─Sign─┐
│ 4324182021466249494 │         6 │      185 │    1 │
└─────────────────────┴───────────┴──────────┴──────┘

더 알아보기 (Learn more)