Too Many Parts와 Async Insert
🏗️

Too Many Parts와 Async Insert

ClickHouse 분류
Core Architecture
Type
Research
작성자

Ken

ClickHouse MergeTree는 INSERT마다 새로운 Part를 생성하며, 파티션당 active part 수가 임계치(23.6+ 기준 delay 1,000 / throw 3,000)를 넘으면 INSERT를 지연시키거나 거부합니다. Part 개수 증가는 쿼리 성능 저하, 메모리 오버헤드, Merge 부하로 직결됩니다. 이번 글에서는 Part가 실제로 어떤 규칙으로 만들어지는지, Too Many Parts가 발생하는 근본 원인은 무엇인지, 그리고 어떻게 대응해야 하는지를 다룹니다.

ClickHouse의 MergeTree 구조

ClickHouse의 MergeTree 엔진은 데이터를 Part라는 불변(immutable) 단위로 저장합니다. 각 INSERT 쿼리는 새로운 Part를 생성하며, 이러한 Part들은 백그라운드에서 주기적으로 병합(merge)되어 더 큰 Part로 통합됩니다.

Part의 구조적 특징:

  • 각 Part는 정렬된 데이터 블록으로 구성
  • Primary key 순서로 정렬되어 저장
  • 불변성: 한번 생성되면 수정되지 않고, 병합을 통해서만 변경
  • Merge는 같은 파티션 안에서만 일어남 — 서로 다른 파티션의 Part는 절대 병합되지 않음

Part는 실제로 어떤 규칙으로 만들어지는가

"INSERT 1회 = Part 1개"는 절반만 맞는 이야기입니다. 정확한 규칙은 다음과 같습니다.

INSERT 1회는 (터치한 파티션 수) × (블록 수)만큼의 Part를 만듭니다.

  1. 파티션 단위 분할 — 하나의 INSERT에 여러 파티션에 속하는 행이 섞여 있으면, 파티션마다 별도의 Part가 생성됩니다. 예를 들어 월 파티션 테이블에 과거 30개월치 데이터를 한 번에 INSERT하면 최소 30개의 Part가 생깁니다.
  2. 블록 단위 분할 — INSERT되는 데이터는 먼저 블록(block)으로 잘리고, 블록이 Part로 기록됩니다. 블록 크기는 다음 설정이 결정합니다.
설정
기본값
역할
max_insert_block_size
1,048,576 rows
서버가 블록을 형성할 때(HTTP 인터페이스 등)의 최대 블록 크기
min_insert_block_size_rows
1,048,576 rows
이 행 수에 도달할 때까지 작은 블록을 squash(병합)
min_insert_block_size_bytes
268,435,456 (256MB)
이 바이트 수에 도달할 때까지 작은 블록을 squash — 비압축 메모리 기준

min_insert_block_size_*는 "작은 블록들을 얼마나 크게 뭉쳐서 Part 하나로 만들 것인가"의 기준이며, rows와 bytes 중 먼저 도달하는 쪽에서 블록이 잘립니다. 대량 INSERT SELECT나 스트리밍 인입(Kafka, S3Queue 등)에서 Part 크기를 실질적으로 결정하는 것이 이 설정들입니다.

💡 min_insert_block_size_bytes는 디스크의 압축 크기가 아니라 메모리 상의 비압축 크기 기준입니다. "파일 크기 80MB에 맞춰 80MB로 설정"처럼 압축 파일 크기와 1:1로 매핑하면 의도와 다르게 동작할 수 있습니다.

Too Many Parts는 언제 발생하는가

임계치의 정확한 의미 — 테이블당이 아니라 "파티션당"

ClickHouse는 하나의 파티션 안의 active part 수를 기준으로 INSERT를 제어합니다.

설정
기본값 (23.6+)
기본값 (23.6 이전)
동작
parts_to_delay_insert
1,000
150
파티션당 active part가 이 값을 넘으면 INSERT에 인위적 지연 추가
parts_to_throw_insert
3,000
300
파티션당 active part가 이 값을 넘으면 INSERT 거부 (에러)
max_parts_in_total
100,000
동일
테이블 전체 active part 상한
max_avg_part_size_for_too_many_parts
1GB (23.5+)
파티션 내 평균 part 크기가 이 값보다 크면 위 delay/throw 체크를 건너뜀
DB::Exception: Too many parts (3001 with average size of 34.25 MiB).
Merges are processing significantly slower than inserts.

세 가지 함의가 있습니다.

  • 파티션 단위 카운트이므로, 파티션이 잘게 쪼개져 있으면 파티션 하나하나는 임계치에 안 걸려도 테이블 전체 Part 수(max_parts_in_total)와 메타데이터 부담이 폭증할 수 있습니다.
  • max_avg_part_size_for_too_many_parts 덕분에, part가 많아도 평균 크기가 충분히 크면(= merge가 정상 동작 중이면) 에러가 나지 않습니다. Too Many Parts는 본질적으로 "작은 part가 대량으로 쌓이는" 문제입니다.
  • 인터넷의 오래된 자료(그리고 구버전 클러스터의 명시적 설정)에는 150/300이 남아 있는 경우가 많으니, 실제 적용값은 system.merge_tree_settings에서 확인해야 합니다.

