ReplacingMergeTree 엔진 작업
ReplacingMergeTree 엔진 작업 (Working with the ReplacingMergeTree engine)
ReplacingMergeTree 테이블 엔진은 비효율적인 ALTER나 DELETE 문 없이 행에 대한 업데이트 연산을 적용할 수 있게 해 줍니다. 같은 행의 여러 복사본을 삽입하고 하나를 최신 버전으로 표시하면, 백그라운드 프로세스가 오래된 버전을 비동기로 제거해 불변 삽입으로 업데이트를 흉내 냅니다.
출처: 문서
본문
트랜잭션 데이터베이스는 트랜잭션 업데이트·삭제 워크로드에 최적화되어 있는 반면, OLAP 데이터베이스는 그러한 연산에 대해 축소된 보장을 제공합니다. 대신 훨씬 빠른 분석 쿼리의 이점을 위해 배치로 삽입되는 불변 데이터에 최적화합니다. ClickHouse는 뮤테이션을 통한 업데이트 연산과 경량 행 삭제 수단을 제공하지만, 컬럼 지향 구조로 인해 위에서 설명한 대로 이러한 연산은 신중하게 예약되어야 합니다. 이 연산들은 비동기로 처리되고, 단일 스레드로 처리되며, (업데이트의 경우) 데이터가 디스크에서 다시 쓰여져야 합니다. 따라서 많은 수의 작은 변경에는 사용해서는 안 됩니다.
위의 사용 패턴을 피하면서 업데이트·삭제 행의 스트림을 처리하기 위해 ClickHouse 테이블 엔진 ReplacingMergeTree를 사용할 수 있습니다.
삽입된 행의 자동 upsert
ReplacingMergeTree 테이블 엔진은 같은 행의 여러 복사본을 삽입하고 하나를 최신 버전으로 표시할 수 있게 해 줌으로써, 비효율적인 ALTER나 DELETE 문을 사용할 필요 없이 행에 업데이트 연산을 적용할 수 있게 합니다. 그러면 백그라운드 프로세스가 같은 행의 오래된 버전을 비동기로 제거해 불변 삽입을 통해 업데이트 연산을 효율적으로 흉내 냅니다.
이것은 테이블 엔진이 중복 행을 식별하는 능력에 의존합니다. 이것은 ORDER BY 절로 유일성을 결정해 이루어집니다. 즉, 두 행이 ORDER BY에 지정된 컬럼에 대해 같은 값을 가지면 중복으로 간주됩니다. 테이블 정의 시 지정된 version 컬럼은 두 행이 중복으로 식별될 때 행의 최신 버전이 유지되게 합니다. 즉 가장 높은 버전 값을 가진 행이 유지됩니다.
아래 예제에서 이 과정을 설명합니다. 여기서 행은 A 컬럼(테이블의 ORDER BY)으로 고유하게 식별됩니다. 이 행들이 두 배치로 삽입되어 디스크에 두 개의 데이터 파트가 형성되었다고 가정합니다. 나중에 비동기 백그라운드 프로세스 중에 이 파트들이 병합됩니다.
ReplacingMergeTree는 또한 deleted 컬럼을 지정할 수 있게 합니다. 이것은 0 또는 1을 담을 수 있으며, 값 1은 행(및 그 중복)이 삭제되었음을, 그 외에는 0을 나타냅니다. 참고: 삭제된 행은 병합 시 제거되지 않습니다.
이 과정 중 파트 병합 시 다음이 발생합니다:
- A 컬럼에서 값 1로 식별되는 행은 버전 2의 업데이트 행과 버전 3의 삭제 행(deleted 컬럼 값 1)을 모두 가집니다. 삭제로 표시된 최신 행이 따라서 유지됩니다.
- A 컬럼에서 값 2로 식별되는 행은 두 개의 업데이트 행을 가집니다. price 컬럼에 대해 값 6을 가진 나중 행이 유지됩니다.
- A 컬럼에서 값 3으로 식별되는 행은 버전 1의 행과 버전 2의 삭제 행을 가집니다. 이 삭제 행이 유지됩니다.
이 병합 과정의 결과로 최종 상태를 나타내는 네 개의 행이 있습니다:
삭제된 행은 절대 제거되지 않음을 주의하세요. 그것들은 OPTIMIZE table FINAL CLEANUP으로 강제 삭제할 수 있습니다. 이것은 실험적 설정 allow_experimental_replacing_merge_with_cleanup=1을 필요로 합니다. 이것은 다음 조건에서만 발행해야 합니다:
- 연산이 발행된 후에 (청소로 삭제되는 것들에 대해) 오래된 버전의 행이 삽입되지 않을 것이라고 확신할 수 있습니다. 삽입되면 삭제된 행이 더 이상 존재하지 않으므로 잘못 유지됩니다.
- 청소를 발행하기 전에 모든 복제본이 동기화되었는지 확인하세요. 다음 명령으로 달성할 수 있습니다:
SYSTEM SYNC REPLICA table
(1)이 보장되면 삽입을 일시 중지하고 이 명령과 이후 청소가 완료될 때까지 유지할 것을 권장합니다.
ReplacingMergeTree로 삭제를 처리하는 것은 낮거나 중간 정도의 삭제 수(10% 미만)가 있는 테이블에만 권장됩니다. 위 조건으로 청소를 예약할 수 있는 기간이 없다면요.
팁: 더 이상 변경되지 않는 선택적 파티션에 대해서도
OPTIMIZE FINAL CLEANUP을 발행할 수 있습니다.
기본/중복 제거 키 선택
위에서 우리는 ReplacingMergeTree의 경우에도 충족되어야 하는 중요한 추가 제약을 강조했습니다: ORDER BY 컬럼의 값이 변경 전반에 걸쳐 행을 고유하게 식별해야 합니다. Postgres 같은 트랜잭션 데이터베이스에서 마이그레이션한다면, 원래 Postgres 기본 키를 ClickHouse ORDER BY 절에 포함해야 합니다.
ClickHouse 사용자는 쿼리 성능을 최적화하기 위해 테이블의 ORDER BY 절에서 컬럼을 선택하는 것에 익숙할 것입니다. 일반적으로 이 컬럼들은 빈번한 쿼리에 기반해 선택하고 카디널리티가 증가하는 순서로 나열해야 합니다. 중요하게, ReplacingMergeTree는 추가 제약을 부과합니다 — 이 컬럼들은 불변이어야 합니다. 즉 Postgres에서 복제할 때, 기본 Postgres 데이터에서 변경되지 않는 컬럼만 이 절에 추가하세요. 다른 컬럼은 변경될 수 있지만, 고유한 행 식별을 위해서는 일관성이 요구됩니다.
분석 워크로드의 경우 Postgres 기본 키는 점 행 조회를 거의 수행하지 않으므로 일반적으로 별로 쓸모가 없습니다. 컬럼을 카디널리티가 증가하는 순서로 정렬할 것을 권장하고, ORDER BY에서 앞에 나열된 컬럼에 대한 일치가 보통 더 빠르다는 사실을 고려하면, Postgres 기본 키는 ORDER BY의 끝에 추가되어야 합니다(분석적 가치가 없다면). Postgres에서 여러 컬럼이 기본 키를 형성한다면, 카디널리티와 쿼리 값의 가능성을 존중하며 ORDER BY에 추가해야 합니다. 또한 MATERIALIZED 컬럼을 통해 값의 연결(concatenation)을 사용해 고유한 기본 키를 생성할 수도 있습니다.
Stack Overflow 데이터셋의 posts 테이블을 고려해 보세요.
CREATE TABLE stackoverflow.posts_updateable
(
`Version` UInt32,
`Deleted` UInt8,
`Id` Int32 CODEC(Delta(4), ZSTD(1)),
`PostTypeId` Enum8('Question' = 1, 'Answer' = 2, 'Wiki' = 3, 'TagWikiExcerpt' = 4, 'TagWiki' = 5, 'ModeratorNomination' = 6, 'WikiPlaceholder' = 7, 'PrivilegeWiki' = 8),
`AcceptedAnswerId` UInt32,
`CreationDate` DateTime64(3, 'UTC'),
`Score` Int32,
`ViewCount` UInt32 CODEC(Delta(4), ZSTD(1)),
`Body` String,
`OwnerUserId` Int32,
`OwnerDisplayName` String,
`LastEditorUserId` Int32,
`LastEditorDisplayName` String,
`LastEditDate` DateTime64(3, 'UTC') CODEC(Delta(8), ZSTD(1)),
`LastActivityDate` DateTime64(3, 'UTC'),
`Title` String,
`Tags` String,
`AnswerCount` UInt16 CODEC(Delta(2), ZSTD(1)),
`CommentCount` UInt8,
`FavoriteCount` UInt8,
`ContentLicense` LowCardinality(String),
`ParentId` String,
`CommunityOwnedDate` DateTime64(3, 'UTC'),
`ClosedDate` DateTime64(3, 'UTC')
)
ENGINE = ReplacingMergeTree(Version, Deleted)
PARTITION BY toYear(CreationDate)
ORDER BY (PostTypeId, toDate(CreationDate), CreationDate, Id)
(PostTypeId, toDate(CreationDate), CreationDate, Id)의 ORDER BY 키를 사용합니다. 각 게시물에 고유한 Id 컬럼은 행이 중복 제거될 수 있게 합니다. 스키마에 요구되는 대로 Version과 Deleted 컬럼이 추가됩니다.
ReplacingMergeTree 쿼리
병합 시 ReplacingMergeTree는 ORDER BY 컬럼의 값을 고유 식별자로 사용해 중복 행을 식별하고, 최고 버전만 유지하거나 최신 버전이 삭제를 나타내면 모든 중복을 제거합니다. 그러나 이것은 궁극적 정확성만을 제공합니다 — 행이 중복 제거될 것이라는 보장은 하지 않으므로 그것에 의존해서는 안 됩니다.
FINAL로 중복 제거된 데이터 읽기
중복 제거는 백그라운드 병합 중에만 발생하므로 일반 SELECT는 여전히 중복 또는 삭제된 행을 반환할 수 있습니다. 쿼리 시간에 올바른 결과를 읽으려면, 쿼리가 실행될 때 중복 제거와 삭제 제거를 완료하는 FINAL 수정자를 사용하세요.
위의 posts 테이블을 고려해 보세요. 이 데이터셋을 로드하는 일반 방법을 사용할 수 있지만 값 0에 더해 deleted와 version 컬럼을 지정합니다. 예제 목적으로 10000행만 로드합니다.
INSERT INTO stackoverflow.posts_updateable SELECT 0 AS Version, 0 AS Deleted, *
FROM s3('https://datasets-documentation.s3.eu-west-3.amazonaws.com/stackoverflow/parquet/posts/*.parquet') WHERE AnswerCount > 0 LIMIT 10000
0 rows in set. Elapsed: 1.980 sec. Processed 8.19 thousand rows, 3.52 MB (4.14 thousand rows/s., 1.78 MB/s.)
행 수를 확인해 봅시다:
SELECT count() FROM stackoverflow.posts_updateable
┌─count()─┐
│ 10000 │
└─────────┘
1 row in set. Elapsed: 0.002 sec.
이제 게시물-답변 통계를 업데이트합니다. 이 값들을 업데이트하는 대신 5000행의 새 복사본을 삽입하고 버전 번호에 1을 더합니다(이는 테이블에 150행이 존재함을 의미). 간단한 INSERT INTO SELECT로 이것을 시뮬레이션할 수 있습니다:
INSERT INTO posts_updateable SELECT
Version + 1 AS Version,
Deleted,
Id,
PostTypeId,
AcceptedAnswerId,
CreationDate,
Score,
ViewCount,
Body,
OwnerUserId,
OwnerDisplayName,
LastEditorUserId,
LastEditorDisplayName,
LastEditDate,
LastActivityDate,
Title,
Tags,
AnswerCount,
CommentCount,
FavoriteCount,
ContentLicense,
ParentId,
CommunityOwnedDate,
ClosedDate
FROM posts_updateable --select 100 random rows
WHERE (Id % toInt32(floor(randUniform(1, 11)))) = 0
LIMIT 5000
0 rows in set. Elapsed: 4.056 sec. Processed 1.42 million rows, 2.20 GB (349.63 thousand rows/s., 543.39 MB/s.)
추가로 1000개의 임의 게시물을 deleted 컬럼 값 1로 행을 다시 삽입해 삭제합니다. 다시, 간단한 INSERT INTO SELECT로 이것을 시뮬레이션할 수 있습니다.
INSERT INTO posts_updateable SELECT
Version + 1 AS Version,
1 AS Deleted,
Id,
PostTypeId,
AcceptedAnswerId,
CreationDate,
Score,
ViewCount,
Body,
OwnerUserId,
OwnerDisplayName,
LastEditorUserId,
LastEditorDisplayName,
LastEditDate,
LastActivityDate,
Title,
Tags,
AnswerCount + 1 AS AnswerCount,
CommentCount,
FavoriteCount,
ContentLicense,
ParentId,
CommunityOwnedDate,
ClosedDate
FROM posts_updateable --select 100 random rows
WHERE (Id % toInt32(floor(randUniform(1, 11)))) = 0 AND AnswerCount > 0
LIMIT 1000
0 rows in set. Elapsed: 0.166 sec. Processed 135.53 thousand rows, 212.65 MB (816.30 thousand rows/s., 1.28 GB/s.)
위 연산의 결과는 16,000행입니다, 즉 10,000 + 5000 + 1000. 실제로는 원래 총계보다 1000행만 적어야 합니다, 즉 10,000 - 1000 = 9000.
SELECT count()
FROM posts_updateable
┌─count()─┐
│ 10000 │
└─────────┘
1 row in set. Elapsed: 0.002 sec.
결과는 발생한 병합에 따라 달라집니다. 중복 행 때문에 총계가 다름을 볼 수 있습니다. FINAL을 적용하면 올바른 결과가 나옵니다.
SELECT count()
FROM posts_updateable
FINAL
┌─count()─┐
│ 9000 │
└─────────┘
1 row in set. Elapsed: 0.006 sec. Processed 11.81 thousand rows, 212.54 KB (2.14 million rows/s., 38.61 MB/s.)
Peak memory usage: 8.14 MiB.
FINAL 성능
FINAL 연산자는 쿼리에 약간의 성능 오버헤드를 가집니다. 이것은 쿼리가 기본 키 컬럼에서 필터링하지 않을 때 가장 두드러지며, 더 많은 데이터를 읽고 중복 제거 오버헤드를 증가시킵니다. WHERE 조건으로 키 컬럼에서 필터링하면 로드되어 중복 제거로 전달되는 데이터가 줄어듭니다.
WHERE 조건이 키 컬럼을 사용하지 않으면 ClickHouse는 현재 FINAL을 사용할 때 PREWHERE 최적화를 활용하지 않습니다. 이 최적화는 필터링되지 않은 컬럼에 대해 읽히는 행을 줄이는 것을 목표로 합니다. 이 PREWHERE를 흉내 내어 잠재적으로 성능을 개선하는 예제는 여기에서 찾을 수 있습니다.
ReplacingMergeTree로 파티션 활용
ClickHouse의 데이터 병합은 파티션 수준에서 발생합니다. ReplacingMergeTree를 사용할 때 이 파티셔닝 키가 행에 대해 변경되지 않는 것을 보장할 수 있다면, 사용자는 모범 사례에 따라 테이블을 파티셔닝할 것을 권장합니다. 이것은 같은 행에 대한 업데이트가 같은 ClickHouse 파티션으로 보내질 것임을 보장합니다. 여기에 설명된 모범 사례를 준수한다면 Postgres와 같은 파티션 키를 재사용할 수 있습니다.
이것이 사실이라고 가정하면, do_not_merge_across_partitions_select_final=1 설정을 사용해 FINAL 쿼리 성능을 개선할 수 있습니다. 이 설정은 FINAL을 사용할 때 파티션을 독립적으로 병합·처리하게 합니다.
파티셔닝을 사용하지 않는 다음 posts 테이블을 고려해 보세요:
CREATE TABLE stackoverflow.posts_no_part
(
`Version` UInt32,
`Deleted` UInt8,
`Id` Int32 CODEC(Delta(4), ZSTD(1)),
...
)
ENGINE = ReplacingMergeTree
ORDER BY (PostTypeId, toDate(CreationDate), CreationDate, Id)
INSERT INTO stackoverflow.posts_no_part SELECT 0 AS Version, 0 AS Deleted, *
FROM s3('https://datasets-documentation.s3.eu-west-3.amazonaws.com/stackoverflow/parquet/posts/*.parquet')
0 rows in set. Elapsed: 182.895 sec. Processed 59.82 million rows, 38.07 GB (327.07 thousand rows/s., 208.17 MB/s.)
FINAL이 작업을 하도록 보장하기 위해 중복 행을 삽입해 100만 행의 AnswerCount를 증가시켜 업데이트합니다.
INSERT INTO posts_no_part SELECT Version + 1 AS Version, Deleted, Id, PostTypeId, AcceptedAnswerId, CreationDate, Score, ViewCount, Body, OwnerUserId, OwnerDisplayName, LastEditorUserId, LastEditorDisplayName, LastEditDate, LastActivityDate, Title, Tags, AnswerCount + 1 AS AnswerCount, CommentCount, FavoriteCount, ContentLicense, ParentId, CommunityOwnedDate, ClosedDate
FROM posts_no_part
LIMIT 1000000
FINAL로 연도별 답변 합계 계산:
SELECT toYear(CreationDate) AS year, sum(AnswerCount) AS total_answers
FROM posts_no_part
FINAL
GROUP BY year
ORDER BY year ASC
┌─year─┬─total_answers─┐
│ 2008 │ 371480 │
...
│ 2024 │ 127765 │
└──────┴───────────────┘
17 rows in set. Elapsed: 2.338 sec. Processed 122.94 million rows, 1.84 GB (52.57 million rows/s., 788.58 MB/s.)
Peak memory usage: 2.09 GiB.
연도별 파티셔닝된 테이블에 대해 같은 단계를 반복하고, do_not_merge_across_partitions_select_final=1로 위 쿼리를 반복합니다.
CREATE TABLE stackoverflow.posts_with_part
(
`Version` UInt32,
`Deleted` UInt8,
`Id` Int32 CODEC(Delta(4), ZSTD(1)),
...
)
ENGINE = ReplacingMergeTree
PARTITION BY toYear(CreationDate)
ORDER BY (PostTypeId, toDate(CreationDate), CreationDate, Id)
// populate & update omitted
SELECT toYear(CreationDate) AS year, sum(AnswerCount) AS total_answers
FROM posts_with_part
FINAL
GROUP BY year
ORDER BY year ASC
┌─year─┬─total_answers─┐
│ 2008 │ 387832 │
│ 2009 │ 1165506 │
│ 2010 │ 1755437 │
...
│ 2023 │ 787032 │
│ 2024 │ 127765 │
└──────┴───────────────┘
17 rows in set. Elapsed: 0.994 sec. Processed 64.65 million rows, 983.64 MB (65.02 million rows/s., 989.23 MB/s.)
보이듯이, 파티셔닝은 중복 제거 과정이 파티션 수준에서 병렬로 발생하게 함으로써 이 경우 쿼리 성능을 크게 개선했습니다.
병합 동작 고려사항
ClickHouse의 병합 선택 메커니즘은 단순한 파트 병합을 넘어섭니다. 아래에서 ReplacingMergeTree의 맥락에서 이 동작을, 오래된 데이터의 더 공격적인 병합을 가능하게 하는 구성 옵션과 더 큰 파트에 대한 고려를 포함해 살펴봅니다.
병합 선택 로직
병합은 파트 수를 최소화하는 것을 목표로 하지만, 이 목표를 쓰기 증폭(write amplification)의 비용과 균형을 맞춥니다. 결과적으로, 내부 계산에 기반해 과도한 쓰기 증폭을 초래할 파트 범위는 병합에서 제외됩니다. 이 동작은 불필요한 리소스 사용을 방지하고 스토리지 구성 요소의 수명을 연장하는 데 도움이 됩니다.
큰 파트의 병합 동작
ClickHouse의 ReplacingMergeTree 엔진은 지정된 고유 키에 따라 각 행의 최신 버전만 유지하면서 데이터 파트를 병합해 중복 행을 관리하도록 최적화되어 있습니다. 그러나 병합된 파트가 max_bytes_to_merge_at_max_space_in_pool 임계값에 도달하면, min_age_to_force_merge_seconds가 설정되어 있어도 더 이상 병합 대상으로 선택되지 않습니다. 결과적으로 계속되는 데이터 삽입으로 축적될 수 있는 중복을 제거하는 데 자동 병합을 더 이상 의존할 수 없습니다.
이를 해결하려면 OPTIMIZE FINAL을 호출해 파트를 수동으로 병합하고 중복을 제거할 수 있습니다. 자동 병합과 달리 OPTIMIZE FINAL은 max_bytes_to_merge_at_max_space_in_pool 임계값을 우회해, 각 파티션에 단일 파트가 남을 때까지 사용 가능한 리소스(특히 디스크 공간)에만 기반해 파트를 병합합니다. 그러나 이 접근 방식은 큰 테이블에서 메모리를 많이 사용할 수 있고 새 데이터가 추가됨에 따라 반복 실행이 필요할 수 있습니다.
성능을 유지하는 더 지속 가능한 해결책은 테이블 파티셔닝을 권장합니다. 이것은 데이터 파트가 최대 병합 크기에 도달하지 않게 하고 지속적인 수동 최적화의 필요성을 줄이는 데 도움이 됩니다.
파티셔닝과 파티션 간 병합
"ReplacingMergeTree로 파티션 활용"에서 논의했듯이 모범 사례로 테이블 파티셔닝을 권장합니다. 파티셔닝은 더 효율적인 병합을 위해 데이터를 격리하고, 특히 쿼리 실행 중에 파티션 간 병합을 피합니다. 이 동작은 23.12 이후 버전에서 개선되었습니다: 파티션 키가 정렬 키의 접두사이면 쿼리 시간에 파티션 간 병합이 수행되지 않아 쿼리 성능이 빨라집니다.
더 나은 쿼리 성능을 위한 병합 조정
기본적으로 min_age_to_force_merge_seconds와 min_age_to_force_merge_on_partition_only는 각각 0과 false로 설정되어 이 기능들을 비활성화합니다. 이 구성에서 ClickHouse는 파티션 연령에 기반한 병합 강제 없이 표준 병합 동작을 적용합니다.
min_age_to_force_merge_seconds 값이 지정되면 ClickHouse는 지정된 기간보다 오래된 파트에 대해 일반 병합 휴리스틱을 무시합니다. 이것은 일반적으로 전체 파트 수를 최소화하는 것이 목표일 때만 효과적이지만, 쿼리 시간에 병합이 필요한 파트 수를 줄여 ReplacingMergeTree에서 쿼리 성능을 개선할 수 있습니다.
이 동작은 min_age_to_force_merge_on_partition_only=true를 설정해 더 조정할 수 있으며, 공격적 병합을 위해 파티션의 모든 파트가 min_age_to_force_merge_seconds보다 오래되어야 합니다. 이 구성은 오래된 파티션이 시간이 지나면서 단일 파트로 병합되게 하여 데이터를 통합하고 쿼리 성능을 유지합니다.
권장 설정
병합 동작 조정은 고급 연산입니다. 프로덕션 워크로드에서 이 설정들을 활성화하기 전에 ClickHouse 지원에 상담할 것을 권장합니다.
대부분의 경우 min_age_to_force_merge_seconds를 파티션 기간보다 훨씬 낮은 낮은 값으로 설정하는 것이 선호됩니다. 이것은 파트 수를 최소화하고 FINAL 연산자로 쿼리 시간에 불필요한 병합을 방지합니다.
예를 들어 이미 단일 파트로 병합된 월별 파티션을 고려해 보세요. 이 파티션 안에 작고 우연한 삽입이 새 파트를 만들면, ClickHouse가 병합이 완료될 때까지 여러 파트를 읽어야 하므로 쿼리 성능이 저하될 수 있습니다. min_age_to_force_merge_seconds를 설정하면 이 파트들이 공격적으로 병합되어 쿼리 성능 저하를 방지할 수 있습니다.