ClickHouse — 열 지향 분석 DB 를 속까지 · ReplacingMergeTree · 讲解
ReplacingMergeTree — 갱신과 삭제를 INSERT 로 흉내 내기
한 줄 요약
ReplacingMergeTree 는 정렬 키가 같은 행을 병합 때 하나로 줄이는 엔진이다. 버전 열이 크면 이기고, 이긴 행에 삭제 표시가 있으면 그 키는 없는 것으로 본다. 병합은 언제 일어날지 모르므로, 정확한 답이 필요한 조회는 FINAL 이나 argMax 로 조회 시점에 같은 규칙을 적용해야 한다.
왜 이게 필요했나
운영 DB 의 사용자 표를 분석 DB 로 옮기면 곧 "요금제가 바뀌었다", "탈퇴했다" 는 변경이 따라온다. 행 지향 DB 라면 UPDATE·DELETE 한 줄이지만, 앞 모듈들에서 봤듯이 MergeTree 의 파트는 한 번 쓰이면 고치지 않는다. 행 하나를 바꾸려면 그 행이 든 파트의 열 파일을 다시 써야 하고, 그걸 변경마다 하면 쓰기가 폭증한다.
그래서 발상을 뒤집는다. 바꾸지 말고 새 버전을 한 줄 더 넣는다. 탈퇴도 "탈퇴했다" 는 버전을 넣는다. 공식 가이드(Working with the ReplacingMergeTree engine)는 이것을 "불변 INSERT 로 갱신을 흉내 낸다" 고 표현한다. 옛 버전을 치우는 일은 어차피 일어나는 백그라운드 병합에 얹는다. 쓰기는 늘 덧붙이기(append)라 빠르고, 대가는 "병합 전까지는 옛 버전도 보인다" 는 것이다.
어떻게 동작하나
엔진 선언은 ReplacingMergeTree(ver, is_deleted) 다. 레퍼런스 문서가 정한 규칙은 이렇다.
- "같은 행" 은
ORDER BY가 정한다. PRIMARY KEY 가 아니다. 정렬 키 값이 같은 행들이 한 무리다. - 무리 안에서는 ver 가 가장 큰 행이 남는다. ver 가 같으면 가장 나중에 넣은 행이 남는다(실습 서버에서 같은 ver 로 'a' 뒤 'b' 를 넣으니 'b' 가 남았다).
is_deleted는 ver 가 있을 때만 쓸 수 있고, 이긴 행이 1 이면 그 키는 조회에서 빠진다. 그런데 병합은 탈퇴 표시 행을 지우지 않고 남겨 둔다. 나중에 더 낮은 버전이 들어와도 탈퇴가 이기게 하려는 것이다.- 병합 때 탈퇴 행까지 지우려면 표 설정
allow_experimental_replacing_merge_with_cleanup = 1을 켜고OPTIMIZE ... FINAL CLEANUP을 낸다. 설정 없이 내면 "Experimental merges with CLEANUP are not allowed" 로 거절된다.
이 실습의 자료(사용자 100,000명 · 갱신 30,058행 · 탈퇴 5,108행)로 잰 숫자가 규칙을 그대로 보여 준다. 병합을 멈추고 넣으면 count() 는 135,166행, FINAL 을 붙이면 94,892명이다. OPTIMIZE ... FINAL 로 합친 뒤에도 행은 100,000개 — 사용자당 하나지만 그중 5,108개가 탈퇴 표시 행이라 FINAL 없이 세면 여전히 틀린다. CLEANUP 병합 뒤에야 94,892행이 된다.
FINAL 은 조회하면서 병합 규칙을 적용한다. 같은 답을 argMax 로도 낼 수 있다.
SELECT plan, count() FROM ( SELECT user_id, argMax(plan, ver) AS plan, argMax(is_deleted, ver) AS d FROM rmt.users GROUP BY user_id) WHERE d = 0 GROUP BY plan;argMax(x, ver) 는 ver 가 가장 큰 행의 x 다. 무엇을 골라야 하는지를 쿼리가 직접 말하므로 FINAL 을 쓸 수 없는 곳(다른 엔진, 여러 표를 합친 결과)에서도 통한다.
한 가지 더 — 한 INSERT 안의 중복은 넣는 순간 줄어든다. 기본 설정 optimize_on_insert = 1 때문에, 세 버전을 한 INSERT 로 넣었더니 파트가 처음부터 100,000행이었다. "병합 전" 을 보고 싶으면 버전을 따로 넣고, 그 표의 병합을 SYSTEM STOP MERGES 로 멈춰야 한다. 멈춘 표에 OPTIMIZE 를 내면 "Cancelled merging parts" 로 거절된다.
현장에서 만나는 모습
가장 흔한 사고는 정렬 키에 바뀌는 열을 넣은 것이다. 조회 성능 때문에 ORDER BY (user_id, plan) 처럼 잡으면, 요금제가 바뀐 사용자는 정렬 키가 달라져 "다른 행" 이 된다. 실습에서 이렇게 만든 표는 FINAL 로 세어도 115,062행(95,919명)이 나왔다 — 옛 요금제 행이 영영 합쳐지지 않고, 탈퇴 표시도 옛 요금제 행을 가리지 못해 탈퇴자가 살아 있다. 가이드가 "ORDER BY 열은 변하지 않아야 한다" 고 못 박는 이유다.
두 번째는 CLEANUP 뒤의 재전송이다. 파이프라인이 옛 묶음을 다시 보내는 일은 흔하다(재처리, 오프셋 되감기). 탈퇴 표시가 남아 있는 표는 ver 3 이 ver 1 을 이기니 끄떡없지만, CLEANUP 으로 탈퇴 행을 지운 표에서는 비교할 상대가 없어 옛 행이 그대로 살아난다. 실습에서 사용자 1–1000 의 처음 적재 행을 다시 넣었더니 CLEANUP 한 표에서만 56명이 되살아났다. 가이드는 CLEANUP 을 "옛 버전이 다시 들어오지 않는다고 확신할 수 있을 때만" 쓰라고 한다.
세 번째는 FINAL 의 비용이다. FINAL 은 조회 때 병합을 하므로 거르는 조건이 정렬 키에 걸릴수록 싸다. 파티션 키가 행마다 바뀌지 않는다면 파티션별로 따로 처리하게 하는 do_not_merge_across_partitions_select_final 도 가이드에 나온다.
다음 실습에서 할 것
rmt.users 를 ReplacingMergeTree(ver, is_deleted) 로 만들고 병합을 멈춘 채 변경 기록 세 묶음을 넣는다. FINAL 없이·있이 센 행 수를 비교하고, FINAL 없이 argMax 로 요금제별 현재 사용자 수를 구한다. 새 표 두 개에 버전별로 옮겨 하나는 보통 병합, 하나는 CLEANUP 병합을 한 뒤 남은 행을 세고, 정렬 키에 plan 을 넣은 표가 무엇을 되살리는지 본다. 마지막으로 옛 행을 다시 넣어 두 표가 어떻게 다르게 반응하는지 기록한다.