# ClickHouse Distributed INSERT가 머지를 폭증시킬 때 - 비동기 전송 큐와 batch 옵션의 함정
관측 데이터를 ClickHouse 클러스터에 쌓다 보면 조회가 느려지는 날이 옵니다. 디스크를 의심하지만 CPU를 쪼개 보면 SELECT는 일부이고 대부분이 백그라운드 머지입니다. 원인을 따라가면 작은 INSERT가 너무 자주 들어오고, Distributed 테이블이 그것을 샤드 수만큼 더 잘게 쪼개고 있는 경우가 많습니다.
이때 손이 가는 설정이 distributed_background_insert_batch입니다. 이름만 보면 "쪼개진 것을 다시 묶어 준다"는 해결책 같습니다. 하지만 수신 샤드가 이미 포화된 상태에서 켜면 전송 큐가 아예 멈출 수 있습니다. 이 글에서는 Distributed 비동기 전송의 동작 방식과 batch 옵션이 큐를 막는 구조, 설정을 되돌릴 때의 함정을 정리합니다. 샤딩과 복제의 기본 구조는 분산 ClickHouse의 두 축에서 다뤘습니다.
# 1. Distributed INSERT는 샤드마다 파일을 만든다
Distributed 테이블에 INSERT하면 기본 동작은 비동기 전송입니다.
When inserted in the table, the data block is just written to the local file system. The data is sent to the remote servers in the background as soon as possible.
받은 블록을 샤딩 키로 나누고, 샤드별 디렉터리에 파일로 씁니다. 클라이언트는 이 시점에 성공 응답을 받습니다. 실제 전송은 백그라운드 스레드가 샤드별 큐를 비우면서 합니다.
/var/lib/clickhouse/data/<db>/<distributed_table>/ # Atomic DB에서는 store/<uuid 앞 3자>/<uuid>/로 가는 심볼릭 링크
├── shard1_replica1/ # 샤드 주소별 디렉터리 = 샤드별 전송 큐 (internal_replication=true면 shard1_all_replicas)
│ ├── 1.bin
│ └── 2.bin
├── shard2_replica1/
└── shard3_replica1/
여기서 첫 번째 증폭이 생깁니다. 3샤드 클러스터라면 INSERT 한 번이 샤드별 INSERT 세 번이 됩니다. 각 샤드의 MergeTree는 받은 INSERT마다 파트를 최소 하나 만듭니다. 행이 여러 파티션에 걸치면 파트는 더 늘어납니다.
설정 이름은 23.10에서 바뀌었습니다. 옛 이름은 별칭으로 남아 있어서 버전마다 문서와 설정 파일의 이름이 다를 수 있습니다.
| 23.10 이전 | 23.10 이후 |
|---|---|
insert_distributed_sync | distributed_foreground_insert |
distributed_directory_monitor_batch_inserts | distributed_background_insert_batch |
distributed_directory_monitor_split_batch_on_failure | distributed_background_insert_split_batch_on_failure |
distributed_directory_monitor_sleep_time_ms | distributed_background_insert_sleep_time_ms |
# 2. 작은 INSERT가 머지를 부르는 계산
ClickHouse 문서의 권장은 분명합니다.
We recommend inserting data in batches of at least 1,000 rows, and ideally between 10,000–100,000 rows. Fewer, larger inserts reduce the number of parts written, minimize merge load, and lower overall system resource usage.
같은 문서는 빈도도 초당 INSERT 한 번 정도로 유지하라고 권합니다. 관측 파이프라인은 이 권장을 쉽게 어깁니다. OpenTelemetry Collector의 batch 프로세서 기본 timeout은 200ms입니다. 수집기 인스턴스가 여러 개이고 신호별로 테이블이 나뉘어 있으면 INSERT 빈도는 곱으로 늘어납니다.
파트 생성 속도 ≈ 수집기 인스턴스 수 × 테이블 수 × (1 / 배치 주기) × 샤드 수
예) 수집기 6개 × 테이블 4개 × 초당 1회 × 3샤드 = 초당 약 72개 파트
파트가 쌓이는 속도를 머지가 따라가지 못하면 파티션당 활성 파트 수가 늘어납니다. 그 수가 parts_to_delay_insert(기본 1000)를 넘으면 INSERT가 지연되고, parts_to_throw_insert(기본 3000)를 넘으면 Too many parts로 거부됩니다. 두 기본값은 23.6에서 150, 300에서 올라간 값입니다. 오류가 나기 전까지는 머지가 CPU를 조용히 잠식하는 구간이 길게 이어집니다.
가장 효과가 큰 처방은 보내는 쪽에서 묶는 것입니다. 수신 측에서 다시 묶는 것보다 비용이 훨씬 작습니다.
# OpenTelemetry Collector - 배치 주기를 늘려 INSERT 횟수를 줄인다
processors:
batch:
send_batch_size: 50000
timeout: 5s # 데이터가 보이기까지 최대 수 초 늦어지는 대가
timeout을 1s에서 5s로 늘리면 INSERT 횟수는 이론상 최대 1/5로 줄어듭니다. 대가는 데이터가 보이기까지의 지연입니다. 최근 ClickHouse 익스포터는 외부 batch 프로세서 대신 익스포터 자체의 sending_queue.batch를 쓰라고 권장하니, 쓰는 익스포터 버전의 README를 확인해야 합니다. 서버 측에서 작은 INSERT를 모아 주는 async_insert도 대안입니다. 26.3부터는 기본값이 켜짐으로 바뀌었으므로, 업그레이드 전후로 INSERT 패턴이 달라질 수 있습니다.
# 3. distributed_background_insert_batch는 무엇을 묶는가
보내는 쪽을 고칠 수 없거나 그것만으로 부족하면 Distributed 큐 단계에서 묶는 방법이 있습니다.
When batch sending is enabled, the Distributed table engine tries to send multiple files of inserted data in one operation instead of sending them separately.
기본값은 꺼짐(0)입니다. 꺼져 있으면 큐 파일 하나가 원격 INSERT 하나가 됩니다. 켜면 여러 파일을 INSERT 하나로 보냅니다. 수신 샤드가 받는 INSERT 수가 줄고, 그만큼 파트 수도 줄어듭니다.
소스 코드(DistributedAsyncInsertDirectoryQueue.cpp)를 보면 묶는 규칙이 두 가지 있습니다.
- 설정이 같은 파일끼리만 묶습니다. 각 큐 파일 헤더에는 원래 INSERT의 쿼리와 설정이 그대로 저장됩니다. 묶음은 이 헤더(설정, 쿼리, 클라이언트 정보, 블록 구조)가 같은 파일끼리만 만들어집니다.
- 묶음 크기는
min_insert_block_size_rows/_bytes로 정해집니다. 묶음이 이 값에 도달하면 전송하고, 남은 것은 한 차례 처리가 끝날 때 보냅니다. 큐가 많이 밀려 있으면 묶음 하나가 이 상한(기본 약 100만 행, 256MiB)까지 커질 수 있습니다. 기본값은 버전에 따라 다를 수 있습니다.
두 번째 규칙이 다음 절의 문제로 이어집니다. 평소에는 작은 묶음이지만 큐가 밀릴수록 묶음이 커집니다. 그리고 큐는 수신 샤드가 느릴 때 밀립니다.
# 4. 묶음 하나가 큐 전체를 막는 구조
전송이 실패하면 어떻게 되는지가 핵심입니다. 묶음은 통째로 다시 보냅니다.
// src/Storages/Distributed/DistributedAsyncInsertBatch.cpp (master)
/// Since the batch is sent as a whole (in case of failure, the whole batch
/// will be repeated), ...
실패한 묶음의 파일 목록은 따로 기록됩니다. 다음 시도 때는 정확히 같은 묶음을 먼저 다시 보냅니다. Replicated 테이블의 중복 제거가 블록 단위로 동작하기 때문입니다.
For deduplication in Replicated tables to work, in case of error we must try to re-send exactly the same batches.
묶음을 쪼개 다시 보내는 탈출구가 distributed_background_insert_split_batch_on_failure입니다. 하지만 쪼개기는 특정 오류 코드에서만 일어납니다.
static bool isSplittableErrorCode(int code, bool remote)
{
return code == ErrorCodes::MEMORY_LIMIT_EXCEEDED
|| code == ErrorCodes::ARGUMENT_OUT_OF_BOUND
|| code == ErrorCodes::TOO_MANY_PARTS
|| code == ErrorCodes::TOO_MANY_BYTES
|| code == ErrorCodes::TOO_MANY_ROWS_OR_BYTES
|| code == ErrorCodes::TOO_MANY_PARTITIONS
|| code == ErrorCodes::DISTRIBUTED_TOO_MANY_PENDING_BYTES
|| code == ErrorCodes::DISTRIBUTED_BROKEN_BATCH_INFO
/* ... 손상 판정 코드 */;
}
TIMEOUT_EXCEEDED나 네트워크 오류는 목록에 없습니다. 시간 초과로 실패한 묶음은 쪼개지지 않고 같은 크기로 계속 재시도됩니다.
이제 조각을 맞춰 보면 다음 순서로 큐가 멈춥니다.
- 머지 적체로 수신 샤드의 CPU가 포화됩니다.
- 전송이 느려져 큐가 밀리고 밀린 큐 때문에 묶음이 커집니다.
- 큐 파일 헤더에는 원래 INSERT의 설정이 실려 있고, 전송할 때 이 설정으로 타임아웃을 정하고 원격 INSERT에도 넘깁니다. 그래서 커진 묶음도 작은 INSERT를 기준으로 정해진 시간 한도(
send_timeout,receive_timeout, 원격에 함께 넘어가는max_execution_time등) 안에 끝나야 합니다. 어떤 한도에 먼저 걸리는지는 설정과 버전에 따라 다를 수 있습니다. - 시간 초과로 실패하면 같은 묶음을 다시 보냅니다. 쪼개지지 않으니 다음 시도도 실패합니다.
- 그 샤드의 큐에서 이 묶음 뒤의 파일은 전부 대기합니다.
| 실패 원인 | split_batch_on_failure=1일 때 | 결과 |
|---|---|---|
MEMORY_LIMIT_EXCEEDED, TOO_MANY_PARTS 등 | 파일 단위로 쪼개 재시도 | 진행됨 |
| 손상된 파일(체크섬 불일치 등) | 해당 파일을 broken/으로 이동 | 진행됨, 해당 데이터는 격리 |
TIMEOUT_EXCEEDED, 연결 오류 | 쪼개지 않고 같은 묶음 재시도 | 정체 |
정체 중에도 데이터는 디스크 큐에 남아 있으므로 유실은 아닙니다. 다만 큐가 디스크를 계속 차지하고 조회 결과는 점점 과거에 머뭅니다. 문서도 자동 쪼개기에 기대지 말라고 경고합니다.
You should not rely on automatic batch splitting, since this may hurt performance.
판단 기준: batch 옵션은 수신 샤드에 여유가 있을 때 파트 수를 줄이는 최적화입니다. 수신 샤드가 포화된 상태를 구하는 응급처치가 아닙니다. 포화 상태라면 파일 단위 전송이 느려도 큐를 꾸준히 줄입니다. 먼저 보내는 쪽 배치와 머지 부하를 해결하고, 수신 샤드 CPU에 여유가 생긴 뒤에 batch를 검토하는 순서가 맞습니다.
# 5. 설정을 되돌릴 때 - 키를 지워도 안 꺼질 수 있다
batch를 켰다가 문제를 보고 되돌린다고 해 봅시다. 설정 프로파일에서 해당 키를 지우는 것이 자연스러운 방법입니다.
<!-- users.d/profile.xml -->
<clickhouse>
<profiles>
<default>
<!-- 키를 지우는 대신 기본값을 명시한다 -->
<distributed_background_insert_batch>0</distributed_background_insert_batch>
</default>
</profiles>
</clickhouse>
ClickHouse는 사용자 설정 파일이 바뀌면 재시작 없이 다시 읽습니다. 그런데 프로파일에서 값을 바꾸는 것과 키를 지우는 것은 다르게 동작할 수 있습니다. 키를 지워도 이전 값이 재시작 전까지 남는다는 보고가 업스트림에 열려 있습니다.
If I remove a setting from users XML to reset a setting it requires restart ?
관련 수정 PR(#115306)은 원인을 "새로 계산된 프로파일을 오래된 전역 컨텍스트 스냅샷 위에 적용하기 때문"이라고 설명합니다. 이 글을 쓰는 시점에 이슈와 PR은 모두 열려 있습니다. 버전에 따라 동작이 다를 수 있으므로, 되돌릴 때는 키를 지우지 말고 기본값을 명시하는 쪽이 안전합니다. 적용 후에는 실제 값을 직접 확인해야 합니다.
SELECT name, value, changed
FROM system.settings
WHERE name LIKE 'distributed_background_insert%';
changed = 1인데 값이 의도와 다르면 아직 이전 값이 살아 있는 것입니다. 명시한 값이 반영된 것을 확인하고, 서버를 한 번 재시작한 뒤에 키를 정리하면 됩니다.
# 6. 진단 쿼리
증상이 "느리다"뿐일 때 아래 순서로 보면 어느 단계가 막혔는지 가려낼 수 있습니다.
1) 전송 큐가 쌓이는지, 오류가 반복되는지
SELECT
database,
table,
data_path,
is_blocked,
error_count,
data_files,
formatReadableSize(data_compressed_bytes) AS pending,
broken_data_files,
last_exception_time,
substring(last_exception, 1, 120) AS last_exception
FROM system.distribution_queue
WHERE data_files > 0
ORDER BY data_files DESC;
data_files가 시간이 지나도 줄지 않고 error_count만 오르면 정체입니다. last_exception에 TIMEOUT_EXCEEDED가 보이면 4절의 상황입니다.
2) 파트 생성과 머지의 비율
SELECT
toStartOfMinute(event_time) AS minute,
countIf(event_type = 'NewPart') AS new_parts,
countIf(event_type = 'MergeParts') AS merges
FROM system.part_log
WHERE event_time > now() - INTERVAL 1 HOUR
GROUP BY minute
ORDER BY minute;
분당 new_parts가 수백 단위로 꾸준하면 INSERT가 너무 잘게 들어오는 것입니다. 보내는 쪽 배치를 바꾼 뒤 이 수치가 줄었는지로 효과를 확인합니다.
3) 지금 돌고 있는 머지와 대기 중인 전송
SELECT metric, value
FROM system.metrics
WHERE metric IN ('BackgroundMergesAndMutationsPoolTask',
'DistributedFilesToInsert',
'BrokenDistributedFilesToInsert',
'DelayedInserts');
# 7. 놓치기 쉬운 부하 - 시스템 로그 테이블
INSERT가 잦으면 시스템 로그도 그만큼 많이 쓰입니다. 일부는 기본으로 켜져 있습니다.
| 설정 | 기본값 | 쓰는 테이블 |
|---|---|---|
log_processors_profiles | 1 (24.3부터) | system.processors_profile_log |
log_query_views | 1 | system.query_views_log |
query_profiler_real_time_period_ns | 1000000000 (1초) | system.trace_log |
query_profiler_cpu_time_period_ns | 1000000000 (1초) | system.trace_log |
이 테이블들도 MergeTree이므로 쓰기와 머지 비용이 듭니다. 쿼리 단위 프로파일링을 평소에 쓰지 않는다면 프로파일에서 끄고, 필요할 때 세션에서 SET으로 켜면 됩니다. query_log, part_log는 위 진단에 쓰이므로 남겨 둡니다. 남겨 둘 로그에는 TTL을 걸어 크기를 묶어 둡니다.
<profiles>
<default>
<log_processors_profiles>0</log_processors_profiles>
<log_query_views>0</log_query_views>
<query_profiler_real_time_period_ns>0</query_profiler_real_time_period_ns>
<query_profiler_cpu_time_period_ns>0</query_profiler_cpu_time_period_ns>
</default>
</profiles>
# 8. 트러블슈팅 요약
| 증상 | 원인 | 해결 |
|---|---|---|
| CPU 대부분이 머지 | 작은 INSERT × 샤드 수만큼 파트 생성 | 보내는 쪽 배치 주기·크기 확대, async_insert 검토 |
Too many parts / INSERT 지연 | 머지가 파트 생성 속도를 못 따라감 | 위와 동일, 파티션 키 재검토 |
| batch를 켠 뒤 큐가 멈춤 | 커진 묶음이 시간 초과, 쪼개지지 않고 통째로 재시도 | batch 끄기, 수신 샤드 부하 먼저 해결 |
MEMORY_LIMIT_EXCEEDED로 큐 정체 | 묶음이 수신 측 메모리 한도 초과 | split_batch_on_failure=1 |
| 설정을 지웠는데 값이 그대로 | 프로파일에서 빠진 설정이 재시작 전까지 남는 문제 | 기본값을 명시, system.settings로 확인 |
| 디스크가 Distributed 디렉터리로 참 | 전송 큐 정체 | system.distribution_queue로 원인 확인 |
# 9. 마무리
- Distributed 비동기 INSERT는 블록을 샤드 수만큼 쪼개 따로 보냅니다. 작은 INSERT는 샤드 수만큼 증폭되어 머지 부하가 됩니다. 처방은 보내는 쪽에서 크게 묶는 것입니다.
distributed_background_insert_batch는 실패하면 묶음을 통째로 다시 보냅니다. 시간 초과는 쪼개기 대상이 아닙니다. 그래서 수신 샤드가 포화된 상태에서 켜면 큐가 멈춥니다. 여유가 있을 때 쓰는 최적화입니다.- 설정을 되돌릴 때는 키를 지우지 말고 기본값을 명시한 뒤
system.settings로 실제 값을 확인해야 합니다.
# 참고
- ClickHouse - Distributed Table Engine (opens new window)
- ClickHouse - system.distribution_queue (opens new window)
- ClickHouse - Selecting an insert strategy (opens new window)
- ClickHouse - Asynchronous inserts (opens new window)
- ClickHouse - Session Settings (opens new window)
- ClickHouse - system.trace_log (opens new window)
- ClickHouse 소스 - DistributedAsyncInsertBatch.cpp (opens new window)
- ClickHouse 소스 - DistributedAsyncInsertDirectoryQueue.cpp (opens new window)
- ClickHouse #55978 - Distributed 설정 이름 변경 (23.10) (opens new window)
- ClickHouse #50856 - parts_to_delay/throw_insert 기본값 변경 (23.6) (opens new window)
- ClickHouse #46418 - 프로파일에서 지운 설정이 재시작 전까지 남는 문제 (opens new window)
- ClickHouse #115306 - 사용자 재로드 시 전역 컨텍스트 스냅샷 수정 PR (opens new window)
- OpenTelemetry Collector - Batch Processor (opens new window)
- OpenTelemetry Collector Contrib - ClickHouse Exporter (opens new window)