홈으로 돌아가기

ClickHouse에 데이터 로드: 배치, async_insert, 모니터링

올바른 배칭 전략으로 ClickHouse에 데이터를 로드하는 실용 가이드. 작은 INSERT가 수천 개의 파트를 생성하고 성능을 저하시키는 이유를 설명합니다. 모든 방법을 보여줍니다: INSERT VALUES (테스트 전용), clickhouse-client 및 HTTP API(gzip 포함)를 통한 CSV/JSONEachRow/TSV 로드, 데이터베이스 내 복사를 위한 INSERT SELECT, 고주파 스트림(플레이어로부터 초당 수천 이벤트)을 위한 async_insert(버퍼 설정 포함), max_insert_block_size 및 max_batch_size 매개변수. system.query_log를 통한 모니터링을 위한 SQL 쿼리와 clickhouse-driver를 사용한 parquet 배치 로드를 위한 준비된 Python 스크립트를 제공합니다. 모든 예제는 betting.bets 테이블을 사용합니다.

ClickHouse: 배치로 데이터를 로드하고 프로덕션을 중단시키지 않는 방법
Advertisement 728x90

ClickHouse에 데이터 로드: 한 행씩 삽입을 멈추고 처리 속도를 500배 향상시킨 방법

재난 이야기: 초당 1000개 쿼리와 프로덕션 장애

저는 실시간 베팅 분석 프로젝트를 진행 중이었습니다. 매일 5000만 건의 이벤트가 들어왔습니다. INSERT INTO bets VALUES (...)로 한 번에 하나씩 처리해도 괜찮다고 생각했습니다. ClickHouse는 초당 500개의 삽입을 처리했지만, 우리는 2000개가 필요했습니다. 서버가 멈췄습니다: 디스크 큐가 폭주했고, 백그라운드 병합이 수천 개의 작은 파트를 생성했습니다. 프로덕션이 다운되었습니다.

알고 보니 ClickHouse는 MySQL이 아니었습니다. 대량 삽입을 위해 설계되었으며, 개별 INSERT용이 아닙니다. 로더를 10만 개 레코드씩 배치로 다시 작성한 후, 성능 저하 없이 초당 1만 개의 삽입을 달성했습니다.

아래는 작은 CSV부터 프로덕션급 배처까지 모든 로드 방법과 제가 밤을 새게 만든 함정들입니다.

Google AdInline article slot

1. 단일 행이 왜 해로운가 (원하더라도)

ClickHouse는 INSERT마다 디스크에 새로운 파트를 생성합니다. 파트는 8192행의 그레인을 포함하지만, 단 1행을 삽입해도 작은 .bin 파일이 있는 새 파트가 물리적으로 생성됩니다.

결과:

  • 1행씩 1만 번 INSERT = 1만 개의 파트
  • SELECT 쿼리는 1만 개의 파일을 열어야 함
  • 백그라운드 병합이 이를 결합하려고 시도하여 CPU에 부담
  • 파트 메타데이터로 디스크 공간 낭비

제 경험상 규칙: 배치는 최소 1000행 또는 1MB의 압축 데이터여야 합니다.

Google AdInline article slot

2. INSERT ... VALUES — 테스트용, 프로덕션용 아님

-- 로컬 실험 전용
INSERT INTO betting.bets (user_id, created_at, amount, odds, sport, outcome)
VALUES 
    (1001, now(), 50.00, 2.1, 'football', 'win'),
    (1002, now(), 100.00, 1.8, 'basketball', 'loss'),
    (1003, now(), 75.00, 3.0, 'tennis', 'win');

프로덕션에서는 절대 이렇게 하지 마세요. VALUES에 1000행을 배치하더라도 SQL 파싱이 병목이 됩니다.

3. INSERT ... FORMAT CSV / JSONEachRow / TSV — 파일에 생명을 불어넣는 방법

ClickHouse는 여러 형식을 즉시 이해합니다.

CSV 파일 예시 bets.csv:

