LabHub
배우기 러닝패스 코스

ClickHouse — 열 지향 분석 DB 를 속까지 · 뮤테이션·경량 DELETE·TTL · 이론

뮤테이션·경량 DELETE·TTL — 바꿀 수 없는 파트를 바꾸는 법

LabHub 에서 이어서 보기

한 줄 요약

MergeTree 의 파트는 한 번 쓰면 바뀌지 않는다. 그래서 UPDATE·DELETE 는 파트를 통째로 새로 쓰는 비동기 작업(뮤테이션) 이 되고, 경량 DELETE 는 지울 행을 가리는 표시만 쓰고 실제 제거는 병합에 미루며, TTL 은 병합이 일어날 때 기한이 지난 행·값을 빼거나 요약하는 규칙이다. 셋 다 "지금 당장 이 행만" 이 아니라는 점을 알아야 한다.

개념 지도: 파트를 통째로 새로 쓰는 비동기 작업(뮤테이션) · 지울 행을 가리는 표시만 쓰고 실제 제거는 병합에 미루며 · 병합이 일어날 때 기한이 지난 행·값을 빼거나 요약하는 규칙 · 되도록 쓰지 말라

왜 이게 필요했나

앞 모듈들에서 파트가 불변이라는 설계가 쓰기와 압축을 빠르게 한다는 것을 봤다. 대가는 수정이다. 개인정보 삭제 요청, 잘못 들어온 상태값 정정, 오래된 로그 정리는 어느 분석 DB 에나 온다. 행 지향 DB 처럼 그 자리에서 몇 바이트를 고칠 수 없으니, ClickHouse 는 이 요구를 세 가지 도구로 나눴다 — 드물고 무거운 정정은 뮤테이션, 잦은 삭제는 경량 DELETE, 규칙으로 표현되는 수명 관리는 TTL. 공식 문서의 "Avoid mutations" 가 첫 번째 도구를 되도록 쓰지 말라고 적는 이유를 숫자로 보는 것이 이 모듈의 목표다.

어떻게 동작하나

왼쪽에 활성 파트 all_1_1_1 이 있다. ALTER UPDATE 는 뮤테이션으로 등록되어 바뀌는 행이 2% 여도 파트 전체를 읽어 새 파트 all_1_1_1_2 를 쓰고, 옛 파트는 비활성이 된다. 이름 끝의 2 가 뮤테이션 번호다. 가운데에는 경량 DELETE 가 있다. 파트의 다른 열은 그대로 두고 숨은 열 _row_exists 에 지울 행을 0 으로 표시할 뿐이라, SELECT 는 그 행을 가리지만 파트의 rows 는 줄지 않는다. 오른쪽에는 병합이 있다. OPTIMIZE FINAL 이나 백그라운드 병합이 가려진 행을 실제로 빼고, 같은 때에 TTL 규칙이 기한 지난 행을 지우거나 열 값을 기본값으로 바꾸거나 GROUP BY 로 한 줄로 말아 넣는다

뮤테이션. ALTER TABLE t UPDATE ... WHERE ...ALTER TABLE t DELETE WHERE ... 는 문서가 일부러 ALTER 로 시작하게 만든 무거운 명령이다. 제출하면 system.mutations 에 한 줄이 생기고 백그라운드에서 해당 행이 든 파트를 새로 써서 원자적으로 바꿔 끼운다. 기본은 비동기이고 mutations_sync = 2 를 주면 끝날 때까지 기다린다. 이 파드에서 40만 행 파트의 2%(8천여 행)의 status 를 바꾸자 파트 이름이 all_1_1_1 에서 all_1_1_1_2 로 바뀌었고, system.part_log 의 MutatePart 기록은 40만 행을 읽고 40만 행을 썼다. 이름 끝의 2 가 그 뮤테이션의 번호다. 문서가 적는 성질 몇 가지 — 뮤테이션은 제출 순서대로 적용되고 제출 전에 들어온 자료에만 걸리며, 되돌릴 수 없고 서버를 재시작해도 이어서 돈다. 정렬 키·파티션 키 열은 UPDATE 할 수 없다.

실패한 뮤테이션은 더 무섭다. 문자열 이메일을 toUInt32 로 바꾸라는 뮤테이션을 넣자 system.mutations 에 is_done = 0, latest_fail_error_code_name = CANNOT_PARSE_TEXT 가 남고 스스로 멈추지 않았다. 그 뒤에 mutations_sync = 2 로 넣은 멀쩡한 UPDATE 도 앞의 것에 막혀 UNFINISHED 오류로 돌아왔다. 끝내는 방법은 KILL MUTATION 뿐이다.

