중복 제거 전략
중복 제거 전략 (Deduplication strategies)
중복 제거(Deduplication)는 데이터셋의 중복 행을 제거하는 과정입니다. ClickHouse는 삽입 속도에 최적화되어 있고 저장 파일이 불변이므로, 중복 제거는 즉시가 아니라 궁극적(eventual)으로 일어납니다. ReplacingMergeTree와 CollapsingMergeTree 계열 엔진으로 구현해요.
출처: 문서
본문
중복 제거(Deduplication)는 데이터셋의 중복 행을 제거하는 과정을 말합니다. OLTP 데이터베이스에서는 각 행이 고유한 기본 키를 가지므로 쉽게 수행되지만 — 삽입이 느려지는 대가가 따릅니다. 삽입되는 모든 행을 먼저 검색해야 하고, 발견되면 대체해야 합니다.
ClickHouse는 데이터 삽입 속도에 맞춰 구축되었습니다. 저장 파일은 불변이고 ClickHouse는 행을 삽입하기 전에 기존 기본 키를 확인하지 않습니다 — 그래서 중복 제거는 조금 더 노력이 필요합니다. 이것은 또한 중복 제거가 즉시가 아니라는 뜻입니다 — 궁극적(eventual)이며, 몇 가지 부작용이 있습니다:
- 어느 순간에도 테이블에 중복(같은 정렬 키를 가진 행)이 여전히 있을 수 있습니다.
- 중복 행의 실제 제거는 파트 병합 중에 발생합니다.
- 쿼리는 중복 가능성을 허용해야 합니다.
| ClickHouse는 중복 제거와 다른 많은 주제에 대한 무료 교육을 제공합니다. "Deleting and Updating Data" 교육 모듈이 시작하기 좋은 곳입니다. |
중복 제거 옵션
중복 제거는 ClickHouse에서 다음 테이블 엔진으로 구현됩니다:
-
ReplacingMergeTree테이블 엔진: 이 테이블 엔진으로 같은 정렬 키를 가진 중복 행이 병합 중에 제거됩니다.ReplacingMergeTree는 upsert 동작(쿼리가 마지막으로 삽입된 행을 반환하길 원할 때)을 흉내 내는 데 좋은 옵션입니다. -
콜랩싱 행:
CollapsingMergeTree와VersionedCollapsingMergeTree테이블 엔진은 기존 행이 "취소"되고 새 행이 삽입되는 로직을 사용합니다.ReplacingMergeTree보다 구현이 더 복잡하지만, 데이터가 아직 병합되었는지를 걱정하지 않고 쿼리와 집계를 더 단순하게 작성할 수 있습니다. 이 두 테이블 엔진은 데이터를 자주 갱신해야 할 때 유용합니다.
아래에서 두 기법을 모두 살펴봅니다. 더 자세한 내용은 무료 주문형 "Deleting and Updating Data" 교육 모듈을 확인하세요.
Upsert에 ReplacingMergeTree 사용
조회 수를 나타내는 views 컬럼을 가진 Hacker News 댓글을 담는 간단한 예제를 살펴봅시다. 기사가 게시될 때 새 행을 삽입하고, 값이 증가하면 총 조회 수로 하루에 한 번 새 행을 upsert한다고 가정해 봅시다:
CREATE TABLE hackernews_rmt (
id UInt32,
author String,
comment String,
views UInt64
)
ENGINE = ReplacingMergeTree
PRIMARY KEY (author, id)
두 행을 삽입해 봅시다:
INSERT INTO hackernews_rmt VALUES
(1, 'ricardo', 'This is post #1', 0),
(2, 'ch_fan', 'This is post #2', 0)
views 컬럼을 갱신하려면 같은 기본 키로 새 행을 삽입합니다(views 컬럼의 새 값에 주목):
INSERT INTO hackernews_rmt VALUES
(1, 'ricardo', 'This is post #1', 100),
(2, 'ch_fan', 'This is post #2', 200)
테이블은 이제 4개의 행을 가집니다:
SELECT *
FROM hackernews_rmt
┌─id─┬─author──┬─comment─────────┬─views─┐
│ 2 │ ch_fan │ This is post #2 │ 0 │
│ 1 │ ricardo │ This is post #1 │ 0 │
└────┴─────────┴─────────────────┴───────┘
┌─id─┬─author──┬─comment─────────┬─views─┐
│ 2 │ ch_fan │ This is post #2 │ 200 │
│ 1 │ ricardo │ This is post #1 │ 100 │
└────┴─────────┴─────────────────┴───────┘
출력의 위 분리된 상자는 두 파트를 보여 줍니다. 이 데이터는 아직 병합되지 않았으므로 중복 행이 아직 제거되지 않았습니다. SELECT 쿼리에 FINAL 키워드를 사용하면 쿼리 결과의 논리적 병합이 됩니다:
SELECT *
FROM hackernews_rmt
FINAL
┌─id─┬─author──┬─comment─────────┬─views─┐
│ 2 │ ch_fan │ This is post #2 │ 200 │
│ 1 │ ricardo │ This is post #1 │ 100 │
└────┴─────────┴─────────────────┴───────┘
결과는 2행뿐이고, 반환되는 행은 마지막으로 삽입된 행입니다.
FINAL 사용은 데이터 양이 적을 때 괜찮습니다. 대량의 데이터를 다룰 때는 FINAL을 사용하는 것이 최선의 선택이 아닐 수 있습니다. 컬럼의 최신 값을 찾는 더 나은 옵션을 논의해 봅시다.
FINAL 피하기
두 고유 행 모두에 대해 views 컬럼을 다시 갱신해 봅시다:
INSERT INTO hackernews_rmt VALUES
(1, 'ricardo', 'This is post #1', 150),
(2, 'ch_fan', 'This is post #2', 250)
테이블은 이제 6개의 행을 가집니다. 실제 병합이 아직 일어나지 않았기 때문입니다(FINAL을 사용했을 때의 쿼리 시간 병합만 있었음).
SELECT *
FROM hackernews_rmt
┌─id─┬─author──┬─comment─────────┬─views─┐
│ 2 │ ch_fan │ This is post #2 │ 200 │
│ 1 │ ricardo │ This is post #1 │ 100 │
└────┴─────────┴─────────────────┴───────┘
┌─id─┬─author──┬─comment─────────┬─views─┐
│ 2 │ ch_fan │ This is post #2 │ 0 │
│ 1 │ ricardo │ This is post #1 │ 0 │
└────┴─────────┴─────────────────┴───────┘
┌─id─┬─author──┬─comment─────────┬─views─┐
│ 2 │ ch_fan │ This is post #2 │ 250 │
│ 1 │ ricardo │ This is post #1 │ 150 │
└────┴─────────┴─────────────────┴───────┘
FINAL을 사용하는 대신 비즈니스 로직을 사용해 봅시다 — views 컬럼이 항상 증가한다는 것을 알고 있으므로, 원하는 컬럼으로 그룹핑한 뒤 max 함수로 가장 큰 값을 가진 행을 선택할 수 있습니다:
SELECT
id,
author,
comment,
max(views)
FROM hackernews_rmt
GROUP BY (id, author, comment)
┌─id─┬─author──┬─comment─────────┬─max(views)─┐
│ 2 │ ch_fan │ This is post #2 │ 250 │
│ 1 │ ricardo │ This is post #1 │ 150 │
└────┴─────────┴─────────────────┴────────────┘
위 쿼리처럼 그룹핑하는 것은 FINAL 키워드를 사용하는 것보다(쿼리 성능 측면에서) 실제로 더 효율적일 수 있습니다.
우리의 "Deleting and Updating Data" 교육 모듈은 이 예제를 확장하며, ReplacingMergeTree로 version 컬럼을 사용하는 방법을 포함합니다.
자주 컬럼을 갱신할 때 CollapsingMergeTree 사용
컬럼 갱신은 기존 행을 삭제하고 새 값으로 대체하는 것을 포함합니다. 이미 보았듯이, ClickHouse에서 이런 유형의 뮤테이션은 궁극적으로 — 병합 중에 — 발생합니다. 갱신할 행이 많으면 ALTER TABLE..UPDATE를 피하고 기존 데이터 옆에 새 데이터를 삽입하는 것이 실제로 더 효율적일 수 있습니다. 데이터가 오래됐는지 새 것인지를 나타내는 컬럼을 추가할 수 있습니다… 그리고 실제로 이 동작을 이미 매우 잘 구현하는 테이블 엔진이 있습니다. 특히 오래된 데이터를 자동으로 삭제해 주기까지 합니다. 어떻게 동작하는지 살펴봅시다.
Hacker News 댓글의 조회 수를 외부 시스템으로 추적하고, 몇 시간마다 데이터를 ClickHouse로 푸시한다고 가정해 봅시다. 오래된 행이 삭제되고 새 행이 각 Hacker News 댓글의 새 상태를 나타내길 원합니다. CollapsingMergeTree를 사용해 이 동작을 구현할 수 있습니다.
조회 수를 저장할 테이블을 정의해 봅시다:
CREATE TABLE hackernews_views (
id UInt32,
author String,
views UInt64,
sign Int8
)
ENGINE = CollapsingMergeTree(sign)
PRIMARY KEY (id, author)
hackernews_views 테이블에 sign이라는 이름의 Int8 컬럼이 있고 이것이 sign 컬럼으로 불리는 것에 주목하세요. sign 컬럼의 이름은 임의지만 Int8 데이터 타입이 필요하며, 컬럼 이름이 CollapsingMergeTree 테이블의 생성자에 전달되었음을 주목하세요.
CollapsingMergeTree 테이블의 sign 컬럼은 무엇인가요? 그것은 행의 상태(state)를 나타내며, sign 컬럼은 1 또는 -1만 될 수 있습니다. 동작 방식은 다음과 같습니다:
- 두 행이 같은 기본 키(기본 키와 다르면 정렬 순서)를 가지지만 sign 컬럼 값이 다르면, +1로 삽입된 마지막 행이 상태 행이 되고 나머지 행은 서로 취소합니다.
- 서로 취소하는 행은 병합 중에 삭제됩니다.
- 짝이 맞지 않는 행은 유지됩니다.
hackernews_views 테이블에 행을 추가해 봅시다. 이 기본 키에 대한 유일한 행이므로 상태를 1로 설정합니다:
INSERT INTO hackernews_views VALUES
(123, 'ricardo', 0, 1)
이제 views 컬럼을 변경하고 싶다고 가정해 봅시다. 기존 행을 취소하는 행과 새 상태를 담은 행 두 개를 삽입합니다:
INSERT INTO hackernews_views VALUES
(123, 'ricardo', 0, -1),
(123, 'ricardo', 150, 1)
테이블은 이제 기본 키 (123, 'ricardo')를 가진 3개의 행을 가집니다:
SELECT *
FROM hackernews_views
┌──id─┬─author──┬─views─┬─sign─┐
│ 123 │ ricardo │ 0 │ -1 │
│ 123 │ ricardo │ 150 │ 1 │
└─────┴─────────┴───────┴──────┘
┌──id─┬─author──┬─views─┬─sign─┐
│ 123 │ ricardo │ 0 │ 1 │
└─────┴─────────┴───────┴──────┘
FINAL을 추가하면 현재 상태 행을 반환하는 것에 주목하세요:
SELECT *
FROM hackernews_views
FINAL
┌──id─┬─author──┬─views─┬─sign─┐
│ 123 │ ricardo │ 150 │ 1 │
└─────┴─────────┴───────┴──────┘
물론 대규모 테이블에서 FINAL을 사용하는 것은 권장되지 않습니다.
예제에서 views 컬럼에 전달된 값은 실제로 필요 없으며, 오래된 행의 현재 views 값과 일치할 필요도 없습니다. 사실 기본 키와 -1만으로 행을 취소할 수 있습니다:
INSERT INTO hackernews_views(id, author, sign) VALUES
(123, 'ricardo', -1)
여러 스레드에서의 실시간 갱신
CollapsingMergeTree 테이블에서는 행이 sign 컬럼으로 서로를 취소하고, 행의 상태는 마지막으로 삽입된 행에 의해 결정됩니다. 그러나 다른 스레드에서 행을 삽입할 때 행이 순서 없이 삽입될 수 있다면 문제가 될 수 있습니다. 이 상황에서는 "마지막" 행을 사용하는 것이 동작하지 않습니다.
여기서 VersionedCollapsingMergeTree가 유용합니다 — CollapsingMergeTree처럼 행을 콜랩스하지만, 마지막으로 삽입된 행을 유지하는 대신 지정한 버전 컬럼의 가장 높은 값을 가진 행을 유지합니다.
예제를 살펴봅시다. Hacker News 댓글의 조회 수를 추적하고 데이터가 자주 갱신된다고 가정해 봅시다. 보고가 병합을 강제하거나 기다리지 않고 최신 값을 사용하길 원합니다. CollapsedMergeTree와 비슷한 테이블로 시작하되, 행 상태의 버전을 저장하는 컬럼을 추가합니다:
CREATE TABLE hackernews_views_vcmt (
id UInt32,
author String,
views UInt64,
sign Int8,
version UInt32
)
ENGINE = VersionedCollapsingMergeTree(sign, version)
PRIMARY KEY (id, author)
테이블이 VersionsedCollapsingMergeTree 엔진을 사용하고 sign 컬럼과 version 컬럼을 전달하는 것에 주목하세요. 테이블 동작 방식은 다음과 같습니다:
- 같은 기본 키와 버전, 다른 sign을 가진 각 행 쌍을 삭제합니다.
- 행이 삽입된 순서는 중요하지 않습니다.
- 버전 컬럼이 기본 키의 일부가 아니라면 ClickHouse는 그것을 마지막 필드로 기본 키에 암시적으로 추가합니다.
쿼리를 작성할 때도 같은 유형의 로직을 사용합니다 — 기본 키로 그룹핑하고, 취소되었지만 아직 삭제되지 않은 행을 피하는 영리한 로직을 사용합니다. hackernews_views_vcmt 테이블에 몇 개의 행을 추가해 봅시다:
INSERT INTO hackernews_views_vcmt VALUES
(1, 'ricardo', 0, 1, 1),
(2, 'ch_fan', 0, 1, 1),
(3, 'kenny', 0, 1, 1)
이제 두 행을 갱신하고 하나를 삭제합니다. 행을 취소하려면 이전 버전 번호를 포함해야 합니다(기본 키의 일부이므로):
INSERT INTO hackernews_views_vcmt VALUES
(1, 'ricardo', 0, -1, 1),
(1, 'ricardo', 50, 1, 2),
(2, 'ch_fan', 0, -1, 1),
(3, 'kenny', 0, -1, 1),
(3, 'kenny', 1000, 1, 2)
sign 컬럼에 기반해 값을 영리하게 더하고 빼는 이전과 같은 쿼리를 실행합니다:
SELECT
id,
author,
sum(views * sign)
FROM hackernews_views_vcmt
GROUP BY (id, author)
HAVING sum(sign) > 0
ORDER BY id ASC
결과는 두 행입니다:
┌─id─┬─author──┬─sum(multiply(views, sign))─┐
│ 1 │ ricardo │ 50 │
│ 3 │ kenny │ 1000 │
└────┴─────────┴────────────────────────────┘
테이블 병합을 강제해 봅시다:
OPTIMIZE TABLE hackernews_views_vcmt
결과에는 두 행만 있어야 합니다:
SELECT *
FROM hackernews_views_vcmt
┌─id─┬─author──┬─views─┬─sign─┬─version─┐
│ 1 │ ricardo │ 50 │ 1 │ 2 │
│ 3 │ kenny │ 1000 │ 1 │ 2 │
└────┴─────────┴───────┴──────┴─────────┘
여러 클라이언트 및/또는 스레드에서 행을 삽입하면서 중복 제거를 구현하려 할 때 VersionedCollapsingMergeTree 테이블은 꽤 유용합니다.
내 행이 중복 제거되지 않는 이유는?
삽입된 행이 중복 제거되지 않는 한 가지 이유는 INSERT 문에서 비멱등(non-idempotent) 함수나 표현식을 사용하기 때문입니다. 예를 들어 createdAt DateTime64(3) DEFAULT now() 컬럼으로 행을 삽입하면, 각 행이 createdAt 컬럼에 대해 고유한 기본값을 가지므로 행이 고유하다는 것이 보장됩니다. MergeTree / ReplicatedMergeTree 테이블 엔진은 각 삽입된 행이 고유한 체크섬을 만들므로 행을 중복 제거할 방법을 알지 못합니다.
이 경우 각 행 배치에 대해 자신의 insert_deduplication_token을 지정하여 같은 배치의 여러 삽입이 같은 행을 다시 삽입하지 않게 할 수 있습니다. 이 설정 사용 방법에 대한 자세한 내용은 insert_deduplication_token 문서를 참고하세요.