Google AdInline article slot
1001,2024-03-15 10:00:00,50.00,2.1,football,win
1002,2024-03-15 10:01:00,100.00,1.8,basketball,loss
1003,2024-03-15 10:02:00,75.00,3.0,tennis,win

로드:

INSERT INTO betting.bets FORMAT CSV

하지만 데이터를 stdin으로 파이프해야 합니다. 따라서 스크립트에서는 다음을 사용하세요:

clickhouse-client --query="INSERT INTO betting.bets FORMAT CSV" < bets.csv

JSONEachRow — 로그에 이상적:

{"user_id":1001,"created_at":"2024-03-15 10:00:00","amount":50.00,"odds":2.1,"sport":"football","outcome":"win"}
{"user_id":1002,"created_at":"2024-03-15 10:01:00","amount":100.00,"odds":1.8,"sport":"basketball","outcome":"loss"}

로드:

clickhouse-client --query="INSERT INTO betting.bets FORMAT JSONEachRow" < bets.json

TabSeparated — 가장 빠름 (파싱이 적음):

clickhouse-client --query="INSERT INTO betting.bets FORMAT TSV" < bets.tsv

4. 파일에서 clickhouse-client를 통한 로드 — 백업에 제일 좋아하는 방법

# 작은 파일
clickhouse-client --query="INSERT INTO betting.bets FORMAT CSV" < /tmp/daily_bets.csv

# 진행 상황 표시와 함께 큰 파일
clickhouse-client \
  --query="INSERT INTO betting.bets FORMAT CSV" \
  --progress \
  --max_insert_block_size=100000 \
  < /data/historical_bets_2023.csv

중요 플래그: --max_insert_block_size=100000 — 파일을 10만 행 블록으로 분할합니다. 이를 통해 메모리 오버플로 없이 테라바이트 크기의 파일을 삽입할 수 있습니다.

배운 점: FORMAT은 입력 데이터의 구조를 정의하며 출력이 아닙니다. 쿼리 자체에는 데이터가 없으며 stdin을 통해 들어옵니다.

5. POST를 사용한 HTTP API — 명령줄 접근이 없을 때

# curl을 통한 CSV
curl -X POST "http://localhost:8123/?query=INSERT+INTO+betting.bets+FORMAT+CSV" \
  --data-binary @bets.csv

# JSONEachRow
curl -X POST "http://localhost:8123/?query=INSERT+INTO+betting.bets+FORMAT+JSONEachRow" \
  --data-binary @bets.json \
  -u analyst:password

# 실시간 GZIP (대역폭 절약)
gzip -c bets.csv | curl -X POST "http://localhost:8123/?query=INSERT+INTO+betting.bets+FORMAT+CSV" \
  --data-binary @- \
  --header "Content-Encoding: gzip"

왜 POST인가, GET이 아닌가: GET 요청은 데이터를 포함하여 완전히 로깅됩니다. ?query=INSERT...를 통해 삽입하면 비밀번호와 데이터가 프록시 로그에 남을 수 있습니다.

6. INSERT SELECT — ClickHouse 내부에서 데이터 이동

대량 재로드의 가장 빠른 방법은 외부로 내보내지 않는 것입니다.

-- 1월 데이터를 아카이브 테이블로 복사
INSERT INTO betting.bets_archive
SELECT * FROM betting.bets
WHERE created_at >= '2024-01-01' AND created_at < '2024-02-01';

-- 실시간 집계
INSERT INTO betting.daily_stats (date, total_bets, total_amount)
SELECT 
    toDate(created_at) AS date,
    count() AS total_bets,
    sum(amount) AS total_amount
FROM betting.bets
WHERE created_at >= today() - 7
GROUP BY date;

속도: INSERT SELECT는 클라이언트-서버 교환을 피하며 로컬 전송입니다. 수십억 행을 몇 분 안에 이동할 수 있습니다.

7. 비동기 삽입: 플레이어로부터 초당 수천 개의 이벤트가 올 때