성능 저하의 근본 원인

1. 쿼리 성능 저하

  • 파티션 pruning과 primary index로 스캔 범위는 줄일 수 있지만, 쿼리 플래닝 시 후보 Part의 메타데이터·인덱스 검사 비용은 Part 수에 비례
  • 같은 데이터가 작은 Part 수백 개로 쪼개져 있으면 순차 I/O가 랜덤 I/O에 가까워지고, 파일 디스크립터 사용량 급증

2. 메모리 오버헤드

  • 각 Part마다 메타데이터와 primary index를 메모리에 유지
  • 같은 데이터라도 Part가 10배 많으면 인덱스·메타데이터 오버헤드가 그만큼 커짐

3. Merge 부하

  • 백그라운드 merge가 INSERT 속도를 따라가지 못하면 part가 누적 (에러 메시지 그대로 "Merges are processing significantly slower than inserts")
  • Merge 과정에서 CPU, Disk I/O 집중 사용, 임시 디스크 공간 추가 필요

Too Many Parts의 4가지 근본 원인

① 잦은 소량 INSERT — 가장 흔한 원인. row 단위 INSERT를 초당 수십~수백 회 수행하면 merge가 감당할 수 없는 속도로 작은 part가 생성됩니다.

-- 안티패턴: 개별 row INSERT
INSERT INTO events VALUES (1, 'user1', now());
INSERT INTO events VALUES (2, 'user2', now());
INSERT INTO events VALUES (3, 'user3', now());
-- → 3개의 Parts 생성

② 고카디널리티 파티션 키 — 자주 간과되는 원인. 파티션 간에는 merge가 일어나지 않으므로, 파티션 키의 유니크 값이 많으면(예: PARTITION BY toStartOfHour(ts), PARTITION BY user_id) 각 파티션에 병합되지 못한 소량 part가 흩어져 테이블 전체 part 수가 폭증합니다. Too Many Parts뿐 아니라 "Too many inactive parts" 에러의 단골 원인이기도 합니다. 시계열 데이터는 일반적으로 월 단위(toYYYYMM)가 안전한 시작점이며, 테이블당 파티션 수는 수십~수백 개 수준을 유지하는 것이 좋습니다.

③ 여러 파티션에 걸친 INSERT — 하나의 배치가 파티션 N개를 터치하면 part가 N개 이상 생성됩니다. 과거 데이터 백필(backfill)이나 지연 도착(late-arriving) 이벤트가 많은 파이프라인에서 배치 크기를 키웠는데도 part가 줄지 않는다면 이 경우를 의심해야 합니다.

④ Merge가 따라가지 못하는 리소스 상태 — INSERT 패턴이 정상이어도 디스크 처리량 부족, 여유 공간 부족, CPU 경합으로 merge 백로그가 쌓이면 결국 같은 증상이 나타납니다. system.merges와 서버 로그에서 merge 실패·정체 여부를 확인해야 합니다.

대응 방안

1. Batch Insert 활용

가장 기본적이고 효과적인 방법입니다.

-- 권장: 배치로 묶어서 INSERT
INSERT INTO events VALUES
    (1, 'user1', now()),
    (2, 'user2', now()),
    ...
    (10000, 'user10000', now());
-- → (단일 파티션이라면) 1개의 Part만 생성

권장 배치 기준:

  • 최소 1,000 rows, 권장 10,000~100,000 rows (또는 10MB~100MB per batch)
  • 테이블당 동기 INSERT는 초당 1회 내외를 목표
  • 배치를 파티션 키 기준으로 정렬/그룹핑해서 한 배치가 터치하는 파티션 수를 최소화

2. Async Insert 설정

클라이언트에서 배치를 만들기 어렵다면(다수의 에이전트, 소량 다빈도 유입), ClickHouse 21.11부터 도입된 Async Insert가 서버 측에서 작은 INSERT들을 버퍼링해 배치로 처리합니다.

-- 쿼리(또는 사용자 프로파일) 레벨 설정
INSERT INTO events
SETTINGS async_insert=1, wait_for_async_insert=1
VALUES (1, 'user1', now());

주요 파라미터 (버전에 따라 기본값이 다릅니다)

파라미터
기본값
설명
async_insert
0
async insert 활성화 여부
wait_for_async_insert
1
버퍼가 실제 테이블에 flush될 때까지 클라이언트가 대기할지 여부
async_insert_max_data_size
10MiB (23.8+에서 1MB→10MiB 상향)
버퍼 크기. 도달 시 flush
async_insert_busy_timeout_ms
200ms 상한
첫 INSERT 후 최대 대기 시간. 24.2+에서는 적응형(adaptive)으로 동작하여 부하에 따라 자동 조절
async_insert_deduplicate
0
async insert 블록 단위 중복 제거 (Replicated 테이블 재시도 시나리오용)
⚠️ wait_for_async_insert=0은 신중하게 사용해야 합니다. 클라이언트가 버퍼 적재 시점에 바로 응답을 받으므로 처리량은 높아지지만, 이후 flush 단계에서 발생하는 에러(파싱 실패, Too many parts 등)를 감지할 수 없어 조용한 데이터 유실로 이어질 수 있습니다. 유실이 허용되지 않는 데이터라면 기본값(=1)을 유지하는 것을 권장합니다.

