VersionedCollapsingMergeTree 테이블 엔진

VersionedCollapsingMergeTree 테이블 엔진

이 엔진은:

  • 끊임없이 변하는 객체 상태를 빠르게 쓸 수 있게 해줘요
  • 백그라운드에서 이전 객체 상태를 삭제해요. 이는 저장 볼륨을 크게 줄여줘요

자세한 내용은 Collapsing 섹션을 참고해요. 이 엔진은 MergeTree에서 상속하고 데이터 파트 병합 알고리즘에 행 축소 로직을 추가해요. VersionedCollapsingMergeTreeCollapsingMergeTree와 같은 목적을 수행하지만, 여러 스레드로 어떤 순서로든 데이터를 삽입할 수 있게 하는 다른 축소 알고리즘을 사용해요. 특히 Version 컬럼은 행이 잘못된 순서로 삽입되더라도 올바르게 축소하는 데 도움을 줘요. 반대로 CollapsingMergeTree는 엄격하게 연속적인 삽입만 허용해요.

출처: 문서

본문

테이블 생성하기

CREATE TABLE [IF NOT EXISTS] [db.]table_name [ON CLUSTER cluster]
(
    name1 [type1] [DEFAULT|MATERIALIZED|ALIAS expr1],
    name2 [type2] [DEFAULT|MATERIALIZED|ALIAS expr2],
    ...
) ENGINE = VersionedCollapsingMergeTree(sign, version)
[PARTITION BY expr]
[ORDER BY expr]
[SAMPLE BY expr]
[SETTINGS name=value, ...]

쿼리 매개변수에 대한 설명은 query description을 참고해요.

엔진 매개변수

VersionedCollapsingMergeTree(sign, version)
Parameter Description Type
sign 행 유형의 컬럼 이름: 1은 "state" 행, -1은 "cancel" 행 Int8
version 객체 상태 버전의 컬럼 이름 Int*, UInt*, Date, Date32, DateTime 또는 DateTime64

쿼리 절

VersionedCollapsingMergeTree 테이블을 만들 때 MergeTree 테이블을 만들 때와 같은 절들이 필요해요.

Collapsing

데이터

어떤 객체의 끊임없이 변화하는 데이터를 저장해야 하는 상황을 고려해요. 객체당 하나의 행을 두고 변화가 있을 때마다 행을 업데이트하는 것이 합리적이에요. 그러나 업데이트 연산은 스토리지의 데이터 재작성을 요구하므로 DBMS에게 비싸고 느려요. 데이터를 빠르게 써야 한다면 업데이트는 받아들일 수 없지만, 다음과 같이 객체의 변경 사항을 순차적으로 쓸 수는 있어요.

행을 쓸 때 Sign 컬럼을 사용해요. Sign = 1이면 행이 객체의 상태라는 뜻이에요 ("state" 행이라고 부르자). Sign = -1이면 같은 속성을 가진 객체의 상태 취소를 나타내요 ("cancel" 행이라고 부르자). 또한 Version 컬럼을 사용해요. 이 컬럼은 객체의 각 상태를 별도의 숫자로 식별해야 해요.

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

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

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

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

첫 행은 객체(사용자)의 이전 상태를 취소해요. 취소된 상태의 필드를 Sign을 제외하고 모두 복사해야 해요. 두 번째 행은 현재 상태를 포함해요. 사용자 활동의 마지막 상태만 필요하므로, 행들

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

을 삭제해 객체의 무효(이전) 상태를 축소할 수 있어요. VersionedCollapsingMergeTree는 데이터 파트를 병합하는 동안 이 작업을 해요. 각 변경마다 두 행이 필요한 이유는 Algorithm을 참고해요.

사용 참고사항

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

알고리즘

ClickHouse가 데이터 파트를 병합할 때 같은 기본 키와 버전을 갖고 Sign이 다른 각 행 쌍을 삭제해요. 행 순서는 중요하지 않아요. ClickHouse가 데이터를 삽입할 때 기본 키로 행을 정렬해요. Version 컬럼이 기본 키에 없으면 ClickHouse가 이를 암시적으로 기본 키의 마지막 필드로 추가하고 정렬에 사용해요.

데이터 선택

ClickHouse는 같은 기본 키를 가진 모든 행이 같은 결과 데이터 파트, 심지어 같은 물리 서버에 있을 것도 보장하지 않아요. 이는 데이터 쓰기와 이후 데이터 파트 병합 둘 다에 해당돼요. 또한 ClickHouse는 SELECT 쿼리를 여러 스레드로 처리하며 결과의 행 순서를 예측할 수 없어요. 이는 VersionedCollapsingMergeTree 테이블에서 완전히 "축소된" 데이터를 얻으려면 집계가 필요하다는 뜻이에요.

축소를 마무리하려면 GROUP BY 절과 부호를 고려하는 집계 함수로 쿼리를 작성해요. 예를 들어 수량을 계산하려면 count() 대신 sum(Sign)을 사용해요. 무언가의 합을 계산하려면 sum(x) 대신 sum(Sign * x)를 사용하고 HAVING sum(Sign) > 0을 추가해요.

집계 count, sum, avg는 이렇게 계산할 수 있어요. uniq 집계는 객체에 축소되지 않은 상태가 하나 이상 있으면 계산할 수 있어요. minmax 집계는 VersionedCollapsingMergeTree가 축소된 상태의 값 내역을 저장하지 않으므로 계산할 수 없어요.

"축소"를 적용해 데이터를 추출해야 하지만 집계는 필요 없다면(예: 최신 값이 특정 조건과 일치하는 행이 있는지 확인), FROM 절에 FINAL 수정자를 사용할 수 있어요. 이 접근 방식은 비효율적이며 큰 테이블에는 사용하지 말아야 해요.

사용 예시

예시 데이터:

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

테이블 생성:

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

데이터 삽입:

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

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

데이터 가져오기:

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

여기서 무엇이 보이고 축소된 파트는 어디 있을까요? 두 개의 INSERT 쿼리로 두 개의 데이터 파트를 만들었어요. SELECT 쿼리는 두 스레드에서 수행되었고 결과는 행의 무작위 순서예요. 데이터 파트가 아직 병합되지 않았으므로 축소는 일어나지 않았어요. ClickHouse는 예측할 수 없는 알 수 없는 시점에 데이터 파트를 병합해요. 그래서 집계가 필요한 거예요.

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

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

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

이것은 데이터를 선택하는 매우 비효율적인 방법이에요. 큰 테이블에는 사용하지 마세요.

더 알아보기 (Learn more)