기존 INSERT는 동기식입니다: 클라이언트는 데이터가 디스크에 기록될 때까지 기다립니다. 초당 1만 개의 삽입에서는 부담이 됩니다.

async_insert는 메모리에 데이터를 버퍼링하고 배치로 삽입합니다.

config.xml 또는 SETTINGS를 통해 활성화:

-- 세션 수준
SET async_insert = 1;
SET wait_for_async_insert = 0;  -- 완료를 기다리지 않음
SET async_insert_max_data_size = 10000000;  -- 10 MB 버퍼
SET async_insert_busy_timeout_ms = 200;     -- 200ms마다 플러시

INSERT INTO betting.bets FORMAT JSONEachRow
{"user_id":1001,"amount":50,"odds":2.1}
{"user_id":1002,"amount":100,"odds":1.8}

작동 방식:

  1. 클라이언트가 작은 삽입을 보냄
  2. ClickHouse가 메모리 버퍼에 축적
  3. 버퍼가 가득 차거나(10MB) 타임아웃(200ms)이 만료되면 단일 대량 삽입 발생

나를 태운 점: wait_for_async_insert=0은 클라이언트가 삽입 실패 여부를 알 수 없음을 의미합니다. 네트워크 문제로 데이터 손실이 발생할 수 있습니다. 신뢰성이 중요하면 wait_for_async_insert=1로 설정하세요. 성능은 떨어지지만 치명적이지는 않습니다.

8. 설정: max_insert_block_size와 max_batch_size

<!-- config.xml -->
<max_insert_block_size>1048576</max_insert_block_size>  <!-- 100만 행 -->
<max_block_size>65536</max_block_size>

max_insert_block_size — ClickHouse가 한 번에 처리하는 최대 블록 크기입니다. 삽입이 더 크면 블록으로 분할됩니다.

프로덕션 규칙: max_insert_block_size를 예상 배치 크기보다 2~4배 작게 설정하세요. 그러면 실수로 큰 INSERT가 발생해도 메모리가 죽지 않습니다.

# 단일 삽입에 강제 적용
clickhouse-client --query="INSERT INTO bets FORMAT CSV" \
  --max_insert_block_size=500000 < huge_file.csv

9. system.query_log를 통한 삽입 모니터링

얼마나 많은 행이 삽입되었는지 추측하지 마세요. system.query_log를 확인하세요.

-- 최근 INSERT 쿼리
SELECT 
    event_time,
    query_duration_ms,
    read_rows,
    written_rows,
    result_rows,
    memory_usage,
    query
FROM system.query_log
WHERE type = 'QueryFinish'
  AND query LIKE '%INSERT INTO betting.bets%'
  AND event_time >= now() - INTERVAL 1 HOUR
ORDER BY event_time DESC
LIMIT 20;

내 모니터링 대시보드: 지난 1시간 동안의 평균 배치 크기, 실패한 삽입 수, 초당 행 속도를 보여줍니다.

SELECT 
    toStartOfMinute(event_time) AS minute,
    count() AS inserts,
    avg(written_rows) AS avg_batch_size,
    sum(written_rows) AS total_rows,
    avg(query_duration_ms) AS avg_latency_ms
FROM system.query_log
WHERE type = 'QueryFinish'
  AND query LIKE '%INSERT INTO betting.bets%'
  AND event_time >= now() - INTERVAL 1 HOUR
GROUP BY minute
ORDER BY minute DESC;

10. 과거 데이터 배치 로드를 위한 Python 스크립트

실제 프로젝트에서 S3의 parquet 파일에서 3년치 베팅 내역을 로드했습니다. 다음은 20억 행을 처리한 스크립트입니다:

#!/usr/bin/env python3
from clickhouse_driver import Client
import pandas as pd
import glob
from tqdm import tqdm

# ClickHouse에 연결
client = Client(
    host='clickhouse.prod.internal',
    port=9000,
    user='loader',
    password='strong_password',
    database='betting'
)

