LabHub
学习 学习路径 课程

ClickHouse — 열 지향 분석 DB 를 속까지 · 파트와 병합 · 讲解

파트와 병합 — INSERT 가 파트를 만들고 병합이 줄인다

在 LabHub 中继续学习

한 줄 요약

MergeTree 에서 INSERT 한 번은 새 파트를 적어도 하나 만들고, 백그라운드 병합이 작은 파트들을 큰 파트로 합친다. 파트가 병합보다 빨리 쌓이면 서버는 INSERT 를 거절한다(TOO_MANY_PARTS). 그래서 ClickHouse 에 넣는 일의 핵심은 "몇 행을 넣느냐" 가 아니라 "파트를 몇 개 만드느냐" 다.

概念图: INSERT 한 번은 새 파트를 적어도 하나 · 병합 · "파트를 몇 개 만드느냐" · 파트 이름이 이력이다.

왜 이게 필요했나

앞 모듈에서 파트는 한 번 쓰이면 바뀌지 않는다고 했다. 그 덕분에 쓰기는 잠금 없이 새 디렉터리 하나를 만드는 일로 끝나고, 읽기는 이미 정렬·압축된 파일을 훑기만 하면 된다. 그런데 이 설계에는 청구서가 따라온다. 행을 한 줄씩 넣으면 파트도 한 줄짜리가 하나씩 생긴다. 조회는 파트마다 인덱스를 따로 보고 파일을 따로 열어야 하니 파트가 많을수록 느려지고, 병합은 그 작은 파트들을 다시 읽어 다시 써야 한다.

행 지향 DB 에 익숙한 사람은 이벤트가 올 때마다 INSERT 를 한 줄씩 보내는 코드를 쉽게 쓴다. ClickHouse 에서는 그 코드가 운영 장애가 된다. 이 모듈은 파트가 생기고 합쳐지는 과정을 system.part_log 의 사건으로 따라가고, 문턱을 넘었을 때 무엇이 막히는지, 서버가 대신 모아 주는 비동기 INSERT 가 무엇을 바꾸는지 숫자로 본다.

어떻게 동작하나

세 장면. 병합을 멈춘 표에 INSERT 문 10개를 보내면 파트 all_1_1_0 부터 all_10_10_0 까지 10개가 생기고, 병합을 켜 OPTIMIZE FINAL 하면 블록 1–10 을 덮는 파트 하나가 된다. parts_to_throw_insert 를 5로 낮추면 파트 5개에서 여섯 번째 INSERT 가 TOO_MANY_PARTS 로 거절된다. 기다리지 않는 비동기 INSERT 5개는 버퍼에 모였다가 한 번에 파트 하나로 쓰인다

파트 이름이 이력이다. all_3_7_2 는 파티션 all, 블록 번호 3부터 7까지를 덮고, 병합 수준 2 라는 뜻이다. INSERT 가 만든 파트는 블록 번호 하나를 받아 all_N_N_0 이 되고, 공식 문서(Part merges)대로 병합할 때마다 수준이 하나씩 오른다. 실습 파드에서 병합을 멈춘 표에 INSERT 문 10개를 보내면 all_1_1_0all_10_10_0 이 그대로 남는다.

INSERT 한 번 ≠ 파트 하나. 서버는 들어온 행을 블록으로 모아 블록마다 파트를 쓴다. INSERT ... SELECT 는 min_insert_block_size_rows(기본 1,048,449)만큼 모일 때까지 블록을 붙인다. 100만 행을 min_insert_block_size_rows = 250000 으로 넣으니 파트가 4개(261,636 × 3 + 215,092) 생겼다. 파티션이 있는 표라면 블록이 파티션마다 또 나뉜다 — 다음 모듈의 주제다.

사건은 part_log 에 남는다. 이 파드는 system.part_log 를 켜 두었다. 파트가 생기면 NewPart(어느 INSERT 의 query_id 가 만들었는지 함께), 합쳐지면 MergeParts(무엇을 합쳤는지 merged_from 에)가 한 줄씩 쌓인다. system.parts 는 "지금" 만 보여 주지만 part_log 는 "어떻게 여기까지 왔는지" 를 보여 준다. 표를 지우고 같은 이름으로 다시 만들면 옛 기록과 섞이므로 table_uuid 로 거른다.