3. 테이블·서버 설정

설정은 "원인을 해결한 다음에 만지는 것"입니다. 특히 parts_to_throw_insert를 올리는 것은 문제를 미루는 것이지 해결이 아니며, 메타데이터 오버헤드와 쿼리 성능 저하를 키울 수 있습니다.

CREATE TABLE events (
    event_time DateTime,
    user_id UInt64,
    event_type String
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_time)   -- 저카디널리티 파티션 키 (월 단위)
ORDER BY (user_id, event_time);
-- parts_to_delay_insert / parts_to_throw_insert는
-- 23.6+ 기본값(1000/3000)을 그대로 두는 것을 권장.
-- 구버전에서 넘어온 명시적 설정(150/300)이 있다면 제거 검토.

Merge 동시성은 테이블 설정이 아니라 서버 레벨 백그라운드 풀이 결정합니다.

<!-- /etc/clickhouse-server/config.d/merges.xml -->
<clickhouse>
    <!-- 백그라운드 merge/mutation 스레드 풀 (기본 16) -->
    <background_pool_size>16</background_pool_size>
</clickhouse>

참고로 max_part_loading_threads, max_part_removal_threads는 서버 기동 시 part 로딩과 part 삭제에 관련된 설정으로, merge 동시성과는 무관합니다.

4. 모니터링 및 진단

임계치가 파티션당 기준이므로, 모니터링도 파티션 단위로 봐야 합니다.

알람 기준 예시: MaxPartCountForPartition이 500(= delay 임계치 1,000의 절반)을 넘으면 경고, 지속 증가 추세면 심각. 순간 피크보다 정상 상태(steady-state)에서 part 수가 수렴하는지가 핵심 판단 기준입니다.

5. 실전 아키텍처 패턴

패턴 A: Async Insert + Materialized View (권장)

패턴 B: Buffer 테이블 (레거시 / 특수 상황용)

Buffer 엔진은 async insert가 등장하기 전의 서버 측 배칭 수단입니다. 현재는 대부분의 경우 async insert가 우선 선택지이며, Buffer는 async insert를 쓸 수 없는 제약이 있을 때만 검토하는 것을 권장합니다.

CREATE TABLE events_buffer AS events
ENGINE = Buffer(default, events, 16, 10, 100, 10000, 1000000, 10485760, 104857600);

단점: 서버 비정상 종료 시 버퍼 데이터 유실, 메모리 상주, Replicated 테이블의 중복 제거와 궁합 문제.

핵심 체크리스트

INSERT 패턴 점검

  • 개별 row insert 피하기 — 최소 1,000 rows, 권장 10,000+ rows 배치
  • 테이블당 동기 INSERT 초당 1회 내외 유지
  • 한 배치가 여러 파티션에 걸치지 않는지 확인 (백필/지연 도착 데이터 주의)
  • 클라이언트 배칭이 어려우면 Async insert 활성화 (wait_for_async_insert=1 유지)

파티션 키 점검

  • 파티션 키 카디널리티 확인 — 시계열은 월 단위(toYYYYMM)가 기본 시작점
  • 테이블당 파티션 수 수십~수백 개 수준 유지
  • 파티션 간에는 merge가 없다는 점을 항상 전제

모니터링 설정

  • MaxPartCountForPartition 기반 알람 (500 경고 등, 임계치 대비 상대값으로)
  • system.query_log에서 INSERT당 평균 행 수 주기 점검
  • DelayedInserts / RejectedInserts 이벤트 추적
  • system.merges로 병합 백로그 확인

설정 검토

  • 구버전에서 넘어온 낮은 임계치(150/300) 명시 설정이 없는지 system.merge_tree_settings로 확인
  • 임계치 상향은 원인 해결 후 최후 수단으로만
  • 대량 인입 파이프라인은 min_insert_block_size_rows/bytes로 Part 형성 크기 제어

결론

Too Many Parts는 ClickHouse의 버그가 아니라 "merge가 따라갈 수 없는 속도로 작은 part를 만들고 있다"는 워크로드 신호입니다. 따라서 해법도 임계치를 올리는 것이 아니라 part 생성 속도를 낮추는 것 — 즉 배치 크기를 키우고(클라이언트 배칭 또는 async insert), 파티션 키의 카디널리티를 낮추고, 한 INSERT가 터치하는 파티션 수를 줄이는 것입니다. parts_to_throw_insert를 올리는 것은 증상을 미룰 뿐 메타데이터 오버헤드와 쿼리 성능 저하라는 비용을 키웁니다. 파티션당 part 수와 INSERT당 평균 행 수, 이 두 지표를 상시 모니터링하면서 워크로드에 맞는 배치 전략을 찾아가는 것이 정석입니다.

다음 글에서는 이 원리가 오브젝트 스토리지 연속 인입(S3Queue)에서 어떻게 적용되는지 — commit 단위와 insert block 단위의 정합 문제를 다룹니다.