# 대량 삽입을 위한 최적화
client.execute("SET max_insert_block_size = 1000000")
client.execute("SET min_insert_block_size_rows = 500000")
client.execute("SET min_insert_block_size_bytes = 100000000")  # 100 MB

def insert_batch(df, table='bets'):
    """Native 프로토콜을 통해 DataFrame을 배치로 삽입"""
    # clickhouse-driver가 자동으로 타입 변환
    client.execute(f"INSERT INTO {table} FORMAT TabSeparated", 
                   df.to_csv(sep='\t', header=False, index=False),
                   types_check=True)

def stream_parquet_files(pattern='/data/bets_*.parquet'):
    files = glob.glob(pattern)
    batch_size = 500000
    buffer = []
    
    for file in tqdm(files, desc="파일 처리 중"):
        # 메모리에 모두 로드하지 않도록 parquet을 청크 단위로 읽기
        for chunk in pd.read_parquet(file, chunksize=batch_size):
            # ClickHouse에 맞게 타입 변환
            chunk['created_at'] = pd.to_datetime(chunk['created_at'])
            chunk['amount'] = chunk['amount'].astype('float64')
            chunk['odds'] = chunk['odds'].astype('float64')
            
            buffer.append(chunk)
            if len(buffer) >= 5:  # 5개 청크(각 50만 행) = 250만 행
                df_batch = pd.concat(buffer, ignore_index=True)
                insert_batch(df_batch)
                buffer = []
    
    # 남은 데이터
    if buffer:
        df_batch = pd.concat(buffer, ignore_index=True)
        insert_batch(df_batch)

if __name__ == '__main__':
    stream_parquet_files()

requests를 사용한 대안 (pandas 없이):

import requests
import gzip
import io

def insert_via_http_csv_gz(filepath):
    with open(filepath, 'rb') as f:
        compressed = gzip.compress(f.read())
    
    response = requests.post(
        'http://localhost:8123/?query=INSERT+INTO+betting.bets+FORMAT+CSV',
        data=compressed,
        headers={'Content-Encoding': 'gzip'},
        auth=('loader', 'password')
    )
    
    if response.status_code != 200:
        raise Exception(f"삽입 실패: {response.text}")
    
    print(f"{filepath} 삽입 완료")

# 여러 파일을 배치로 전송하는 예시
for file in glob.glob('/data/bets_*.csv.gz'):
    insert_via_http_csv_gz(file)

일반적인 로드 오류 (그리고 해결 방법)

오류: Memory limit (total) exceeded
원인: 삽입 블록이 너무 큼.
해결 방법: max_insert_block_size를 줄이거나 파일을 여러 조각으로 분할.

오류: Too many parts
원인: 너무 많은 작은 INSERT 또는 높은 처리량에서 async_insert 비활성화.
해결 방법: async_insert 활성화, min_rows_for_wide_part 증가.

오류: Code: 252. DB::Exception: Too many simultaneous inserts
원인: 동일한 파티션에 대한 병렬 삽입.
해결 방법: 해당 테이블에 max_insert_threads = 1 설정.

오류: 파티션이 병합되지 않음
원인: ORDER BY 순서에 맞지 않게 데이터 삽입.
해결 방법: 데이터가 테이블의 ORDER BY와 동일하게 정렬되었는지 확인.

다음은?

이제 작은 CSV부터 프로덕션 스트림까지 모든 방법으로 데이터를 로드하는 방법을 알게 되었습니다. 다음 글에서는 쿼리 최적화와 고급 집계에 대해 다룹니다.

마지막 조언: 프로덕션에서 작업한다면 어떤 INSERTmax_insert_block_sizeasync_insert 없이 스크립트를 떠나지 마세요. 저는 그 레이크를 세 번 밟았습니다 — 충분합니다.


이전 글:
다음 글: ClickHouse의 SELECT 쿼리: 10년간 PostgreSQL을 사용하다가 사고방식을 바꾼 방법

— Editorial Team

Advertisement 728x90

다음 읽기