병합은 멈출 수 있지만 OPTIMIZE 도 같이 멈춘다. SYSTEM STOP MERGES 표 는 그 표의 병합을 멈춘다(서버를 다시 켜면 풀린다). 실측해 보니 멈춘 표에 OPTIMIZE ... FINAL 을 치면 Cancelled merging parts(ABORTED) 로 거절됐다. SYSTEM START MERGES 로 푼 직후에는 백그라운드 병합이 먼저 10개를 all_1_10_1 로 합쳤고, FINAL 이 그 한 파트를 다시 써 all_1_10_2 가 됐다. 문서대로 FINAL 은 이미 한 파트여도 병합을 한다. 어느 경로로 왔는지는 매번 다를 수 있으니 part_log 로 확인한다.

문턱. 파티션 하나의 활성 파트가 parts_to_delay_insert 를 넘으면 INSERT 를 일부러 늦추고, parts_to_throw_insert 에 이르면 Too many parts ... Merges are processing significantly slower than inserts 로 거절한다(오류 252). 이 값은 표 설정이다. 5로 낮추고 병합을 멈춘 채 한 행씩 넣으니 5개째까지 들어가고 여섯 번째가 거절됐다. 풀어 주는 방법은 파트를 줄이는 것 — 병합을 켜고 합친 뒤 다시 넣으면 들어간다.

비동기 INSERT 는 서버가 모아 준다. 26.8 의 기본값은 async_insert = 1, wait_for_async_insert = 1 이다. 실습 파드에서 INSERT ... VALUES 를 보내면 query_log 에 InsertAsyncInsertFlush 두 쿼리가 남는다. 그런데 기다리는 모드에서 한 클라이언트가 하나씩 보내면 매번 버퍼가 비워져 파트도 하나씩 생긴다(5번 → 5개). 여러 INSERT 가 한 파트가 되려면 동시에 버퍼에 있어야 한다. 기다리지 않는 모드(wait_for_async_insert = 0)로 5번 보내고 SYSTEM FLUSH ASYNC INSERT QUEUE 로 비우니 5번이 파트 하나가 됐다. 문서는 기다리지 않는 모드를 권하지 않는다 — 오류가 클라이언트에 돌아오지 않기 때문이다. 실습에서는 시간과 무관하게 모이는 것을 보려고 쓴다. 참고로 INSERT ... SELECT 에는 비동기 INSERT 가 적용되지 않는다.

현장에서 만나는 모습

가장 흔한 장애가 "Too many parts" 다. 원인은 거의 늘 둘 중 하나 — 한 줄씩 넣는 수집기, 또는 너무 잘게 나눈 파티션. 문턱 값을 올리는 것은 증상을 늦출 뿐이라고 문서도 적는다. 먼저 part_log 에서 NewPart 를 query_id 별로 세어 보면 어느 INSERT 가 파트를 쏟아내는지 바로 보인다. 해법은 클라이언트에서 모아 보내거나(문서(Selecting an insert strategy)의 권장은 한 번에 최소 1,000행, 되도록 10,000–100,000행), 그럴 수 없으면 비동기 INSERT 에 맡기는 것이다.

두 번째는 OPTIMIZE ... FINAL 을 크론으로 도는 운영이다. 문서(Avoid OPTIMIZE FINAL)는 이것을 일상 작업이 아니라 관리 작업으로 보라고 한다 — 이미 한 파트여도 다시 쓰므로 큰 표에서는 비싸다. 이 실습에서 FINAL 을 쓰는 것은 파트 수를 고정해 눈으로 보기 위해서다.

다음 실습에서 할 것

병합을 멈춘 parts.events 에 INSERT 문 10개를 보내 파트 10개를 만들고, 100만 행 INSERT 한 번이 블록 크기에 따라 파트 여러 개가 되는 것을 part_log 로 확인한다. 병합을 켜 OPTIMIZE 로 합친 과정을 읽고, 문턱을 5로 낮춘 표에서 TOO_MANY_PARTS 를 일으킨 뒤 풀어 준다. 마지막으로 비동기 INSERT 5개를 파트 하나로 모으고, 표마다 새 파트 수를 한 표로 정리한다.