경량 DELETE. DELETE FROM t WHERE ... 는 내부적으로 UPDATE _row_exists = 0 WHERE ... 뮤테이션이 된다(system.mutations 의 command 에 그대로 보인다). Wide 파트라면 숨은 열 _row_exists 하나만 쓰고 나머지 열 파일은 하드 링크로 재사용한다. 이후 SELECT 는 이 가림막을 PREWHERE 로 적용한다. 이 파드에서 사용자 888 의 22행을 지운 직후 보통 SELECT 는 0행, SETTINGS apply_deleted_mask = 0 은 22행, system.parts 의 rows 는 그대로였다. 행이 실제로 빠진 것은 OPTIMIZE ... FINAL 로 병합한 뒤였다. 문서도 "다음 병합 때 물리적으로 지워진다" 고 적는다. 프로젝션이 있는 표에서는 기본으로 거절된다는 것은 앞 모듈에서 봤다.

TTL. TTL 은 표나 열에 붙는 규칙이고, 병합 때 적용된다. 병합이 없으면 문서의 merge_with_ttl_timeout(기본 4시간)마다 TTL 병합을 시도한다. 세 가지 쓰임이 있다.

| 형태 | 예 | 기한이 지나면 |
| --- | --- | --- |
| 열 TTL | email String TTL event_date + INTERVAL 90 DAY | 그 열 값을 타입 기본값('')으로 |
| 행 TTL + WHERE | TTL event_date + INTERVAL 180 DAY DELETE WHERE status = 'test' | 조건에 맞는 행을 삭제 |
| GROUP BY 요약 | TTL ts + INTERVAL 30 DAY GROUP BY user_id, toStartOfDay(ts) SET ... | 여러 행을 한 줄로 말아 넣음 |

MODIFY TTL 이나 TTL 이 붙은 MODIFY COLUMN 은 기본(materialize_ttl_after_modify = 1)으로 기존 파트에 적용하는 MATERIALIZE TTL 뮤테이션을 함께 만든다 — 즉 TTL 을 바꾸는 것도 파트를 다시 쓰는 일이다. GROUP BY 요약의 묶음 열은 기본 키의 앞부분이어야 한다. 이 파드에서 2024년 3월 조회 기록 20만 행은 INSERT 직후에는 20만 행 그대로였고, OPTIMIZE FINAL 뒤 (사용자, 날짜) 15,500행으로 줄었다.

TTL 식이 now() 기준이라 결과가 시간에 따라 바뀐다는 점도 알아 둔다. 이 실습은 자료를 전부 2024년으로 두고 법적 보존 행은 if(legal_hold = 1, toDate('2100-01-01'), ...) 로 먼 미래를 주어, 채점 시각과 상관없이 같은 행이 같은 판정을 받게 했다.

현장에서 만나는 모습

"GDPR 삭제 요청이 하루 수백 건인데 건마다 ALTER DELETE 를 친다" 는 설계는 곧 뮤테이션 대기열을 쌓는다. 표의 모든 뮤테이션은 차례로 돌고, 하나가 실패로 멈추면 뒤가 모두 막힌다. 삭제가 잦다면 경량 DELETE 나 ReplacingMergeTree 의 삭제 표시, 파티션 단위 DROP 을 먼저 검토한다.

두 번째는 "경량 DELETE 를 했으니 디스크가 비었겠지" 라는 오해다. 가림막만 썼으므로 공간은 병합 뒤에야 돌아온다. 법적으로 "언제까지 물리 삭제" 를 약속해야 한다면 문서가 권하듯 min_age_to_force_merge_seconds 나 ALTER DELETE 를 쓴다.

세 번째는 TTL 이 "안 먹는다" 는 신고다. TTL 은 병합 때만 적용되므로 조용한 표에서는 몇 시간 늦을 수 있다. 확인할 때는 MATERIALIZE TTL 로 당장 적용하고, 평소에는 TTL 과 같은 시간 단위로 파티션을 나눠 통째로 떨어지게 하는 것이 문서의 권장이다.

다음 실습에서 할 것

mut.events 40만 행을 파트 하나로 만든 뒤, ALTER UPDATE 가 파트를 통째로 다시 쓰는 것을 part_log 로 확인한다. 사용자 777 은 ALTER DELETE 로, 888 은 경량 DELETE 로 지워 가림막과 물리 삭제의 차이를 센다. email 열 TTL 과 test 행 TTL 을 적용하고, 조회 기록을 GROUP BY TTL 로 요약한 뒤, 마지막으로 실패해 멈춘 뮤테이션을 기록하고 KILL 한다.